auth: Add GitHub OIDC auth (#278)

Adds support for auth'ing to the registry via [GitHub Actions
OIDC](https://docs.github.com/en/actions/concepts/security/openid-connect).

Fixes #271

## Motivation and Context

People will probably want to publish to the registry from CI
environments. DNS auth can work here fine
(https://github.com/modelcontextprotocol/registry/issues/270). But for
people who haven't set up DNS, the standard GitHub device flow won't
really work here. We could make this nice by supporting GitHub Actions
OIDC.

## How Has This Been Tested?

Tested locally with a real GitHub OIDC token from a personal repo.

## Breaking Changes

None

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
This commit is contained in:
adam jones 2025-08-18 22:05:06 +01:00 • committed by GitHub
commit 12ab632d32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 802 additions and 193 deletions

View file

@ -0,0 +1,200 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"github.com/danielgtaylor/huma/v2"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/model"
)
// GitHubTokenExchangeInput represents the input for GitHub token exchange
type GitHubTokenExchangeInput struct {
Body struct {
GitHubToken string `json:"github_token" doc:"GitHub OAuth token" required:"true"`
}
}
// GitHubHandler handles GitHub authentication
type GitHubHandler struct {
config *config.Config
jwtManager *auth.JWTManager
baseURL string // Configurable for testing
}
// NewGitHubHandler creates a new GitHub handler
func NewGitHubHandler(cfg *config.Config) *GitHubHandler {
return &GitHubHandler{
config: cfg,
jwtManager: auth.NewJWTManager(cfg),
baseURL: "https://api.github.com",
}
}
// SetBaseURL sets the base URL for GitHub API (used for testing)
func (h *GitHubHandler) SetBaseURL(url string) {
h.baseURL = url
}
// RegisterGitHubATEndpoint registers the GitHub access token authentication endpoint
func RegisterGitHubATEndpoint(api huma.API, cfg *config.Config) {
handler := NewGitHubHandler(cfg)
// GitHub token exchange endpoint
huma.Register(api, huma.Operation{
OperationID: "exchange-github-token",
Method: http.MethodPost,
Path: "/v0/auth/github-at",
Summary: "Exchange GitHub OAuth access token for Registry JWT",
Description: "Exchange a GitHub OAuth access token for a short-lived Registry JWT token",
Tags: []string{"auth"},
}, 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 &v0.Response[auth.TokenResponse]{
Body: *response,
}, nil
})
}
// 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
user, err := h.getGitHubUser(ctx, githubToken)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub user: %w", err)
}
// Get user's organizations
orgs, err := h.getGitHubUserOrgs(ctx, user.Login, githubToken)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub organizations: %w", err)
}
// Build permissions based on user and organizations
permissions := h.buildPermissions(user.Login, orgs)
// Create JWT claims with GitHub user info
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHubAT,
AuthMethodSubject: user.Login,
Permissions: permissions,
}
// Generate Registry JWT token
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, claims)
if err != nil {
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
}
return tokenResponse, nil
}
type GitHubUserOrOrg struct {
Login string `json:"login"`
ID int `json:"id"`
}
// getGitHubUser gets the authenticated user's information
func (h *GitHubHandler) getGitHubUser(ctx context.Context, token string) (*GitHubUserOrOrg, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/user", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, body)
}
var user GitHubUserOrOrg
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("failed to decode user response: %w", err)
}
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)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get user organizations: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, 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)
}
return orgs, 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
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{
Action: auth.PermissionActionPublish,
ResourcePattern: fmt.Sprintf("io.github.%s/*", username),
})
// Add permissions for each organization
for _, org := range orgs {
permissions = append(permissions, auth.Permission{
Action: auth.PermissionActionPublish,
ResourcePattern: fmt.Sprintf("io.github.%s/*", org.Login),
})
}
return permissions
}
func isValidGitHubName(name string) bool {
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(name)
}

View file

@ -0,0 +1,627 @@
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/modelcontextprotocol/registry/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
githubUserEndpoint = "/user"
githubOrgsEndpoint = "/users/testuser/orgs"
)
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, model.AuthMethodGitHubAT, 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:
orgs := []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
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 "/users/user with spaces/orgs":
orgs := []v0auth.GitHubUserOrOrg{}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(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)
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:
orgs := []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
}
}))
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)
assert.Empty(t, claims.Permissions) // No permissions because one org has invalid name
})
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 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: model.AuthMethodGitHubAT,
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, model.AuthMethodGitHubAT, 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: model.AuthMethodGitHubAT,
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: model.AuthMethodGitHubAT,
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: 0, // Should return nil if any name is invalid
},
}
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 "/users/" + tc.username + "/orgs":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(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)
}
}

View file

@ -0,0 +1,297 @@
package auth
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/golang-jwt/jwt/v5"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/model"
)
// GitHubOIDCTokenExchangeInput represents the input for GitHub OIDC token exchange
type GitHubOIDCTokenExchangeInput struct {
Body struct {
OIDCToken string `json:"oidc_token" doc:"GitHub Actions OIDC token" required:"true"`
}
}
// GitHubOIDCClaims represents the claims we need from a GitHub OIDC token
type GitHubOIDCClaims struct {
jwt.RegisteredClaims
RepositoryOwner string `json:"repository_owner"` // e.g., "octo-org"
}
// JWKS represents a JSON Web Key Set
type JWKS struct {
Keys []JWK `json:"keys"`
}
// JWK represents a JSON Web Key
type JWK struct {
KTY string `json:"kty"`
KID string `json:"kid"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
}
// OIDCValidator defines the interface for OIDC token validation
type OIDCValidator interface {
ValidateToken(ctx context.Context, token string, audience string) (*GitHubOIDCClaims, error)
}
// GitHubOIDCValidator validates GitHub OIDC tokens
type GitHubOIDCValidator struct {
jwksURL string
issuer string
}
// NewGitHubOIDCValidator creates a new GitHub OIDC validator
func NewGitHubOIDCValidator() *GitHubOIDCValidator {
return &GitHubOIDCValidator{
jwksURL: "https://token.actions.githubusercontent.com/.well-known/jwks",
issuer: "https://token.actions.githubusercontent.com",
}
}
// NewMockOIDCValidator creates a mock validator for testing
func NewMockOIDCValidator(jwksURL, issuer string) *GitHubOIDCValidator {
return &GitHubOIDCValidator{
jwksURL: jwksURL,
issuer: issuer,
}
}
// ValidateToken validates a GitHub OIDC token
func (v *GitHubOIDCValidator) ValidateToken(ctx context.Context, tokenString string, audience string) (*GitHubOIDCClaims, error) {
// Parse token to get header for key ID
token, err := jwt.ParseWithClaims(
tokenString,
&GitHubOIDCClaims{},
func(token *jwt.Token) (any, error) {
// Get key ID from header
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("missing kid in token header")
}
// Find matching public key
publicKey, err := v.getPublicKey(ctx, kid)
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
return publicKey, nil
},
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithExpirationRequired(),
)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
if !token.Valid {
return nil, fmt.Errorf("invalid token")
}
// Extract and validate claims
claims, ok := token.Claims.(*GitHubOIDCClaims)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
// Validate issuer
if claims.Issuer != v.issuer {
return nil, fmt.Errorf("invalid issuer: expected %s, got %s", v.issuer, claims.Issuer)
}
// Validate audience
foundAudience := false
for _, aud := range claims.Audience {
if aud == audience {
foundAudience = true
break
}
}
if !foundAudience {
return nil, fmt.Errorf("invalid audience: expected %s, got %v", audience, claims.Audience)
}
// Validate repository format
if claims.RepositoryOwner == "" {
return nil, fmt.Errorf("repository owner claim is required")
}
return claims, nil
}
// fetchJWKS fetches the JSON Web Key Set from GitHub
func (v *GitHubOIDCValidator) fetchJWKS(ctx context.Context) (*JWKS, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("JWKS endpoint returned status %d: %s", resp.StatusCode, body)
}
var jwks JWKS
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return nil, fmt.Errorf("failed to decode JWKS: %w", err)
}
return &jwks, nil
}
// getPublicKey extracts the RSA public key for the given key ID
func (v *GitHubOIDCValidator) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
// Fetch JWKS from GitHub
jwks, err := v.fetchJWKS(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
}
for _, key := range jwks.Keys {
if key.KID == kid {
return v.parseRSAPublicKey(key)
}
}
return nil, fmt.Errorf("key with ID %s not found", kid)
}
// parseRSAPublicKey converts JWK to RSA public key
func (v *GitHubOIDCValidator) parseRSAPublicKey(jwk JWK) (*rsa.PublicKey, error) {
if jwk.KTY != "RSA" {
return nil, fmt.Errorf("invalid key type: expected RSA, got %s", jwk.KTY)
}
// Decode modulus (n)
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
if err != nil {
return nil, fmt.Errorf("failed to decode modulus: %w", err)
}
// Decode exponent (e)
eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E)
if err != nil {
return nil, fmt.Errorf("failed to decode exponent: %w", err)
}
// Convert to big integers
n := new(big.Int).SetBytes(nBytes)
e := new(big.Int).SetBytes(eBytes)
return &rsa.PublicKey{
N: n,
E: int(e.Int64()),
}, nil
}
// GitHubOIDCHandler handles GitHub OIDC authentication
type GitHubOIDCHandler struct {
config *config.Config
jwtManager *auth.JWTManager
validator OIDCValidator
}
// NewGitHubOIDCHandler creates a new GitHub OIDC handler
func NewGitHubOIDCHandler(cfg *config.Config) *GitHubOIDCHandler {
return &GitHubOIDCHandler{
config: cfg,
jwtManager: auth.NewJWTManager(cfg),
validator: NewGitHubOIDCValidator(),
}
}
// SetValidator sets a custom OIDC validator (used for testing)
func (h *GitHubOIDCHandler) SetValidator(validator OIDCValidator) {
h.validator = validator
}
// RegisterGitHubOIDCEndpoint registers the GitHub OIDC authentication endpoint
func RegisterGitHubOIDCEndpoint(api huma.API, cfg *config.Config) {
handler := NewGitHubOIDCHandler(cfg)
// GitHub OIDC token exchange endpoint
huma.Register(api, huma.Operation{
OperationID: "exchange-github-oidc-token",
Method: http.MethodPost,
Path: "/v0/auth/github-oidc",
Summary: "Exchange GitHub OIDC token for Registry JWT",
Description: "Exchange a GitHub Actions OIDC token for a short-lived Registry JWT token",
Tags: []string{"auth"},
}, func(ctx context.Context, input *GitHubOIDCTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.ExchangeToken(ctx, input.Body.OIDCToken)
if err != nil {
return nil, huma.Error401Unauthorized("Token exchange failed", err)
}
return &v0.Response[auth.TokenResponse]{
Body: *response,
}, nil
})
}
// ExchangeToken exchanges a GitHub OIDC token for a Registry JWT token
func (h *GitHubOIDCHandler) ExchangeToken(ctx context.Context, oidcToken string) (*auth.TokenResponse, error) {
// Validate OIDC token with audience "mcp-registry"
claims, err := h.validator.ValidateToken(ctx, oidcToken, "mcp-registry")
if err != nil {
return nil, fmt.Errorf("failed to validate OIDC token: %w", err)
}
// Extract repository information and build permissions
permissions := h.buildPermissions(claims)
// Create JWT claims with GitHub OIDC info
jwtClaims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHubOIDC,
AuthMethodSubject: claims.Subject, // e.g. "repo:octo-org/octo-repo:environment:prod"
Permissions: permissions,
}
// Generate Registry JWT token
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, jwtClaims)
if err != nil {
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
}
return tokenResponse, nil
}
func (h *GitHubOIDCHandler) buildPermissions(claims *GitHubOIDCClaims) []auth.Permission {
permissions := []auth.Permission{}
// Validate repository owner name
if !isValidGitHubName(claims.RepositoryOwner) {
return nil
}
// Grant publish permissions for the repository owner's namespace
// We grant io.github.<owner>/* rather than io.github./repo/* because many people have monorepo setups where they want to deploy multiple servers from
// This also reflects GitHub's permission model, in that GitHub Actions can push to any GitHub package in the repository owner's namespace (e.g. for GHCR)
permissions = append(permissions, auth.Permission{
Action: auth.PermissionActionPublish,
ResourcePattern: fmt.Sprintf("io.github.%s/*", claims.RepositoryOwner),
})
return permissions
}

View file

@ -0,0 +1,217 @@
package auth_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
internalauth "github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Mock OIDC validator for testing
type MockOIDCValidator struct {
validateFunc func(ctx context.Context, token string, audience string) (*auth.GitHubOIDCClaims, error)
}
func (m *MockOIDCValidator) ValidateToken(ctx context.Context, token string, audience string) (*auth.GitHubOIDCClaims, error) {
return m.validateFunc(ctx, token, audience)
}
func TestGitHubOIDCHandler_ExchangeToken(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // 32 bytes hex
}
handler := auth.NewGitHubOIDCHandler(cfg)
tests := []struct {
name string
mockValidator *MockOIDCValidator
expectError bool
expectedSubject string
expectedPerms int
}{
{
name: "successful token exchange",
mockValidator: &MockOIDCValidator{
validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) {
return &auth.GitHubOIDCClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "repo:octo-org/octo-repo:environment:prod",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
Audience: jwt.ClaimStrings{"mcp-registry"},
},
RepositoryOwner: "octo-org",
}, nil
},
},
expectError: false,
expectedSubject: "repo:octo-org/octo-repo:environment:prod",
expectedPerms: 1,
},
{
name: "validation failure",
mockValidator: &MockOIDCValidator{
validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) {
return nil, fmt.Errorf("token validation failed")
},
},
expectError: true,
},
{
name: "invalid repository owner name",
mockValidator: &MockOIDCValidator{
validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) {
return &auth.GitHubOIDCClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "repo:invalid@name/octo-repo:environment:prod",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
Audience: jwt.ClaimStrings{"mcp-registry"},
},
RepositoryOwner: "invalid@name", // invalid character
}, nil
},
},
expectError: false, // Handler should succeed but return empty permissions
expectedSubject: "repo:invalid@name/octo-repo:environment:prod",
expectedPerms: 0, // No permissions due to invalid name
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler.SetValidator(tt.mockValidator)
response, err := handler.ExchangeToken(context.Background(), "test-oidc-token")
if tt.expectError {
assert.Error(t, err)
assert.Nil(t, response)
} else {
assert.NoError(t, err)
assert.NotNil(t, response)
assert.NotEmpty(t, response.RegistryToken)
assert.Greater(t, response.ExpiresAt, 0)
// Validate the generated JWT token
jwtManager := internalauth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(context.Background(), response.RegistryToken)
require.NoError(t, err)
assert.Equal(t, model.AuthMethodGitHubOIDC, claims.AuthMethod)
assert.Equal(t, tt.expectedSubject, claims.AuthMethodSubject)
assert.Len(t, claims.Permissions, tt.expectedPerms)
if tt.expectedPerms > 0 {
assert.Equal(t, internalauth.PermissionActionPublish, claims.Permissions[0].Action)
assert.True(t, strings.HasPrefix(claims.Permissions[0].ResourcePattern, "io.github."))
}
}
})
}
}
func TestBuildPermissionsFromOIDC(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewGitHubOIDCHandler(cfg)
tests := []struct {
name string
claims *auth.GitHubOIDCClaims
expectedPerms []internalauth.Permission
}{
{
name: "valid repository owner",
claims: &auth.GitHubOIDCClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "repo:octo-org/octo-repo:environment:prod",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
Audience: jwt.ClaimStrings{"mcp-registry"},
},
RepositoryOwner: "octo-org",
},
expectedPerms: []internalauth.Permission{
{
Action: internalauth.PermissionActionPublish,
ResourcePattern: "io.github.octo-org/*",
},
},
},
{
name: "invalid repository owner name",
claims: &auth.GitHubOIDCClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "repo:invalid@name/octo-repo:environment:prod",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
Audience: jwt.ClaimStrings{"mcp-registry"},
},
RepositoryOwner: "invalid@name", // contains invalid character
},
expectedPerms: nil, // No permissions for invalid names
},
{
name: "user repository",
claims: &auth.GitHubOIDCClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "repo:username/octo-repo:environment:prod",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
Audience: jwt.ClaimStrings{"mcp-registry"},
},
RepositoryOwner: "username",
},
expectedPerms: []internalauth.Permission{
{
Action: internalauth.PermissionActionPublish,
ResourcePattern: "io.github.username/*",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Access the method through reflection since it's not exported
// For testing purposes, we'll create a simple test by calling ExchangeToken
// and validating the resulting permissions
mockValidator := &MockOIDCValidator{
validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) {
return tt.claims, nil
},
}
handler.SetValidator(mockValidator)
response, err := handler.ExchangeToken(context.Background(), "test-token")
if tt.expectedPerms == nil {
// For invalid names, we expect empty permissions but successful token generation
assert.NoError(t, err)
assert.NotNil(t, response)
// Validate the JWT to check permissions
jwtManager := internalauth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(context.Background(), response.RegistryToken)
require.NoError(t, err)
assert.Len(t, claims.Permissions, 0)
} else {
assert.NoError(t, err)
assert.NotNil(t, response)
// Validate the JWT to check permissions
jwtManager := internalauth.NewJWTManager(cfg)
claims, err := jwtManager.ValidateToken(context.Background(), response.RegistryToken)
require.NoError(t, err)
assert.Equal(t, tt.expectedPerms, claims.Permissions)
}
})
}
}

View file

@ -7,8 +7,11 @@ import (
// RegisterAuthEndpoints registers all authentication endpoints
func RegisterAuthEndpoints(api huma.API, cfg *config.Config) {
// Register GitHub authentication endpoint
RegisterGitHubEndpoint(api, cfg)
// Register GitHub access token authentication endpoint
RegisterGitHubATEndpoint(api, cfg)
// Register GitHub OIDC authentication endpoint
RegisterGitHubOIDCEndpoint(api, cfg)
// Register anonymous authentication endpoint
RegisterNoneEndpoint(api, cfg)

View file

@ -6,6 +6,7 @@ import (
"net/http"
"github.com/danielgtaylor/huma/v2"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/model"
@ -41,17 +42,13 @@ func RegisterNoneEndpoint(api huma.API, cfg *config.Config) {
Summary: "Get anonymous Registry JWT",
Description: "Get a short-lived Registry JWT token for publishing to io.modelcontextprotocol.anonymous/* namespace",
Tags: []string{"auth"},
}, func(ctx context.Context, _ *struct{}) (*struct {
Body auth.TokenResponse `json:"body"`
}, error) {
}, func(ctx context.Context, _ *struct{}) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.GetAnonymousToken(ctx)
if err != nil {
return nil, huma.Error500InternalServerError("Failed to generate token", err)
}
return &struct {
Body auth.TokenResponse `json:"body"`
}{
return &v0.Response[auth.TokenResponse]{
Body: *response,
}, nil
})

View file

@ -73,7 +73,7 @@ func TestPublishIntegration(t *testing.T) {
// Generate valid JWT token
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethod: model.AuthMethodGitHubAT,
AuthMethodSubject: "testuser",
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"},
@ -210,7 +210,7 @@ func TestPublishIntegration(t *testing.T) {
// Generate valid JWT token but with different permissions
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethod: model.AuthMethodGitHubAT,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.myuser/*"},
},

View file

@ -96,7 +96,7 @@ func TestPublishEndpoint(t *testing.T) {
},
},
tokenClaims: &auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethod: model.AuthMethodGitHubAT,
AuthMethodSubject: "example",
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
@ -187,7 +187,7 @@ func TestPublishEndpoint(t *testing.T) {
},
},
tokenClaims: &auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethod: model.AuthMethodGitHubAT,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
},

View file

@ -31,7 +31,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
t.Run("generate and verify valid token", func(t *testing.T) {
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethod: model.AuthMethodGitHubAT,
AuthMethodSubject: "testuser",
Permissions: []auth.Permission{
{
@ -50,7 +50,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
// Verify token
verifiedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
require.NoError(t, err)
assert.Equal(t, model.AuthMethodGitHub, verifiedClaims.AuthMethod)
assert.Equal(t, model.AuthMethodGitHubAT, verifiedClaims.AuthMethod)
assert.Equal(t, "testuser", verifiedClaims.AuthMethodSubject)
assert.Equal(t, "mcp-registry", verifiedClaims.Issuer)
assert.Len(t, verifiedClaims.Permissions, 1)
@ -100,7 +100,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), // Already expired
},
AuthMethod: model.AuthMethodGitHub,
AuthMethod: model.AuthMethodGitHubAT,
AuthMethodSubject: "testuser",
}
@ -126,7 +126,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
differentJWTManager := auth.NewJWTManager(differentCfg)
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethod: model.AuthMethodGitHubAT,
AuthMethodSubject: "testuser",
}
@ -149,7 +149,7 @@ func TestJWTManager_GenerateAndVerifyToken(t *testing.T) {
t.Run("multiple permissions", func(t *testing.T) {
claims := auth.JWTClaims{
AuthMethod: model.AuthMethodGitHub,
AuthMethod: model.AuthMethodGitHubAT,
AuthMethodSubject: "admin",
Permissions: []auth.Permission{
{

View file

@ -4,8 +4,10 @@ package model
type AuthMethod string
const (
// GitHub OAuth authentication
AuthMethodGitHub AuthMethod = "github"
// GitHub OAuth authentication (access token)
AuthMethodGitHubAT AuthMethod = "github-at"
// GitHub Actions OIDC authentication
AuthMethodGitHubOIDC AuthMethod = "github-oidc"
// No authentication - should only be used for local development and testing
AuthMethodNone AuthMethod = "none"
)