mirror of
https://github.com/therootcompany/golib.git
synced 2026-04-24 04:38:02 +00:00
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)
202 lines
6.5 KiB
Markdown
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.
|