From 0800ea2491981fce31110bfcbb0e67537e675602 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 17 Mar 2026 04:15:24 -0600 Subject: [PATCH] WIP: feat: add auth/jwt/examples --- auth/jwt/examples/acme-jws/acme_test.go | 186 ++++++++++++ auth/jwt/examples/acme-jws/main.go | 53 ++++ auth/jwt/examples/cached-keys/main.go | 95 ++++++ auth/jwt/examples/custom-header/main.go | 60 ++++ auth/jwt/examples/dpop-jws/main.go | 123 ++++++++ auth/jwt/examples/http-middleware/main.go | 142 +++++++++ auth/jwt/examples/mcp-server-auth/main.go | 298 +++++++++++++++++++ auth/jwt/examples/mfa-validator/main.go | 138 +++++++++ auth/jwt/examples/oauth-access-token/main.go | 112 +++++++ auth/jwt/examples/oidc-id-token/main.go | 118 ++++++++ auth/jwt/examples/rfc-claims/rfc.go | 26 ++ auth/jwt/examples/rfc-claims/rfc_test.go | 96 ++++++ 12 files changed, 1447 insertions(+) create mode 100644 auth/jwt/examples/acme-jws/acme_test.go create mode 100644 auth/jwt/examples/acme-jws/main.go create mode 100644 auth/jwt/examples/cached-keys/main.go create mode 100644 auth/jwt/examples/custom-header/main.go create mode 100644 auth/jwt/examples/dpop-jws/main.go create mode 100644 auth/jwt/examples/http-middleware/main.go create mode 100644 auth/jwt/examples/mcp-server-auth/main.go create mode 100644 auth/jwt/examples/mfa-validator/main.go create mode 100644 auth/jwt/examples/oauth-access-token/main.go create mode 100644 auth/jwt/examples/oidc-id-token/main.go create mode 100644 auth/jwt/examples/rfc-claims/rfc.go create mode 100644 auth/jwt/examples/rfc-claims/rfc_test.go diff --git a/auth/jwt/examples/acme-jws/acme_test.go b/auth/jwt/examples/acme-jws/acme_test.go new file mode 100644 index 0000000..751a126 --- /dev/null +++ b/auth/jwt/examples/acme-jws/acme_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/therootcompany/golib/auth/jwt" +) + +// TestNewAccountJWS verifies ACME newAccount signing: jwk in header, +// kid absent, typ absent, payload is newAccount body. +func TestNewAccountJWS(t *testing.T) { + pk, err := jwt.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + t.Fatal(err) + } + + pubKey, err := pk.PublicKey() + if err != nil { + t.Fatal(err) + } + jwkBytes, err := json.Marshal(pubKey) + if err != nil { + t.Fatal(err) + } + + payload := NewAccountPayload{ + TermsOfServiceAgreed: true, + Contact: []string{"mailto:cert-admin@example.com"}, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + + hdr := &AcmeHeader{ + URL: "https://acme.example.com/acme/new-account", + Nonce: "abc123-server-nonce", + JWK: json.RawMessage(jwkBytes), + // KID is empty for newAccount -- jwk is used instead. + } + + raw, err := signer.SignRaw(hdr, payloadJSON) + if err != nil { + t.Fatal(err) + } + + // Verify protected header has ACME fields and no typ. + headerJSON, err := base64.RawURLEncoding.DecodeString(string(raw.Protected)) + if err != nil { + t.Fatal(err) + } + + var decoded map[string]any + if err := json.Unmarshal(headerJSON, &decoded); err != nil { + t.Fatal(err) + } + + if _, ok := decoded["typ"]; ok { + t.Error("ACME header must not contain typ") + } + if decoded["alg"] != "EdDSA" { + t.Errorf("alg = %v, want EdDSA", decoded["alg"]) + } + if decoded["url"] != "https://acme.example.com/acme/new-account" { + t.Errorf("url = %v", decoded["url"]) + } + if decoded["nonce"] != "abc123-server-nonce" { + t.Errorf("nonce = %v", decoded["nonce"]) + } + if decoded["jwk"] == nil { + t.Error("newAccount header must contain jwk") + } + if _, ok := decoded["kid"]; ok { + t.Error("newAccount header must not contain kid (mutually exclusive with jwk)") + } + + // Verify signature is present. + if len(raw.Signature) == 0 { + t.Fatal("signature is empty") + } + + // Verify RawJWT marshals as flattened JWS JSON. + flat, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + var flatMap map[string]string + if err := json.Unmarshal(flat, &flatMap); err != nil { + t.Fatalf("flattened JWS is not valid JSON: %v", err) + } + for _, field := range []string{"protected", "payload", "signature"} { + if flatMap[field] == "" { + t.Errorf("flattened JWS missing %q field", field) + } + } + + // Round-trip: unmarshal flattened JWS back into a RawJWT. + var roundTrip jwt.RawJWT + if err := json.Unmarshal(flat, &roundTrip); err != nil { + t.Fatalf("unmarshal flattened JWS: %v", err) + } + if string(roundTrip.Protected) != string(raw.Protected) { + t.Error("round-trip: protected mismatch") + } + if string(roundTrip.Payload) != string(raw.Payload) { + t.Error("round-trip: payload mismatch") + } + if string(roundTrip.Signature) != string(raw.Signature) { + t.Error("round-trip: signature mismatch") + } +} + +// TestAuthenticatedRequestJWS verifies ACME POST-as-GET: kid in header, +// jwk absent, empty payload. +func TestAuthenticatedRequestJWS(t *testing.T) { + pk, err := jwt.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + t.Fatal(err) + } + + // ACME kid is the account URL, not a key thumbprint. + // SignRaw uses the header's KID as-is (no conflict check). + accountURL := "https://acme.example.com/acme/acct/12345" + + hdr := &AcmeHeader{ + RFCHeader: jwt.RFCHeader{KID: accountURL}, + URL: "https://acme.example.com/acme/orders", + Nonce: "def456-server-nonce", + } + + // POST-as-GET: nil payload produces empty payload segment. + raw, err := signer.SignRaw(hdr, nil) + if err != nil { + t.Fatal(err) + } + + // Verify protected header. + headerJSON, err := base64.RawURLEncoding.DecodeString(string(raw.Protected)) + if err != nil { + t.Fatal(err) + } + + var decoded map[string]any + if err := json.Unmarshal(headerJSON, &decoded); err != nil { + t.Fatal(err) + } + + if _, ok := decoded["typ"]; ok { + t.Error("ACME header must not contain typ") + } + if decoded["kid"] != accountURL { + t.Errorf("kid = %v, want %s", decoded["kid"], accountURL) + } + if _, ok := decoded["jwk"]; ok { + t.Error("authenticated request must not contain jwk (mutually exclusive with kid)") + } + if decoded["url"] != "https://acme.example.com/acme/orders" { + t.Errorf("url = %v", decoded["url"]) + } + + // Verify empty payload produces valid flattened JWS. + flat, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + var flatMap map[string]string + if err := json.Unmarshal(flat, &flatMap); err != nil { + t.Fatalf("flattened JWS is not valid JSON: %v", err) + } + if flatMap["payload"] != "" { + t.Errorf("POST-as-GET payload should be empty, got %q", flatMap["payload"]) + } + if flatMap["signature"] == "" { + t.Error("signature is empty") + } +} diff --git a/auth/jwt/examples/acme-jws/main.go b/auth/jwt/examples/acme-jws/main.go new file mode 100644 index 0000000..d6f1da8 --- /dev/null +++ b/auth/jwt/examples/acme-jws/main.go @@ -0,0 +1,53 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example acme-jws demonstrates how to use the jwt library to produce +// ACME (RFC 8555) JWS messages with custom protected header fields. +// +// ACME uses JWS with non-standard header fields: +// - url -- the ACME endpoint URL being requested +// - nonce -- anti-replay nonce obtained from the server +// - jwk -- the account's public key (for newAccount requests) +// - kid -- the account URL (for authenticated requests; mutually exclusive with jwk) +// +// ACME uses "flattened JWS JSON serialization" (RFC 7515 appendix A.7), +// not compact serialization. [jwt.Signer.SignRaw] handles the signing, +// and [jwt.RawJWT.MarshalJSON] produces the flattened JWS JSON: +// +// {"protected":"...","payload":"...","signature":"..."} +// +// See acme_test.go for working examples of both newAccount (jwk) and +// authenticated (kid) request flows. +// +// https://www.rfc-editor.org/rfc/rfc8555 +package main + +import ( + "encoding/json" + + "github.com/therootcompany/golib/auth/jwt" +) + +// AcmeHeader is the ACME JWS protected header. It embeds [jwt.RFCHeader] +// for alg and kid (both omitempty, so typ is never serialized -- ACME +// JWS does not use it), and adds the ACME-specific url, nonce, and jwk +// fields. +type AcmeHeader struct { + jwt.RFCHeader + URL string `json:"url"` + Nonce string `json:"nonce"` + JWK json.RawMessage `json:"jwk,omitempty"` +} + +// NewAccountPayload is the ACME newAccount request body (RFC 8555 §7.3). +type NewAccountPayload struct { + TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` + Contact []string `json:"contact"` +} + +func main() {} diff --git a/auth/jwt/examples/cached-keys/main.go b/auth/jwt/examples/cached-keys/main.go new file mode 100644 index 0000000..eb2212f --- /dev/null +++ b/auth/jwt/examples/cached-keys/main.go @@ -0,0 +1,95 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example cached-keys demonstrates persisting JWKS keys to disk so that a +// service can start verifying tokens immediately on restart without blocking +// on a network fetch. +// +// On startup, keys are loaded from a local file (if it exists) and passed +// as InitialKeys. After each Verifier() call, RefreshedAt is checked to +// detect updates, and keys are saved only when the sorted KIDs differ. +package main + +import ( + "fmt" + "log" + "os" + "slices" + "time" + + "github.com/therootcompany/golib/auth/jwt" + "github.com/therootcompany/golib/auth/jwt/keyfetch" + "github.com/therootcompany/golib/auth/jwt/keyfile" +) + +const ( + jwksURL = "https://accounts.example.com/.well-known/jwks.json" + cacheFile = "jwks-cache.json" +) + +func main() { + // Load cached keys from disk (if any). + initialKeys, err := loadCachedKeys(cacheFile) + if err != nil { + log.Printf("no cached keys: %v", err) + } + + fetcher := &keyfetch.KeyFetcher{ + URL: jwksURL, + RefreshTimeout: 10 * time.Second, + InitialKeys: initialKeys, + } + + // Track when we last saved so we can detect refreshes. + cachedKIDs := sortedKIDs(initialKeys) + lastSaved := time.Time{} + + verifier, err := fetcher.Verifier() + if err != nil { + log.Fatalf("failed to get verifier: %v", err) + } + + // Save if keys were refreshed and KIDs changed. + if fetcher.RefreshedAt().After(lastSaved) { + lastSaved = fetcher.RefreshedAt() + kids := sortedKIDs(verifier.PublicKeys()) + if !slices.Equal(kids, cachedKIDs) { + if err := keyfile.SavePublicJWKs(cacheFile, verifier.PublicKeys()); err != nil { + log.Printf("save cached keys: %v", err) + } else { + cachedKIDs = kids + log.Printf("saved %d keys to %s", len(verifier.PublicKeys()), cacheFile) + } + } + } + + fmt.Printf("verifier ready with %d keys\n", len(verifier.PublicKeys())) +} + +// loadCachedKeys reads a JWKS file and returns the keys. Returns nil +// (not an error) if the file doesn't exist. +func loadCachedKeys(path string) ([]jwt.PublicKey, error) { + jwks, err := keyfile.LoadWellKnownJWKs(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return jwks.Keys, nil +} + +// sortedKIDs returns the KIDs from keys in sorted order. +func sortedKIDs(keys []jwt.PublicKey) []string { + kids := make([]string, len(keys)) + for i := range keys { + kids[i] = keys[i].KID + } + slices.Sort(kids) + return kids +} diff --git a/auth/jwt/examples/custom-header/main.go b/auth/jwt/examples/custom-header/main.go new file mode 100644 index 0000000..511f0d5 --- /dev/null +++ b/auth/jwt/examples/custom-header/main.go @@ -0,0 +1,60 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example custom-header demonstrates reading a custom JOSE header field +// from a decoded JWT using [jwt.DecodeRaw] + [jwt.RawJWT.UnmarshalHeader]. +// +// This is the relying-party pattern: you receive a token and need to +// inspect non-standard header fields before or after verification. +// +// For signing with custom headers, see the dpop-jws example. +package main + +import ( + "fmt" + "log" + + "github.com/therootcompany/golib/auth/jwt" +) + +// MyHeader adds a nonce field to the standard JOSE header. +type MyHeader struct { + jwt.RFCHeader + Nonce string `json:"nonce,omitempty"` +} + +func main() { + // Given a token with a custom "nonce" header field... + pk, err := jwt.NewPrivateKey() + if err != nil { + log.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + log.Fatal(err) + } + token, err := signer.SignToString(&jwt.TokenClaims{Sub: "user123"}) + if err != nil { + log.Fatal(err) + } + + // Decode the raw segments, then unmarshal the header into your struct. + raw, err := jwt.DecodeRaw(token) + if err != nil { + log.Fatal(err) + } + + var h MyHeader + if err := raw.UnmarshalHeader(&h); err != nil { + log.Fatal(err) + } + + fmt.Printf("alg: %s\n", h.Alg) + fmt.Printf("kid: %s\n", h.KID) + fmt.Printf("nonce: %q\n", h.Nonce) // empty - this token has no nonce +} diff --git a/auth/jwt/examples/dpop-jws/main.go b/auth/jwt/examples/dpop-jws/main.go new file mode 100644 index 0000000..72eab75 --- /dev/null +++ b/auth/jwt/examples/dpop-jws/main.go @@ -0,0 +1,123 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example dpop-jws demonstrates how to sign and decode a DPoP proof JWT +// (RFC 9449) with custom JOSE header fields. +// +// DPoP proof tokens use a custom typ ("dpop+jwt") and carry a server +// nonce in the header for replay protection. This example shows how to +// implement [jwt.SignableJWT] with a custom header struct. +// +// On the relying-party side, [jwt.DecodeRaw] + [jwt.RawJWT.UnmarshalHeader] +// gives you access to the custom fields. +// +// https://www.rfc-editor.org/rfc/rfc9449 +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/therootcompany/golib/auth/jwt" +) + +// DPoPHeader extends the standard JOSE header with a DPoP nonce, +// as used in RFC 9449 (DPoP) proof tokens. +type DPoPHeader struct { + jwt.RFCHeader + Nonce string `json:"nonce,omitempty"` +} + +// DPoPJWT is a custom JWT type that carries a DPoP header. +type DPoPJWT struct { + jwt.RawJWT + Header DPoPHeader +} + +// GetHeader implements [jwt.VerifiableJWT]. +func (d *DPoPJWT) GetHeader() jwt.RFCHeader { return d.Header.RFCHeader } + +// SetHeader implements [jwt.SignableJWT]. It merges the signer's +// alg/kid into the DPoP header, then encodes the full protected header. +func (d *DPoPJWT) SetHeader(hdr jwt.Header) error { + d.Header.RFCHeader = *hdr.GetRFCHeader() + data, err := json.Marshal(d.Header) + if err != nil { + return err + } + d.Protected = []byte(base64.RawURLEncoding.EncodeToString(data)) + return nil +} + +func main() { + pk, err := jwt.NewPrivateKey() + if err != nil { + log.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + log.Fatal(err) + } + + claims := 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(), + } + + dpop := &DPoPJWT{Header: DPoPHeader{ + RFCHeader: jwt.RFCHeader{Typ: "dpop+jwt"}, + Nonce: "server-nonce-abc123", + }} + if err := dpop.SetClaims(&claims); err != nil { + log.Fatal(err) + } + + // SignJWT merges alg/kid from the key into our DPoP header. + if err := signer.SignJWT(dpop); err != nil { + log.Fatal(err) + } + tokenStr, err := jwt.Encode(dpop) + if err != nil { + log.Fatal(err) + } + fmt.Println("token:", tokenStr[:40]+"...") + + // --- Relying party side: decode with custom header --- + + raw, err := jwt.DecodeRaw(tokenStr) + if err != nil { + log.Fatal(err) + } + + var h DPoPHeader + if err := raw.UnmarshalHeader(&h); err != nil { + log.Fatal(err) + } + + fmt.Printf("alg: %s\n", h.Alg) + fmt.Printf("kid: %s\n", h.KID) + fmt.Printf("typ: %s\n", h.Typ) + fmt.Printf("nonce: %s\n", h.Nonce) + + // Verify with the standard path. + verifier := signer.Verifier() + jws, err := jwt.Decode(tokenStr) + if err != nil { + log.Fatal(err) + } + if err := verifier.Verify(jws); err != nil { + log.Fatal(err) + } + fmt.Println("signature: valid") +} diff --git a/auth/jwt/examples/http-middleware/main.go b/auth/jwt/examples/http-middleware/main.go new file mode 100644 index 0000000..f9dd290 --- /dev/null +++ b/auth/jwt/examples/http-middleware/main.go @@ -0,0 +1,142 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example http-middleware demonstrates the common pattern of verifying a JWT +// in HTTP middleware, stashing the claims in the request context, and +// extracting them in a downstream handler. +// +// The context accessor pair (WithClaims / ClaimsFromContext) is defined +// here to show how simple it is - no library support needed. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/therootcompany/golib/auth/jwt" +) + +// AppClaims embeds TokenClaims and adds application-specific fields. +type AppClaims struct { + jwt.TokenClaims + Email string `json:"email"` + Roles []string `json:"roles"` +} + +// --- Context accessors --- +// Two lines of code - no library support required. + +type contextKey string + +const claimsKey contextKey = "claims" + +// WithClaims returns a new context carrying the given claims. +func WithClaims(ctx context.Context, c *AppClaims) context.Context { + return context.WithValue(ctx, claimsKey, c) +} + +// ClaimsFromContext extracts claims from the context. +func ClaimsFromContext(ctx context.Context) (*AppClaims, bool) { + c, ok := ctx.Value(claimsKey).(*AppClaims) + return c, ok +} + +func main() { + // --- Setup: create a signer + verifier for demonstration --- + pk, err := jwt.NewPrivateKey() + if err != nil { + log.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + log.Fatal(err) + } + verifier := signer.Verifier() + validator := jwt.NewIDTokenValidator( + []string{"https://example.com"}, + []string{"myapp"}, + nil, // azp + 0, // grace period (0 = default 2s) + ) + + // --- Middleware: verify + validate + stash --- + authMiddleware := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + http.Error(w, "missing bearer token", http.StatusUnauthorized) + return + } + tokenStr := strings.TrimPrefix(auth, "Bearer ") + + jws, err := jwt.Decode(tokenStr) + if err != nil { + http.Error(w, "bad token", http.StatusUnauthorized) + return + } + if err := verifier.Verify(jws); err != nil { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + + var claims AppClaims + if err := jws.UnmarshalClaims(&claims); err != nil { + http.Error(w, "bad claims", http.StatusUnauthorized) + return + } + hdr := jws.GetHeader() + var errs []error + errs = hdr.IsAllowedTyp(errs, []string{"JWT"}) + if err := validator.Validate(errs, &claims, time.Now()); err != nil { + http.Error(w, "invalid claims", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r.WithContext(WithClaims(r.Context(), &claims))) + }) + } + + // --- Handler: extract claims from context --- + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "no claims", http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "hello %s (%s)\n", claims.Sub, claims.Email) + }) + + mux := http.NewServeMux() + mux.Handle("/api/me", authMiddleware(handler)) + + // Mint a token so we can demonstrate the round trip. + claims := &AppClaims{ + TokenClaims: jwt.TokenClaims{ + Iss: "https://example.com", + Sub: "user-42", + Aud: jwt.Listish{"myapp"}, + Exp: time.Now().Add(time.Hour).Unix(), + IAt: time.Now().Unix(), + AuthTime: time.Now().Unix(), + }, + Email: "user@example.com", + Roles: []string{"admin"}, + } + token, err := signer.SignToString(claims) + if err != nil { + log.Fatal(err) + } + fmt.Println("token:", token[:40]+"...") + fmt.Println("curl -H 'Authorization: Bearer ' http://localhost:8080/api/me") + + log.Fatal(http.ListenAndServe(":8080", mux)) +} diff --git a/auth/jwt/examples/mcp-server-auth/main.go b/auth/jwt/examples/mcp-server-auth/main.go new file mode 100644 index 0000000..624697e --- /dev/null +++ b/auth/jwt/examples/mcp-server-auth/main.go @@ -0,0 +1,298 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example mcp-server-auth demonstrates how an MCP (Model Context Protocol) +// server verifies OAuth 2.1 access tokens from MCP clients. +// +// MCP uses OAuth 2.1 for authorization per the spec. This example shows: +// - Bearer token extraction from Authorization headers +// - JWT signature verification and claims validation +// - Scope-based access control for MCP operations +// - A JSON-RPC handler that lists tools or executes them based on granted scopes +// +// Run the server, then use the printed curl commands to test each scope level. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "slices" + "strings" + "time" + + "github.com/therootcompany/golib/auth/jwt" +) + +// MCP scope values for access control. +const ( + scopeRead = "mcp:read" + scopeWrite = "mcp:write" + scopeAdmin = "mcp:admin" +) + +// toolDef is the single source of truth for tool name, description, and +// required scope. Both tools/list and tools/call use this registry. +type toolDef struct { + Name string `json:"name"` + Description string `json:"description"` + Scope string `json:"-"` // minimum scope required to see/call this tool +} + +// toolRegistry is the canonical list of tools this server exposes. +var toolRegistry = []toolDef{ + {"search", "Search the knowledge base", scopeRead}, + {"summarize", "Summarize a document", scopeRead}, + {"create_document", "Create a new document", scopeWrite}, + {"manage_users", "Add or remove users from the workspace", scopeAdmin}, +} + +// --- Context accessors (two lines of code -- no library support required) --- + +type contextKey string + +const claimsKey contextKey = "claims" + +// WithClaims returns a new context carrying the given token claims. +func WithClaims(ctx context.Context, c *jwt.TokenClaims) context.Context { + return context.WithValue(ctx, claimsKey, c) +} + +// ClaimsFromContext extracts claims stashed by the auth middleware. +func ClaimsFromContext(ctx context.Context) (*jwt.TokenClaims, bool) { + c, ok := ctx.Value(claimsKey).(*jwt.TokenClaims) + return c, ok +} + +// --- JSON-RPC types (minimal subset of the MCP protocol) --- + +// JSONRPCRequest is a minimal JSON-RPC 2.0 request envelope. +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +// JSONRPCResponse is a minimal JSON-RPC 2.0 response envelope. +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +// RPCError represents a JSON-RPC 2.0 error object. +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Application-level JSON-RPC error codes (outside the -32768..-32000 reserved range). +const ( + errCodeForbidden = -31403 // insufficient scope +) + +func main() { + // --- Setup: self-signed key pair for demonstration --- + pk, err := jwt.NewPrivateKey() + if err != nil { + log.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + log.Fatal(err) + } + verifier := signer.Verifier() + + // The validator checks standard access token claims per RFC 9068. + // In production, iss and aud would match your authorization server + // and MCP server resource identifier. + validator := jwt.NewAccessTokenValidator( + []string{"https://auth.example.com"}, // expected issuers + []string{"https://mcp.example.com/jsonrpc"}, // expected audiences + 0, // grace period (0 = default 2s) + ) + + // --- Auth middleware: verify signature + validate claims --- + authMiddleware := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + http.Error(w, "missing bearer token", http.StatusUnauthorized) + return + } + tokenStr := strings.TrimPrefix(auth, "Bearer ") + + jws, err := jwt.Decode(tokenStr) + if err != nil { + http.Error(w, "bad token", http.StatusUnauthorized) + return + } + if err := verifier.Verify(jws); err != nil { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + + var claims jwt.TokenClaims + if err := jws.UnmarshalClaims(&claims); err != nil { + http.Error(w, "bad claims", http.StatusUnauthorized) + return + } + if err := validator.Validate(nil, &claims, time.Now()); err != nil { + http.Error(w, "invalid claims: "+err.Error(), http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r.WithContext(WithClaims(r.Context(), &claims))) + }) + } + + // --- MCP JSON-RPC handler --- + mcpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "no claims in context", http.StatusInternalServerError) + return + } + + var req JSONRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeRPCError(w, []byte("null"), -32700, "parse error") + return + } + if req.JSONRPC != "2.0" { + writeRPCError(w, req.ID, -32600, "invalid request: expected jsonrpc 2.0") + return + } + + switch req.Method { + case "tools/list": + handleToolsList(w, req, claims) + case "tools/call": + handleToolsCall(w, req, claims) + default: + writeRPCError(w, req.ID, -32601, fmt.Sprintf("method not found: %s", req.Method)) + } + }) + + mux := http.NewServeMux() + mux.Handle("/mcp", authMiddleware(mcpHandler)) + + // --- Mint demo tokens at each scope level --- + now := time.Now() + scopes := []struct { + label string + scope jwt.SpaceDelimited + }{ + {"read-only", jwt.SpaceDelimited{scopeRead}}, + {"read-write", jwt.SpaceDelimited{scopeRead, scopeWrite}}, + {"admin", jwt.SpaceDelimited{scopeRead, scopeWrite, scopeAdmin}}, + } + + fmt.Println("MCP server listening on :8080") + fmt.Println() + for _, s := range scopes { + token, err := signer.SignToString(&jwt.TokenClaims{ + Iss: "https://auth.example.com", + Sub: "client-agent-1", + Aud: jwt.Listish{"https://mcp.example.com/jsonrpc"}, + Exp: now.Add(time.Hour).Unix(), + IAt: now.Unix(), + JTI: fmt.Sprintf("tok-%s", s.label), + ClientID: "mcp-client-demo", + Scope: s.scope, + }) + if err != nil { + log.Fatal(err) + } + fmt.Printf("--- %s token ---\n", s.label) + fmt.Printf("curl -s -X POST http://localhost:8080/mcp \\\n") + fmt.Printf(" -H 'Authorization: Bearer %s' \\\n", token) + fmt.Printf(" -H 'Content-Type: application/json' \\\n") + fmt.Printf(" -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'\n\n") + } + + log.Fatal(http.ListenAndServe(":8080", mux)) +} + +// handleToolsList returns the tools visible to the caller based on scopes. +func handleToolsList(w http.ResponseWriter, req JSONRPCRequest, claims *jwt.TokenClaims) { + var visible []toolDef + for _, td := range toolRegistry { + if hasScope(claims, td.Scope) { + visible = append(visible, td) + } + } + + writeRPCResult(w, req.ID, map[string]any{ + "tools": visible, + }) +} + +// CallParams holds the parameters for a tools/call request. +type CallParams struct { + Name string `json:"name"` +} + +// handleToolsCall executes a tool if the caller has the required scope. +func handleToolsCall(w http.ResponseWriter, req JSONRPCRequest, claims *jwt.TokenClaims) { + var params CallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + writeRPCError(w, req.ID, -32602, "invalid params") + return + } + + // Look up the tool in the single registry. + var found *toolDef + for i := range toolRegistry { + if toolRegistry[i].Name == params.Name { + found = &toolRegistry[i] + break + } + } + if found == nil { + writeRPCError(w, req.ID, -32602, fmt.Sprintf("unknown tool: %s", params.Name)) + return + } + if !hasScope(claims, found.Scope) { + writeRPCError(w, req.ID, errCodeForbidden, fmt.Sprintf("insufficient scope: %s required", found.Scope)) + return + } + + writeRPCResult(w, req.ID, map[string]any{ + "content": []map[string]string{ + {"type": "text", "text": fmt.Sprintf("executed %s for %s", params.Name, claims.Sub)}, + }, + }) +} + +// hasScope checks whether the token's scope claim contains the given value. +func hasScope(claims *jwt.TokenClaims, scope string) bool { + return slices.Contains(claims.Scope, scope) +} + +func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(JSONRPCResponse{ + JSONRPC: "2.0", + ID: id, + Result: result, + }) +} + +func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(JSONRPCResponse{ + JSONRPC: "2.0", + ID: id, + Error: &RPCError{Code: code, Message: message}, + }) +} diff --git a/auth/jwt/examples/mfa-validator/main.go b/auth/jwt/examples/mfa-validator/main.go new file mode 100644 index 0000000..c637fa6 --- /dev/null +++ b/auth/jwt/examples/mfa-validator/main.go @@ -0,0 +1,138 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example mfa-validator demonstrates application-level AMR validation +// after [jwt.Validator.Validate]. The jwt package intentionally +// does not enforce AMR rules because there is no standard registry of +// values - each provider defines its own. This example shows how to +// check required authentication methods and minimum factor counts in +// your own code. +package main + +import ( + "errors" + "fmt" + "log" + "slices" + "time" + + "github.com/therootcompany/golib/auth/jwt" +) + +// MFAPolicy defines what authentication methods a token must contain. +type MFAPolicy struct { + // RequiredAMRs lists method values that must all appear in the + // token's amr claim (e.g. ["pwd", "otp"]). + RequiredAMRs []string + + // MinFactors is the minimum number of distinct amr values the + // token must contain. 0 means no minimum. + MinFactors int +} + +// Validate checks that claims.AMR satisfies the policy. +func (p *MFAPolicy) Validate(claims jwt.Claims) error { + amr := claims.GetTokenClaims().AMR + + if len(amr) == 0 { + return fmt.Errorf("amr claim is missing or empty: %w", jwt.ErrMissingClaim) + } + + for _, required := range p.RequiredAMRs { + if !slices.Contains(amr, required) { + return fmt.Errorf("amr missing %q: %w", required, jwt.ErrInvalidClaim) + } + } + + if p.MinFactors > 0 && len(amr) < p.MinFactors { + return fmt.Errorf( + "amr has %d factor(s), need at least %d: %w", + len(amr), p.MinFactors, jwt.ErrInvalidClaim, + ) + } + + return nil +} + +func main() { + // --- Issuer side: create and sign a token --- + pk, err := jwt.NewPrivateKey() + if err != nil { + log.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + log.Fatal(err) + } + + claims := &jwt.TokenClaims{ + Iss: "https://example.com", + Sub: "user123", + Aud: jwt.Listish{"myapp"}, + Exp: time.Now().Add(time.Hour).Unix(), + IAt: time.Now().Unix(), + AMR: []string{"pwd", "otp"}, + AuthTime: time.Now().Unix(), + } + + tokenStr, err := signer.SignToString(claims) + if err != nil { + log.Fatal(err) + } + fmt.Println("token:", tokenStr[:40]+"...") + + // --- Relying party side: verify => validate => check MFA --- + + // 1. Decode and verify the signature. + jws, err := jwt.Decode(tokenStr) + if err != nil { + log.Fatal(err) + } + verifier := signer.Verifier() + if err := verifier.Verify(jws); err != nil { + log.Fatal(err) + } + + // 2. Unmarshal and validate standard claims. + var got jwt.TokenClaims + if err := jws.UnmarshalClaims(&got); err != nil { + log.Fatal(err) + } + v := jwt.NewIDTokenValidator( + []string{"https://example.com"}, + []string{"myapp"}, + nil, // azp + 0, // grace period (0 = default 2s) + ) + if err := v.Validate(nil, &got, time.Now()); err != nil { + log.Fatal(err) + } + + // 3. Check MFA policy - this is the application-level step. + mfa := &MFAPolicy{ + RequiredAMRs: []string{"pwd", "otp"}, + MinFactors: 2, + } + if err := mfa.Validate(&got); err != nil { + log.Fatal("MFA check failed:", err) + } + fmt.Println("MFA check: passed") + + // --- Demonstrate a failure case --- + weakClaims := &jwt.TokenClaims{ + Iss: "https://example.com", + Sub: "user456", + Aud: jwt.Listish{"myapp"}, + Exp: time.Now().Add(time.Hour).Unix(), + IAt: time.Now().Unix(), + AMR: []string{"pwd"}, // only one factor + } + if err := mfa.Validate(weakClaims); err != nil { + fmt.Println("weak token rejected:", errors.Unwrap(err)) + } +} diff --git a/auth/jwt/examples/oauth-access-token/main.go b/auth/jwt/examples/oauth-access-token/main.go new file mode 100644 index 0000000..02085ff --- /dev/null +++ b/auth/jwt/examples/oauth-access-token/main.go @@ -0,0 +1,112 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example oauth-access-token demonstrates OAuth 2.1 JWT access token +// validation per RFC 9068 using NewAccessTokenValidator with RequiredScopes. +// +// It mints an access token with JTI, ClientID, and Scope fields, then +// walks through the decode / verify / unmarshal / validate pipeline. +package main + +import ( + "fmt" + "log" + "time" + + "github.com/therootcompany/golib/auth/jwt" +) + +func main() { + // --- Setup: create a signer + verifier for demonstration --- + pk, err := jwt.NewPrivateKey() + if err != nil { + log.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + log.Fatal(err) + } + verifier := signer.Verifier() + + // --- Build access-token claims --- + // RFC 9068 requires iss, sub, aud, exp, iat, jti, and client_id. + claims := &jwt.TokenClaims{ + Iss: "https://auth.example.com", + Sub: "user-42", + Aud: jwt.Listish{"https://api.example.com"}, + Exp: time.Now().Add(time.Hour).Unix(), + IAt: time.Now().Unix(), + JTI: "tok-abc-123", + ClientID: "mobile-app", + Scope: jwt.SpaceDelimited{"read:messages", "write:messages", "profile"}, + } + + // --- Mint an access token (typ: at+jwt) --- + tok, err := jwt.NewAccessToken(claims) + if err != nil { + log.Fatal(err) + } + if err := signer.SignJWT(tok); err != nil { + log.Fatal(err) + } + tokenStr, err := tok.Encode() + if err != nil { + log.Fatal(err) + } + fmt.Println("access token:", tokenStr[:40]+"...") + + // --- Decode (parse without verifying) --- + jws, err := jwt.Decode(tokenStr) + if err != nil { + log.Fatal("decode:", err) + } + fmt.Printf("header typ: %s\n", jws.GetHeader().Typ) + + // --- Verify signature --- + if err := verifier.Verify(jws); err != nil { + log.Fatal("verify:", err) + } + fmt.Println("signature: OK") + + // --- Unmarshal claims --- + var got jwt.TokenClaims + if err := jws.UnmarshalClaims(&got); err != nil { + log.Fatal("unmarshal:", err) + } + fmt.Printf("sub: %s client_id: %s jti: %s\n", got.Sub, got.ClientID, got.JTI) + fmt.Printf("scope: %v\n", got.Scope) + + // --- Validate typ header + claims together --- + validator := jwt.NewAccessTokenValidator( + []string{"https://auth.example.com"}, // allowed issuers + []string{"https://api.example.com"}, // allowed audiences + 0, // grace period (0 = default 2s) + ) + validator.RequiredScopes = []string{"read:messages", "profile"} + + // Thread header errors into Validate so all findings appear in one error. + hdr := jws.GetHeader() + var errs []error + errs = hdr.IsAllowedTyp(errs, []string{"at+jwt"}) + if err := validator.Validate(errs, &got, time.Now()); err != nil { + log.Fatal("validate:", err) + } + fmt.Println("claims: OK (typ, iss, sub, aud, exp, iat, jti, client_id, scope all valid)") + + // --- Demonstrate scope rejection --- + strict := jwt.NewAccessTokenValidator( + []string{"https://auth.example.com"}, + []string{"https://api.example.com"}, + 0, + ) + strict.RequiredScopes = []string{"admin:delete"} // not in the token + + if err := strict.Validate(nil, &got, time.Now()); err != nil { + fmt.Println("expected rejection:", err) + } +} diff --git a/auth/jwt/examples/oidc-id-token/main.go b/auth/jwt/examples/oidc-id-token/main.go new file mode 100644 index 0000000..8abf439 --- /dev/null +++ b/auth/jwt/examples/oidc-id-token/main.go @@ -0,0 +1,118 @@ +// Copyright 2026 AJ ONeal (https://therootcompany.com) +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// SPDX-License-Identifier: MPL-2.0 + +// Example oidc-id-token demonstrates OIDC ID Token validation using +// jwt.StandardClaims, which carries the full set of OIDC profile, email, +// and phone fields alongside the core token claims. +package main + +import ( + "fmt" + "log" + "time" + + "github.com/therootcompany/golib/auth/jwt" +) + +func main() { + // --- Setup: create a signer + verifier for demonstration --- + pk, err := jwt.NewPrivateKey() + if err != nil { + log.Fatal(err) + } + signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk}) + if err != nil { + log.Fatal(err) + } + verifier := signer.Verifier() + + // Create an ID Token validator that checks iss, sub, aud, exp, iat, + // and auth_time (per OIDC Core §3.1.3.7). + validator := jwt.NewIDTokenValidator( + []string{"https://accounts.example.com"}, // allowed issuers + []string{"my-app-client-id"}, // allowed audiences + nil, // azp - no authorized-party restriction + 0, // grace period (0 = default 2s) + ) + + // --- Mint an ID Token with OIDC profile and contact fields --- + claims := &jwt.StandardClaims{ + TokenClaims: jwt.TokenClaims{ + Iss: "https://accounts.example.com", + Sub: "user-42", + Aud: jwt.Listish{"my-app-client-id"}, + Exp: time.Now().Add(time.Hour).Unix(), + IAt: time.Now().Unix(), + AuthTime: time.Now().Unix(), + Nonce: "abc123", + }, + + // OIDC profile fields + Name: "Jane Doe", + GivenName: "Jane", + FamilyName: "Doe", + PreferredUsername: "janedoe", + Picture: "https://example.com/janedoe/photo.jpg", + Locale: "en-US", + + // Contact fields with NullBool for *_verified + Email: "jane@example.com", + EmailVerified: jwt.NullBool{Bool: true, Valid: true}, + + PhoneNumber: "+1-555-867-5309", + PhoneNumberVerified: jwt.NullBool{Bool: false, Valid: true}, + } + + token, err := signer.SignToString(claims) + if err != nil { + log.Fatal(err) + } + fmt.Println("signed ID token:", token[:40]+"...") + + // --- Decode, verify signature, unmarshal, validate --- + jws, err := jwt.Decode(token) + if err != nil { + log.Fatal("decode:", err) + } + + if err := verifier.Verify(jws); err != nil { + log.Fatal("verify:", err) + } + + var got jwt.StandardClaims + if err := jws.UnmarshalClaims(&got); err != nil { + log.Fatal("unmarshal:", err) + } + + hdr := jws.GetHeader() + var errs []error + errs = hdr.IsAllowedTyp(errs, []string{"JWT"}) + if err := validator.Validate(errs, &got, time.Now()); err != nil { + log.Fatal("validate:", err) + } + + // --- Print the decoded OIDC claims --- + fmt.Println() + fmt.Println("=== ID Token Claims ===") + fmt.Println("iss: ", got.Iss) + fmt.Println("sub: ", got.Sub) + fmt.Println("aud: ", got.Aud) + fmt.Println("nonce: ", got.Nonce) + fmt.Println() + fmt.Println("name: ", got.Name) + fmt.Println("given_name: ", got.GivenName) + fmt.Println("family_name: ", got.FamilyName) + fmt.Println("preferred: ", got.PreferredUsername) + fmt.Println("picture: ", got.Picture) + fmt.Println("locale: ", got.Locale) + fmt.Println() + fmt.Println("email: ", got.Email) + fmt.Printf("email_verified: value=%v valid=%v\n", got.EmailVerified.Bool, got.EmailVerified.Valid) + fmt.Println("phone: ", got.PhoneNumber) + fmt.Printf("phone_verified: value=%v valid=%v\n", got.PhoneNumberVerified.Bool, got.PhoneNumberVerified.Valid) +} diff --git a/auth/jwt/examples/rfc-claims/rfc.go b/auth/jwt/examples/rfc-claims/rfc.go new file mode 100644 index 0000000..90b8a92 --- /dev/null +++ b/auth/jwt/examples/rfc-claims/rfc.go @@ -0,0 +1,26 @@ +// Package rfc demonstrates a permissive RFC 7519 validator using +// [jwt.Validator] with a minimal Checks bitmask. +// +// Use this approach when tokens legitimately omit OIDC-required claims +// such as sub or aud. Prefer [jwt.NewIDTokenValidator] or +// [jwt.NewAccessTokenValidator] when you control token issuance and +// want full compliance enforced. +package rfc + +import ( + "github.com/therootcompany/golib/auth/jwt" +) + +// NewRFCValidator returns a [jwt.Validator] that checks only what +// RFC 7519 requires by default: exp, iat, and nbf. +// +// Iss and aud are checked when their allowlists are non-nil. +// Additional checks can be enabled by OR-ing more Check* flags +// onto the returned Validator's Checks field. +func NewRFCValidator(iss, aud []string) *jwt.Validator { + return &jwt.Validator{ + Checks: jwt.ChecksConfigured | jwt.CheckExp | jwt.CheckIAt | jwt.CheckNBf, + Iss: iss, + Aud: aud, + } +} diff --git a/auth/jwt/examples/rfc-claims/rfc_test.go b/auth/jwt/examples/rfc-claims/rfc_test.go new file mode 100644 index 0000000..0cc4d03 --- /dev/null +++ b/auth/jwt/examples/rfc-claims/rfc_test.go @@ -0,0 +1,96 @@ +package rfc_test + +import ( + "errors" + "testing" + "time" + + "github.com/therootcompany/golib/auth/jwt" + + rfc "github.com/therootcompany/golib/auth/jwt/examples/rfc-claims" +) + +// TestNewRFCValidator shows how to use [rfc.NewRFCValidator] to build +// a permissive [jwt.Validator] that checks only exp, iat, and nbf, +// then opt in to additional checks by OR-ing more flags. +func TestNewRFCValidator(t *testing.T) { + now := time.Now() + + claims := jwt.TokenClaims{ + Iss: "https://example.com", + Aud: jwt.Listish{"myapp"}, + Exp: now.Add(time.Hour).Unix(), + IAt: now.Unix(), + } + + v := rfc.NewRFCValidator( + []string{"https://example.com"}, + []string{"myapp"}, + ) + + if err := v.Validate(nil, &claims, now); err != nil { + t.Fatalf("NewRFCValidator rejected valid claims: %v", err) + } + + // Opt in to sub checking by adding the flag. + v.Checks |= jwt.CheckSub + if err := v.Validate(nil, &claims, now); !errors.Is(err, jwt.ErrMissingClaim) { + t.Fatalf("expected ErrMissingClaim for missing sub, got: %v", err) + } + + // Expired token must be rejected. + expired := claims + expired.Exp = now.Add(-time.Hour).Unix() + v.Checks &^= jwt.CheckSub // remove sub check for this test + if err := v.Validate(nil, &expired, now); !errors.Is(err, jwt.ErrAfterExp) { + t.Fatalf("expected ErrAfterExp, got: %v", err) + } +} + +// TestDirectCheckMethods shows how to call the individual check methods on +// [jwt.TokenClaims] directly, without using a [jwt.Validator] at all. +// This is useful for one-off validations or building a fully custom validator. +func TestDirectCheckMethods(t *testing.T) { + now := time.Now() + skew := 2 * time.Second + + claims := jwt.TokenClaims{ + Iss: "https://example.com", + Aud: jwt.Listish{"myapp"}, + Exp: now.Add(time.Hour).Unix(), + IAt: now.Unix(), + } + + // Call individual check methods - each appends errors to the slice. + var errs []error + errs = claims.IsAllowedIss(errs, []string{"https://example.com"}) + errs = claims.HasAllowedAud(errs, []string{"myapp"}) + errs = claims.IsBeforeExp(errs, now, skew) + errs = claims.IsAfterIAt(errs, now, skew) + + // No errors when all checks pass. + if err := errors.Join(errs...); err != nil { + t.Fatalf("direct checks rejected valid claims: %v", err) + } + + // Now validate a bad token the same way. + bad := jwt.TokenClaims{ + Iss: "https://evil.com", + Exp: now.Add(-time.Hour).Unix(), + } + + var badErrs []error + badErrs = bad.IsAllowedIss(badErrs, []string{"https://example.com"}) + badErrs = bad.IsBeforeExp(badErrs, now, skew) + + err := errors.Join(badErrs...) + if err == nil { + t.Fatal("expected errors from bad claims") + } + if !errors.Is(err, jwt.ErrInvalidClaim) { + t.Fatalf("expected ErrInvalidClaim for bad iss, got: %v", err) + } + if !errors.Is(err, jwt.ErrAfterExp) { + t.Fatalf("expected ErrAfterExp for expired token, got: %v", err) + } +}