WIP: feat: add auth/jwt/examples

This commit is contained in:
AJ ONeal 2026-03-17 04:15:24 -06:00
parent 0d99234914
commit 0800ea2491
No known key found for this signature in database
12 changed files with 1447 additions and 0 deletions

View File

@ -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")
}
}

View File

@ -0,0 +1,53 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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() {}

View File

@ -0,0 +1,95 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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
}

View File

@ -0,0 +1,60 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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
}

View File

@ -0,0 +1,123 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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")
}

View File

@ -0,0 +1,142 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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 <token>' http://localhost:8080/api/me")
log.Fatal(http.ListenAndServe(":8080", mux))
}

View File

@ -0,0 +1,298 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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, &params); 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},
})
}

View File

@ -0,0 +1,138 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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))
}
}

View File

@ -0,0 +1,112 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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)
}
}

View File

@ -0,0 +1,118 @@
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (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)
}

View File

@ -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,
}
}

View File

@ -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)
}
}