// Pocket-ID OIDC-backed Bearer token validator. // // Phase 1 wires this up for the production path: the signing-service // discovers the issuer, caches the JWKS, and auto-refreshes on `kid` // rotation via coreos/go-oidc's RemoteKeySet. package token import ( "context" "errors" "fmt" "net/http" "time" "github.com/coreos/go-oidc/v3/oidc" ) // PocketIDValidator verifies a Bearer JWT against a Pocket-ID issuer and // returns the `sub` claim (which is the Pocket-ID client id). type PocketIDValidator struct { verifier *oidc.IDTokenVerifier issuer string audience string } // NewPocketIDValidator performs OIDC discovery and returns a validator. // If audience is non-empty, the verifier enforces the `aud` claim. // SkipClientIDCheck is used when audience is empty because Pocket ID // subject tokens may be addressed to a service name rather than a client. func NewPocketIDValidator(ctx context.Context, issuer, audience string) (*PocketIDValidator, error) { if issuer == "" { return nil, errors.New("token.PocketID: empty issuer") } provider, err := oidc.NewProvider(ctx, issuer) if err != nil { return nil, fmt.Errorf("token.PocketID: oidc discovery for %q: %w", issuer, err) } cfg := &oidc.Config{ ClientID: audience, SkipClientIDCheck: audience == "", } v := provider.Verifier(cfg) return &PocketIDValidator{verifier: v, issuer: issuer, audience: audience}, nil } // Validate implements Validator. It parses, verifies and returns `sub`. func (p *PocketIDValidator) Validate(ctx context.Context, bearer string) (string, error) { if bearer == "" { return "", ErrInvalid } idTok, err := p.verifier.Verify(ctx, bearer) if err != nil { return "", fmt.Errorf("%w: %v", ErrInvalid, err) } if idTok.Subject == "" { return "", fmt.Errorf("%w: missing sub claim", ErrInvalid) } return idTok.Subject, nil } // Issuer returns the configured issuer URL (for logs / healthz). func (p *PocketIDValidator) Issuer() string { return p.issuer } // NewPocketIDValidatorWithHTTPClient is the same as NewPocketIDValidator // but uses the supplied HTTP client (test seams, custom timeouts). func NewPocketIDValidatorWithHTTPClient(ctx context.Context, issuer, audience string, client *http.Client) (*PocketIDValidator, error) { if client != nil { ctx = oidc.ClientContext(ctx, client) } return NewPocketIDValidator(ctx, issuer, audience) } // SignerRefreshInterval is advisory — go-oidc refreshes on `kid` miss but // callers who want proactive refresh can wrap this in a timer. We expose // it so cmd/ can decide a cadence. const SignerRefreshInterval = 15 * time.Minute