fix(auth): grant org namespace only to org Owners, not all members (#1383)
## The bug (scoped to `github-at`)
When you exchange a GitHub OAuth/PAT token for a registry JWT, the
handler granted `io.github.<org>/*` publish permission for **every**
organization you belong to. It derived that list from [`GET
/users/{username}/orgs`](https://docs.github.com/en/rest/orgs/orgs#list-organizations-for-a-user),
which:
1. returns only **public** org memberships, and
2. carries **no role** — an org Owner and a brand-new member look
identical.
So membership was silently treated as publishing authority. Anyone who
is (or whose public membership lists them as) a member of `acme` could
publish — and, because publish is a pure namespace-glob with no
per-server ownership check, **overwrite** — any server under
`io.github.acme/*`.
## The fix
Switch to [`GET
/user/memberships/orgs`](https://docs.github.com/en/rest/orgs/members#list-organization-memberships-for-the-authenticated-user),
which returns the **authenticated caller's role** per org (and includes
private memberships), and grant the org namespace only when the role is
`admin` (org Owner).
- **Personal namespaces are unchanged** — you always get
`io.github.<your-username>/*`.
- **Org namespaces now require Owner.** GitHub has no org-level
"maintainer" role; org membership is either `admin` (Owner) or `member`.
We grant only on `admin`.
- **`read:org` is the only scope needed.** A minimal token without it
still authenticates and still publishes to your personal namespace; a
genuine missing-scope `403` from the memberships call is treated as "no
admin orgs" rather than a hard failure, so we don't push anyone to
over-scope a token. A `403`/`429` that is actually a rate limit
(`X-RateLimit-Remaining: 0` / `Retry-After`) fails closed instead, so a
throttle can't silently strip a real Owner's org grant. Critically, the
token needs **no repository scopes** — the registry never reads or
writes code, so an org Owner can hand CI a near-powerless `read:org`
token.
The authorization decision stays stateless and delegated to GitHub — no
new storage, no new endpoints, no schema changes — consistent with
[design principle #2](docs/design/design-principles.md) (minimal
operational burden).
### Containing the CI token
The registry-side change makes the org Owner the only one who can mint
org-namespace authority. But if that Owner drops the resulting PAT into
a plain repo secret, **every repository writer** can reach it (via
branch/tag pushes that run repo-controlled build/test code in the
privileged job) — quietly re-widening publish access beyond Owners.
External fork contributors still cannot reach it by default (no secrets
on fork PRs, no branch push), barring misconfigurations like
`pull_request_target`-with-checkout or self-hosted runners on public
repos.
`docs/modelcontextprotocol-io/github-actions.mdx` documents how to
contain that blast radius:
- store the token as a **GitHub Actions Environment secret** (the PAT
example uses `environment:`),
- restrict that environment to the default branch / release tags and
require PR reviews on it, so only reviewed, maintainer-approved code can
reach the token,
- optionally add a **required environment reviewer** for explicit
per-publish approval,
- and avoid the two footguns (`pull_request_target` checkout,
self-hosted runners on public repos).
This shrinks *who holds* the org credential; it does not change the fact
that an authorized publish can still overwrite a different server in the
same namespace until per-server publisher ownership exists (the
follow-up noted below).
## Why this shape, given the alternatives
[Pree's
writeup](https://github.com/modelcontextprotocol/registry/issues/982#issuecomment-3982959559)
([Notion analysis
doc](https://www.notion.so/MCP-Registry-Authorization-Model-Analysis-Issue-982-31736f79f74980bc90c6e53de1c564c5))
is the clearest framing of the problem and the option space, laying out
three broad solutions:
- **Approach A — GitHub App as the authority layer.** Strong model, but
its natural unit of authority is a *repo*, so it needs a repo→server
mapping to authorize a namespace. The safe, flexible version of that
mapping is registry-managed state — which collapses into Approach B.
It's also GitHub-only.
- **Approach B — a registry-managed grants table** (OIDC bootstrap, or
device-flow admin check). The most capable and provider-agnostic option,
and probably where v1 wants to go. But it introduces exactly the
"registry manages an authorization database" layer we wanted to avoid
taking on right now: revocation, sync, audit, migration.
- **Approach C — scoped bearer tokens (npm-style).** Good ergonomics,
but again implies issuance/storage the registry would own.
The two-property test (does the registry control the decision? is it
granted through an act requiring real authority?) is the right lens.
This change satisfies it **without** new state: the registry makes the
decision (it filters on role), and the authority is real (live GitHub
Owner role, re-checked on every short-lived token exchange).
**What this deliberately gives up**, so reviewers can weigh it honestly:
- **Owners are always in the loop.** Only org Owners can publish org
servers; there's no delegated "this team can publish this server" — that
needs Approach B's state. Reduced flexibility in exchange for zero new
infrastructure.
- **Blast radius within an org is unchanged.** An Owner (or Owner-scoped
CI token) can still overwrite *any* server under the org namespace,
because there's still no per-server publisher ownership. That
hijack-an-existing-server problem is real but **orthogonal** to this fix
and belongs in its own change (record publisher identity per server;
enforce it on update) — a follow-up to take alongside the OIDC hardening
below.
- **OIDC is untouched and remains coarser.** The `github-oidc` path
still grants the whole `io.github.<owner>/*` namespace to any workflow
with `id-token: write`, i.e. effectively anyone with write access to any
repo in the org, and it excludes non-Actions CI (Jenkins/GitLab can't
mint GitHub Actions OIDC). The admin-PAT path here is the
higher-assurance option for org publishing; OIDC stays as a
lower-assurance Actions convenience to be tightened separately.
## Revocation
The registry JWT is **5-minute, no-refresh** (per #264/#273), so loss of
Owner status propagates within minutes on the next token exchange, with
no revocation list to maintain.
## Tests
`go test ./internal/api/handlers/v0/auth/...` passes, including:
- member-role org is **not** granted; only admin orgs are
- a missing-scope `403` (no `read:org`) still yields the personal
namespace and no error
- an admin org on a **later page** of memberships is still granted
(pagination correctness)
- a `5xx` from the memberships call **fails closed** (no token issued)
rather than degrading to personal-only
- a rate-limit `403` (`X-RateLimit-Remaining: 0` / `Retry-After`)
**fails closed** — not mistaken for a missing-scope `403`
- existing org/name-validation tests updated to the new endpoint + role
shape
`gofmt`/`go vet`/`golangci-lint` (CI-pinned v2.11.4) clean; CI green on
all checks.
## Live verification
Confirmed end-to-end against real `api.github.com` (not just mocks) by
exercising this branch's `ExchangeToken` and decoding the `permissions`
claim of the returned JWT.
A dedicated org, **Tadasant Test Org** (login `Tadasant-Test-Org`), is
Owned by one account and has a second account joined as a plain
**member**. Both memberships are `active`, verified via the same
endpoint the fix uses (`GET /user/memberships/orgs/{org}`):
| Token (role) | `io.github.Tadasant-Test-Org/*` granted? | Other
patterns granted |
|---|---|---|
| Owner (`admin`) | ✅ yes | personal `io.github.<owner>/*` |
| member | ❌ no | personal `io.github.<member>/*` + the orgs that
account actually Owns |
The member case is the meaningful one: that membership is **active and
returned** by `GET /user/memberships/orgs`, and is excluded purely
because its role is `member`, not `admin`. Under the previous `GET
/users/{username}/orgs` behavior (membership = authority, no role), the
same active membership **would have granted** the org namespace. The
role filter is also selective rather than blanket-deny — the member
account still receives the namespaces of the orgs where it genuinely
holds Owner.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Radoslav Dimitrov <radoslav@stacklok.com>
This commit is contained in:
parent
224e23dc2c
commit
2bc37b1999
5 changed files with 573 additions and 37 deletions
|
|
@ -47,6 +47,14 @@ Successfully authenticated!
|
|||
✓ Successfully logged in
|
||||
```
|
||||
|
||||
### Personal vs. organization namespaces
|
||||
|
||||
GitHub authentication always grants your personal namespace, `io.github.<your-username>/*`.
|
||||
|
||||
To publish under an **organization** namespace (`io.github.<orgname>/*`), you must be an **Owner** of that organization. Ordinary org membership is no longer sufficient: the registry checks your membership role and only grants the org namespace to admins. This prevents anyone who merely belongs to an org from publishing — or overwriting — servers under the org's name.
|
||||
|
||||
If you authenticate with a Personal Access Token (for example in CI), the token must include the `read:org` scope for the registry to see your organization role. A token without `read:org` can still publish to your personal namespace, but org publishing will be silently unavailable. The token needs **no** repository scopes — the registry never reads or writes your code.
|
||||
|
||||
## DNS Authentication
|
||||
|
||||
DNS authentication is a domain-based authentication method that relies on a DNS TXT record.
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ on:
|
|||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
# Source MCP_GITHUB_TOKEN from a protected Environment, not a plain repo
|
||||
# secret, and restrict that environment to your default branch / release
|
||||
# tags. See "Securing your registry token in CI" below for why this matters.
|
||||
environment: mcp-registry-publish
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
|
@ -192,16 +196,33 @@ jobs:
|
|||
|
||||
## Step 2: Add Secrets
|
||||
|
||||
You may need to add a secret to the repository depending on which authentication method you choose:
|
||||
You may need to add a secret depending on which authentication method you choose:
|
||||
|
||||
- **GitHub OIDC Authentication**: No dedicated secret necessary.
|
||||
- **GitHub PAT Authentication**: Add a `MCP_GITHUB_TOKEN` secret with a GitHub Personal Access Token (PAT) that has `read:org` and `read:user` scopes.
|
||||
- **GitHub PAT Authentication**: Add a `MCP_GITHUB_TOKEN` secret with a GitHub Personal Access Token (PAT) that has the `read:org` scope. `read:org` is what lets the registry confirm you are an **Owner** of the organization before granting its namespace; the token needs **no** repository scopes (the registry never reads or writes your code), and no other scopes are required — `read:user` is not needed, and a bare token still authenticates and publishes to your personal namespace. For an **organization** token, store it as an **Environment secret** on the `mcp-registry-publish` environment rather than a plain repository secret — see "Securing your registry token in CI" below for why this matters and how to configure the environment.
|
||||
- **DNS Authentication**: Add a `MCP_PRIVATE_KEY` secret with your Ed25519 private key.
|
||||
|
||||
You may also need to add secrets for your package registry. For example, the workflow above needs an `NPM_TOKEN` secret with your npm token.
|
||||
|
||||
For information about how to add secrets to a repository, see [Using secrets in GitHub Actions](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets).
|
||||
|
||||
### Securing your registry token in CI
|
||||
|
||||
Whichever token you use to authenticate, treat it as a high-value credential. When you authenticate to an **organization** namespace, the resulting registry token can publish — and overwrite — **any** server under `io.github.<org>/*`, not just the one in this repository. The relevant question for your threat model is therefore: *who can cause code to run in a job where this secret is exposed?* By default that is **every repository writer** (via branch or tag pushes), not only org Owners — so a plain repo secret quietly widens publish access to everyone with write access.
|
||||
|
||||
GitHub gives you the tools to close that gap. We recommend, in increasing order of strength:
|
||||
|
||||
1. **Store the token as an [Environment secret](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), not a repository or organization secret.** A job can only read an environment secret when it declares `environment:` (as the PAT example above does). Note that the `environment:` key alone protects nothing: if the environment does not exist yet, GitHub **auto-creates it with no protection rules** on first run, and a job that declares `environment:` still also receives ordinary repository and organization secrets. The protection comes from storing the token *as a secret on that environment* and configuring the environment (steps 2–3 below) — not from the `environment:` key by itself.
|
||||
2. **Restrict that environment to your default branch and/or release tags** with a [deployment branch rule](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments#deployment-branches-and-tags), and protect that branch with [required pull request reviews](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches). Now only reviewed, maintainer-approved code can ever reach the token.
|
||||
3. **(Strongest) Add a [required reviewer](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments#required-reviewers) to the environment** so each publish pauses for an explicit human approval.
|
||||
|
||||
Two pitfalls to avoid regardless of the above:
|
||||
|
||||
- **Never publish from a `pull_request_target` workflow that checks out PR-head code.** That trigger runs with your secrets in the base-repo context, so an untrusted fork PR could exfiltrate the token. Environment branch rules do **not** protect you here (the ref is still your base branch) — only required reviewers do.
|
||||
- **Avoid self-hosted runners for the publish job on public repositories**, where fork PRs may be able to schedule jobs onto them.
|
||||
|
||||
By default, pull requests **from forks** receive no secrets and cannot push branches, so external contributors cannot reach the token without one of the misconfigurations above.
|
||||
|
||||
## Step 3: Tag and Release
|
||||
|
||||
Create and push a version tag to trigger the workflow:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
|
@ -57,7 +58,7 @@ func RegisterGitHubATEndpoint(api huma.API, pathPrefix string, cfg *config.Confi
|
|||
}, func(ctx context.Context, input *GitHubTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
|
||||
response, err := handler.ExchangeToken(ctx, input.Body.GitHubToken)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Token exchange failed", err)
|
||||
return nil, tokenExchangeError(err)
|
||||
}
|
||||
|
||||
return &v0.Response[auth.TokenResponse]{
|
||||
|
|
@ -66,6 +67,17 @@ func RegisterGitHubATEndpoint(api huma.API, pathPrefix string, cfg *config.Confi
|
|||
})
|
||||
}
|
||||
|
||||
// tokenExchangeError maps an internal ExchangeToken failure to a client-facing
|
||||
// 401 without leaking internal detail. huma serializes extra error args into the
|
||||
// response body, and the wrapped err here can include the raw upstream GitHub
|
||||
// response body captured by readErrorBody — so passing it to huma would echo that
|
||||
// detail back to the caller (CWE-209). We log it server-side (like the sibling
|
||||
// handlers in servers.go) and return only a generic message.
|
||||
func tokenExchangeError(err error) error {
|
||||
log.Printf("github-at token exchange failed: %v", err)
|
||||
return huma.Error401Unauthorized("Token exchange failed")
|
||||
}
|
||||
|
||||
// ExchangeToken exchanges a GitHub OAuth token for a Registry JWT token
|
||||
func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) (*auth.TokenResponse, error) {
|
||||
// Get GitHub user information
|
||||
|
|
@ -74,8 +86,9 @@ func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) (
|
|||
return nil, fmt.Errorf("failed to get GitHub user: %w", err)
|
||||
}
|
||||
|
||||
// Get user's organizations
|
||||
orgs, err := h.getGitHubUserOrgs(ctx, user.Login, githubToken)
|
||||
// Get the organizations the user administers. Org namespaces are only granted
|
||||
// to org Owners (membership role "admin"), not to ordinary members.
|
||||
orgs, err := h.getGitHubAdminOrgs(ctx, githubToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub organizations: %w", err)
|
||||
}
|
||||
|
|
@ -133,8 +146,87 @@ func (h *GitHubHandler) getGitHubUser(ctx context.Context, token string) (*GitHu
|
|||
return &user, nil
|
||||
}
|
||||
|
||||
func (h *GitHubHandler) getGitHubUserOrgs(ctx context.Context, username string, token string) ([]GitHubUserOrOrg, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/users/"+username+"/orgs", nil)
|
||||
// githubOrgRoleAdmin is the membership role GitHub returns for an organization
|
||||
// Owner. It is the only role we treat as carrying publish authority for the org
|
||||
// namespace. GET /user/memberships/orgs reports the caller's role as either
|
||||
// "admin" (Owner) or "member" (GitHub has no org-level "maintainer" role); only
|
||||
// "admin" carries publish authority, so a "member" is intentionally not granted.
|
||||
const githubOrgRoleAdmin = "admin"
|
||||
|
||||
// githubMembershipStateActive is the membership state for an accepted (as opposed
|
||||
// to merely invited/pending) org membership. We only honor active memberships so a
|
||||
// not-yet-accepted owner invitation never grants the org namespace.
|
||||
const githubMembershipStateActive = "active"
|
||||
|
||||
// orgMembershipsPageSize is the page size we request when listing the user's org
|
||||
// memberships. 100 is GitHub's maximum.
|
||||
const orgMembershipsPageSize = 100
|
||||
|
||||
// maxOrgMembershipPages bounds the pagination loop. At orgMembershipsPageSize=100
|
||||
// this covers 10,000 org memberships — far beyond any real user — and exists only
|
||||
// as a backstop so a misbehaving or redirected upstream cannot drive an unbounded
|
||||
// number of requests.
|
||||
const maxOrgMembershipPages = 100
|
||||
|
||||
// githubOrgMembership is one entry from GET /user/memberships/orgs: the
|
||||
// authenticated user's membership in a single organization, including their role.
|
||||
type githubOrgMembership struct {
|
||||
State string `json:"state"`
|
||||
Role string `json:"role"`
|
||||
Organization GitHubUserOrOrg `json:"organization"`
|
||||
}
|
||||
|
||||
// getGitHubAdminOrgs returns the organizations in which the authenticated user is
|
||||
// an Owner (membership role "admin").
|
||||
//
|
||||
// This deliberately uses GET /user/memberships/orgs rather than
|
||||
// GET /users/{username}/orgs. The latter only returns *public* memberships and
|
||||
// carries no role, so it cannot distinguish an Owner from an ordinary member —
|
||||
// historically every org a user belonged to was granted publish access to its
|
||||
// namespace regardless of the user's role. The memberships endpoint returns the
|
||||
// caller's role per org (and includes private memberships), letting us grant the
|
||||
// org namespace only to people who actually administer the org.
|
||||
//
|
||||
// The endpoint requires the read:org scope. A minimal token used only for
|
||||
// personal-namespace publishing won't have it and will get a 403 here; that is
|
||||
// treated as "no admin orgs" (see fetchOrgMembershipsPage) so personal publishing
|
||||
// keeps working without asking users to over-scope their token.
|
||||
func (h *GitHubHandler) getGitHubAdminOrgs(ctx context.Context, token string) ([]GitHubUserOrOrg, error) {
|
||||
var adminOrgs []GitHubUserOrOrg
|
||||
|
||||
for page := 1; page <= maxOrgMembershipPages; page++ {
|
||||
memberships, err := h.fetchOrgMembershipsPage(ctx, token, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range memberships {
|
||||
// state=active is already requested in the query string, but re-check it
|
||||
// here as defense in depth: if that param is ever dropped or changed, this
|
||||
// keeps a pending (not-yet-accepted) owner invitation from being granted
|
||||
// the org namespace.
|
||||
if m.State == githubMembershipStateActive && m.Role == githubOrgRoleAdmin {
|
||||
adminOrgs = append(adminOrgs, m.Organization)
|
||||
}
|
||||
}
|
||||
|
||||
// A short page is the last page: return the complete result.
|
||||
if len(memberships) < orgMembershipsPageSize {
|
||||
return adminOrgs, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Every page up to the cap was full. A real user never has this many org
|
||||
// memberships, so rather than return a possibly-truncated set (which would
|
||||
// silently strip a legitimate Owner's grant), fail closed.
|
||||
return nil, fmt.Errorf("org memberships exceeded %d pages; refusing to issue a possibly-truncated grant", maxOrgMembershipPages)
|
||||
}
|
||||
|
||||
// fetchOrgMembershipsPage fetches a single page of the authenticated user's
|
||||
// active organization memberships.
|
||||
func (h *GitHubHandler) fetchOrgMembershipsPage(ctx context.Context, token string, page int) ([]githubOrgMembership, error) {
|
||||
url := fmt.Sprintf("%s/user/memberships/orgs?state=active&per_page=%d&page=%d", h.baseURL, orgMembershipsPageSize, page)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
|
@ -145,44 +237,69 @@ func (h *GitHubHandler) getGitHubUserOrgs(ctx context.Context, username string,
|
|||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user organizations: %w", err)
|
||||
return nil, fmt.Errorf("failed to get user organization memberships: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// GitHub returns 403 here for two very different reasons, which we must not
|
||||
// conflate:
|
||||
// 1. The token lacks the read:org scope. This is the common, benign case for a
|
||||
// minimal personal-publishing token: GET /user still works, but org
|
||||
// memberships are forbidden. We degrade gracefully to "no admin orgs" so
|
||||
// personal-namespace publishing keeps working without over-scoping.
|
||||
// 2. Rate limiting. GitHub signals primary/secondary rate limits with 403 (or
|
||||
// 429), setting X-RateLimit-Remaining: 0 and/or Retry-After. Degrading here
|
||||
// would silently strip a legitimate Owner's org grant, so we fail closed
|
||||
// (return an error) rather than mistake a throttle for "not an admin".
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
if resp.Header.Get("X-RateLimit-Remaining") == "0" || resp.Header.Get("Retry-After") != "" {
|
||||
return nil, fmt.Errorf("GitHub API rate limit exceeded while listing org memberships (status 403): %s", readErrorBody(resp.Body))
|
||||
}
|
||||
// A SAML/SSO-enforced org returns 403 with an X-GitHub-SSO header when the
|
||||
// token has not been SSO-authorized. That is an Owner being blocked, not a
|
||||
// missing scope, so fail closed rather than silently dropping the org grant
|
||||
// (which would degrade a legitimate Owner to personal-only with no signal).
|
||||
if resp.Header.Get("X-GitHub-SSO") != "" {
|
||||
return nil, fmt.Errorf("GitHub org memberships require SSO authorization for this token (status 403): %s", readErrorBody(resp.Body))
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, readErrorBody(resp.Body))
|
||||
}
|
||||
|
||||
var orgs []GitHubUserOrOrg
|
||||
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode organizations response: %w", err)
|
||||
var memberships []githubOrgMembership
|
||||
if err := json.NewDecoder(resp.Body).Decode(&memberships); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode organization memberships response: %w", err)
|
||||
}
|
||||
|
||||
return orgs, nil
|
||||
return memberships, nil
|
||||
}
|
||||
|
||||
// buildPermissions builds permissions based on GitHub user and their organizations
|
||||
func (h *GitHubHandler) buildPermissions(username string, orgs []GitHubUserOrOrg) []auth.Permission {
|
||||
permissions := []auth.Permission{}
|
||||
|
||||
// Assert user and org names match expected regex, to harden against people doing weird things in names
|
||||
// Assert the username matches the expected regex, to harden against people doing
|
||||
// weird things in names. The username is the caller's own identity, so if it is
|
||||
// invalid we grant nothing.
|
||||
if !isValidGitHubName(username) {
|
||||
return nil
|
||||
}
|
||||
for _, org := range orgs {
|
||||
if !isValidGitHubName(org.Login) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Add permission for user's own namespace
|
||||
permissions = append(permissions, auth.Permission{
|
||||
permissions := []auth.Permission{{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: fmt.Sprintf("io.github.%s/*", username),
|
||||
})
|
||||
}}
|
||||
|
||||
// Add permissions for each organization
|
||||
// Add permissions for each organization the user administers (Owner role). Skip
|
||||
// any org whose name fails validation rather than rejecting the whole set, so one
|
||||
// unexpected org name can't strip the caller's personal namespace or their other
|
||||
// (valid) org grants.
|
||||
for _, org := range orgs {
|
||||
if !isValidGitHubName(org.Login) {
|
||||
continue
|
||||
}
|
||||
permissions = append(permissions, auth.Permission{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: fmt.Sprintf("io.github.%s/*", org.Login),
|
||||
|
|
|
|||
33
internal/api/handlers/v0/auth/github_at_internal_test.go
Normal file
33
internal/api/handlers/v0/auth/github_at_internal_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The 401 returned to the client must not echo the internal error, which can
|
||||
// include the raw upstream GitHub response body captured by readErrorBody
|
||||
// (CWE-209). huma serializes extra error args into the response body, so the fix
|
||||
// is to log server-side and return only a generic message. This mirrors the
|
||||
// regression test added for the GET /v0/servers 500 leak (#1338).
|
||||
func TestTokenExchangeError_doesNotLeakDetail(t *testing.T) {
|
||||
const internal = `failed to get GitHub organizations: GitHub API error (status 500): {"message":"internal-host db detail"}`
|
||||
|
||||
err := tokenExchangeError(errors.New(internal))
|
||||
require.Error(t, err)
|
||||
|
||||
var se huma.StatusError
|
||||
require.True(t, errors.As(err, &se))
|
||||
assert.Equal(t, 401, se.GetStatus())
|
||||
|
||||
body, marshalErr := json.Marshal(err)
|
||||
require.NoError(t, marshalErr)
|
||||
assert.NotContains(t, string(body), "internal-host db detail")
|
||||
assert.NotContains(t, string(body), "status 500")
|
||||
assert.Contains(t, string(body), "Token exchange failed")
|
||||
}
|
||||
|
|
@ -22,9 +22,82 @@ import (
|
|||
|
||||
const (
|
||||
githubUserEndpoint = "/user"
|
||||
githubOrgsEndpoint = "/users/testuser/orgs"
|
||||
// githubOrgsEndpoint is the endpoint used to list the authenticated user's org
|
||||
// memberships (with role). It replaces the old public-only /users/{user}/orgs.
|
||||
githubOrgsEndpoint = "/user/memberships/orgs"
|
||||
)
|
||||
|
||||
// orgMembership mirrors a single entry of GET /user/memberships/orgs for use in
|
||||
// mock GitHub API responses. Role is "admin" (Owner) or "member".
|
||||
type orgMembership struct {
|
||||
State string `json:"state"`
|
||||
Role string `json:"role"`
|
||||
Organization v0auth.GitHubUserOrOrg `json:"organization"`
|
||||
}
|
||||
|
||||
// adminMemberships wraps orgs as active "admin"-role memberships, the shape the
|
||||
// memberships endpoint returns for an org Owner.
|
||||
func adminMemberships(orgs []v0auth.GitHubUserOrOrg) []orgMembership {
|
||||
memberships := make([]orgMembership, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
memberships = append(memberships, orgMembership{State: "active", Role: "admin", Organization: org})
|
||||
}
|
||||
return memberships
|
||||
}
|
||||
|
||||
// newMockGitHubServer returns a mock GitHub API server that serves a fixed
|
||||
// "testuser" on /user and delegates the org-memberships endpoint to orgsHandler.
|
||||
// It keeps individual test cases small (only the memberships behavior varies),
|
||||
// which also keeps their cyclomatic complexity down.
|
||||
func newMockGitHubServer(orgsHandler http.HandlerFunc) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{Login: "testuser", ID: 12345}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgsHandler(w, r)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// grantedPatterns exchanges the token and returns the resource patterns granted
|
||||
// in the resulting JWT.
|
||||
func grantedPatterns(t *testing.T, cfg *config.Config, server *httptest.Server) []string {
|
||||
t.Helper()
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(server.URL)
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
claims, err := auth.NewJWTManager(cfg).ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
patterns := make([]string, 0, len(claims.Permissions))
|
||||
for _, perm := range claims.Permissions {
|
||||
patterns = append(patterns, perm.ResourcePattern)
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
// assertExchangeFailsClosed asserts that a token exchange against server returns
|
||||
// an error and no token (used for the fail-closed 403 variants).
|
||||
func assertExchangeFailsClosed(t *testing.T, cfg *config.Config, server *httptest.Server) {
|
||||
t.Helper()
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(server.URL)
|
||||
|
||||
response, err := handler.ExchangeToken(context.Background(), "valid-github-token")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
}
|
||||
|
||||
func TestGitHubHandler_ExchangeToken(t *testing.T) {
|
||||
// Create test handler with mock config
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
|
|
@ -96,12 +169,12 @@ func TestGitHubHandler_ExchangeToken(t *testing.T) {
|
|||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{
|
||||
memberships := adminMemberships([]v0auth.GitHubUserOrOrg{
|
||||
{Login: "test-org-1", ID: 1},
|
||||
{Login: "test-org-2", ID: 2},
|
||||
}
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
json.NewEncoder(w).Encode(memberships) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
|
@ -225,10 +298,9 @@ func TestGitHubHandler_ExchangeToken(t *testing.T) {
|
|||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case "/users/user with spaces/orgs":
|
||||
orgs := []v0auth.GitHubUserOrOrg{}
|
||||
case githubOrgsEndpoint:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
json.NewEncoder(w).Encode([]orgMembership{}) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
|
@ -264,12 +336,12 @@ func TestGitHubHandler_ExchangeToken(t *testing.T) {
|
|||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{
|
||||
memberships := adminMemberships([]v0auth.GitHubUserOrOrg{
|
||||
{Login: "valid-org", ID: 1},
|
||||
{Login: "org with spaces", ID: 2}, // Invalid name
|
||||
}
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
json.NewEncoder(w).Encode(memberships) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
|
@ -290,7 +362,15 @@ func TestGitHubHandler_ExchangeToken(t *testing.T) {
|
|||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
||||
assert.Empty(t, claims.Permissions) // No permissions because one org has invalid name
|
||||
|
||||
// The invalid org is skipped, but the personal namespace and the valid org
|
||||
// are still granted — one weird org name must not strip everything else.
|
||||
patterns := make([]string, 0, len(claims.Permissions))
|
||||
for _, perm := range claims.Permissions {
|
||||
patterns = append(patterns, perm.ResourcePattern)
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"io.github.testuser/*", "io.github.valid-org/*"}, patterns)
|
||||
assert.NotContains(t, patterns, "io.github.org with spaces/*")
|
||||
})
|
||||
|
||||
t.Run("malformed JSON response", func(t *testing.T) {
|
||||
|
|
@ -317,6 +397,274 @@ func TestGitHubHandler_ExchangeToken(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestGitHubHandler_ExchangeToken_OrgRoles(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
t.Run("member-role org is not granted, only admin orgs", func(t *testing.T) {
|
||||
// The user is an Owner (admin) of one org and an ordinary member of another.
|
||||
// Only the org they administer should yield a publish permission.
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{Login: "testuser", ID: 12345}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
memberships := []orgMembership{
|
||||
{State: "active", Role: "admin", Organization: v0auth.GitHubUserOrOrg{Login: "admin-org", ID: 1}},
|
||||
{State: "active", Role: "member", Organization: v0auth.GitHubUserOrOrg{Login: "member-org", ID: 2}},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(memberships) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
patterns := make([]string, 0, len(claims.Permissions))
|
||||
for _, perm := range claims.Permissions {
|
||||
patterns = append(patterns, perm.ResourcePattern)
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"io.github.testuser/*", "io.github.admin-org/*"}, patterns)
|
||||
assert.NotContains(t, patterns, "io.github.member-org/*")
|
||||
})
|
||||
|
||||
t.Run("missing read:org scope (403) still grants personal namespace", func(t *testing.T) {
|
||||
// A minimal token without read:org authenticates fine but is forbidden from
|
||||
// reading org memberships. Personal-namespace publishing must still work.
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{Login: "testuser", ID: 12345}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"message": "Token does not have the required scope"}`)) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, claims.Permissions, 1)
|
||||
assert.Equal(t, "io.github.testuser/*", claims.Permissions[0].ResourcePattern)
|
||||
})
|
||||
|
||||
t.Run("admin org on a later page is still granted (pagination)", func(t *testing.T) {
|
||||
// Page 1 is a full page (100) of member-role orgs; the only admin org is on
|
||||
// page 2. The pagination loop must not stop at the first page, or the Owner's
|
||||
// org grant would be silently dropped.
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{Login: "testuser", ID: 12345}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Query().Get("page") == "1" {
|
||||
page1 := make([]orgMembership, 0, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
page1 = append(page1, orgMembership{
|
||||
State: "active", Role: "member",
|
||||
Organization: v0auth.GitHubUserOrOrg{Login: fmt.Sprintf("member-org-%d", i), ID: 1000 + i},
|
||||
})
|
||||
}
|
||||
json.NewEncoder(w).Encode(page1) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
// page 2: a single admin org, signalling the last (short) page.
|
||||
page2 := []orgMembership{
|
||||
{State: "active", Role: "admin", Organization: v0auth.GitHubUserOrOrg{Login: "late-admin-org", ID: 2}},
|
||||
}
|
||||
json.NewEncoder(w).Encode(page2) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
patterns := make([]string, 0, len(claims.Permissions))
|
||||
for _, perm := range claims.Permissions {
|
||||
patterns = append(patterns, perm.ResourcePattern)
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"io.github.testuser/*", "io.github.late-admin-org/*"}, patterns)
|
||||
})
|
||||
|
||||
t.Run("memberships server error fails closed (no token issued)", func(t *testing.T) {
|
||||
// A 5xx (or any non-200, non-scope-403) from the memberships endpoint must
|
||||
// abort the exchange rather than silently degrade to personal-only perms.
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{Login: "testuser", ID: 12345}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
})
|
||||
|
||||
t.Run("rate-limit 403 fails closed (not treated as missing scope)", func(t *testing.T) {
|
||||
// A 403 with X-RateLimit-Remaining: 0 is a throttle, not a missing-scope
|
||||
// signal. Degrading would strip a real Owner's org grant, so it must error.
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{Login: "testuser", ID: 12345}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"message": "API rate limit exceeded"}`)) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitHubHandler_ExchangeToken_MembershipFiltering(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{JWTPrivateKey: hex.EncodeToString(testSeed)}
|
||||
|
||||
t.Run("pending admin membership is not granted (state filter)", func(t *testing.T) {
|
||||
// A user invited as an Owner but who has not accepted has role "admin" with
|
||||
// state "pending". They are not an Owner yet, so the org must not be granted
|
||||
// even if the state=active query filter is somehow bypassed.
|
||||
server := newMockGitHubServer(func(w http.ResponseWriter, _ *http.Request) {
|
||||
memberships := []orgMembership{
|
||||
{State: "pending", Role: "admin", Organization: v0auth.GitHubUserOrOrg{Login: "pending-org", ID: 1}},
|
||||
{State: "active", Role: "admin", Organization: v0auth.GitHubUserOrOrg{Login: "active-org", ID: 2}},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(memberships) //nolint:errcheck
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
patterns := grantedPatterns(t, cfg, server)
|
||||
assert.ElementsMatch(t, []string{"io.github.testuser/*", "io.github.active-org/*"}, patterns)
|
||||
assert.NotContains(t, patterns, "io.github.pending-org/*")
|
||||
})
|
||||
|
||||
t.Run("billing_manager role is not granted", func(t *testing.T) {
|
||||
server := newMockGitHubServer(func(w http.ResponseWriter, _ *http.Request) {
|
||||
memberships := []orgMembership{
|
||||
{State: "active", Role: "billing_manager", Organization: v0auth.GitHubUserOrOrg{Login: "billing-org", ID: 1}},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(memberships) //nolint:errcheck
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
patterns := grantedPatterns(t, cfg, server)
|
||||
assert.Equal(t, []string{"io.github.testuser/*"}, patterns)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitHubHandler_ExchangeToken_403FailClosed(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{JWTPrivateKey: hex.EncodeToString(testSeed)}
|
||||
|
||||
t.Run("Retry-After 403 fails closed (secondary rate limit)", func(t *testing.T) {
|
||||
// A 403 carrying only Retry-After (no X-RateLimit-Remaining: 0) is a secondary
|
||||
// rate limit, not a missing scope. Degrading would strip a real Owner's grant.
|
||||
server := newMockGitHubServer(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"message": "You have exceeded a secondary rate limit"}`)) //nolint:errcheck
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
assertExchangeFailsClosed(t, cfg, server)
|
||||
})
|
||||
|
||||
t.Run("SSO-enforced 403 fails closed (not treated as missing scope)", func(t *testing.T) {
|
||||
// A SAML/SSO-enforced org returns 403 with X-GitHub-SSO when the token is not
|
||||
// SSO-authorized. That is an Owner being blocked, so it must fail closed rather
|
||||
// than silently degrade to personal-only.
|
||||
server := newMockGitHubServer(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-GitHub-SSO", "required; two_factor_authentication=false")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"message": "Resource protected by organization SAML enforcement."}`)) //nolint:errcheck
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
assertExchangeFailsClosed(t, cfg, server)
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWTTokenValidation(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
|
|
@ -521,7 +869,16 @@ func TestValidGitHubNames(t *testing.T) {
|
|||
orgs: []v0auth.GitHubUserOrOrg{
|
||||
{Login: "invalid org", ID: 1},
|
||||
},
|
||||
wantPerms: 0, // Should return nil if any name is invalid
|
||||
wantPerms: 1, // Personal namespace kept; the invalid org is skipped
|
||||
},
|
||||
{
|
||||
name: "valid username with one valid and one invalid org",
|
||||
username: "valid-user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{
|
||||
{Login: "valid-org", ID: 1},
|
||||
{Login: "invalid org", ID: 2},
|
||||
},
|
||||
wantPerms: 2, // Personal + valid org; the invalid org is skipped
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -537,9 +894,9 @@ func TestValidGitHubNames(t *testing.T) {
|
|||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case "/users/" + tc.username + "/orgs":
|
||||
case githubOrgsEndpoint:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tc.orgs) //nolint:errcheck
|
||||
json.NewEncoder(w).Encode(adminMemberships(tc.orgs)) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
|
|
|||
Loading…
Reference in a new issue