mirror of
https://github.com/therootcompany/golib.git
synced 2026-04-24 04:38:02 +00:00
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:
parent
2f946d28b5
commit
52ffecb5b3
201
auth/ajwt/REDESIGN.md
Normal file
201
auth/ajwt/REDESIGN.md
Normal 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.
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user