golib/auth/ajwt/REDESIGN.md
AJ ONeal 52ffecb5b3
ajwt: rename encode/fetch functions, add Signer.ToJWKs/ToJWKsJSON
Renames:
- EncodePublicJWK → ToJWK (package-level, single key)
- MarshalPublicJWKs → ToJWKs (package-level, raw JSON bytes)
- FetchJWKs → FetchJWKsURL
- FetchJWKsFromOIDC → FetchOIDCURL
- FetchJWKsFromOAuth2 → FetchOAuth2URL

New:
- ToJWKsJSON([]PublicJWK) (JWKsJSON, error) — package-level, struct form
- Issuer.ToJWKsJSON() (JWKsJSON, error)
- Signer.ToJWKs() ([]byte, error)
- Signer.ToJWKsJSON() (JWKsJSON, error)
2026-03-13 11:33:04 -06:00

6.5 KiB

ajwt Redesign Notes

API Summary

Fetch functions (pub.go — standalone, no Issuer needed)

FetchJWKs(ctx, jwksURL string)            ([]PublicJWK, error)
FetchJWKsFromOIDC(ctx, baseURL string)    ([]PublicJWK, error)  // /.well-known/openid-configuration → jwks_uri
FetchJWKsFromOAuth2(ctx, baseURL string)  ([]PublicJWK, error)  // /.well-known/oauth-authorization-server → jwks_uri

Both discovery functions share an internal fetchJWKsFromDiscovery(ctx, discoveryURL) helper that parses the jwks_uri field and fetches the keys. They also return issuer from the discovery doc so constructors can set iss.URL correctly.

Constructors (jwt.go)

New(issURL string, keys []PublicJWK, v *Validator) *Issuer
NewWithJWKs(ctx, jwksURL string, v *Validator)      (*Issuer, error)
NewWithOIDC(ctx, baseURL string, v *Validator)      (*Issuer, error)
NewWithOAuth2(ctx, baseURL string, v *Validator)    (*Issuer, error)

NewWithOIDC/NewWithOAuth2 set iss.URL from the discovery document's issuer field (not just the caller's baseURL) because OIDC requires them to match.

v *Validator is optional (nil = UnsafeVerify only; VerifyAndValidate requires non-nil).

Issuer struct (unexported fields, immutable after construction)

type Issuer struct {
    URL       string  // exported for inspection
    validator *Validator
    keys      map[string]crypto.PublicKey  // kid → key
}

func (iss *Issuer) UnsafeVerify(tokenStr string) (*JWS, error)
func (iss *Issuer) VerifyAndValidate(tokenStr string, claims StandardClaimsSource, now time.Time) (*JWS, []string, error)

UnsafeVerify = Decode + sig verify + iss check. "Unsafe" = forgery-safe, but exp/aud/etc. are NOT checked. Caller is responsible for claim validation.

VerifyAndValidate = UnsafeVerify + UnmarshalClaims(claims) + Validator.Validate(claims, now). Requires iss.validator != nil.

Validator

type Validator struct {
    IgnoreIss      bool
    Iss            string   // rarely needed — Issuer.UnsafeVerify already checks iss
    IgnoreSub      bool
    Sub            string
    IgnoreAud      bool
    Aud            string
    IgnoreExp      bool
    IgnoreJti      bool
    Jti            string
    IgnoreIat      bool
    IgnoreAuthTime bool
    MaxAge         time.Duration
    IgnoreNonce    bool
    Nonce          string
    IgnoreAmr      bool
    RequiredAmrs   []string
    IgnoreAzp      bool
    Azp            string
}

func (v Validator) Validate(claims StandardClaimsSource, now time.Time) ([]string, error)

Validate calls claims.GetStandardClaims() to extract the standard fields, then runs ValidateStandardClaims. The caller does not need to unmarshal separately.

Standalone validation (no Issuer)

ValidateStandardClaims(claims StandardClaims, v Validator, now time.Time) ([]string, error)

StandardClaimsSource interface (the key design question)

// StandardClaimsSource is implemented for free by any struct that embeds StandardClaims.
type StandardClaimsSource interface {
    GetStandardClaims() StandardClaims
}

StandardClaims itself implements it:

func (sc StandardClaims) GetStandardClaims() StandardClaims { return sc }

Because of Go's method promotion, any embedding struct gets this for free:

type AppClaims struct {
    ajwt.StandardClaims        // promotes GetStandardClaims() — zero boilerplate
    Email string `json:"email"`
    Roles []string `json:"roles"`
}
// AppClaims now satisfies StandardClaimsSource automatically.

Why not generics?

A generic Issuer[C StandardClaimsSource] locks one claims type per issuer instance. In practice, a service uses the same Issuer (created at startup) across different handlers that may want different claims types. A package-level generic function works too:

// Package-level generic (no generic Issuer needed):
jws, claims, errs, err := ajwt.VerifyAndValidate[AppClaims](iss, tokenStr, time.Now())

This avoids the output-parameter form, but Go can't infer C from the return type so the type argument is always required at the call site. It's a viable option if the output-parameter form feels awkward.

Current recommendation: output-parameter form with StandardClaimsSource. It mirrors json.Unmarshal ergonomics and keeps the Issuer non-generic.

var claims AppClaims
jws, errs, err := iss.VerifyAndValidate(tokenStr, &claims, time.Now())
// claims is populated AND standard claims are validated
// add custom validation here: if claims.Email == "" { ... }

PublicJWK

type PublicJWK struct {
    Key crypto.PublicKey
    KID string   // key ID from JWKS; set to Thumbprint() if absent in source
    Use string   // "sig", "enc", etc.
}

// Thumbprint computes the RFC 7638 JWK Thumbprint (SHA-256 of canonical key fields).
// Can be used as a KID when none is provided.
func (k PublicJWK) Thumbprint() (string, error)

Thumbprint canonical forms (lexicographic field order per RFC 7638):

  • EC: {"crv":…, "kty":"EC", "x":…, "y":…}
  • RSA: {"e":…, "kty":"RSA", "n":…}
  • OKP: {"crv":"Ed25519", "kty":"OKP", "x":…}

When parsing a JWKS where a key has no kid field, auto-populate KID from Thumbprint().

Typed accessors (already exist):

func (k PublicJWK) ECDSA() (*ecdsa.PublicKey, bool)
func (k PublicJWK) RSA()   (*rsa.PublicKey, bool)
func (k PublicJWK) EdDSA() (ed25519.PublicKey, bool)

Full flow examples

With VerifyAndValidate

iss, err := ajwt.NewWithOIDC(ctx, "https://accounts.google.com",
    &ajwt.Validator{Aud: "my-client-id", IgnoreIss: true})

// Per request:
var claims AppClaims
jws, errs, err := iss.VerifyAndValidate(tokenStr, &claims, time.Now())
if err != nil { /* hard error: bad sig, expired, etc. */ }
if len(errs) > 0 { /* soft errors: wrong aud, missing amr, etc. */ }
// Custom checks:
if claims.Email == "" { ... }

With UnsafeVerify (custom validation only)

iss, err := ajwt.New("https://example.com", keys, nil)

jws, err := iss.UnsafeVerify(tokenStr)
var claims AppClaims
jws.UnmarshalClaims(&claims)
errs, err := ajwt.ValidateStandardClaims(claims.StandardClaims,
    ajwt.Validator{Aud: "myapp"}, time.Now())
// plus custom checks

Open questions

  • Should VerifyAndValidate with nil validator error, or silently behave like UnsafeVerify? → Lean toward error: loud failure beats silent no-op.
  • Should Validator.IgnoreIss default to true when used via VerifyAndValidate (since UnsafeVerify already checks iss)? → Document it; don't auto-set — caller controls what they validate.