diff --git a/auth/jwt/SKILL.md b/auth/jwt/SKILL.md new file mode 100644 index 0000000..814d4d1 --- /dev/null +++ b/auth/jwt/SKILL.md @@ -0,0 +1,181 @@ +--- +name: jwt +description: Go JWT/JWS/JWK library patterns. Use when writing code that signs, verifies, or validates JWTs, implements OIDC/OAuth flows, builds JWKS endpoints, creates ACME JWS messages, or works with custom JOSE headers. +--- + +## Core flow + +Issuer: `NewSigner` -> `Signer.SignToString` (or `Sign` + `Encode`) +Relying party: `Decode` -> `Verifier.Verify` -> `RawJWT.UnmarshalClaims` -> `Validator.Validate` + +## Signing JWTs + +```go +pk, _ := jwt.NewPrivateKey() // Ed25519 by default +signer, _ := jwt.NewSigner([]*jwt.PrivateKey{pk}) + +token, _ := signer.SignToString(&jwt.TokenClaims{ + Iss: "https://auth.example.com", + Sub: "user123", + Aud: jwt.Listish{"https://api.example.com"}, + Exp: time.Now().Add(time.Hour).Unix(), + IAt: time.Now().Unix(), +}) +``` + +`SignToString` is the one-step path. Use `Sign` when you need the JWT object. + +## Verifying + validating + +```go +verifier, _ := jwt.NewVerifier(pubKeys) +jws, _ := verifier.VerifyJWT(tokenStr) // decode + verify in one step + +var claims MyCustomClaims +_ = jws.UnmarshalClaims(&claims) + +validator := jwt.NewIDTokenValidator( + []string{"https://auth.example.com"}, // iss (nil=skip, ["*"]=any) + []string{"https://api.example.com"}, // aud (nil=skip, ["*"]=any) + nil, // azp (nil=skip) + 0, // grace period (0 = 2s default) +) +_ = validator.Validate(nil, &claims, time.Now()) +``` + +Two-step alternative: `jwt.Decode(tokenStr)` then `verifier.Verify(jws)`. + +## Claims embedding pattern + +Embed `TokenClaims` (minimal) or `StandardClaims` (with name/email/picture) to satisfy the `Claims` interface for free: + +```go +type MyClaims struct { + jwt.TokenClaims + OrgID string `json:"org_id"` + Roles []string `json:"roles"` +} +``` + +`TokenClaims` fields: Iss, Sub, Aud, Exp, NBf, IAt, JTI, AuthTime, Nonce, AMR, AzP, ClientID, Scope (SpaceDelimited). +`StandardClaims` adds: Name, GivenName, FamilyName, Email, EmailVerified, Picture, and more. + +## Custom JOSE headers + +Embed `RFCHeader` in your header struct and implement `SignableJWT`: + +```go +type DPoPHeader struct { + jwt.RFCHeader + Nonce string `json:"nonce,omitempty"` +} + +type DPoPJWT struct { + jwt.RawJWT + Header DPoPHeader +} + +func (d *DPoPJWT) GetHeader() jwt.RFCHeader { return d.Header.RFCHeader } +func (d *DPoPJWT) SetHeader(hdr jwt.Header) error { + d.Header.RFCHeader = *hdr.GetRFCHeader() + data, _ := json.Marshal(d.Header) + d.Protected = []byte(base64.RawURLEncoding.EncodeToString(data)) + return nil +} +func (d *DPoPJWT) SetSignature(sig []byte) { d.Signature = sig } +``` + +Then sign with `signer.SignJWT(dpopJWT)`. + +## Raw JWS signing (ACME, non-JWT protocols) + +`SignRaw` signs arbitrary headers + payload without JWT semantics. Only `alg` is set from the key; KID is caller-controlled. + +```go +hdr := &AcmeHeader{ + URL: "https://acme.example.com/acme/new-account", + Nonce: "server-nonce", + JWK: jwkBytes, +} +raw, _ := signer.SignRaw(hdr, payloadJSON) +flatJSON, _ := json.Marshal(raw) // flattened JWS JSON +``` + +Header structs must embed `jwt.RFCHeader` and satisfy the `Header` interface (which `RFCHeader` provides via `GetRFCHeader()`). `RFCHeader` uses `omitempty` on KID and Typ so they are absent when empty. + +## JWKS endpoint + +`Signer` has `WellKnownJWKs`. Serve it directly: + +```go +json.Marshal(&signer.WellKnownJWKs) // {"keys":[...]} +``` + +## Validators + +- `NewIDTokenValidator(iss, aud, azp, gracePeriod)` -- OIDC ID tokens (checks sub, exp, iat, auth_time) +- `NewAccessTokenValidator(iss, aud, gracePeriod)` -- RFC 9068 access tokens (checks sub, exp, iat, jti, client_id) +- Struct literal `&Validator{Checks: ...}` with `Checks` bitmask for custom validation +- A zero-value `Validator` returns `ErrMisconfigured` -- always use a constructor or set flags + +Iss/Aud/AzP slice semantics: nil=unchecked, `[]string{}`=misconfigured, `[]string{"*"}`=any, `[]string{"x"}`=must match. + +## Listish (JSON quirk) + +`Listish` handles the JWT "aud" claim quirk: RFC 7519 allows it to be either a single string `"x"` or an array `["x","y"]`. Unmarshal accepts both; Marshal outputs string for single values, array for multiple. + +## SpaceDelimited (trinary) + +`SpaceDelimited` is a slice that marshals as a space-separated string in JSON. Three states: `nil` (absent, omitted via omitzero), `SpaceDelimited{}` (present empty string `""`), `SpaceDelimited{"a","b"}` (populated `"a b"`). + +## Typ header validation + +Call `hdr.IsAllowedTyp(errs, allowed)` between Verify and Validate. Case-insensitive per RFC 7515. +Constants: `DefaultTokenTyp = "JWT"`, `AccessTokenTyp = "at+jwt"` (RFC 9068). + +## Key types + +- `NewPrivateKey()` -- generates Ed25519 (default, recommended) +- `PrivateKey` / `PublicKey` -- type-safe wrappers, marshal as JWK +- Algorithm derived from key type automatically (EdDSA, ES256, ES384, ES512, RS256) + +## CLI tool (cmd/jwt) + +``` +jwt sign --key [claims-json] sign claims into a compact JWT +jwt inspect [token] decode and display (with OIDC/OAuth2 discovery) +jwt verify --key [token] verify signature and validate claims +jwt keygen [--alg EdDSA] generate a fresh private key (JWK) +``` + +Key sources: `--key` flag, `JWT_PRIVATE_KEY` / `JWT_PRIVATE_KEY_FILE` / `JWT_PUBLIC_JWK` env vars. +Time claims: `--exp 1h`, `--nbf -5s`, `--iat +0s` (relative to `--time`), or absolute Unix epoch. + +## File layout + +| File | Contents | +|------|----------| +| `jwt.go` | Interfaces (VerifiableJWT, SignableJWT), RawJWT, JWT, Header, RFCHeader, Encode/Decode | +| `sign.go` | Signer, SignJWT, SignRaw, key validation | +| `verify.go` | Verifier, NewVerifier, Verify, VerifyJWT | +| `validate.go` | Validator, Checks bitmask, constructors, per-claim check methods | +| `claims.go` | TokenClaims, StandardClaims, Claims interface | +| `types.go` | NullBool, Listish, SpaceDelimited, token type constants | +| `jwk.go` | PrivateKey, PublicKey, NewPrivateKey, JWK marshal/unmarshal, thumbprint | +| `errors.go` | Sentinel errors, ValidationError, GetOAuth2Error | +| `keyfetch/` | KeyFetcher (lazy JWKS fetch + cache), FetchURL, FetchOIDC, FetchOAuth2 | +| `keyfile/` | Load/Save keys from local files (JWK, PEM, DER) | + +## Examples + +See `examples/` for complete working code: +- `oidc-id-token` -- standard OIDC flow with NewIDTokenValidator +- `oauth-access-token` -- RFC 9068 access tokens with NewAccessTokenValidator +- `http-middleware` -- bearer token middleware +- `mcp-server-auth` -- MCP agent auth +- `acme-jws` -- ACME JWS with SignRaw (with tests) +- `custom-header` -- reading custom JOSE header fields +- `dpop-jws` -- DPoP proof tokens with custom typ and nonce +- `cached-keys` -- remote JWKS fetching with disk persistence +- `mfa-validator` -- auth_time + MaxAge validation +- `rfc-claims` -- standard claims usage