77 lines
2.6 KiB
Go
77 lines
2.6 KiB
Go
|
|
// 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
|