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)
This commit is contained in:
AJ ONeal 2026-03-13 11:33:04 -06:00
parent 2f946d28b5
commit 52ffecb5b3
No known key found for this signature in database
5 changed files with 244 additions and 19 deletions

201
auth/ajwt/REDESIGN.md Normal file
View File

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

View File

@ -95,7 +95,7 @@ func (f *JWKsFetcher) Issuer(ctx context.Context) (*Issuer, error) {
return ci.iss, nil return ci.iss, nil
} }
keys, err := FetchJWKs(ctx, f.URL) keys, err := FetchJWKsURL(ctx, f.URL)
if err != nil { if err != nil {
// On error, serve stale keys within the stale window. // On error, serve stale keys within the stale window.
if ci := f.cached.Load(); ci != nil && f.KeepOnError { if ci := f.cached.Load(); ci != nil && f.KeepOnError {

View File

@ -565,9 +565,14 @@ func (iss *Issuer) PublicKeys() []PublicJWK {
return iss.pubKeys 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. // ToJWKs serializes the Issuer's public keys as a JWKS JSON document.
func (iss *Issuer) ToJWKs() ([]byte, error) { func (iss *Issuer) ToJWKs() ([]byte, error) {
return MarshalPublicJWKs(iss.pubKeys) return ToJWKs(iss.pubKeys)
} }
// Verify decodes tokenStr and verifies its signature. // Verify decodes tokenStr and verifies its signature.

View File

@ -158,10 +158,10 @@ type JWKsJSON struct {
Keys []PublicJWKJSON `json:"keys"` 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). // 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) { switch key := k.Key.(type) {
case *ecdsa.PublicKey: case *ecdsa.PublicKey:
var crv string var crv string
@ -173,7 +173,7 @@ func EncodePublicJWK(k PublicJWK) (PublicJWKJSON, error) {
case elliptic.P521(): case elliptic.P521():
crv = "P-521" crv = "P-521"
default: 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 byteLen := (key.Curve.Params().BitSize + 7) / 8
xBytes := make([]byte, byteLen) xBytes := make([]byte, byteLen)
@ -209,27 +209,36 @@ func EncodePublicJWK(k PublicJWK) (PublicJWKJSON, error) {
}, nil }, nil
default: 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. // ToJWKsJSON converts a slice of [PublicJWK] to a [JWKsJSON] struct.
func MarshalPublicJWKs(keys []PublicJWK) ([]byte, error) { func ToJWKsJSON(keys []PublicJWK) (JWKsJSON, error) {
jsonKeys := make([]PublicJWKJSON, 0, len(keys)) jsonKeys := make([]PublicJWKJSON, 0, len(keys))
for _, k := range keys { for _, k := range keys {
jk, err := EncodePublicJWK(k) jk, err := ToJWK(k)
if err != nil { if err != nil {
return nil, err return JWKsJSON{}, err
} }
jsonKeys = append(jsonKeys, jk) 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. // 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch JWKS: %w", err) 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) 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. // 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" discoveryURL := strings.TrimRight(baseURL, "/") + "/.well-known/openid-configuration"
keys, _, err := fetchJWKsFromDiscovery(ctx, discoveryURL) keys, _, err := fetchJWKsFromDiscovery(ctx, discoveryURL)
return keys, err return keys, err
} }
// FetchJWKsFromOAuth2 fetches JWKS via OAuth 2.0 authorization server metadata (RFC 8414) // FetchOAuth2URL fetches JWKS via OAuth 2.0 authorization server metadata (RFC 8414)
// from baseURL. // from the given base URL.
// //
// It fetches {baseURL}/.well-known/oauth-authorization-server and reads the jwks_uri field. // 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" discoveryURL := strings.TrimRight(baseURL, "/") + "/.well-known/oauth-authorization-server"
keys, _, err := fetchJWKsFromDiscovery(ctx, discoveryURL) keys, _, err := fetchJWKsFromDiscovery(ctx, discoveryURL)
return keys, err 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") 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 { if err != nil {
return nil, "", err return nil, "", err
} }

View File

@ -84,6 +84,16 @@ func (s *Signer) Issuer() *Issuer {
return New(s.PublicKeys()) 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 // PublicKeys returns the public-key side of each signing key, in the same order
// as the signers were provided to [NewSigner]. // as the signers were provided to [NewSigner].
func (s *Signer) PublicKeys() []PublicJWK { func (s *Signer) PublicKeys() []PublicJWK {