A community based topic aggregation platform built on atproto

feat(auth): implement DPoP token binding (RFC 9449)

Add proof-of-possession verification for OAuth access tokens:
- DPoPVerifier for validating DPoP proof JWTs
- NonceCache for replay attack prevention with background cleanup
- JWK thumbprint calculation per RFC 7638
- Support for ES256 signing algorithm
- Configurable clock skew and proof age limits

Security features:
- Validates htm (HTTP method) and htu (HTTP URI) claims
- Enforces iat freshness within 5-minute window
- Tracks jti values to prevent proof reuse
- Calculates and validates JWK thumbprints for token binding

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+1405
+484
internal/atproto/auth/dpop.go
··· 1 + package auth 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "crypto/sha256" 7 + "encoding/base64" 8 + "encoding/json" 9 + "fmt" 10 + "math/big" 11 + "strings" 12 + "sync" 13 + "time" 14 + 15 + indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto" 16 + "github.com/golang-jwt/jwt/v5" 17 + ) 18 + 19 + // NonceCache provides replay protection for DPoP proofs by tracking seen jti values. 20 + // This prevents an attacker from reusing a captured DPoP proof within the validity window. 21 + // Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks. 22 + type NonceCache struct { 23 + seen map[string]time.Time // jti -> expiration time 24 + stopCh chan struct{} 25 + maxAge time.Duration // How long to keep entries 26 + cleanup time.Duration // How often to clean up expired entries 27 + mu sync.RWMutex 28 + } 29 + 30 + // NewNonceCache creates a new nonce cache for DPoP replay protection. 31 + // maxAge should match or exceed DPoPVerifier.MaxProofAge. 32 + func NewNonceCache(maxAge time.Duration) *NonceCache { 33 + nc := &NonceCache{ 34 + seen: make(map[string]time.Time), 35 + maxAge: maxAge, 36 + cleanup: maxAge / 2, // Clean up at half the max age 37 + stopCh: make(chan struct{}), 38 + } 39 + 40 + // Start background cleanup goroutine 41 + go nc.cleanupLoop() 42 + 43 + return nc 44 + } 45 + 46 + // CheckAndStore checks if a jti has been seen before and stores it if not. 47 + // Returns true if the jti is fresh (not a replay), false if it's a replay. 48 + func (nc *NonceCache) CheckAndStore(jti string) bool { 49 + nc.mu.Lock() 50 + defer nc.mu.Unlock() 51 + 52 + now := time.Now() 53 + expiry := now.Add(nc.maxAge) 54 + 55 + // Check if already seen 56 + if existingExpiry, seen := nc.seen[jti]; seen { 57 + // Still valid (not expired) - this is a replay 58 + if existingExpiry.After(now) { 59 + return false 60 + } 61 + // Expired entry - allow reuse and update expiry 62 + } 63 + 64 + // Store the new jti 65 + nc.seen[jti] = expiry 66 + return true 67 + } 68 + 69 + // cleanupLoop periodically removes expired entries from the cache 70 + func (nc *NonceCache) cleanupLoop() { 71 + ticker := time.NewTicker(nc.cleanup) 72 + defer ticker.Stop() 73 + 74 + for { 75 + select { 76 + case <-ticker.C: 77 + nc.cleanupExpired() 78 + case <-nc.stopCh: 79 + return 80 + } 81 + } 82 + } 83 + 84 + // cleanupExpired removes expired entries from the cache 85 + func (nc *NonceCache) cleanupExpired() { 86 + nc.mu.Lock() 87 + defer nc.mu.Unlock() 88 + 89 + now := time.Now() 90 + for jti, expiry := range nc.seen { 91 + if expiry.Before(now) { 92 + delete(nc.seen, jti) 93 + } 94 + } 95 + } 96 + 97 + // Stop stops the cleanup goroutine. Call this when done with the cache. 98 + func (nc *NonceCache) Stop() { 99 + close(nc.stopCh) 100 + } 101 + 102 + // Size returns the number of entries in the cache (for testing/monitoring) 103 + func (nc *NonceCache) Size() int { 104 + nc.mu.RLock() 105 + defer nc.mu.RUnlock() 106 + return len(nc.seen) 107 + } 108 + 109 + // DPoPClaims represents the claims in a DPoP proof JWT (RFC 9449) 110 + type DPoPClaims struct { 111 + jwt.RegisteredClaims 112 + 113 + // HTTP method of the request (e.g., "GET", "POST") 114 + HTTPMethod string `json:"htm"` 115 + 116 + // HTTP URI of the request (without query and fragment parts) 117 + HTTPURI string `json:"htu"` 118 + 119 + // Access token hash (optional, for token binding) 120 + AccessTokenHash string `json:"ath,omitempty"` 121 + } 122 + 123 + // DPoPProof represents a parsed and verified DPoP proof 124 + type DPoPProof struct { 125 + RawPublicJWK map[string]interface{} 126 + Claims *DPoPClaims 127 + PublicKey interface{} // *ecdsa.PublicKey or similar 128 + Thumbprint string // JWK thumbprint (base64url) 129 + } 130 + 131 + // DPoPVerifier verifies DPoP proofs for OAuth token binding 132 + type DPoPVerifier struct { 133 + // Optional: custom nonce validation function (for server-issued nonces) 134 + ValidateNonce func(nonce string) bool 135 + 136 + // NonceCache for replay protection (optional but recommended) 137 + // If nil, jti replay protection is disabled 138 + NonceCache *NonceCache 139 + 140 + // Maximum allowed clock skew for timestamp validation 141 + MaxClockSkew time.Duration 142 + 143 + // Maximum age of DPoP proof (prevents replay with old proofs) 144 + MaxProofAge time.Duration 145 + } 146 + 147 + // NewDPoPVerifier creates a DPoP verifier with sensible defaults including replay protection 148 + func NewDPoPVerifier() *DPoPVerifier { 149 + maxProofAge := 5 * time.Minute 150 + return &DPoPVerifier{ 151 + MaxClockSkew: 30 * time.Second, 152 + MaxProofAge: maxProofAge, 153 + NonceCache: NewNonceCache(maxProofAge), 154 + } 155 + } 156 + 157 + // NewDPoPVerifierWithoutReplayProtection creates a DPoP verifier without replay protection. 158 + // This should only be used in testing or when replay protection is handled externally. 159 + func NewDPoPVerifierWithoutReplayProtection() *DPoPVerifier { 160 + return &DPoPVerifier{ 161 + MaxClockSkew: 30 * time.Second, 162 + MaxProofAge: 5 * time.Minute, 163 + NonceCache: nil, // No replay protection 164 + } 165 + } 166 + 167 + // Stop stops background goroutines. Call this when shutting down. 168 + func (v *DPoPVerifier) Stop() { 169 + if v.NonceCache != nil { 170 + v.NonceCache.Stop() 171 + } 172 + } 173 + 174 + // VerifyDPoPProof verifies a DPoP proof JWT and returns the parsed proof 175 + func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, error) { 176 + // Parse the DPoP JWT without verification first to extract the header 177 + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) 178 + token, _, err := parser.ParseUnverified(dpopProof, &DPoPClaims{}) 179 + if err != nil { 180 + return nil, fmt.Errorf("failed to parse DPoP proof: %w", err) 181 + } 182 + 183 + // Extract and validate the header 184 + header, ok := token.Header["typ"].(string) 185 + if !ok || header != "dpop+jwt" { 186 + return nil, fmt.Errorf("invalid DPoP proof: typ must be 'dpop+jwt', got '%s'", header) 187 + } 188 + 189 + alg, ok := token.Header["alg"].(string) 190 + if !ok { 191 + return nil, fmt.Errorf("invalid DPoP proof: missing alg header") 192 + } 193 + 194 + // Extract the JWK from the header 195 + jwkRaw, ok := token.Header["jwk"] 196 + if !ok { 197 + return nil, fmt.Errorf("invalid DPoP proof: missing jwk header") 198 + } 199 + 200 + jwkMap, ok := jwkRaw.(map[string]interface{}) 201 + if !ok { 202 + return nil, fmt.Errorf("invalid DPoP proof: jwk must be an object") 203 + } 204 + 205 + // Parse the public key from JWK 206 + publicKey, err := parseJWKToPublicKey(jwkMap) 207 + if err != nil { 208 + return nil, fmt.Errorf("invalid DPoP proof JWK: %w", err) 209 + } 210 + 211 + // Calculate the JWK thumbprint 212 + thumbprint, err := CalculateJWKThumbprint(jwkMap) 213 + if err != nil { 214 + return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err) 215 + } 216 + 217 + // Now verify the signature 218 + verifiedToken, err := jwt.ParseWithClaims(dpopProof, &DPoPClaims{}, func(token *jwt.Token) (interface{}, error) { 219 + // Verify the signing method matches what we expect 220 + switch alg { 221 + case "ES256": 222 + if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { 223 + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 224 + } 225 + case "ES384", "ES512": 226 + if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { 227 + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 228 + } 229 + case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512": 230 + // RSA methods - we primarily support ES256 for atproto 231 + return nil, fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg) 232 + default: 233 + return nil, fmt.Errorf("unsupported DPoP algorithm: %s", alg) 234 + } 235 + return publicKey, nil 236 + }) 237 + if err != nil { 238 + return nil, fmt.Errorf("DPoP proof signature verification failed: %w", err) 239 + } 240 + 241 + claims, ok := verifiedToken.Claims.(*DPoPClaims) 242 + if !ok { 243 + return nil, fmt.Errorf("invalid DPoP claims type") 244 + } 245 + 246 + // Validate the claims 247 + if err := v.validateDPoPClaims(claims, httpMethod, httpURI); err != nil { 248 + return nil, err 249 + } 250 + 251 + return &DPoPProof{ 252 + Claims: claims, 253 + PublicKey: publicKey, 254 + Thumbprint: thumbprint, 255 + RawPublicJWK: jwkMap, 256 + }, nil 257 + } 258 + 259 + // validateDPoPClaims validates the DPoP proof claims 260 + func (v *DPoPVerifier) validateDPoPClaims(claims *DPoPClaims, expectedMethod, expectedURI string) error { 261 + // Validate jti (unique identifier) is present 262 + if claims.ID == "" { 263 + return fmt.Errorf("DPoP proof missing jti claim") 264 + } 265 + 266 + // Validate htm (HTTP method) 267 + if !strings.EqualFold(claims.HTTPMethod, expectedMethod) { 268 + return fmt.Errorf("DPoP proof htm mismatch: expected %s, got %s", expectedMethod, claims.HTTPMethod) 269 + } 270 + 271 + // Validate htu (HTTP URI) - compare without query/fragment 272 + expectedURIBase := stripQueryFragment(expectedURI) 273 + claimURIBase := stripQueryFragment(claims.HTTPURI) 274 + if expectedURIBase != claimURIBase { 275 + return fmt.Errorf("DPoP proof htu mismatch: expected %s, got %s", expectedURIBase, claimURIBase) 276 + } 277 + 278 + // Validate iat (issued at) is present and recent 279 + if claims.IssuedAt == nil { 280 + return fmt.Errorf("DPoP proof missing iat claim") 281 + } 282 + 283 + now := time.Now() 284 + iat := claims.IssuedAt.Time 285 + 286 + // Check clock skew (not too far in the future) 287 + if iat.After(now.Add(v.MaxClockSkew)) { 288 + return fmt.Errorf("DPoP proof iat is in the future") 289 + } 290 + 291 + // Check proof age (not too old) 292 + if now.Sub(iat) > v.MaxProofAge { 293 + return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge) 294 + } 295 + 296 + // SECURITY: Check for replay attack using jti 297 + // Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks 298 + if v.NonceCache != nil { 299 + if !v.NonceCache.CheckAndStore(claims.ID) { 300 + return fmt.Errorf("DPoP proof replay detected: jti %s already used", claims.ID) 301 + } 302 + } 303 + 304 + return nil 305 + } 306 + 307 + // VerifyTokenBinding verifies that the DPoP proof binds to the access token 308 + // by comparing the proof's thumbprint to the token's cnf.jkt claim 309 + func (v *DPoPVerifier) VerifyTokenBinding(proof *DPoPProof, expectedThumbprint string) error { 310 + if proof.Thumbprint != expectedThumbprint { 311 + return fmt.Errorf("DPoP proof thumbprint mismatch: token expects %s, proof has %s", 312 + expectedThumbprint, proof.Thumbprint) 313 + } 314 + return nil 315 + } 316 + 317 + // CalculateJWKThumbprint calculates the JWK thumbprint per RFC 7638 318 + // The thumbprint is the base64url-encoded SHA-256 hash of the canonical JWK representation 319 + func CalculateJWKThumbprint(jwk map[string]interface{}) (string, error) { 320 + kty, ok := jwk["kty"].(string) 321 + if !ok { 322 + return "", fmt.Errorf("JWK missing kty") 323 + } 324 + 325 + // Build the canonical JWK representation based on key type 326 + // Per RFC 7638, only specific members are included, in lexicographic order 327 + var canonical map[string]string 328 + 329 + switch kty { 330 + case "EC": 331 + crv, ok := jwk["crv"].(string) 332 + if !ok { 333 + return "", fmt.Errorf("EC JWK missing crv") 334 + } 335 + x, ok := jwk["x"].(string) 336 + if !ok { 337 + return "", fmt.Errorf("EC JWK missing x") 338 + } 339 + y, ok := jwk["y"].(string) 340 + if !ok { 341 + return "", fmt.Errorf("EC JWK missing y") 342 + } 343 + // Lexicographic order: crv, kty, x, y 344 + canonical = map[string]string{ 345 + "crv": crv, 346 + "kty": kty, 347 + "x": x, 348 + "y": y, 349 + } 350 + case "RSA": 351 + e, ok := jwk["e"].(string) 352 + if !ok { 353 + return "", fmt.Errorf("RSA JWK missing e") 354 + } 355 + n, ok := jwk["n"].(string) 356 + if !ok { 357 + return "", fmt.Errorf("RSA JWK missing n") 358 + } 359 + // Lexicographic order: e, kty, n 360 + canonical = map[string]string{ 361 + "e": e, 362 + "kty": kty, 363 + "n": n, 364 + } 365 + case "OKP": 366 + crv, ok := jwk["crv"].(string) 367 + if !ok { 368 + return "", fmt.Errorf("OKP JWK missing crv") 369 + } 370 + x, ok := jwk["x"].(string) 371 + if !ok { 372 + return "", fmt.Errorf("OKP JWK missing x") 373 + } 374 + // Lexicographic order: crv, kty, x 375 + canonical = map[string]string{ 376 + "crv": crv, 377 + "kty": kty, 378 + "x": x, 379 + } 380 + default: 381 + return "", fmt.Errorf("unsupported JWK key type: %s", kty) 382 + } 383 + 384 + // Serialize to JSON (Go's json.Marshal produces lexicographically ordered keys for map[string]string) 385 + canonicalJSON, err := json.Marshal(canonical) 386 + if err != nil { 387 + return "", fmt.Errorf("failed to serialize canonical JWK: %w", err) 388 + } 389 + 390 + // SHA-256 hash 391 + hash := sha256.Sum256(canonicalJSON) 392 + 393 + // Base64url encode (no padding) 394 + thumbprint := base64.RawURLEncoding.EncodeToString(hash[:]) 395 + 396 + return thumbprint, nil 397 + } 398 + 399 + // parseJWKToPublicKey parses a JWK map to a Go public key 400 + func parseJWKToPublicKey(jwkMap map[string]interface{}) (interface{}, error) { 401 + // Convert map to JSON bytes for indigo's parser 402 + jwkBytes, err := json.Marshal(jwkMap) 403 + if err != nil { 404 + return nil, fmt.Errorf("failed to serialize JWK: %w", err) 405 + } 406 + 407 + // Try to parse with indigo's crypto package 408 + pubKey, err := indigoCrypto.ParsePublicJWKBytes(jwkBytes) 409 + if err != nil { 410 + return nil, fmt.Errorf("failed to parse JWK: %w", err) 411 + } 412 + 413 + // Convert indigo's PublicKey to Go's ecdsa.PublicKey 414 + jwk, err := pubKey.JWK() 415 + if err != nil { 416 + return nil, fmt.Errorf("failed to get JWK from public key: %w", err) 417 + } 418 + 419 + // Use our existing conversion function 420 + return atcryptoJWKToECDSAFromIndigoJWK(jwk) 421 + } 422 + 423 + // atcryptoJWKToECDSAFromIndigoJWK converts an indigo JWK to Go ecdsa.PublicKey 424 + func atcryptoJWKToECDSAFromIndigoJWK(jwk *indigoCrypto.JWK) (*ecdsa.PublicKey, error) { 425 + if jwk.KeyType != "EC" { 426 + return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType) 427 + } 428 + 429 + xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) 430 + if err != nil { 431 + return nil, fmt.Errorf("invalid JWK X coordinate: %w", err) 432 + } 433 + yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y) 434 + if err != nil { 435 + return nil, fmt.Errorf("invalid JWK Y coordinate: %w", err) 436 + } 437 + 438 + var curve ecdsa.PublicKey 439 + switch jwk.Curve { 440 + case "P-256": 441 + curve.Curve = ecdsaP256Curve() 442 + case "P-384": 443 + curve.Curve = ecdsaP384Curve() 444 + case "P-521": 445 + curve.Curve = ecdsaP521Curve() 446 + default: 447 + return nil, fmt.Errorf("unsupported curve: %s", jwk.Curve) 448 + } 449 + 450 + curve.X = new(big.Int).SetBytes(xBytes) 451 + curve.Y = new(big.Int).SetBytes(yBytes) 452 + 453 + return &curve, nil 454 + } 455 + 456 + // Helper functions for elliptic curves 457 + func ecdsaP256Curve() elliptic.Curve { return elliptic.P256() } 458 + func ecdsaP384Curve() elliptic.Curve { return elliptic.P384() } 459 + func ecdsaP521Curve() elliptic.Curve { return elliptic.P521() } 460 + 461 + // stripQueryFragment removes query and fragment from a URI 462 + func stripQueryFragment(uri string) string { 463 + if idx := strings.Index(uri, "?"); idx != -1 { 464 + uri = uri[:idx] 465 + } 466 + if idx := strings.Index(uri, "#"); idx != -1 { 467 + uri = uri[:idx] 468 + } 469 + return uri 470 + } 471 + 472 + // ExtractCnfJkt extracts the cnf.jkt (confirmation key thumbprint) from JWT claims 473 + func ExtractCnfJkt(claims *Claims) (string, error) { 474 + if claims.Confirmation == nil { 475 + return "", fmt.Errorf("token missing cnf claim (no DPoP binding)") 476 + } 477 + 478 + jkt, ok := claims.Confirmation["jkt"].(string) 479 + if !ok || jkt == "" { 480 + return "", fmt.Errorf("token cnf claim missing jkt (DPoP key thumbprint)") 481 + } 482 + 483 + return jkt, nil 484 + }
+921
internal/atproto/auth/dpop_test.go
··· 1 + package auth 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "crypto/rand" 7 + "crypto/sha256" 8 + "encoding/base64" 9 + "encoding/json" 10 + "strings" 11 + "testing" 12 + "time" 13 + 14 + "github.com/golang-jwt/jwt/v5" 15 + "github.com/google/uuid" 16 + ) 17 + 18 + // === Test Helpers === 19 + 20 + // testECKey holds a test ES256 key pair 21 + type testECKey struct { 22 + privateKey *ecdsa.PrivateKey 23 + publicKey *ecdsa.PublicKey 24 + jwk map[string]interface{} 25 + thumbprint string 26 + } 27 + 28 + // generateTestES256Key generates a test ES256 key pair and JWK 29 + func generateTestES256Key(t *testing.T) *testECKey { 30 + t.Helper() 31 + 32 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 33 + if err != nil { 34 + t.Fatalf("Failed to generate test key: %v", err) 35 + } 36 + 37 + // Encode public key coordinates as base64url 38 + xBytes := privateKey.PublicKey.X.Bytes() 39 + yBytes := privateKey.PublicKey.Y.Bytes() 40 + 41 + // P-256 coordinates must be 32 bytes (pad if needed) 42 + xBytes = padTo32Bytes(xBytes) 43 + yBytes = padTo32Bytes(yBytes) 44 + 45 + x := base64.RawURLEncoding.EncodeToString(xBytes) 46 + y := base64.RawURLEncoding.EncodeToString(yBytes) 47 + 48 + jwk := map[string]interface{}{ 49 + "kty": "EC", 50 + "crv": "P-256", 51 + "x": x, 52 + "y": y, 53 + } 54 + 55 + // Calculate thumbprint 56 + thumbprint, err := CalculateJWKThumbprint(jwk) 57 + if err != nil { 58 + t.Fatalf("Failed to calculate thumbprint: %v", err) 59 + } 60 + 61 + return &testECKey{ 62 + privateKey: privateKey, 63 + publicKey: &privateKey.PublicKey, 64 + jwk: jwk, 65 + thumbprint: thumbprint, 66 + } 67 + } 68 + 69 + // padTo32Bytes pads a byte slice to 32 bytes (required for P-256 coordinates) 70 + func padTo32Bytes(b []byte) []byte { 71 + if len(b) >= 32 { 72 + return b 73 + } 74 + padded := make([]byte, 32) 75 + copy(padded[32-len(b):], b) 76 + return padded 77 + } 78 + 79 + // createDPoPProof creates a DPoP proof JWT for testing 80 + func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string { 81 + t.Helper() 82 + 83 + claims := &DPoPClaims{ 84 + RegisteredClaims: jwt.RegisteredClaims{ 85 + ID: jti, 86 + IssuedAt: jwt.NewNumericDate(iat), 87 + }, 88 + HTTPMethod: method, 89 + HTTPURI: uri, 90 + } 91 + 92 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 93 + token.Header["typ"] = "dpop+jwt" 94 + token.Header["jwk"] = key.jwk 95 + 96 + tokenString, err := token.SignedString(key.privateKey) 97 + if err != nil { 98 + t.Fatalf("Failed to create DPoP proof: %v", err) 99 + } 100 + 101 + return tokenString 102 + } 103 + 104 + // === JWK Thumbprint Tests (RFC 7638) === 105 + 106 + func TestCalculateJWKThumbprint_EC_P256(t *testing.T) { 107 + // Test with known values from RFC 7638 Appendix A (adapted for P-256) 108 + jwk := map[string]interface{}{ 109 + "kty": "EC", 110 + "crv": "P-256", 111 + "x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzGl96oc0CWuis", 112 + "y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Hv5J0fSKbE", 113 + } 114 + 115 + thumbprint, err := CalculateJWKThumbprint(jwk) 116 + if err != nil { 117 + t.Fatalf("CalculateJWKThumbprint failed: %v", err) 118 + } 119 + 120 + if thumbprint == "" { 121 + t.Error("Expected non-empty thumbprint") 122 + } 123 + 124 + // Verify it's valid base64url 125 + _, err = base64.RawURLEncoding.DecodeString(thumbprint) 126 + if err != nil { 127 + t.Errorf("Thumbprint is not valid base64url: %v", err) 128 + } 129 + 130 + // Verify length (SHA-256 produces 32 bytes = 43 base64url chars) 131 + if len(thumbprint) != 43 { 132 + t.Errorf("Expected thumbprint length 43, got %d", len(thumbprint)) 133 + } 134 + } 135 + 136 + func TestCalculateJWKThumbprint_Deterministic(t *testing.T) { 137 + // Same key should produce same thumbprint 138 + jwk := map[string]interface{}{ 139 + "kty": "EC", 140 + "crv": "P-256", 141 + "x": "test-x-coordinate", 142 + "y": "test-y-coordinate", 143 + } 144 + 145 + thumbprint1, err := CalculateJWKThumbprint(jwk) 146 + if err != nil { 147 + t.Fatalf("First CalculateJWKThumbprint failed: %v", err) 148 + } 149 + 150 + thumbprint2, err := CalculateJWKThumbprint(jwk) 151 + if err != nil { 152 + t.Fatalf("Second CalculateJWKThumbprint failed: %v", err) 153 + } 154 + 155 + if thumbprint1 != thumbprint2 { 156 + t.Errorf("Thumbprints are not deterministic: %s != %s", thumbprint1, thumbprint2) 157 + } 158 + } 159 + 160 + func TestCalculateJWKThumbprint_DifferentKeys(t *testing.T) { 161 + // Different keys should produce different thumbprints 162 + jwk1 := map[string]interface{}{ 163 + "kty": "EC", 164 + "crv": "P-256", 165 + "x": "coordinate-x-1", 166 + "y": "coordinate-y-1", 167 + } 168 + 169 + jwk2 := map[string]interface{}{ 170 + "kty": "EC", 171 + "crv": "P-256", 172 + "x": "coordinate-x-2", 173 + "y": "coordinate-y-2", 174 + } 175 + 176 + thumbprint1, err := CalculateJWKThumbprint(jwk1) 177 + if err != nil { 178 + t.Fatalf("First CalculateJWKThumbprint failed: %v", err) 179 + } 180 + 181 + thumbprint2, err := CalculateJWKThumbprint(jwk2) 182 + if err != nil { 183 + t.Fatalf("Second CalculateJWKThumbprint failed: %v", err) 184 + } 185 + 186 + if thumbprint1 == thumbprint2 { 187 + t.Error("Different keys produced same thumbprint (collision)") 188 + } 189 + } 190 + 191 + func TestCalculateJWKThumbprint_MissingKty(t *testing.T) { 192 + jwk := map[string]interface{}{ 193 + "crv": "P-256", 194 + "x": "test-x", 195 + "y": "test-y", 196 + } 197 + 198 + _, err := CalculateJWKThumbprint(jwk) 199 + if err == nil { 200 + t.Error("Expected error for missing kty, got nil") 201 + } 202 + if err != nil && !contains(err.Error(), "missing kty") { 203 + t.Errorf("Expected error about missing kty, got: %v", err) 204 + } 205 + } 206 + 207 + func TestCalculateJWKThumbprint_EC_MissingCrv(t *testing.T) { 208 + jwk := map[string]interface{}{ 209 + "kty": "EC", 210 + "x": "test-x", 211 + "y": "test-y", 212 + } 213 + 214 + _, err := CalculateJWKThumbprint(jwk) 215 + if err == nil { 216 + t.Error("Expected error for missing crv, got nil") 217 + } 218 + if err != nil && !contains(err.Error(), "missing crv") { 219 + t.Errorf("Expected error about missing crv, got: %v", err) 220 + } 221 + } 222 + 223 + func TestCalculateJWKThumbprint_EC_MissingX(t *testing.T) { 224 + jwk := map[string]interface{}{ 225 + "kty": "EC", 226 + "crv": "P-256", 227 + "y": "test-y", 228 + } 229 + 230 + _, err := CalculateJWKThumbprint(jwk) 231 + if err == nil { 232 + t.Error("Expected error for missing x, got nil") 233 + } 234 + if err != nil && !contains(err.Error(), "missing x") { 235 + t.Errorf("Expected error about missing x, got: %v", err) 236 + } 237 + } 238 + 239 + func TestCalculateJWKThumbprint_EC_MissingY(t *testing.T) { 240 + jwk := map[string]interface{}{ 241 + "kty": "EC", 242 + "crv": "P-256", 243 + "x": "test-x", 244 + } 245 + 246 + _, err := CalculateJWKThumbprint(jwk) 247 + if err == nil { 248 + t.Error("Expected error for missing y, got nil") 249 + } 250 + if err != nil && !contains(err.Error(), "missing y") { 251 + t.Errorf("Expected error about missing y, got: %v", err) 252 + } 253 + } 254 + 255 + func TestCalculateJWKThumbprint_RSA(t *testing.T) { 256 + // Test RSA key thumbprint calculation 257 + jwk := map[string]interface{}{ 258 + "kty": "RSA", 259 + "e": "AQAB", 260 + "n": "test-modulus", 261 + } 262 + 263 + thumbprint, err := CalculateJWKThumbprint(jwk) 264 + if err != nil { 265 + t.Fatalf("CalculateJWKThumbprint failed for RSA: %v", err) 266 + } 267 + 268 + if thumbprint == "" { 269 + t.Error("Expected non-empty thumbprint for RSA key") 270 + } 271 + } 272 + 273 + func TestCalculateJWKThumbprint_OKP(t *testing.T) { 274 + // Test OKP (Octet Key Pair) thumbprint calculation 275 + jwk := map[string]interface{}{ 276 + "kty": "OKP", 277 + "crv": "Ed25519", 278 + "x": "test-x-coordinate", 279 + } 280 + 281 + thumbprint, err := CalculateJWKThumbprint(jwk) 282 + if err != nil { 283 + t.Fatalf("CalculateJWKThumbprint failed for OKP: %v", err) 284 + } 285 + 286 + if thumbprint == "" { 287 + t.Error("Expected non-empty thumbprint for OKP key") 288 + } 289 + } 290 + 291 + func TestCalculateJWKThumbprint_UnsupportedKeyType(t *testing.T) { 292 + jwk := map[string]interface{}{ 293 + "kty": "UNKNOWN", 294 + } 295 + 296 + _, err := CalculateJWKThumbprint(jwk) 297 + if err == nil { 298 + t.Error("Expected error for unsupported key type, got nil") 299 + } 300 + if err != nil && !contains(err.Error(), "unsupported JWK key type") { 301 + t.Errorf("Expected error about unsupported key type, got: %v", err) 302 + } 303 + } 304 + 305 + func TestCalculateJWKThumbprint_CanonicalJSON(t *testing.T) { 306 + // RFC 7638 requires lexicographic ordering of keys in canonical JSON 307 + // This test verifies that the canonical JSON is correctly ordered 308 + 309 + jwk := map[string]interface{}{ 310 + "kty": "EC", 311 + "crv": "P-256", 312 + "x": "x-coord", 313 + "y": "y-coord", 314 + } 315 + 316 + // The canonical JSON should be: {"crv":"P-256","kty":"EC","x":"x-coord","y":"y-coord"} 317 + // (lexicographically ordered: crv, kty, x, y) 318 + 319 + canonical := map[string]string{ 320 + "crv": "P-256", 321 + "kty": "EC", 322 + "x": "x-coord", 323 + "y": "y-coord", 324 + } 325 + 326 + canonicalJSON, err := json.Marshal(canonical) 327 + if err != nil { 328 + t.Fatalf("Failed to marshal canonical JSON: %v", err) 329 + } 330 + 331 + expectedHash := sha256.Sum256(canonicalJSON) 332 + expectedThumbprint := base64.RawURLEncoding.EncodeToString(expectedHash[:]) 333 + 334 + actualThumbprint, err := CalculateJWKThumbprint(jwk) 335 + if err != nil { 336 + t.Fatalf("CalculateJWKThumbprint failed: %v", err) 337 + } 338 + 339 + if actualThumbprint != expectedThumbprint { 340 + t.Errorf("Thumbprint doesn't match expected canonical JSON hash\nExpected: %s\nGot: %s", 341 + expectedThumbprint, actualThumbprint) 342 + } 343 + } 344 + 345 + // === DPoP Proof Verification Tests === 346 + 347 + func TestVerifyDPoPProof_Valid(t *testing.T) { 348 + verifier := NewDPoPVerifier() 349 + key := generateTestES256Key(t) 350 + 351 + method := "POST" 352 + uri := "https://api.example.com/resource" 353 + iat := time.Now() 354 + jti := uuid.New().String() 355 + 356 + proof := createDPoPProof(t, key, method, uri, iat, jti) 357 + 358 + result, err := verifier.VerifyDPoPProof(proof, method, uri) 359 + if err != nil { 360 + t.Fatalf("VerifyDPoPProof failed for valid proof: %v", err) 361 + } 362 + 363 + if result == nil { 364 + t.Fatal("Expected non-nil proof result") 365 + } 366 + 367 + if result.Claims.HTTPMethod != method { 368 + t.Errorf("Expected method %s, got %s", method, result.Claims.HTTPMethod) 369 + } 370 + 371 + if result.Claims.HTTPURI != uri { 372 + t.Errorf("Expected URI %s, got %s", uri, result.Claims.HTTPURI) 373 + } 374 + 375 + if result.Claims.ID != jti { 376 + t.Errorf("Expected jti %s, got %s", jti, result.Claims.ID) 377 + } 378 + 379 + if result.Thumbprint != key.thumbprint { 380 + t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint) 381 + } 382 + } 383 + 384 + func TestVerifyDPoPProof_InvalidSignature(t *testing.T) { 385 + verifier := NewDPoPVerifier() 386 + key := generateTestES256Key(t) 387 + wrongKey := generateTestES256Key(t) 388 + 389 + method := "POST" 390 + uri := "https://api.example.com/resource" 391 + iat := time.Now() 392 + jti := uuid.New().String() 393 + 394 + // Create proof with one key 395 + proof := createDPoPProof(t, key, method, uri, iat, jti) 396 + 397 + // Parse and modify to use wrong key's JWK in header (signature won't match) 398 + parts := splitJWT(proof) 399 + header := parseJWTHeader(t, parts[0]) 400 + header["jwk"] = wrongKey.jwk 401 + modifiedHeader := encodeJSON(t, header) 402 + tamperedProof := modifiedHeader + "." + parts[1] + "." + parts[2] 403 + 404 + _, err := verifier.VerifyDPoPProof(tamperedProof, method, uri) 405 + if err == nil { 406 + t.Error("Expected error for invalid signature, got nil") 407 + } 408 + if err != nil && !contains(err.Error(), "signature verification failed") { 409 + t.Errorf("Expected signature verification error, got: %v", err) 410 + } 411 + } 412 + 413 + func TestVerifyDPoPProof_WrongHTTPMethod(t *testing.T) { 414 + verifier := NewDPoPVerifier() 415 + key := generateTestES256Key(t) 416 + 417 + method := "POST" 418 + wrongMethod := "GET" 419 + uri := "https://api.example.com/resource" 420 + iat := time.Now() 421 + jti := uuid.New().String() 422 + 423 + proof := createDPoPProof(t, key, method, uri, iat, jti) 424 + 425 + _, err := verifier.VerifyDPoPProof(proof, wrongMethod, uri) 426 + if err == nil { 427 + t.Error("Expected error for HTTP method mismatch, got nil") 428 + } 429 + if err != nil && !contains(err.Error(), "htm mismatch") { 430 + t.Errorf("Expected htm mismatch error, got: %v", err) 431 + } 432 + } 433 + 434 + func TestVerifyDPoPProof_WrongURI(t *testing.T) { 435 + verifier := NewDPoPVerifier() 436 + key := generateTestES256Key(t) 437 + 438 + method := "POST" 439 + uri := "https://api.example.com/resource" 440 + wrongURI := "https://api.example.com/different" 441 + iat := time.Now() 442 + jti := uuid.New().String() 443 + 444 + proof := createDPoPProof(t, key, method, uri, iat, jti) 445 + 446 + _, err := verifier.VerifyDPoPProof(proof, method, wrongURI) 447 + if err == nil { 448 + t.Error("Expected error for URI mismatch, got nil") 449 + } 450 + if err != nil && !contains(err.Error(), "htu mismatch") { 451 + t.Errorf("Expected htu mismatch error, got: %v", err) 452 + } 453 + } 454 + 455 + func TestVerifyDPoPProof_URIWithQuery(t *testing.T) { 456 + // URI comparison should strip query and fragment 457 + verifier := NewDPoPVerifier() 458 + key := generateTestES256Key(t) 459 + 460 + method := "POST" 461 + baseURI := "https://api.example.com/resource" 462 + uriWithQuery := baseURI + "?param=value" 463 + iat := time.Now() 464 + jti := uuid.New().String() 465 + 466 + proof := createDPoPProof(t, key, method, baseURI, iat, jti) 467 + 468 + // Should succeed because query is stripped 469 + _, err := verifier.VerifyDPoPProof(proof, method, uriWithQuery) 470 + if err != nil { 471 + t.Fatalf("VerifyDPoPProof failed for URI with query: %v", err) 472 + } 473 + } 474 + 475 + func TestVerifyDPoPProof_URIWithFragment(t *testing.T) { 476 + // URI comparison should strip query and fragment 477 + verifier := NewDPoPVerifier() 478 + key := generateTestES256Key(t) 479 + 480 + method := "POST" 481 + baseURI := "https://api.example.com/resource" 482 + uriWithFragment := baseURI + "#section" 483 + iat := time.Now() 484 + jti := uuid.New().String() 485 + 486 + proof := createDPoPProof(t, key, method, baseURI, iat, jti) 487 + 488 + // Should succeed because fragment is stripped 489 + _, err := verifier.VerifyDPoPProof(proof, method, uriWithFragment) 490 + if err != nil { 491 + t.Fatalf("VerifyDPoPProof failed for URI with fragment: %v", err) 492 + } 493 + } 494 + 495 + func TestVerifyDPoPProof_ExpiredProof(t *testing.T) { 496 + verifier := NewDPoPVerifier() 497 + key := generateTestES256Key(t) 498 + 499 + method := "POST" 500 + uri := "https://api.example.com/resource" 501 + // Proof issued 10 minutes ago (exceeds default MaxProofAge of 5 minutes) 502 + iat := time.Now().Add(-10 * time.Minute) 503 + jti := uuid.New().String() 504 + 505 + proof := createDPoPProof(t, key, method, uri, iat, jti) 506 + 507 + _, err := verifier.VerifyDPoPProof(proof, method, uri) 508 + if err == nil { 509 + t.Error("Expected error for expired proof, got nil") 510 + } 511 + if err != nil && !contains(err.Error(), "too old") { 512 + t.Errorf("Expected 'too old' error, got: %v", err) 513 + } 514 + } 515 + 516 + func TestVerifyDPoPProof_FutureProof(t *testing.T) { 517 + verifier := NewDPoPVerifier() 518 + key := generateTestES256Key(t) 519 + 520 + method := "POST" 521 + uri := "https://api.example.com/resource" 522 + // Proof issued 1 minute in the future (exceeds MaxClockSkew) 523 + iat := time.Now().Add(1 * time.Minute) 524 + jti := uuid.New().String() 525 + 526 + proof := createDPoPProof(t, key, method, uri, iat, jti) 527 + 528 + _, err := verifier.VerifyDPoPProof(proof, method, uri) 529 + if err == nil { 530 + t.Error("Expected error for future proof, got nil") 531 + } 532 + if err != nil && !contains(err.Error(), "in the future") { 533 + t.Errorf("Expected 'in the future' error, got: %v", err) 534 + } 535 + } 536 + 537 + func TestVerifyDPoPProof_WithinClockSkew(t *testing.T) { 538 + verifier := NewDPoPVerifier() 539 + key := generateTestES256Key(t) 540 + 541 + method := "POST" 542 + uri := "https://api.example.com/resource" 543 + // Proof issued 15 seconds in the future (within MaxClockSkew of 30s) 544 + iat := time.Now().Add(15 * time.Second) 545 + jti := uuid.New().String() 546 + 547 + proof := createDPoPProof(t, key, method, uri, iat, jti) 548 + 549 + _, err := verifier.VerifyDPoPProof(proof, method, uri) 550 + if err != nil { 551 + t.Fatalf("VerifyDPoPProof failed for proof within clock skew: %v", err) 552 + } 553 + } 554 + 555 + func TestVerifyDPoPProof_MissingJti(t *testing.T) { 556 + verifier := NewDPoPVerifier() 557 + key := generateTestES256Key(t) 558 + 559 + method := "POST" 560 + uri := "https://api.example.com/resource" 561 + iat := time.Now() 562 + 563 + claims := &DPoPClaims{ 564 + RegisteredClaims: jwt.RegisteredClaims{ 565 + // No ID (jti) 566 + IssuedAt: jwt.NewNumericDate(iat), 567 + }, 568 + HTTPMethod: method, 569 + HTTPURI: uri, 570 + } 571 + 572 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 573 + token.Header["typ"] = "dpop+jwt" 574 + token.Header["jwk"] = key.jwk 575 + 576 + proof, err := token.SignedString(key.privateKey) 577 + if err != nil { 578 + t.Fatalf("Failed to create test proof: %v", err) 579 + } 580 + 581 + _, err = verifier.VerifyDPoPProof(proof, method, uri) 582 + if err == nil { 583 + t.Error("Expected error for missing jti, got nil") 584 + } 585 + if err != nil && !contains(err.Error(), "missing jti") { 586 + t.Errorf("Expected missing jti error, got: %v", err) 587 + } 588 + } 589 + 590 + func TestVerifyDPoPProof_MissingTypHeader(t *testing.T) { 591 + verifier := NewDPoPVerifier() 592 + key := generateTestES256Key(t) 593 + 594 + method := "POST" 595 + uri := "https://api.example.com/resource" 596 + iat := time.Now() 597 + jti := uuid.New().String() 598 + 599 + claims := &DPoPClaims{ 600 + RegisteredClaims: jwt.RegisteredClaims{ 601 + ID: jti, 602 + IssuedAt: jwt.NewNumericDate(iat), 603 + }, 604 + HTTPMethod: method, 605 + HTTPURI: uri, 606 + } 607 + 608 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 609 + // Don't set typ header 610 + token.Header["jwk"] = key.jwk 611 + 612 + proof, err := token.SignedString(key.privateKey) 613 + if err != nil { 614 + t.Fatalf("Failed to create test proof: %v", err) 615 + } 616 + 617 + _, err = verifier.VerifyDPoPProof(proof, method, uri) 618 + if err == nil { 619 + t.Error("Expected error for missing typ header, got nil") 620 + } 621 + if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") { 622 + t.Errorf("Expected typ header error, got: %v", err) 623 + } 624 + } 625 + 626 + func TestVerifyDPoPProof_WrongTypHeader(t *testing.T) { 627 + verifier := NewDPoPVerifier() 628 + key := generateTestES256Key(t) 629 + 630 + method := "POST" 631 + uri := "https://api.example.com/resource" 632 + iat := time.Now() 633 + jti := uuid.New().String() 634 + 635 + claims := &DPoPClaims{ 636 + RegisteredClaims: jwt.RegisteredClaims{ 637 + ID: jti, 638 + IssuedAt: jwt.NewNumericDate(iat), 639 + }, 640 + HTTPMethod: method, 641 + HTTPURI: uri, 642 + } 643 + 644 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 645 + token.Header["typ"] = "JWT" // Wrong typ 646 + token.Header["jwk"] = key.jwk 647 + 648 + proof, err := token.SignedString(key.privateKey) 649 + if err != nil { 650 + t.Fatalf("Failed to create test proof: %v", err) 651 + } 652 + 653 + _, err = verifier.VerifyDPoPProof(proof, method, uri) 654 + if err == nil { 655 + t.Error("Expected error for wrong typ header, got nil") 656 + } 657 + if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") { 658 + t.Errorf("Expected typ header error, got: %v", err) 659 + } 660 + } 661 + 662 + func TestVerifyDPoPProof_MissingJWK(t *testing.T) { 663 + verifier := NewDPoPVerifier() 664 + key := generateTestES256Key(t) 665 + 666 + method := "POST" 667 + uri := "https://api.example.com/resource" 668 + iat := time.Now() 669 + jti := uuid.New().String() 670 + 671 + claims := &DPoPClaims{ 672 + RegisteredClaims: jwt.RegisteredClaims{ 673 + ID: jti, 674 + IssuedAt: jwt.NewNumericDate(iat), 675 + }, 676 + HTTPMethod: method, 677 + HTTPURI: uri, 678 + } 679 + 680 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 681 + token.Header["typ"] = "dpop+jwt" 682 + // Don't include JWK 683 + 684 + proof, err := token.SignedString(key.privateKey) 685 + if err != nil { 686 + t.Fatalf("Failed to create test proof: %v", err) 687 + } 688 + 689 + _, err = verifier.VerifyDPoPProof(proof, method, uri) 690 + if err == nil { 691 + t.Error("Expected error for missing jwk header, got nil") 692 + } 693 + if err != nil && !contains(err.Error(), "missing jwk") { 694 + t.Errorf("Expected missing jwk error, got: %v", err) 695 + } 696 + } 697 + 698 + func TestVerifyDPoPProof_CustomTimeSettings(t *testing.T) { 699 + verifier := &DPoPVerifier{ 700 + MaxClockSkew: 1 * time.Minute, 701 + MaxProofAge: 10 * time.Minute, 702 + } 703 + key := generateTestES256Key(t) 704 + 705 + method := "POST" 706 + uri := "https://api.example.com/resource" 707 + // Proof issued 50 seconds in the future (within custom MaxClockSkew) 708 + iat := time.Now().Add(50 * time.Second) 709 + jti := uuid.New().String() 710 + 711 + proof := createDPoPProof(t, key, method, uri, iat, jti) 712 + 713 + _, err := verifier.VerifyDPoPProof(proof, method, uri) 714 + if err != nil { 715 + t.Fatalf("VerifyDPoPProof failed with custom time settings: %v", err) 716 + } 717 + } 718 + 719 + func TestVerifyDPoPProof_HTTPMethodCaseInsensitive(t *testing.T) { 720 + // HTTP method comparison should be case-insensitive per spec 721 + verifier := NewDPoPVerifier() 722 + key := generateTestES256Key(t) 723 + 724 + method := "post" 725 + uri := "https://api.example.com/resource" 726 + iat := time.Now() 727 + jti := uuid.New().String() 728 + 729 + proof := createDPoPProof(t, key, method, uri, iat, jti) 730 + 731 + // Verify with uppercase method 732 + _, err := verifier.VerifyDPoPProof(proof, "POST", uri) 733 + if err != nil { 734 + t.Fatalf("VerifyDPoPProof failed for case-insensitive method: %v", err) 735 + } 736 + } 737 + 738 + // === Token Binding Verification Tests === 739 + 740 + func TestVerifyTokenBinding_Matching(t *testing.T) { 741 + verifier := NewDPoPVerifier() 742 + key := generateTestES256Key(t) 743 + 744 + method := "POST" 745 + uri := "https://api.example.com/resource" 746 + iat := time.Now() 747 + jti := uuid.New().String() 748 + 749 + proof := createDPoPProof(t, key, method, uri, iat, jti) 750 + 751 + result, err := verifier.VerifyDPoPProof(proof, method, uri) 752 + if err != nil { 753 + t.Fatalf("VerifyDPoPProof failed: %v", err) 754 + } 755 + 756 + // Verify token binding with matching thumbprint 757 + err = verifier.VerifyTokenBinding(result, key.thumbprint) 758 + if err != nil { 759 + t.Fatalf("VerifyTokenBinding failed for matching thumbprint: %v", err) 760 + } 761 + } 762 + 763 + func TestVerifyTokenBinding_Mismatch(t *testing.T) { 764 + verifier := NewDPoPVerifier() 765 + key := generateTestES256Key(t) 766 + wrongKey := generateTestES256Key(t) 767 + 768 + method := "POST" 769 + uri := "https://api.example.com/resource" 770 + iat := time.Now() 771 + jti := uuid.New().String() 772 + 773 + proof := createDPoPProof(t, key, method, uri, iat, jti) 774 + 775 + result, err := verifier.VerifyDPoPProof(proof, method, uri) 776 + if err != nil { 777 + t.Fatalf("VerifyDPoPProof failed: %v", err) 778 + } 779 + 780 + // Verify token binding with wrong thumbprint 781 + err = verifier.VerifyTokenBinding(result, wrongKey.thumbprint) 782 + if err == nil { 783 + t.Error("Expected error for thumbprint mismatch, got nil") 784 + } 785 + if err != nil && !contains(err.Error(), "thumbprint mismatch") { 786 + t.Errorf("Expected thumbprint mismatch error, got: %v", err) 787 + } 788 + } 789 + 790 + // === ExtractCnfJkt Tests === 791 + 792 + func TestExtractCnfJkt_Valid(t *testing.T) { 793 + expectedJkt := "test-thumbprint-123" 794 + claims := &Claims{ 795 + Confirmation: map[string]interface{}{ 796 + "jkt": expectedJkt, 797 + }, 798 + } 799 + 800 + jkt, err := ExtractCnfJkt(claims) 801 + if err != nil { 802 + t.Fatalf("ExtractCnfJkt failed for valid claims: %v", err) 803 + } 804 + 805 + if jkt != expectedJkt { 806 + t.Errorf("Expected jkt %s, got %s", expectedJkt, jkt) 807 + } 808 + } 809 + 810 + func TestExtractCnfJkt_MissingCnf(t *testing.T) { 811 + claims := &Claims{ 812 + // No Confirmation 813 + } 814 + 815 + _, err := ExtractCnfJkt(claims) 816 + if err == nil { 817 + t.Error("Expected error for missing cnf, got nil") 818 + } 819 + if err != nil && !contains(err.Error(), "missing cnf claim") { 820 + t.Errorf("Expected missing cnf error, got: %v", err) 821 + } 822 + } 823 + 824 + func TestExtractCnfJkt_NilCnf(t *testing.T) { 825 + claims := &Claims{ 826 + Confirmation: nil, 827 + } 828 + 829 + _, err := ExtractCnfJkt(claims) 830 + if err == nil { 831 + t.Error("Expected error for nil cnf, got nil") 832 + } 833 + if err != nil && !contains(err.Error(), "missing cnf claim") { 834 + t.Errorf("Expected missing cnf error, got: %v", err) 835 + } 836 + } 837 + 838 + func TestExtractCnfJkt_MissingJkt(t *testing.T) { 839 + claims := &Claims{ 840 + Confirmation: map[string]interface{}{ 841 + "other": "value", 842 + }, 843 + } 844 + 845 + _, err := ExtractCnfJkt(claims) 846 + if err == nil { 847 + t.Error("Expected error for missing jkt, got nil") 848 + } 849 + if err != nil && !contains(err.Error(), "missing jkt") { 850 + t.Errorf("Expected missing jkt error, got: %v", err) 851 + } 852 + } 853 + 854 + func TestExtractCnfJkt_EmptyJkt(t *testing.T) { 855 + claims := &Claims{ 856 + Confirmation: map[string]interface{}{ 857 + "jkt": "", 858 + }, 859 + } 860 + 861 + _, err := ExtractCnfJkt(claims) 862 + if err == nil { 863 + t.Error("Expected error for empty jkt, got nil") 864 + } 865 + if err != nil && !contains(err.Error(), "missing jkt") { 866 + t.Errorf("Expected missing jkt error, got: %v", err) 867 + } 868 + } 869 + 870 + func TestExtractCnfJkt_WrongType(t *testing.T) { 871 + claims := &Claims{ 872 + Confirmation: map[string]interface{}{ 873 + "jkt": 123, // Not a string 874 + }, 875 + } 876 + 877 + _, err := ExtractCnfJkt(claims) 878 + if err == nil { 879 + t.Error("Expected error for wrong type jkt, got nil") 880 + } 881 + if err != nil && !contains(err.Error(), "missing jkt") { 882 + t.Errorf("Expected missing jkt error, got: %v", err) 883 + } 884 + } 885 + 886 + // === Helper Functions for Tests === 887 + 888 + // splitJWT splits a JWT into its three parts 889 + func splitJWT(token string) []string { 890 + return []string{ 891 + token[:strings.IndexByte(token, '.')], 892 + token[strings.IndexByte(token, '.')+1 : strings.LastIndexByte(token, '.')], 893 + token[strings.LastIndexByte(token, '.')+1:], 894 + } 895 + } 896 + 897 + // parseJWTHeader parses a base64url-encoded JWT header 898 + func parseJWTHeader(t *testing.T, encoded string) map[string]interface{} { 899 + t.Helper() 900 + decoded, err := base64.RawURLEncoding.DecodeString(encoded) 901 + if err != nil { 902 + t.Fatalf("Failed to decode header: %v", err) 903 + } 904 + 905 + var header map[string]interface{} 906 + if err := json.Unmarshal(decoded, &header); err != nil { 907 + t.Fatalf("Failed to unmarshal header: %v", err) 908 + } 909 + 910 + return header 911 + } 912 + 913 + // encodeJSON encodes a value to base64url-encoded JSON 914 + func encodeJSON(t *testing.T, v interface{}) string { 915 + t.Helper() 916 + data, err := json.Marshal(v) 917 + if err != nil { 918 + t.Fatalf("Failed to marshal JSON: %v", err) 919 + } 920 + return base64.RawURLEncoding.EncodeToString(data) 921 + }