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

202 lines
6.5 KiB
Markdown

# 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.