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
|
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 {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user