registry/internal/api/handlers/v0/auth/oidc.go
Radoslav Dimitrov 8759d9d8c0
ci: bump golangci-lint to v2.13.1 to fix panic on Go 1.27 (#1561)
CI is red on `main` and on every open PR (including the Dependabot PRs
#1531, #1552–#1554) with:

```
panic: file requires newer Go version go1.27 (application built with go1.26)
golangci-lint exit with code 2
```

## Cause

`actions/setup-go` with `go-version: 'stable'` started resolving to
**go1.27.0**. The pinned golangci-lint **v2.11.4** release binary is
built with **go1.26**, and its `go/types` refuses to typecheck source
files declaring a newer language version. Nothing in the failing PRs is
at fault.

## Fix

Bump to **golangci-lint v2.13.1**, whose release binaries are built with
go1.27, so linting works under `stable` again.

That bump also pulls in newer linter versions, which surface 57 findings
this tree did not have before. Handled as follows:

| Linter | Count | Resolution |
|---|---|---|
| `goconst` | 50 | 39 were repeated literals in `_test.go` files —
goconst started counting those in v2.12.0 (golangci-lint#6480 exposed
`ignore-tests`). Fixture data like `"1.0.0"` (89 occurrences) is not a
missing constant, so `goconst.ignore-tests: true`. The other 11 were
real duplication in production code (OpenAPI tags, the `bearer` security
scheme name, `$ref`, `http`/`https`) and became named constants. |
| `gosec` G710 | 1 | **True positive, fixed.** See below. |
| `staticcheck` SA1019 | 6 | Go 1.26 deprecated `ecdsa.PublicKey.X/Y`
and `PrivateKey.D`. Annotated with the same targeted `nolint` +
rationale already used for `ScalarBaseMult` in
`cmd/publisher/auth/common.go`. |

### The open redirect is real

`TrailingSlashMiddleware` copied the entire `*url.URL` before rewriting
the path. For an absolute-form request URI — legal for a Go HTTP server
to parse, e.g. `GET http://evil.com/foo/ HTTP/1.1` — `r.URL.Scheme` and
`r.URL.Host` are populated, and they were echoed straight back in the
`Location` header. The redirect target is now built from the cleaned
path and query alone. The existing `path.Clean` hardening for
GHSA-v8vw-gw5j-w7m6 (protocol-relative `//evil.com/`) is unchanged.

### Also

Switched `gomodguard` → `gomodguard_v2`; the former is deprecated as of
v2.12.0 and was emitting a warning on every run.

## Verification

- `golangci-lint v2.13.1` against this tree: **0 issues**, no
deprecation warnings
- `make validate`: all validations passed
- `go build ./...` and tests for every touched package pass (DB-backed
tests need Postgres; left to CI)

## Note on migrating the ecdsa key paths

Moving those six sites to `PublicKey.Bytes` /
`ParseUncompressedPublicKey` / `crypto/ecdh` touches key parsing and
signature verification. It should be its own reviewed PR rather than
riding along on a CI unblock. Happy to open one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:14:13 +03:00

298 lines
8.4 KiB
Go

package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"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"
)
// OIDCTokenExchangeInput represents the input for OIDC token exchange
type OIDCTokenExchangeInput struct {
Body struct {
OIDCToken string `json:"oidc_token" doc:"OIDC ID token from any provider" required:"true"`
}
}
// OIDCClaims represents the claims we extract from any OIDC token
type OIDCClaims struct {
Subject string `json:"sub"`
Issuer string `json:"iss"`
Audience []string `json:"aud"`
ExtraClaims map[string]any `json:"-"`
}
// GenericOIDCValidator defines the interface for validating OIDC tokens from any provider
type GenericOIDCValidator interface {
ValidateToken(ctx context.Context, token string) (*OIDCClaims, error)
}
// StandardOIDCValidator validates OIDC tokens using go-oidc library
type StandardOIDCValidator struct {
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
}
// NewStandardOIDCValidator creates a new standard OIDC validator using go-oidc
func NewStandardOIDCValidator(issuer, clientID string) (*StandardOIDCValidator, error) {
ctx := context.Background()
// Initialize the OIDC provider
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
}
// Create ID token verifier
verifierConfig := &oidc.Config{
ClientID: clientID,
}
verifier := provider.Verifier(verifierConfig)
return &StandardOIDCValidator{
provider: provider,
verifier: verifier,
}, nil
}
// ValidateToken validates an OIDC ID token using go-oidc library
func (v *StandardOIDCValidator) ValidateToken(ctx context.Context, tokenString string) (*OIDCClaims, error) {
// Verify and parse the ID token using go-oidc
idToken, err := v.verifier.Verify(ctx, tokenString)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
// Extract all claims
var allClaims map[string]any
if err := idToken.Claims(&allClaims); err != nil {
return nil, fmt.Errorf("failed to extract claims: %w", err)
}
// Build our claims structure
oidcClaims := &OIDCClaims{
Subject: idToken.Subject,
Issuer: idToken.Issuer,
ExtraClaims: make(map[string]any),
}
// Extract audience
if aud, ok := allClaims["aud"]; ok {
switch v := aud.(type) {
case string:
oidcClaims.Audience = []string{v}
case []any:
for _, a := range v {
if s, ok := a.(string); ok {
oidcClaims.Audience = append(oidcClaims.Audience, s)
}
}
}
}
// Store all non-standard claims in ExtraClaims
standardClaims := map[string]bool{
"iss": true, "sub": true, "aud": true, "exp": true, "nbf": true, "iat": true, "jti": true,
}
for key, value := range allClaims {
if !standardClaims[key] {
oidcClaims.ExtraClaims[key] = value
}
}
return oidcClaims, nil
}
// OIDCHandler handles configurable OIDC authentication
type OIDCHandler struct {
config *config.Config
jwtManager *auth.JWTManager
validator GenericOIDCValidator
}
// NewOIDCHandler creates a new OIDC handler
func NewOIDCHandler(cfg *config.Config) *OIDCHandler {
if !cfg.OIDCEnabled {
panic("OIDC is not enabled - should not create OIDC handler")
}
if cfg.OIDCIssuer == "" {
panic("OIDC issuer is required when OIDC is enabled")
}
validator, err := NewStandardOIDCValidator(cfg.OIDCIssuer, cfg.OIDCClientID)
if err != nil {
panic(fmt.Sprintf("Failed to initialize OIDC validator: %v", err))
}
return &OIDCHandler{
config: cfg,
jwtManager: auth.NewJWTManager(cfg),
validator: validator,
}
}
// SetValidator sets a custom OIDC validator (used for testing)
func (h *OIDCHandler) SetValidator(validator GenericOIDCValidator) {
h.validator = validator
}
// RegisterOIDCEndpoints registers all OIDC authentication endpoints
func RegisterOIDCEndpoints(api huma.API, pathPrefix string, cfg *config.Config) {
if !cfg.OIDCEnabled {
return // Skip registration if OIDC is not enabled
}
handler := NewOIDCHandler(cfg)
// Direct token exchange endpoint
huma.Register(api, huma.Operation{
OperationID: "exchange-oidc-token" + strings.ReplaceAll(pathPrefix, "/", "-"),
Method: http.MethodPost,
Path: pathPrefix + "/auth/oidc",
Summary: "Exchange OIDC ID token for Registry JWT",
Description: "Exchange an OIDC ID token from any configured provider for a short-lived Registry JWT token",
Tags: []string{tagAuth},
}, func(ctx context.Context, input *OIDCTokenExchangeInput) (*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 an OIDC ID token for a Registry JWT token
func (h *OIDCHandler) ExchangeToken(ctx context.Context, oidcToken string) (*auth.TokenResponse, error) {
// Validate OIDC token
claims, err := h.validator.ValidateToken(ctx, oidcToken)
if err != nil {
return nil, fmt.Errorf("failed to validate OIDC token: %w", err)
}
// Validate extra claims if configured
if err := h.validateExtraClaims(claims); err != nil {
return nil, fmt.Errorf("extra claims validation failed: %w", err)
}
// Build permissions based on claims and configuration
permissions := h.buildPermissions(claims)
// Create JWT claims
jwtClaims := auth.JWTClaims{
AuthMethod: auth.MethodOIDC,
AuthMethodSubject: claims.Subject,
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
}
// validateExtraClaims validates additional claims based on configuration
func (h *OIDCHandler) validateExtraClaims(claims *OIDCClaims) error {
if h.config.OIDCExtraClaims == "" {
return nil // No extra validation required
}
// Parse extra claims configuration
var extraClaimsRules []map[string]any
if err := json.Unmarshal([]byte(h.config.OIDCExtraClaims), &extraClaimsRules); err != nil {
return fmt.Errorf("invalid extra claims configuration: %w", err)
}
// Validate each rule
for _, rule := range extraClaimsRules {
for key, expectedValue := range rule {
actualValue, exists := claims.ExtraClaims[key]
if !exists {
return fmt.Errorf("claim validation failed: required claim %s not found", key)
}
if !claimMatches(actualValue, expectedValue) {
return fmt.Errorf("claim validation failed: %s expected %v, got %v", key, expectedValue, actualValue)
}
}
}
return nil
}
// claimMatches reports whether a claim value satisfies the expected configuration.
// Both sides may be a scalar or a slice; for slice-typed claims (groups, roles, scp,
// aud, etc.) any single overlap is treated as a match, mirroring how OIDC consumers
// normally interpret list claims.
func claimMatches(actual, expected any) bool {
actualList := toAnySlice(actual)
expectedList := toAnySlice(expected)
for _, e := range expectedList {
for _, a := range actualList {
if a == e {
return true
}
}
}
return false
}
func toAnySlice(v any) []any {
switch s := v.(type) {
case []any:
return s
case []string:
out := make([]any, len(s))
for i, x := range s {
out[i] = x
}
return out
default:
return []any{v}
}
}
// buildPermissions builds permissions based on OIDC claims and configuration
func (h *OIDCHandler) buildPermissions(_ *OIDCClaims) []auth.Permission {
var permissions []auth.Permission
// Parse permission patterns from configuration
if h.config.OIDCPublishPerms != "" {
for _, pattern := range strings.Split(h.config.OIDCPublishPerms, ",") {
pattern = strings.TrimSpace(pattern)
if pattern != "" {
permissions = append(permissions, auth.Permission{
Action: auth.PermissionActionPublish,
ResourcePattern: pattern,
})
}
}
}
if h.config.OIDCEditPerms != "" {
for _, pattern := range strings.Split(h.config.OIDCEditPerms, ",") {
pattern = strings.TrimSpace(pattern)
if pattern != "" {
permissions = append(permissions, auth.Permission{
Action: auth.PermissionActionEdit,
ResourcePattern: pattern,
})
}
}
}
return permissions
}