registry/internal/api/handlers/v0/auth/github_oidc.go
Radoslav Dimitrov 7a4216c528
refactor: rename readBody to readErrorBody and cap body size (#1246)
## Summary

Follow-up to #1241. Two small tweaks to the `readBody` helper:

- **Rename `readBody` → `readErrorBody`.** The helper is only ever
called on the error path (after a non-2xx status check), where we want a
small string for diagnostics. The generic name made it look reusable on
the success path, where streaming JSON decoding is the right approach.
The new name pins the intended use down.
- **Cap the read with `io.LimitReader` (8 KiB).** Defense in depth —
error diagnostics never need more than a few KB, and we shouldn't buffer
an arbitrarily large body from an upstream just to format an error
message. 8 KiB is well above any realistic GitHub API or JWKS error
response.

No behavior change on the happy path. On the unhappy path, error
messages are now bounded.

## Test plan

- [x] `go build ./...`
- [x] `go test ./internal/api/handlers/v0/auth/...`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-04 07:09:57 -04:00

297 lines
8.7 KiB
Go

package auth
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"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"
)
// 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 {
return nil, fmt.Errorf("JWKS endpoint returned status %d: %s", resp.StatusCode, readErrorBody(resp.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, pathPrefix string, cfg *config.Config) {
handler := NewGitHubOIDCHandler(cfg)
// GitHub OIDC token exchange endpoint
huma.Register(api, huma.Operation{
OperationID: "exchange-github-oidc-token" + strings.ReplaceAll(pathPrefix, "/", "-"),
Method: http.MethodPost,
Path: pathPrefix + "/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) {
if h.config.GitHubOIDCAudience == "" {
return nil, fmt.Errorf("GitHub OIDC exchange disabled: this deployment has no audience configured")
}
claims, err := h.validator.ValidateToken(ctx, oidcToken, h.config.GitHubOIDCAudience)
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: auth.MethodGitHubOIDC,
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
}