Follow-up to #1383, surfaced while validating that change end-to-end against staging. ## Why #1383 hardened org-namespace publishing to require Owner (`admin`) role, read via `GET /user/memberships/orgs`. Validating on staging revealed a gap: the docs only mention the classic `read:org` scope, but a **fine-grained PAT** (GitHub's recommended token type) needs a *different* permission to expose org membership. Without it, GitHub returns no org memberships for the token, so the exchange silently grants only the personal namespace — with no hint why org publishing "doesn't work." This is **not** a code bug (the behavior is fail-closed and correct given GitHub's response); it's a documentation/UX gap. ## What - **Docs** (`authentication.mdx`, `github-actions.mdx`): spell out both token types — - Classic PAT → `read:org` scope - Fine-grained PAT → **Organization permissions → Members → Read-only** (the equivalent), noting a fine-grained PAT is bound to a single resource owner. - Also clarified `read:user` is not required. - **Test** (`github_at_test.go`): cover the `maxOrgMembershipPages` pagination cap — when every page is full, the exchange **fails closed** (no token) rather than returning a possibly-truncated org grant. This path was previously untested. - **Comment** (`github_at.go`): qualify the `X-GitHub-SSO` 403 check as best-effort — we have not confirmed the memberships *list* endpoint emits that header on SSO enforcement (it may just omit the org). The check is safe either way, but the comment no longer overstates it as a guarantee. ## Verification (live, against staging) Confirmed by exchanging real tokens for `rdimitrov` on `staging.registry.modelcontextprotocol.io`: - **Classic `read:org` PAT** across 16 real org memberships → the JWT granted exactly the 8 **admin** orgs + personal namespace, and **zero** of the 8 **member** orgs (incl. `stacklok`, `modelcontextprotocol`) — the core #1383 behavior, working. - **Fine-grained PAT**: without Members:read → personal namespace only (fail-closed); after adding Members:read → the org (Owner) namespace is granted. This is what the new docs describe. No runtime behavior change here beyond the added test; the docs and comment are the substance. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1011 lines
33 KiB
Go
1011 lines
33 KiB
Go
package auth_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
v0auth "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
|
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
|
"github.com/modelcontextprotocol/registry/internal/config"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
githubUserEndpoint = "/user"
|
|
// 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)
|
|
_, err := rand.Read(testSeed)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: hex.EncodeToString(testSeed),
|
|
}
|
|
|
|
t.Run("successful token exchange with user only", func(t *testing.T) {
|
|
// Create mock GitHub API server
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify authorization header
|
|
authHeader := r.Header.Get("Authorization")
|
|
assert.Equal(t, "Bearer valid-github-token", authHeader)
|
|
|
|
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:
|
|
orgs := []v0auth.GitHubUserOrOrg{}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
|
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, response)
|
|
assert.NotEmpty(t, response.RegistryToken)
|
|
assert.Greater(t, response.ExpiresAt, 0)
|
|
|
|
// Validate the JWT token
|
|
jwtManager := auth.NewJWTManager(cfg)
|
|
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, auth.MethodGitHubAT, claims.AuthMethod)
|
|
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
|
assert.Len(t, claims.Permissions, 1)
|
|
assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action)
|
|
assert.Equal(t, "io.github.testuser/*", claims.Permissions[0].ResourcePattern)
|
|
})
|
|
|
|
t.Run("successful token exchange with organizations", func(t *testing.T) {
|
|
// Create mock GitHub API server
|
|
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 := 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(memberships) //nolint:errcheck
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
|
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, response)
|
|
|
|
// Validate the JWT token
|
|
jwtManager := auth.NewJWTManager(cfg)
|
|
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
|
assert.Len(t, claims.Permissions, 3) // User + 2 orgs
|
|
|
|
// Check permissions
|
|
expectedPatterns := []string{
|
|
"io.github.testuser/*",
|
|
"io.github.test-org-1/*",
|
|
"io.github.test-org-2/*",
|
|
}
|
|
for i, perm := range claims.Permissions {
|
|
assert.Equal(t, auth.PermissionActionPublish, perm.Action)
|
|
assert.Equal(t, expectedPatterns[i], perm.ResourcePattern)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid token returns error", func(t *testing.T) {
|
|
// Create mock GitHub API server that returns 401
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"message": "Bad credentials"}`)) //nolint:errcheck
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "invalid-token")
|
|
|
|
require.Error(t, err)
|
|
assert.Nil(t, response)
|
|
assert.Contains(t, err.Error(), "GitHub API error")
|
|
assert.Contains(t, err.Error(), "401")
|
|
})
|
|
|
|
t.Run("GitHub API error on user fetch", func(t *testing.T) {
|
|
// Create mock GitHub API server that returns 500
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == githubUserEndpoint {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`{"message": "Internal server error"}`)) //nolint:errcheck
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "valid-token")
|
|
|
|
require.Error(t, err)
|
|
assert.Nil(t, response)
|
|
assert.Contains(t, err.Error(), "failed to get GitHub user")
|
|
})
|
|
|
|
t.Run("GitHub API error on orgs fetch", func(t *testing.T) {
|
|
// Create mock GitHub API server
|
|
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)
|
|
w.Write([]byte(`{"message": "Internal server error"}`)) //nolint:errcheck
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "valid-token")
|
|
|
|
require.Error(t, err)
|
|
assert.Nil(t, response)
|
|
assert.Contains(t, err.Error(), "failed to get GitHub organizations")
|
|
})
|
|
|
|
t.Run("invalid GitHub username returns empty permissions", func(t *testing.T) {
|
|
// Create mock GitHub API server with invalid username
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case githubUserEndpoint:
|
|
user := v0auth.GitHubUserOrOrg{
|
|
Login: "user with spaces", // Invalid name
|
|
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")
|
|
json.NewEncoder(w).Encode([]orgMembership{}) //nolint:errcheck
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "valid-token")
|
|
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, response)
|
|
|
|
// Validate the JWT token
|
|
jwtManager := auth.NewJWTManager(cfg)
|
|
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "user with spaces", claims.AuthMethodSubject)
|
|
assert.Empty(t, claims.Permissions) // No permissions due to invalid name
|
|
})
|
|
|
|
t.Run("invalid org name is filtered out", func(t *testing.T) {
|
|
// Create mock GitHub API server with invalid org name
|
|
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 := 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(memberships) //nolint:errcheck
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "valid-token")
|
|
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, response)
|
|
|
|
// Validate the JWT token
|
|
jwtManager := auth.NewJWTManager(cfg)
|
|
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
|
|
|
// 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) {
|
|
// Create mock GitHub API server that returns invalid JSON
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == githubUserEndpoint {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{invalid json`)) //nolint:errcheck
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "valid-token")
|
|
|
|
require.Error(t, err)
|
|
assert.Nil(t, response)
|
|
assert.Contains(t, err.Error(), "failed to decode")
|
|
})
|
|
}
|
|
|
|
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 TestGitHubHandler_ExchangeToken_PaginationCap(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("all-full pages past the cap fails closed", func(t *testing.T) {
|
|
// Every page comes back full (100), so the loop never sees a short final page
|
|
// and runs to maxOrgMembershipPages. Rather than return a possibly-truncated
|
|
// org set, the exchange must fail closed (error, no token).
|
|
server := newMockGitHubServer(func(w http.ResponseWriter, _ *http.Request) {
|
|
page := make([]orgMembership, 0, 100)
|
|
for i := 0; i < 100; i++ {
|
|
page = append(page, orgMembership{
|
|
State: "active", Role: "member",
|
|
Organization: v0auth.GitHubUserOrOrg{Login: fmt.Sprintf("org-%d", i), ID: i},
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(page) //nolint:errcheck
|
|
})
|
|
defer server.Close()
|
|
|
|
assertExchangeFailsClosed(t, cfg, server)
|
|
})
|
|
}
|
|
|
|
func TestJWTTokenValidation(t *testing.T) {
|
|
testSeed := make([]byte, ed25519.SeedSize)
|
|
_, err := rand.Read(testSeed)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: hex.EncodeToString(testSeed),
|
|
}
|
|
|
|
jwtManager := auth.NewJWTManager(cfg)
|
|
ctx := context.Background()
|
|
|
|
t.Run("generate and validate token", func(t *testing.T) {
|
|
// Create test claims
|
|
claims := auth.JWTClaims{
|
|
AuthMethod: auth.MethodGitHubAT,
|
|
AuthMethodSubject: "testuser",
|
|
Permissions: []auth.Permission{
|
|
{
|
|
Action: auth.PermissionActionPublish,
|
|
ResourcePattern: "io.github.testuser/*",
|
|
},
|
|
},
|
|
}
|
|
|
|
// Generate token
|
|
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, tokenResponse.RegistryToken)
|
|
|
|
// Validate token
|
|
validatedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, auth.MethodGitHubAT, validatedClaims.AuthMethod)
|
|
assert.Equal(t, "testuser", validatedClaims.AuthMethodSubject)
|
|
assert.Len(t, validatedClaims.Permissions, 1)
|
|
})
|
|
|
|
t.Run("token expiration", func(t *testing.T) {
|
|
// Create claims with past expiration
|
|
pastTime := time.Now().Add(-1 * time.Hour)
|
|
claims := auth.JWTClaims{
|
|
AuthMethod: auth.MethodGitHubAT,
|
|
AuthMethodSubject: "testuser",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(pastTime),
|
|
IssuedAt: jwt.NewNumericDate(pastTime.Add(-1 * time.Hour)),
|
|
},
|
|
}
|
|
|
|
// Generate token
|
|
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
|
require.NoError(t, err)
|
|
|
|
// Validate token - should fail due to expiration
|
|
_, err = jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "token is expired")
|
|
})
|
|
|
|
t.Run("invalid signature", func(t *testing.T) {
|
|
// Create test claims
|
|
claims := auth.JWTClaims{
|
|
AuthMethod: auth.MethodGitHubAT,
|
|
AuthMethodSubject: "testuser",
|
|
}
|
|
|
|
// Generate token
|
|
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
|
require.NoError(t, err)
|
|
|
|
// Tamper with the token
|
|
tamperedToken := tokenResponse.RegistryToken + "tampered"
|
|
|
|
// Validate token - should fail due to invalid signature
|
|
_, err = jwtManager.ValidateToken(ctx, tamperedToken)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestPermissionResourceMatching(t *testing.T) {
|
|
testSeed := make([]byte, ed25519.SeedSize)
|
|
_, err := rand.Read(testSeed)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: hex.EncodeToString(testSeed),
|
|
}
|
|
|
|
jwtManager := auth.NewJWTManager(cfg)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
resource string
|
|
pattern string
|
|
action auth.PermissionAction
|
|
expectedMatch bool
|
|
}{
|
|
{
|
|
name: "exact match",
|
|
resource: "io.github.testuser/myrepo",
|
|
pattern: "io.github.testuser/myrepo",
|
|
action: auth.PermissionActionPublish,
|
|
expectedMatch: true,
|
|
},
|
|
{
|
|
name: "wildcard match",
|
|
resource: "io.github.testuser/myrepo",
|
|
pattern: "io.github.testuser/*",
|
|
action: auth.PermissionActionPublish,
|
|
expectedMatch: true,
|
|
},
|
|
{
|
|
name: "global wildcard",
|
|
resource: "io.github.anyuser/anyrepo",
|
|
pattern: "*",
|
|
action: auth.PermissionActionPublish,
|
|
expectedMatch: true,
|
|
},
|
|
{
|
|
name: "no match different user",
|
|
resource: "io.github.otheruser/repo",
|
|
pattern: "io.github.testuser/*",
|
|
action: auth.PermissionActionPublish,
|
|
expectedMatch: false,
|
|
},
|
|
{
|
|
name: "no match different action",
|
|
resource: "io.github.testuser/repo",
|
|
pattern: "io.github.testuser/*",
|
|
action: auth.PermissionActionEdit,
|
|
expectedMatch: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
permissions := []auth.Permission{
|
|
{
|
|
Action: auth.PermissionActionPublish,
|
|
ResourcePattern: tc.pattern,
|
|
},
|
|
}
|
|
|
|
hasPermission := jwtManager.HasPermission(tc.resource, tc.action, permissions)
|
|
assert.Equal(t, tc.expectedMatch, hasPermission)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidGitHubNames(t *testing.T) {
|
|
// Create a minimal handler to test name validation
|
|
testSeed := make([]byte, ed25519.SeedSize)
|
|
_, err := rand.Read(testSeed)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: hex.EncodeToString(testSeed),
|
|
}
|
|
|
|
validNameTests := []struct {
|
|
name string
|
|
username string
|
|
orgs []v0auth.GitHubUserOrOrg
|
|
wantPerms int
|
|
}{
|
|
{
|
|
name: "valid username only",
|
|
username: "valid-user",
|
|
orgs: []v0auth.GitHubUserOrOrg{},
|
|
wantPerms: 1,
|
|
},
|
|
{
|
|
name: "valid username with numbers",
|
|
username: "user123",
|
|
orgs: []v0auth.GitHubUserOrOrg{},
|
|
wantPerms: 1,
|
|
},
|
|
{
|
|
name: "valid username with org",
|
|
username: "valid-user",
|
|
orgs: []v0auth.GitHubUserOrOrg{
|
|
{Login: "valid-org", ID: 1},
|
|
},
|
|
wantPerms: 2,
|
|
},
|
|
{
|
|
name: "invalid username with spaces",
|
|
username: "invalid user",
|
|
orgs: []v0auth.GitHubUserOrOrg{},
|
|
wantPerms: 0, // Should return nil/empty permissions
|
|
},
|
|
{
|
|
name: "invalid username with special chars",
|
|
username: "user@invalid",
|
|
orgs: []v0auth.GitHubUserOrOrg{},
|
|
wantPerms: 0,
|
|
},
|
|
{
|
|
name: "valid username with invalid org",
|
|
username: "valid-user",
|
|
orgs: []v0auth.GitHubUserOrOrg{
|
|
{Login: "invalid org", ID: 1},
|
|
},
|
|
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
|
|
},
|
|
}
|
|
|
|
for _, tc := range validNameTests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Create mock server
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/user":
|
|
user := v0auth.GitHubUserOrOrg{
|
|
Login: tc.username,
|
|
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")
|
|
json.NewEncoder(w).Encode(adminMemberships(tc.orgs)) //nolint:errcheck
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
// Create handler and set mock server URL
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Test token exchange
|
|
ctx := context.Background()
|
|
response, err := handler.ExchangeToken(ctx, "valid-token")
|
|
require.NoError(t, err)
|
|
|
|
// Validate the JWT token and check permissions
|
|
jwtManager := auth.NewJWTManager(cfg)
|
|
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
|
require.NoError(t, err)
|
|
assert.Len(t, claims.Permissions, tc.wantPerms)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubHandler_Creation(t *testing.T) {
|
|
testSeed := make([]byte, ed25519.SeedSize)
|
|
_, err := rand.Read(testSeed)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: hex.EncodeToString(testSeed),
|
|
}
|
|
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
assert.NotNil(t, handler, "handler should not be nil")
|
|
}
|
|
|
|
func TestConcurrentTokenExchange(t *testing.T) {
|
|
// Test that the handler is thread-safe
|
|
testSeed := make([]byte, ed25519.SeedSize)
|
|
_, err := rand.Read(testSeed)
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
JWTPrivateKey: hex.EncodeToString(testSeed),
|
|
}
|
|
|
|
// Create mock GitHub API server
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/user":
|
|
user := v0auth.GitHubUserOrOrg{
|
|
Login: "testuser",
|
|
ID: 12345,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
|
case githubOrgsEndpoint:
|
|
orgs := []v0auth.GitHubUserOrOrg{}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
|
}
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
handler := v0auth.NewGitHubHandler(cfg)
|
|
handler.SetBaseURL(mockServer.URL)
|
|
|
|
// Run multiple concurrent exchanges
|
|
concurrency := 10
|
|
errors := make(chan error, concurrency)
|
|
|
|
for i := 0; i < concurrency; i++ {
|
|
go func() {
|
|
ctx := context.Background()
|
|
_, err := handler.ExchangeToken(ctx, fmt.Sprintf("token-%d", i))
|
|
errors <- err
|
|
}()
|
|
}
|
|
|
|
// Collect results
|
|
for i := 0; i < concurrency; i++ {
|
|
err := <-errors
|
|
assert.NoError(t, err)
|
|
}
|
|
}
|