diff --git a/auth/ajwt/REDESIGN.md b/auth/ajwt/REDESIGN.md new file mode 100644 index 0000000..ce86e02 --- /dev/null +++ b/auth/ajwt/REDESIGN.md @@ -0,0 +1,201 @@ +# ajwt Redesign Notes + +## API Summary + +### Fetch functions (pub.go — standalone, no Issuer needed) + +```go +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) + +```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) + +```go +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 + +```go +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) + +```go +ValidateStandardClaims(claims StandardClaims, v Validator, now time.Time) ([]string, error) +``` + +## StandardClaimsSource interface (the key design question) + +```go +// StandardClaimsSource is implemented for free by any struct that embeds StandardClaims. +type StandardClaimsSource interface { + GetStandardClaims() StandardClaims +} +``` + +`StandardClaims` itself implements it: + +```go +func (sc StandardClaims) GetStandardClaims() StandardClaims { return sc } +``` + +Because of Go's method promotion, any embedding struct gets this for free: + +```go +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: + +```go +// 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. + +```go +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 + +```go +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): +```go +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 + +```go +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) + +```go +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. diff --git a/auth/ajwt/fetcher.go b/auth/ajwt/fetcher.go index 23f0987..f8a00e9 100644 --- a/auth/ajwt/fetcher.go +++ b/auth/ajwt/fetcher.go @@ -95,7 +95,7 @@ func (f *JWKsFetcher) Issuer(ctx context.Context) (*Issuer, error) { return ci.iss, nil } - keys, err := FetchJWKs(ctx, f.URL) + keys, err := FetchJWKsURL(ctx, f.URL) if err != nil { // On error, serve stale keys within the stale window. if ci := f.cached.Load(); ci != nil && f.KeepOnError { diff --git a/auth/ajwt/jwt.go b/auth/ajwt/jwt.go index 6037821..ad60a18 100644 --- a/auth/ajwt/jwt.go +++ b/auth/ajwt/jwt.go @@ -565,9 +565,14 @@ func (iss *Issuer) PublicKeys() []PublicJWK { return iss.pubKeys } +// ToJWKsJSON returns the Issuer's public keys as a [JWKsJSON] struct. +func (iss *Issuer) ToJWKsJSON() (JWKsJSON, error) { + return ToJWKsJSON(iss.pubKeys) +} + // ToJWKs serializes the Issuer's public keys as a JWKS JSON document. func (iss *Issuer) ToJWKs() ([]byte, error) { - return MarshalPublicJWKs(iss.pubKeys) + return ToJWKs(iss.pubKeys) } // Verify decodes tokenStr and verifies its signature. diff --git a/auth/ajwt/pub.go b/auth/ajwt/pub.go index c0e6d75..59dd690 100644 --- a/auth/ajwt/pub.go +++ b/auth/ajwt/pub.go @@ -158,10 +158,10 @@ type JWKsJSON struct { Keys []PublicJWKJSON `json:"keys"` } -// EncodePublicJWK converts a [PublicJWK] to its JSON representation. +// ToJWK converts a [PublicJWK] to its JSON representation. // // Supported key types: *ecdsa.PublicKey (EC), *rsa.PublicKey (RSA), ed25519.PublicKey (OKP). -func EncodePublicJWK(k PublicJWK) (PublicJWKJSON, error) { +func ToJWK(k PublicJWK) (PublicJWKJSON, error) { switch key := k.Key.(type) { case *ecdsa.PublicKey: var crv string @@ -173,7 +173,7 @@ func EncodePublicJWK(k PublicJWK) (PublicJWKJSON, error) { case elliptic.P521(): crv = "P-521" default: - return PublicJWKJSON{}, fmt.Errorf("EncodePublicJWK: unsupported EC curve %s", key.Curve.Params().Name) + return PublicJWKJSON{}, fmt.Errorf("ToJWK: unsupported EC curve %s", key.Curve.Params().Name) } byteLen := (key.Curve.Params().BitSize + 7) / 8 xBytes := make([]byte, byteLen) @@ -209,27 +209,36 @@ func EncodePublicJWK(k PublicJWK) (PublicJWKJSON, error) { }, nil default: - return PublicJWKJSON{}, fmt.Errorf("EncodePublicJWK: unsupported key type %T", k.Key) + return PublicJWKJSON{}, fmt.Errorf("ToJWK: unsupported key type %T", k.Key) } } -// MarshalPublicJWKs serializes a slice of [PublicJWK] as a JWKS JSON document. -func MarshalPublicJWKs(keys []PublicJWK) ([]byte, error) { +// ToJWKsJSON converts a slice of [PublicJWK] to a [JWKsJSON] struct. +func ToJWKsJSON(keys []PublicJWK) (JWKsJSON, error) { jsonKeys := make([]PublicJWKJSON, 0, len(keys)) for _, k := range keys { - jk, err := EncodePublicJWK(k) + jk, err := ToJWK(k) if err != nil { - return nil, err + return JWKsJSON{}, err } jsonKeys = append(jsonKeys, jk) } - return json.Marshal(JWKsJSON{Keys: jsonKeys}) + return JWKsJSON{Keys: jsonKeys}, nil } -// FetchJWKs retrieves and parses a JWKS document from jwksURL. +// ToJWKs serializes a slice of [PublicJWK] as a JWKS JSON document. +func ToJWKs(keys []PublicJWK) ([]byte, error) { + doc, err := ToJWKsJSON(keys) + if err != nil { + return nil, err + } + return json.Marshal(doc) +} + +// FetchJWKsURL retrieves and parses a JWKS document from the given JWKS endpoint URL. // // ctx is used for the HTTP request timeout and cancellation. -func FetchJWKs(ctx context.Context, jwksURL string) ([]PublicJWK, error) { +func FetchJWKsURL(ctx context.Context, jwksURL string) ([]PublicJWK, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksURL, nil) if err != nil { return nil, fmt.Errorf("fetch JWKS: %w", err) @@ -247,20 +256,20 @@ func FetchJWKs(ctx context.Context, jwksURL string) ([]PublicJWK, error) { return DecodePublicJWKs(resp.Body) } -// FetchJWKsFromOIDC fetches JWKS via OIDC discovery from baseURL. +// FetchOIDCURL fetches JWKS via OIDC discovery from the given base URL. // // It fetches {baseURL}/.well-known/openid-configuration and reads the jwks_uri field. -func FetchJWKsFromOIDC(ctx context.Context, baseURL string) ([]PublicJWK, error) { +func FetchOIDCURL(ctx context.Context, baseURL string) ([]PublicJWK, error) { discoveryURL := strings.TrimRight(baseURL, "/") + "/.well-known/openid-configuration" keys, _, err := fetchJWKsFromDiscovery(ctx, discoveryURL) return keys, err } -// FetchJWKsFromOAuth2 fetches JWKS via OAuth 2.0 authorization server metadata (RFC 8414) -// from baseURL. +// FetchOAuth2URL fetches JWKS via OAuth 2.0 authorization server metadata (RFC 8414) +// from the given base URL. // // It fetches {baseURL}/.well-known/oauth-authorization-server and reads the jwks_uri field. -func FetchJWKsFromOAuth2(ctx context.Context, baseURL string) ([]PublicJWK, error) { +func FetchOAuth2URL(ctx context.Context, baseURL string) ([]PublicJWK, error) { discoveryURL := strings.TrimRight(baseURL, "/") + "/.well-known/oauth-authorization-server" keys, _, err := fetchJWKsFromDiscovery(ctx, discoveryURL) return keys, err @@ -296,7 +305,7 @@ func fetchJWKsFromDiscovery(ctx context.Context, discoveryURL string) ([]PublicJ return nil, "", fmt.Errorf("discovery doc missing jwks_uri field") } - keys, err := FetchJWKs(ctx, doc.JWKsURI) + keys, err := FetchJWKsURL(ctx, doc.JWKsURI) if err != nil { return nil, "", err } diff --git a/auth/ajwt/sign.go b/auth/ajwt/sign.go index 47bafc4..8cc4e83 100644 --- a/auth/ajwt/sign.go +++ b/auth/ajwt/sign.go @@ -84,6 +84,16 @@ func (s *Signer) Issuer() *Issuer { return New(s.PublicKeys()) } +// ToJWKsJSON returns the Signer's public keys as a [JWKsJSON] struct. +func (s *Signer) ToJWKsJSON() (JWKsJSON, error) { + return ToJWKsJSON(s.PublicKeys()) +} + +// ToJWKs serializes the Signer's public keys as a JWKS JSON document. +func (s *Signer) ToJWKs() ([]byte, error) { + return ToJWKs(s.PublicKeys()) +} + // PublicKeys returns the public-key side of each signing key, in the same order // as the signers were provided to [NewSigner]. func (s *Signer) PublicKeys() []PublicJWK {