Skip to content

Commit 12ab632

Browse files
authored
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 (#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
1 parent e9fe6f9 commit 12ab632

File tree

16 files changed

+802
-193
lines changed

16 files changed

+802
-193
lines changed

internal/api/handlers/v0/auth/github.go renamed to internal/api/handlers/v0/auth/github_at.go

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"regexp"
1010

1111
"github.com/danielgtaylor/huma/v2"
12+
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
1213
"github.com/modelcontextprotocol/registry/internal/auth"
1314
"github.com/modelcontextprotocol/registry/internal/config"
1415
"github.com/modelcontextprotocol/registry/internal/model"
@@ -42,29 +43,25 @@ func (h *GitHubHandler) SetBaseURL(url string) {
4243
h.baseURL = url
4344
}
4445

45-
// RegisterGitHubEndpoint registers the GitHub authentication endpoint
46-
func RegisterGitHubEndpoint(api huma.API, cfg *config.Config) {
46+
// RegisterGitHubATEndpoint registers the GitHub access token authentication endpoint
47+
func RegisterGitHubATEndpoint(api huma.API, cfg *config.Config) {
4748
handler := NewGitHubHandler(cfg)
4849

4950
// GitHub token exchange endpoint
5051
huma.Register(api, huma.Operation{
5152
OperationID: "exchange-github-token",
5253
Method: http.MethodPost,
53-
Path: "/v0/auth/github",
54-
Summary: "Exchange GitHub token for Registry JWT",
55-
Description: "Exchange a GitHub OAuth token for a short-lived Registry JWT token",
54+
Path: "/v0/auth/github-at",
55+
Summary: "Exchange GitHub OAuth access token for Registry JWT",
56+
Description: "Exchange a GitHub OAuth access token for a short-lived Registry JWT token",
5657
Tags: []string{"auth"},
57-
}, func(ctx context.Context, input *GitHubTokenExchangeInput) (*struct {
58-
Body auth.TokenResponse `json:"body"`
59-
}, error) {
58+
}, func(ctx context.Context, input *GitHubTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
6059
response, err := handler.ExchangeToken(ctx, input.Body.GitHubToken)
6160
if err != nil {
6261
return nil, huma.Error401Unauthorized("Token exchange failed", err)
6362
}
6463

65-
return &struct {
66-
Body auth.TokenResponse `json:"body"`
67-
}{
64+
return &v0.Response[auth.TokenResponse]{
6865
Body: *response,
6966
}, nil
7067
})
@@ -89,7 +86,7 @@ func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) (
8986

9087
// Create JWT claims with GitHub user info
9188
claims := auth.JWTClaims{
92-
AuthMethod: model.AuthMethodGitHub,
89+
AuthMethod: model.AuthMethodGitHubAT,
9390
AuthMethodSubject: user.Login,
9491
Permissions: permissions,
9592
}

internal/api/handlers/v0/auth/github_test.go renamed to internal/api/handlers/v0/auth/github_at_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func TestGitHubHandler_ExchangeToken(t *testing.T) {
7878
jwtManager := auth.NewJWTManager(cfg)
7979
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
8080
require.NoError(t, err)
81-
assert.Equal(t, model.AuthMethodGitHub, claims.AuthMethod)
81+
assert.Equal(t, model.AuthMethodGitHubAT, claims.AuthMethod)
8282
assert.Equal(t, "testuser", claims.AuthMethodSubject)
8383
assert.Len(t, claims.Permissions, 1)
8484
assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action)
@@ -333,7 +333,7 @@ func TestJWTTokenValidation(t *testing.T) {
333333
t.Run("generate and validate token", func(t *testing.T) {
334334
// Create test claims
335335
claims := auth.JWTClaims{
336-
AuthMethod: model.AuthMethodGitHub,
336+
AuthMethod: model.AuthMethodGitHubAT,
337337
AuthMethodSubject: "testuser",
338338
Permissions: []auth.Permission{
339339
{
@@ -351,7 +351,7 @@ func TestJWTTokenValidation(t *testing.T) {
351351
// Validate token
352352
validatedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
353353
require.NoError(t, err)
354-
assert.Equal(t, model.AuthMethodGitHub, validatedClaims.AuthMethod)
354+
assert.Equal(t, model.AuthMethodGitHubAT, validatedClaims.AuthMethod)
355355
assert.Equal(t, "testuser", validatedClaims.AuthMethodSubject)
356356
assert.Len(t, validatedClaims.Permissions, 1)
357357
})
@@ -360,7 +360,7 @@ func TestJWTTokenValidation(t *testing.T) {
360360
// Create claims with past expiration
361361
pastTime := time.Now().Add(-1 * time.Hour)
362362
claims := auth.JWTClaims{
363-
AuthMethod: model.AuthMethodGitHub,
363+
AuthMethod: model.AuthMethodGitHubAT,
364364
AuthMethodSubject: "testuser",
365365
RegisteredClaims: jwt.RegisteredClaims{
366366
ExpiresAt: jwt.NewNumericDate(pastTime),
@@ -381,7 +381,7 @@ func TestJWTTokenValidation(t *testing.T) {
381381
t.Run("invalid signature", func(t *testing.T) {
382382
// Create test claims
383383
claims := auth.JWTClaims{
384-
AuthMethod: model.AuthMethodGitHub,
384+
AuthMethod: model.AuthMethodGitHubAT,
385385
AuthMethodSubject: "testuser",
386386
}
387387

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"crypto/rsa"
6+
"encoding/base64"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"math/big"
11+
"net/http"
12+
13+
"github.com/danielgtaylor/huma/v2"
14+
"github.com/golang-jwt/jwt/v5"
15+
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
16+
"github.com/modelcontextprotocol/registry/internal/auth"
17+
"github.com/modelcontextprotocol/registry/internal/config"
18+
"github.com/modelcontextprotocol/registry/internal/model"
19+
)
20+
21+
// GitHubOIDCTokenExchangeInput represents the input for GitHub OIDC token exchange
22+
type GitHubOIDCTokenExchangeInput struct {
23+
Body struct {
24+
OIDCToken string `json:"oidc_token" doc:"GitHub Actions OIDC token" required:"true"`
25+
}
26+
}
27+
28+
// GitHubOIDCClaims represents the claims we need from a GitHub OIDC token
29+
type GitHubOIDCClaims struct {
30+
jwt.RegisteredClaims
31+
RepositoryOwner string `json:"repository_owner"` // e.g., "octo-org"
32+
}
33+
34+
// JWKS represents a JSON Web Key Set
35+
type JWKS struct {
36+
Keys []JWK `json:"keys"`
37+
}
38+
39+
// JWK represents a JSON Web Key
40+
type JWK struct {
41+
KTY string `json:"kty"`
42+
KID string `json:"kid"`
43+
Use string `json:"use"`
44+
N string `json:"n"`
45+
E string `json:"e"`
46+
}
47+
48+
// OIDCValidator defines the interface for OIDC token validation
49+
type OIDCValidator interface {
50+
ValidateToken(ctx context.Context, token string, audience string) (*GitHubOIDCClaims, error)
51+
}
52+
53+
// GitHubOIDCValidator validates GitHub OIDC tokens
54+
type GitHubOIDCValidator struct {
55+
jwksURL string
56+
issuer string
57+
}
58+
59+
// NewGitHubOIDCValidator creates a new GitHub OIDC validator
60+
func NewGitHubOIDCValidator() *GitHubOIDCValidator {
61+
return &GitHubOIDCValidator{
62+
jwksURL: "https://token.actions.githubusercontent.com/.well-known/jwks",
63+
issuer: "https://token.actions.githubusercontent.com",
64+
}
65+
}
66+
67+
// NewMockOIDCValidator creates a mock validator for testing
68+
func NewMockOIDCValidator(jwksURL, issuer string) *GitHubOIDCValidator {
69+
return &GitHubOIDCValidator{
70+
jwksURL: jwksURL,
71+
issuer: issuer,
72+
}
73+
}
74+
75+
// ValidateToken validates a GitHub OIDC token
76+
func (v *GitHubOIDCValidator) ValidateToken(ctx context.Context, tokenString string, audience string) (*GitHubOIDCClaims, error) {
77+
// Parse token to get header for key ID
78+
token, err := jwt.ParseWithClaims(
79+
tokenString,
80+
&GitHubOIDCClaims{},
81+
func(token *jwt.Token) (any, error) {
82+
// Get key ID from header
83+
kid, ok := token.Header["kid"].(string)
84+
if !ok {
85+
return nil, fmt.Errorf("missing kid in token header")
86+
}
87+
88+
// Find matching public key
89+
publicKey, err := v.getPublicKey(ctx, kid)
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to get public key: %w", err)
92+
}
93+
94+
return publicKey, nil
95+
},
96+
jwt.WithValidMethods([]string{"RS256"}),
97+
jwt.WithExpirationRequired(),
98+
)
99+
100+
if err != nil {
101+
return nil, fmt.Errorf("failed to parse token: %w", err)
102+
}
103+
if !token.Valid {
104+
return nil, fmt.Errorf("invalid token")
105+
}
106+
107+
// Extract and validate claims
108+
claims, ok := token.Claims.(*GitHubOIDCClaims)
109+
if !ok {
110+
return nil, fmt.Errorf("invalid token claims")
111+
}
112+
113+
// Validate issuer
114+
if claims.Issuer != v.issuer {
115+
return nil, fmt.Errorf("invalid issuer: expected %s, got %s", v.issuer, claims.Issuer)
116+
}
117+
118+
// Validate audience
119+
foundAudience := false
120+
for _, aud := range claims.Audience {
121+
if aud == audience {
122+
foundAudience = true
123+
break
124+
}
125+
}
126+
if !foundAudience {
127+
return nil, fmt.Errorf("invalid audience: expected %s, got %v", audience, claims.Audience)
128+
}
129+
130+
// Validate repository format
131+
if claims.RepositoryOwner == "" {
132+
return nil, fmt.Errorf("repository owner claim is required")
133+
}
134+
135+
return claims, nil
136+
}
137+
138+
// fetchJWKS fetches the JSON Web Key Set from GitHub
139+
func (v *GitHubOIDCValidator) fetchJWKS(ctx context.Context) (*JWKS, error) {
140+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil)
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to create request: %w", err)
143+
}
144+
145+
resp, err := http.DefaultClient.Do(req)
146+
if err != nil {
147+
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
148+
}
149+
defer resp.Body.Close()
150+
151+
if resp.StatusCode != http.StatusOK {
152+
body, _ := io.ReadAll(resp.Body)
153+
return nil, fmt.Errorf("JWKS endpoint returned status %d: %s", resp.StatusCode, body)
154+
}
155+
156+
var jwks JWKS
157+
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
158+
return nil, fmt.Errorf("failed to decode JWKS: %w", err)
159+
}
160+
161+
return &jwks, nil
162+
}
163+
164+
// getPublicKey extracts the RSA public key for the given key ID
165+
func (v *GitHubOIDCValidator) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
166+
// Fetch JWKS from GitHub
167+
jwks, err := v.fetchJWKS(ctx)
168+
if err != nil {
169+
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
170+
}
171+
172+
for _, key := range jwks.Keys {
173+
if key.KID == kid {
174+
return v.parseRSAPublicKey(key)
175+
}
176+
}
177+
return nil, fmt.Errorf("key with ID %s not found", kid)
178+
}
179+
180+
// parseRSAPublicKey converts JWK to RSA public key
181+
func (v *GitHubOIDCValidator) parseRSAPublicKey(jwk JWK) (*rsa.PublicKey, error) {
182+
if jwk.KTY != "RSA" {
183+
return nil, fmt.Errorf("invalid key type: expected RSA, got %s", jwk.KTY)
184+
}
185+
186+
// Decode modulus (n)
187+
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
188+
if err != nil {
189+
return nil, fmt.Errorf("failed to decode modulus: %w", err)
190+
}
191+
192+
// Decode exponent (e)
193+
eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E)
194+
if err != nil {
195+
return nil, fmt.Errorf("failed to decode exponent: %w", err)
196+
}
197+
198+
// Convert to big integers
199+
n := new(big.Int).SetBytes(nBytes)
200+
e := new(big.Int).SetBytes(eBytes)
201+
202+
return &rsa.PublicKey{
203+
N: n,
204+
E: int(e.Int64()),
205+
}, nil
206+
}
207+
208+
// GitHubOIDCHandler handles GitHub OIDC authentication
209+
type GitHubOIDCHandler struct {
210+
config *config.Config
211+
jwtManager *auth.JWTManager
212+
validator OIDCValidator
213+
}
214+
215+
// NewGitHubOIDCHandler creates a new GitHub OIDC handler
216+
func NewGitHubOIDCHandler(cfg *config.Config) *GitHubOIDCHandler {
217+
return &GitHubOIDCHandler{
218+
config: cfg,
219+
jwtManager: auth.NewJWTManager(cfg),
220+
validator: NewGitHubOIDCValidator(),
221+
}
222+
}
223+
224+
// SetValidator sets a custom OIDC validator (used for testing)
225+
func (h *GitHubOIDCHandler) SetValidator(validator OIDCValidator) {
226+
h.validator = validator
227+
}
228+
229+
// RegisterGitHubOIDCEndpoint registers the GitHub OIDC authentication endpoint
230+
func RegisterGitHubOIDCEndpoint(api huma.API, cfg *config.Config) {
231+
handler := NewGitHubOIDCHandler(cfg)
232+
233+
// GitHub OIDC token exchange endpoint
234+
huma.Register(api, huma.Operation{
235+
OperationID: "exchange-github-oidc-token",
236+
Method: http.MethodPost,
237+
Path: "/v0/auth/github-oidc",
238+
Summary: "Exchange GitHub OIDC token for Registry JWT",
239+
Description: "Exchange a GitHub Actions OIDC token for a short-lived Registry JWT token",
240+
Tags: []string{"auth"},
241+
}, func(ctx context.Context, input *GitHubOIDCTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
242+
response, err := handler.ExchangeToken(ctx, input.Body.OIDCToken)
243+
if err != nil {
244+
return nil, huma.Error401Unauthorized("Token exchange failed", err)
245+
}
246+
247+
return &v0.Response[auth.TokenResponse]{
248+
Body: *response,
249+
}, nil
250+
})
251+
}
252+
253+
// ExchangeToken exchanges a GitHub OIDC token for a Registry JWT token
254+
func (h *GitHubOIDCHandler) ExchangeToken(ctx context.Context, oidcToken string) (*auth.TokenResponse, error) {
255+
// Validate OIDC token with audience "mcp-registry"
256+
claims, err := h.validator.ValidateToken(ctx, oidcToken, "mcp-registry")
257+
if err != nil {
258+
return nil, fmt.Errorf("failed to validate OIDC token: %w", err)
259+
}
260+
261+
// Extract repository information and build permissions
262+
permissions := h.buildPermissions(claims)
263+
264+
// Create JWT claims with GitHub OIDC info
265+
jwtClaims := auth.JWTClaims{
266+
AuthMethod: model.AuthMethodGitHubOIDC,
267+
AuthMethodSubject: claims.Subject, // e.g. "repo:octo-org/octo-repo:environment:prod"
268+
Permissions: permissions,
269+
}
270+
271+
// Generate Registry JWT token
272+
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, jwtClaims)
273+
if err != nil {
274+
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
275+
}
276+
277+
return tokenResponse, nil
278+
}
279+
280+
func (h *GitHubOIDCHandler) buildPermissions(claims *GitHubOIDCClaims) []auth.Permission {
281+
permissions := []auth.Permission{}
282+
283+
// Validate repository owner name
284+
if !isValidGitHubName(claims.RepositoryOwner) {
285+
return nil
286+
}
287+
288+
// Grant publish permissions for the repository owner's namespace
289+
// We grant io.github.<owner>/* rather than io.github./repo/* because many people have monorepo setups where they want to deploy multiple servers from
290+
// 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)
291+
permissions = append(permissions, auth.Permission{
292+
Action: auth.PermissionActionPublish,
293+
ResourcePattern: fmt.Sprintf("io.github.%s/*", claims.RepositoryOwner),
294+
})
295+
296+
return permissions
297+
}

0 commit comments

Comments
 (0)