A community based topic aggregation platform built on atproto
1package auth 2 3import ( 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 indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto" 15 "github.com/golang-jwt/jwt/v5" 16 "github.com/google/uuid" 17) 18 19// === Test Helpers === 20 21// testECKey holds a test ES256 key pair 22type testECKey struct { 23 privateKey *ecdsa.PrivateKey 24 publicKey *ecdsa.PublicKey 25 jwk map[string]interface{} 26 thumbprint string 27} 28 29// generateTestES256Key generates a test ES256 key pair and JWK 30func generateTestES256Key(t *testing.T) *testECKey { 31 t.Helper() 32 33 privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 34 if err != nil { 35 t.Fatalf("Failed to generate test key: %v", err) 36 } 37 38 // Encode public key coordinates as base64url 39 xBytes := privateKey.PublicKey.X.Bytes() 40 yBytes := privateKey.PublicKey.Y.Bytes() 41 42 // P-256 coordinates must be 32 bytes (pad if needed) 43 xBytes = padTo32Bytes(xBytes) 44 yBytes = padTo32Bytes(yBytes) 45 46 x := base64.RawURLEncoding.EncodeToString(xBytes) 47 y := base64.RawURLEncoding.EncodeToString(yBytes) 48 49 jwk := map[string]interface{}{ 50 "kty": "EC", 51 "crv": "P-256", 52 "x": x, 53 "y": y, 54 } 55 56 // Calculate thumbprint 57 thumbprint, err := CalculateJWKThumbprint(jwk) 58 if err != nil { 59 t.Fatalf("Failed to calculate thumbprint: %v", err) 60 } 61 62 return &testECKey{ 63 privateKey: privateKey, 64 publicKey: &privateKey.PublicKey, 65 jwk: jwk, 66 thumbprint: thumbprint, 67 } 68} 69 70// padTo32Bytes pads a byte slice to 32 bytes (required for P-256 coordinates) 71func padTo32Bytes(b []byte) []byte { 72 if len(b) >= 32 { 73 return b 74 } 75 padded := make([]byte, 32) 76 copy(padded[32-len(b):], b) 77 return padded 78} 79 80// createDPoPProof creates a DPoP proof JWT for testing 81func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string { 82 t.Helper() 83 84 claims := &DPoPClaims{ 85 RegisteredClaims: jwt.RegisteredClaims{ 86 ID: jti, 87 IssuedAt: jwt.NewNumericDate(iat), 88 }, 89 HTTPMethod: method, 90 HTTPURI: uri, 91 } 92 93 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 94 token.Header["typ"] = "dpop+jwt" 95 token.Header["jwk"] = key.jwk 96 97 tokenString, err := token.SignedString(key.privateKey) 98 if err != nil { 99 t.Fatalf("Failed to create DPoP proof: %v", err) 100 } 101 102 return tokenString 103} 104 105// === JWK Thumbprint Tests (RFC 7638) === 106 107func TestCalculateJWKThumbprint_EC_P256(t *testing.T) { 108 // Test with known values from RFC 7638 Appendix A (adapted for P-256) 109 jwk := map[string]interface{}{ 110 "kty": "EC", 111 "crv": "P-256", 112 "x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzGl96oc0CWuis", 113 "y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Hv5J0fSKbE", 114 } 115 116 thumbprint, err := CalculateJWKThumbprint(jwk) 117 if err != nil { 118 t.Fatalf("CalculateJWKThumbprint failed: %v", err) 119 } 120 121 if thumbprint == "" { 122 t.Error("Expected non-empty thumbprint") 123 } 124 125 // Verify it's valid base64url 126 _, err = base64.RawURLEncoding.DecodeString(thumbprint) 127 if err != nil { 128 t.Errorf("Thumbprint is not valid base64url: %v", err) 129 } 130 131 // Verify length (SHA-256 produces 32 bytes = 43 base64url chars) 132 if len(thumbprint) != 43 { 133 t.Errorf("Expected thumbprint length 43, got %d", len(thumbprint)) 134 } 135} 136 137func TestCalculateJWKThumbprint_Deterministic(t *testing.T) { 138 // Same key should produce same thumbprint 139 jwk := map[string]interface{}{ 140 "kty": "EC", 141 "crv": "P-256", 142 "x": "test-x-coordinate", 143 "y": "test-y-coordinate", 144 } 145 146 thumbprint1, err := CalculateJWKThumbprint(jwk) 147 if err != nil { 148 t.Fatalf("First CalculateJWKThumbprint failed: %v", err) 149 } 150 151 thumbprint2, err := CalculateJWKThumbprint(jwk) 152 if err != nil { 153 t.Fatalf("Second CalculateJWKThumbprint failed: %v", err) 154 } 155 156 if thumbprint1 != thumbprint2 { 157 t.Errorf("Thumbprints are not deterministic: %s != %s", thumbprint1, thumbprint2) 158 } 159} 160 161func TestCalculateJWKThumbprint_DifferentKeys(t *testing.T) { 162 // Different keys should produce different thumbprints 163 jwk1 := map[string]interface{}{ 164 "kty": "EC", 165 "crv": "P-256", 166 "x": "coordinate-x-1", 167 "y": "coordinate-y-1", 168 } 169 170 jwk2 := map[string]interface{}{ 171 "kty": "EC", 172 "crv": "P-256", 173 "x": "coordinate-x-2", 174 "y": "coordinate-y-2", 175 } 176 177 thumbprint1, err := CalculateJWKThumbprint(jwk1) 178 if err != nil { 179 t.Fatalf("First CalculateJWKThumbprint failed: %v", err) 180 } 181 182 thumbprint2, err := CalculateJWKThumbprint(jwk2) 183 if err != nil { 184 t.Fatalf("Second CalculateJWKThumbprint failed: %v", err) 185 } 186 187 if thumbprint1 == thumbprint2 { 188 t.Error("Different keys produced same thumbprint (collision)") 189 } 190} 191 192func TestCalculateJWKThumbprint_MissingKty(t *testing.T) { 193 jwk := map[string]interface{}{ 194 "crv": "P-256", 195 "x": "test-x", 196 "y": "test-y", 197 } 198 199 _, err := CalculateJWKThumbprint(jwk) 200 if err == nil { 201 t.Error("Expected error for missing kty, got nil") 202 } 203 if err != nil && !contains(err.Error(), "missing kty") { 204 t.Errorf("Expected error about missing kty, got: %v", err) 205 } 206} 207 208func TestCalculateJWKThumbprint_EC_MissingCrv(t *testing.T) { 209 jwk := map[string]interface{}{ 210 "kty": "EC", 211 "x": "test-x", 212 "y": "test-y", 213 } 214 215 _, err := CalculateJWKThumbprint(jwk) 216 if err == nil { 217 t.Error("Expected error for missing crv, got nil") 218 } 219 if err != nil && !contains(err.Error(), "missing crv") { 220 t.Errorf("Expected error about missing crv, got: %v", err) 221 } 222} 223 224func TestCalculateJWKThumbprint_EC_MissingX(t *testing.T) { 225 jwk := map[string]interface{}{ 226 "kty": "EC", 227 "crv": "P-256", 228 "y": "test-y", 229 } 230 231 _, err := CalculateJWKThumbprint(jwk) 232 if err == nil { 233 t.Error("Expected error for missing x, got nil") 234 } 235 if err != nil && !contains(err.Error(), "missing x") { 236 t.Errorf("Expected error about missing x, got: %v", err) 237 } 238} 239 240func TestCalculateJWKThumbprint_EC_MissingY(t *testing.T) { 241 jwk := map[string]interface{}{ 242 "kty": "EC", 243 "crv": "P-256", 244 "x": "test-x", 245 } 246 247 _, err := CalculateJWKThumbprint(jwk) 248 if err == nil { 249 t.Error("Expected error for missing y, got nil") 250 } 251 if err != nil && !contains(err.Error(), "missing y") { 252 t.Errorf("Expected error about missing y, got: %v", err) 253 } 254} 255 256func TestCalculateJWKThumbprint_RSA(t *testing.T) { 257 // Test RSA key thumbprint calculation 258 jwk := map[string]interface{}{ 259 "kty": "RSA", 260 "e": "AQAB", 261 "n": "test-modulus", 262 } 263 264 thumbprint, err := CalculateJWKThumbprint(jwk) 265 if err != nil { 266 t.Fatalf("CalculateJWKThumbprint failed for RSA: %v", err) 267 } 268 269 if thumbprint == "" { 270 t.Error("Expected non-empty thumbprint for RSA key") 271 } 272} 273 274func TestCalculateJWKThumbprint_OKP(t *testing.T) { 275 // Test OKP (Octet Key Pair) thumbprint calculation 276 jwk := map[string]interface{}{ 277 "kty": "OKP", 278 "crv": "Ed25519", 279 "x": "test-x-coordinate", 280 } 281 282 thumbprint, err := CalculateJWKThumbprint(jwk) 283 if err != nil { 284 t.Fatalf("CalculateJWKThumbprint failed for OKP: %v", err) 285 } 286 287 if thumbprint == "" { 288 t.Error("Expected non-empty thumbprint for OKP key") 289 } 290} 291 292func TestCalculateJWKThumbprint_UnsupportedKeyType(t *testing.T) { 293 jwk := map[string]interface{}{ 294 "kty": "UNKNOWN", 295 } 296 297 _, err := CalculateJWKThumbprint(jwk) 298 if err == nil { 299 t.Error("Expected error for unsupported key type, got nil") 300 } 301 if err != nil && !contains(err.Error(), "unsupported JWK key type") { 302 t.Errorf("Expected error about unsupported key type, got: %v", err) 303 } 304} 305 306func TestCalculateJWKThumbprint_CanonicalJSON(t *testing.T) { 307 // RFC 7638 requires lexicographic ordering of keys in canonical JSON 308 // This test verifies that the canonical JSON is correctly ordered 309 310 jwk := map[string]interface{}{ 311 "kty": "EC", 312 "crv": "P-256", 313 "x": "x-coord", 314 "y": "y-coord", 315 } 316 317 // The canonical JSON should be: {"crv":"P-256","kty":"EC","x":"x-coord","y":"y-coord"} 318 // (lexicographically ordered: crv, kty, x, y) 319 320 canonical := map[string]string{ 321 "crv": "P-256", 322 "kty": "EC", 323 "x": "x-coord", 324 "y": "y-coord", 325 } 326 327 canonicalJSON, err := json.Marshal(canonical) 328 if err != nil { 329 t.Fatalf("Failed to marshal canonical JSON: %v", err) 330 } 331 332 expectedHash := sha256.Sum256(canonicalJSON) 333 expectedThumbprint := base64.RawURLEncoding.EncodeToString(expectedHash[:]) 334 335 actualThumbprint, err := CalculateJWKThumbprint(jwk) 336 if err != nil { 337 t.Fatalf("CalculateJWKThumbprint failed: %v", err) 338 } 339 340 if actualThumbprint != expectedThumbprint { 341 t.Errorf("Thumbprint doesn't match expected canonical JSON hash\nExpected: %s\nGot: %s", 342 expectedThumbprint, actualThumbprint) 343 } 344} 345 346// === DPoP Proof Verification Tests === 347 348func TestVerifyDPoPProof_Valid(t *testing.T) { 349 verifier := NewDPoPVerifier() 350 key := generateTestES256Key(t) 351 352 method := "POST" 353 uri := "https://api.example.com/resource" 354 iat := time.Now() 355 jti := uuid.New().String() 356 357 proof := createDPoPProof(t, key, method, uri, iat, jti) 358 359 result, err := verifier.VerifyDPoPProof(proof, method, uri) 360 if err != nil { 361 t.Fatalf("VerifyDPoPProof failed for valid proof: %v", err) 362 } 363 364 if result == nil { 365 t.Fatal("Expected non-nil proof result") 366 } 367 368 if result.Claims.HTTPMethod != method { 369 t.Errorf("Expected method %s, got %s", method, result.Claims.HTTPMethod) 370 } 371 372 if result.Claims.HTTPURI != uri { 373 t.Errorf("Expected URI %s, got %s", uri, result.Claims.HTTPURI) 374 } 375 376 if result.Claims.ID != jti { 377 t.Errorf("Expected jti %s, got %s", jti, result.Claims.ID) 378 } 379 380 if result.Thumbprint != key.thumbprint { 381 t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint) 382 } 383} 384 385func TestVerifyDPoPProof_InvalidSignature(t *testing.T) { 386 verifier := NewDPoPVerifier() 387 key := generateTestES256Key(t) 388 wrongKey := generateTestES256Key(t) 389 390 method := "POST" 391 uri := "https://api.example.com/resource" 392 iat := time.Now() 393 jti := uuid.New().String() 394 395 // Create proof with one key 396 proof := createDPoPProof(t, key, method, uri, iat, jti) 397 398 // Parse and modify to use wrong key's JWK in header (signature won't match) 399 parts := splitJWT(proof) 400 header := parseJWTHeader(t, parts[0]) 401 header["jwk"] = wrongKey.jwk 402 modifiedHeader := encodeJSON(t, header) 403 tamperedProof := modifiedHeader + "." + parts[1] + "." + parts[2] 404 405 _, err := verifier.VerifyDPoPProof(tamperedProof, method, uri) 406 if err == nil { 407 t.Error("Expected error for invalid signature, got nil") 408 } 409 if err != nil && !contains(err.Error(), "signature verification failed") { 410 t.Errorf("Expected signature verification error, got: %v", err) 411 } 412} 413 414func TestVerifyDPoPProof_WrongHTTPMethod(t *testing.T) { 415 verifier := NewDPoPVerifier() 416 key := generateTestES256Key(t) 417 418 method := "POST" 419 wrongMethod := "GET" 420 uri := "https://api.example.com/resource" 421 iat := time.Now() 422 jti := uuid.New().String() 423 424 proof := createDPoPProof(t, key, method, uri, iat, jti) 425 426 _, err := verifier.VerifyDPoPProof(proof, wrongMethod, uri) 427 if err == nil { 428 t.Error("Expected error for HTTP method mismatch, got nil") 429 } 430 if err != nil && !contains(err.Error(), "htm mismatch") { 431 t.Errorf("Expected htm mismatch error, got: %v", err) 432 } 433} 434 435func TestVerifyDPoPProof_WrongURI(t *testing.T) { 436 verifier := NewDPoPVerifier() 437 key := generateTestES256Key(t) 438 439 method := "POST" 440 uri := "https://api.example.com/resource" 441 wrongURI := "https://api.example.com/different" 442 iat := time.Now() 443 jti := uuid.New().String() 444 445 proof := createDPoPProof(t, key, method, uri, iat, jti) 446 447 _, err := verifier.VerifyDPoPProof(proof, method, wrongURI) 448 if err == nil { 449 t.Error("Expected error for URI mismatch, got nil") 450 } 451 if err != nil && !contains(err.Error(), "htu mismatch") { 452 t.Errorf("Expected htu mismatch error, got: %v", err) 453 } 454} 455 456func TestVerifyDPoPProof_URIWithQuery(t *testing.T) { 457 // URI comparison should strip query and fragment 458 verifier := NewDPoPVerifier() 459 key := generateTestES256Key(t) 460 461 method := "POST" 462 baseURI := "https://api.example.com/resource" 463 uriWithQuery := baseURI + "?param=value" 464 iat := time.Now() 465 jti := uuid.New().String() 466 467 proof := createDPoPProof(t, key, method, baseURI, iat, jti) 468 469 // Should succeed because query is stripped 470 _, err := verifier.VerifyDPoPProof(proof, method, uriWithQuery) 471 if err != nil { 472 t.Fatalf("VerifyDPoPProof failed for URI with query: %v", err) 473 } 474} 475 476func TestVerifyDPoPProof_URIWithFragment(t *testing.T) { 477 // URI comparison should strip query and fragment 478 verifier := NewDPoPVerifier() 479 key := generateTestES256Key(t) 480 481 method := "POST" 482 baseURI := "https://api.example.com/resource" 483 uriWithFragment := baseURI + "#section" 484 iat := time.Now() 485 jti := uuid.New().String() 486 487 proof := createDPoPProof(t, key, method, baseURI, iat, jti) 488 489 // Should succeed because fragment is stripped 490 _, err := verifier.VerifyDPoPProof(proof, method, uriWithFragment) 491 if err != nil { 492 t.Fatalf("VerifyDPoPProof failed for URI with fragment: %v", err) 493 } 494} 495 496func TestVerifyDPoPProof_ExpiredProof(t *testing.T) { 497 verifier := NewDPoPVerifier() 498 key := generateTestES256Key(t) 499 500 method := "POST" 501 uri := "https://api.example.com/resource" 502 // Proof issued 10 minutes ago (exceeds default MaxProofAge of 5 minutes) 503 iat := time.Now().Add(-10 * time.Minute) 504 jti := uuid.New().String() 505 506 proof := createDPoPProof(t, key, method, uri, iat, jti) 507 508 _, err := verifier.VerifyDPoPProof(proof, method, uri) 509 if err == nil { 510 t.Error("Expected error for expired proof, got nil") 511 } 512 if err != nil && !contains(err.Error(), "too old") { 513 t.Errorf("Expected 'too old' error, got: %v", err) 514 } 515} 516 517func TestVerifyDPoPProof_FutureProof(t *testing.T) { 518 verifier := NewDPoPVerifier() 519 key := generateTestES256Key(t) 520 521 method := "POST" 522 uri := "https://api.example.com/resource" 523 // Proof issued 1 minute in the future (exceeds MaxClockSkew) 524 iat := time.Now().Add(1 * time.Minute) 525 jti := uuid.New().String() 526 527 proof := createDPoPProof(t, key, method, uri, iat, jti) 528 529 _, err := verifier.VerifyDPoPProof(proof, method, uri) 530 if err == nil { 531 t.Error("Expected error for future proof, got nil") 532 } 533 if err != nil && !contains(err.Error(), "in the future") { 534 t.Errorf("Expected 'in the future' error, got: %v", err) 535 } 536} 537 538func TestVerifyDPoPProof_WithinClockSkew(t *testing.T) { 539 verifier := NewDPoPVerifier() 540 key := generateTestES256Key(t) 541 542 method := "POST" 543 uri := "https://api.example.com/resource" 544 // Proof issued 15 seconds in the future (within MaxClockSkew of 30s) 545 iat := time.Now().Add(15 * time.Second) 546 jti := uuid.New().String() 547 548 proof := createDPoPProof(t, key, method, uri, iat, jti) 549 550 _, err := verifier.VerifyDPoPProof(proof, method, uri) 551 if err != nil { 552 t.Fatalf("VerifyDPoPProof failed for proof within clock skew: %v", err) 553 } 554} 555 556func TestVerifyDPoPProof_MissingJti(t *testing.T) { 557 verifier := NewDPoPVerifier() 558 key := generateTestES256Key(t) 559 560 method := "POST" 561 uri := "https://api.example.com/resource" 562 iat := time.Now() 563 564 claims := &DPoPClaims{ 565 RegisteredClaims: jwt.RegisteredClaims{ 566 // No ID (jti) 567 IssuedAt: jwt.NewNumericDate(iat), 568 }, 569 HTTPMethod: method, 570 HTTPURI: uri, 571 } 572 573 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 574 token.Header["typ"] = "dpop+jwt" 575 token.Header["jwk"] = key.jwk 576 577 proof, err := token.SignedString(key.privateKey) 578 if err != nil { 579 t.Fatalf("Failed to create test proof: %v", err) 580 } 581 582 _, err = verifier.VerifyDPoPProof(proof, method, uri) 583 if err == nil { 584 t.Error("Expected error for missing jti, got nil") 585 } 586 if err != nil && !contains(err.Error(), "missing jti") { 587 t.Errorf("Expected missing jti error, got: %v", err) 588 } 589} 590 591func TestVerifyDPoPProof_MissingTypHeader(t *testing.T) { 592 verifier := NewDPoPVerifier() 593 key := generateTestES256Key(t) 594 595 method := "POST" 596 uri := "https://api.example.com/resource" 597 iat := time.Now() 598 jti := uuid.New().String() 599 600 claims := &DPoPClaims{ 601 RegisteredClaims: jwt.RegisteredClaims{ 602 ID: jti, 603 IssuedAt: jwt.NewNumericDate(iat), 604 }, 605 HTTPMethod: method, 606 HTTPURI: uri, 607 } 608 609 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 610 // Don't set typ header 611 token.Header["jwk"] = key.jwk 612 613 proof, err := token.SignedString(key.privateKey) 614 if err != nil { 615 t.Fatalf("Failed to create test proof: %v", err) 616 } 617 618 _, err = verifier.VerifyDPoPProof(proof, method, uri) 619 if err == nil { 620 t.Error("Expected error for missing typ header, got nil") 621 } 622 if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") { 623 t.Errorf("Expected typ header error, got: %v", err) 624 } 625} 626 627func TestVerifyDPoPProof_WrongTypHeader(t *testing.T) { 628 verifier := NewDPoPVerifier() 629 key := generateTestES256Key(t) 630 631 method := "POST" 632 uri := "https://api.example.com/resource" 633 iat := time.Now() 634 jti := uuid.New().String() 635 636 claims := &DPoPClaims{ 637 RegisteredClaims: jwt.RegisteredClaims{ 638 ID: jti, 639 IssuedAt: jwt.NewNumericDate(iat), 640 }, 641 HTTPMethod: method, 642 HTTPURI: uri, 643 } 644 645 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 646 token.Header["typ"] = "JWT" // Wrong typ 647 token.Header["jwk"] = key.jwk 648 649 proof, err := token.SignedString(key.privateKey) 650 if err != nil { 651 t.Fatalf("Failed to create test proof: %v", err) 652 } 653 654 _, err = verifier.VerifyDPoPProof(proof, method, uri) 655 if err == nil { 656 t.Error("Expected error for wrong typ header, got nil") 657 } 658 if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") { 659 t.Errorf("Expected typ header error, got: %v", err) 660 } 661} 662 663func TestVerifyDPoPProof_MissingJWK(t *testing.T) { 664 verifier := NewDPoPVerifier() 665 key := generateTestES256Key(t) 666 667 method := "POST" 668 uri := "https://api.example.com/resource" 669 iat := time.Now() 670 jti := uuid.New().String() 671 672 claims := &DPoPClaims{ 673 RegisteredClaims: jwt.RegisteredClaims{ 674 ID: jti, 675 IssuedAt: jwt.NewNumericDate(iat), 676 }, 677 HTTPMethod: method, 678 HTTPURI: uri, 679 } 680 681 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 682 token.Header["typ"] = "dpop+jwt" 683 // Don't include JWK 684 685 proof, err := token.SignedString(key.privateKey) 686 if err != nil { 687 t.Fatalf("Failed to create test proof: %v", err) 688 } 689 690 _, err = verifier.VerifyDPoPProof(proof, method, uri) 691 if err == nil { 692 t.Error("Expected error for missing jwk header, got nil") 693 } 694 if err != nil && !contains(err.Error(), "missing jwk") { 695 t.Errorf("Expected missing jwk error, got: %v", err) 696 } 697} 698 699func TestVerifyDPoPProof_CustomTimeSettings(t *testing.T) { 700 verifier := &DPoPVerifier{ 701 MaxClockSkew: 1 * time.Minute, 702 MaxProofAge: 10 * time.Minute, 703 } 704 key := generateTestES256Key(t) 705 706 method := "POST" 707 uri := "https://api.example.com/resource" 708 // Proof issued 50 seconds in the future (within custom MaxClockSkew) 709 iat := time.Now().Add(50 * time.Second) 710 jti := uuid.New().String() 711 712 proof := createDPoPProof(t, key, method, uri, iat, jti) 713 714 _, err := verifier.VerifyDPoPProof(proof, method, uri) 715 if err != nil { 716 t.Fatalf("VerifyDPoPProof failed with custom time settings: %v", err) 717 } 718} 719 720func TestVerifyDPoPProof_HTTPMethodCaseInsensitive(t *testing.T) { 721 // HTTP method comparison should be case-insensitive per spec 722 verifier := NewDPoPVerifier() 723 key := generateTestES256Key(t) 724 725 method := "post" 726 uri := "https://api.example.com/resource" 727 iat := time.Now() 728 jti := uuid.New().String() 729 730 proof := createDPoPProof(t, key, method, uri, iat, jti) 731 732 // Verify with uppercase method 733 _, err := verifier.VerifyDPoPProof(proof, "POST", uri) 734 if err != nil { 735 t.Fatalf("VerifyDPoPProof failed for case-insensitive method: %v", err) 736 } 737} 738 739// === Token Binding Verification Tests === 740 741func TestVerifyTokenBinding_Matching(t *testing.T) { 742 verifier := NewDPoPVerifier() 743 key := generateTestES256Key(t) 744 745 method := "POST" 746 uri := "https://api.example.com/resource" 747 iat := time.Now() 748 jti := uuid.New().String() 749 750 proof := createDPoPProof(t, key, method, uri, iat, jti) 751 752 result, err := verifier.VerifyDPoPProof(proof, method, uri) 753 if err != nil { 754 t.Fatalf("VerifyDPoPProof failed: %v", err) 755 } 756 757 // Verify token binding with matching thumbprint 758 err = verifier.VerifyTokenBinding(result, key.thumbprint) 759 if err != nil { 760 t.Fatalf("VerifyTokenBinding failed for matching thumbprint: %v", err) 761 } 762} 763 764func TestVerifyTokenBinding_Mismatch(t *testing.T) { 765 verifier := NewDPoPVerifier() 766 key := generateTestES256Key(t) 767 wrongKey := generateTestES256Key(t) 768 769 method := "POST" 770 uri := "https://api.example.com/resource" 771 iat := time.Now() 772 jti := uuid.New().String() 773 774 proof := createDPoPProof(t, key, method, uri, iat, jti) 775 776 result, err := verifier.VerifyDPoPProof(proof, method, uri) 777 if err != nil { 778 t.Fatalf("VerifyDPoPProof failed: %v", err) 779 } 780 781 // Verify token binding with wrong thumbprint 782 err = verifier.VerifyTokenBinding(result, wrongKey.thumbprint) 783 if err == nil { 784 t.Error("Expected error for thumbprint mismatch, got nil") 785 } 786 if err != nil && !contains(err.Error(), "thumbprint mismatch") { 787 t.Errorf("Expected thumbprint mismatch error, got: %v", err) 788 } 789} 790 791// === ExtractCnfJkt Tests === 792 793func TestExtractCnfJkt_Valid(t *testing.T) { 794 expectedJkt := "test-thumbprint-123" 795 claims := &Claims{ 796 Confirmation: map[string]interface{}{ 797 "jkt": expectedJkt, 798 }, 799 } 800 801 jkt, err := ExtractCnfJkt(claims) 802 if err != nil { 803 t.Fatalf("ExtractCnfJkt failed for valid claims: %v", err) 804 } 805 806 if jkt != expectedJkt { 807 t.Errorf("Expected jkt %s, got %s", expectedJkt, jkt) 808 } 809} 810 811func TestExtractCnfJkt_MissingCnf(t *testing.T) { 812 claims := &Claims{ 813 // No Confirmation 814 } 815 816 _, err := ExtractCnfJkt(claims) 817 if err == nil { 818 t.Error("Expected error for missing cnf, got nil") 819 } 820 if err != nil && !contains(err.Error(), "missing cnf claim") { 821 t.Errorf("Expected missing cnf error, got: %v", err) 822 } 823} 824 825func TestExtractCnfJkt_NilCnf(t *testing.T) { 826 claims := &Claims{ 827 Confirmation: nil, 828 } 829 830 _, err := ExtractCnfJkt(claims) 831 if err == nil { 832 t.Error("Expected error for nil cnf, got nil") 833 } 834 if err != nil && !contains(err.Error(), "missing cnf claim") { 835 t.Errorf("Expected missing cnf error, got: %v", err) 836 } 837} 838 839func TestExtractCnfJkt_MissingJkt(t *testing.T) { 840 claims := &Claims{ 841 Confirmation: map[string]interface{}{ 842 "other": "value", 843 }, 844 } 845 846 _, err := ExtractCnfJkt(claims) 847 if err == nil { 848 t.Error("Expected error for missing jkt, got nil") 849 } 850 if err != nil && !contains(err.Error(), "missing jkt") { 851 t.Errorf("Expected missing jkt error, got: %v", err) 852 } 853} 854 855func TestExtractCnfJkt_EmptyJkt(t *testing.T) { 856 claims := &Claims{ 857 Confirmation: map[string]interface{}{ 858 "jkt": "", 859 }, 860 } 861 862 _, err := ExtractCnfJkt(claims) 863 if err == nil { 864 t.Error("Expected error for empty jkt, got nil") 865 } 866 if err != nil && !contains(err.Error(), "missing jkt") { 867 t.Errorf("Expected missing jkt error, got: %v", err) 868 } 869} 870 871func TestExtractCnfJkt_WrongType(t *testing.T) { 872 claims := &Claims{ 873 Confirmation: map[string]interface{}{ 874 "jkt": 123, // Not a string 875 }, 876 } 877 878 _, err := ExtractCnfJkt(claims) 879 if err == nil { 880 t.Error("Expected error for wrong type jkt, got nil") 881 } 882 if err != nil && !contains(err.Error(), "missing jkt") { 883 t.Errorf("Expected missing jkt error, got: %v", err) 884 } 885} 886 887// === Helper Functions for Tests === 888 889// splitJWT splits a JWT into its three parts 890func splitJWT(token string) []string { 891 return []string{ 892 token[:strings.IndexByte(token, '.')], 893 token[strings.IndexByte(token, '.')+1 : strings.LastIndexByte(token, '.')], 894 token[strings.LastIndexByte(token, '.')+1:], 895 } 896} 897 898// parseJWTHeader parses a base64url-encoded JWT header 899func parseJWTHeader(t *testing.T, encoded string) map[string]interface{} { 900 t.Helper() 901 decoded, err := base64.RawURLEncoding.DecodeString(encoded) 902 if err != nil { 903 t.Fatalf("Failed to decode header: %v", err) 904 } 905 906 var header map[string]interface{} 907 if err := json.Unmarshal(decoded, &header); err != nil { 908 t.Fatalf("Failed to unmarshal header: %v", err) 909 } 910 911 return header 912} 913 914// encodeJSON encodes a value to base64url-encoded JSON 915func encodeJSON(t *testing.T, v interface{}) string { 916 t.Helper() 917 data, err := json.Marshal(v) 918 if err != nil { 919 t.Fatalf("Failed to marshal JSON: %v", err) 920 } 921 return base64.RawURLEncoding.EncodeToString(data) 922} 923 924// === ES256K (secp256k1) Test Helpers === 925 926// testES256KKey holds a test ES256K key pair using indigo 927type testES256KKey struct { 928 privateKey indigoCrypto.PrivateKey 929 publicKey indigoCrypto.PublicKey 930 jwk map[string]interface{} 931 thumbprint string 932} 933 934// generateTestES256KKey generates a test ES256K (secp256k1) key pair and JWK 935func generateTestES256KKey(t *testing.T) *testES256KKey { 936 t.Helper() 937 938 privateKey, err := indigoCrypto.GeneratePrivateKeyK256() 939 if err != nil { 940 t.Fatalf("Failed to generate ES256K test key: %v", err) 941 } 942 943 publicKey, err := privateKey.PublicKey() 944 if err != nil { 945 t.Fatalf("Failed to get public key from ES256K private key: %v", err) 946 } 947 948 // Get the JWK representation 949 jwkStruct, err := publicKey.JWK() 950 if err != nil { 951 t.Fatalf("Failed to get JWK from ES256K public key: %v", err) 952 } 953 jwk := map[string]interface{}{ 954 "kty": jwkStruct.KeyType, 955 "crv": jwkStruct.Curve, 956 "x": jwkStruct.X, 957 "y": jwkStruct.Y, 958 } 959 960 // Calculate thumbprint 961 thumbprint, err := CalculateJWKThumbprint(jwk) 962 if err != nil { 963 t.Fatalf("Failed to calculate ES256K thumbprint: %v", err) 964 } 965 966 return &testES256KKey{ 967 privateKey: privateKey, 968 publicKey: publicKey, 969 jwk: jwk, 970 thumbprint: thumbprint, 971 } 972} 973 974// createES256KDPoPProof creates a DPoP proof JWT using ES256K for testing 975func createES256KDPoPProof(t *testing.T, key *testES256KKey, method, uri string, iat time.Time, jti string) string { 976 t.Helper() 977 978 // Build claims 979 claims := map[string]interface{}{ 980 "jti": jti, 981 "iat": iat.Unix(), 982 "htm": method, 983 "htu": uri, 984 } 985 986 // Build header 987 header := map[string]interface{}{ 988 "typ": "dpop+jwt", 989 "alg": "ES256K", 990 "jwk": key.jwk, 991 } 992 993 // Encode header and claims 994 headerJSON, err := json.Marshal(header) 995 if err != nil { 996 t.Fatalf("Failed to marshal header: %v", err) 997 } 998 claimsJSON, err := json.Marshal(claims) 999 if err != nil { 1000 t.Fatalf("Failed to marshal claims: %v", err) 1001 } 1002 1003 headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) 1004 claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) 1005 1006 // Sign with indigo 1007 signingInput := headerB64 + "." + claimsB64 1008 signature, err := key.privateKey.HashAndSign([]byte(signingInput)) 1009 if err != nil { 1010 t.Fatalf("Failed to sign ES256K proof: %v", err) 1011 } 1012 1013 signatureB64 := base64.RawURLEncoding.EncodeToString(signature) 1014 return signingInput + "." + signatureB64 1015} 1016 1017// === ES256K Tests === 1018 1019func TestVerifyDPoPProof_ES256K_Valid(t *testing.T) { 1020 verifier := NewDPoPVerifier() 1021 key := generateTestES256KKey(t) 1022 1023 method := "POST" 1024 uri := "https://api.example.com/resource" 1025 iat := time.Now() 1026 jti := uuid.New().String() 1027 1028 proof := createES256KDPoPProof(t, key, method, uri, iat, jti) 1029 1030 result, err := verifier.VerifyDPoPProof(proof, method, uri) 1031 if err != nil { 1032 t.Fatalf("VerifyDPoPProof failed for valid ES256K proof: %v", err) 1033 } 1034 1035 if result == nil { 1036 t.Fatal("Expected non-nil proof result") 1037 } 1038 1039 if result.Claims.HTTPMethod != method { 1040 t.Errorf("Expected method %s, got %s", method, result.Claims.HTTPMethod) 1041 } 1042 1043 if result.Claims.HTTPURI != uri { 1044 t.Errorf("Expected URI %s, got %s", uri, result.Claims.HTTPURI) 1045 } 1046 1047 if result.Thumbprint != key.thumbprint { 1048 t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint) 1049 } 1050} 1051 1052func TestVerifyDPoPProof_ES256K_InvalidSignature(t *testing.T) { 1053 verifier := NewDPoPVerifier() 1054 key := generateTestES256KKey(t) 1055 wrongKey := generateTestES256KKey(t) 1056 1057 method := "POST" 1058 uri := "https://api.example.com/resource" 1059 iat := time.Now() 1060 jti := uuid.New().String() 1061 1062 // Create proof with one key 1063 proof := createES256KDPoPProof(t, key, method, uri, iat, jti) 1064 1065 // Tamper by replacing JWK with wrong key 1066 parts := splitJWT(proof) 1067 header := parseJWTHeader(t, parts[0]) 1068 header["jwk"] = wrongKey.jwk 1069 modifiedHeader := encodeJSON(t, header) 1070 tamperedProof := modifiedHeader + "." + parts[1] + "." + parts[2] 1071 1072 _, err := verifier.VerifyDPoPProof(tamperedProof, method, uri) 1073 if err == nil { 1074 t.Error("Expected error for invalid ES256K signature, got nil") 1075 } 1076 if err != nil && !contains(err.Error(), "signature verification failed") { 1077 t.Errorf("Expected signature verification error, got: %v", err) 1078 } 1079} 1080 1081func TestCalculateJWKThumbprint_ES256K(t *testing.T) { 1082 // Test thumbprint calculation for secp256k1 keys 1083 key := generateTestES256KKey(t) 1084 1085 thumbprint, err := CalculateJWKThumbprint(key.jwk) 1086 if err != nil { 1087 t.Fatalf("CalculateJWKThumbprint failed for ES256K: %v", err) 1088 } 1089 1090 if thumbprint == "" { 1091 t.Error("Expected non-empty thumbprint for ES256K key") 1092 } 1093 1094 // Verify it's valid base64url 1095 _, err = base64.RawURLEncoding.DecodeString(thumbprint) 1096 if err != nil { 1097 t.Errorf("ES256K thumbprint is not valid base64url: %v", err) 1098 } 1099 1100 // Verify length (SHA-256 produces 32 bytes = 43 base64url chars) 1101 if len(thumbprint) != 43 { 1102 t.Errorf("Expected ES256K thumbprint length 43, got %d", len(thumbprint)) 1103 } 1104} 1105 1106// === Algorithm-Curve Binding Tests === 1107 1108func TestVerifyDPoPProof_AlgorithmCurveMismatch_ES256KWithP256Key(t *testing.T) { 1109 verifier := NewDPoPVerifier() 1110 key := generateTestES256Key(t) // P-256 key 1111 1112 method := "POST" 1113 uri := "https://api.example.com/resource" 1114 iat := time.Now() 1115 jti := uuid.New().String() 1116 1117 // Create a proof claiming ES256K but using P-256 key 1118 claims := &DPoPClaims{ 1119 RegisteredClaims: jwt.RegisteredClaims{ 1120 ID: jti, 1121 IssuedAt: jwt.NewNumericDate(iat), 1122 }, 1123 HTTPMethod: method, 1124 HTTPURI: uri, 1125 } 1126 1127 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1128 token.Header["typ"] = "dpop+jwt" 1129 token.Header["alg"] = "ES256K" // Claim ES256K 1130 token.Header["jwk"] = key.jwk // But use P-256 key 1131 1132 proof, err := token.SignedString(key.privateKey) 1133 if err != nil { 1134 t.Fatalf("Failed to create test proof: %v", err) 1135 } 1136 1137 _, err = verifier.VerifyDPoPProof(proof, method, uri) 1138 if err == nil { 1139 t.Error("Expected error for ES256K algorithm with P-256 curve, got nil") 1140 } 1141 if err != nil && !contains(err.Error(), "requires curve secp256k1") { 1142 t.Errorf("Expected curve mismatch error, got: %v", err) 1143 } 1144} 1145 1146func TestVerifyDPoPProof_AlgorithmCurveMismatch_ES256WithSecp256k1Key(t *testing.T) { 1147 verifier := NewDPoPVerifier() 1148 key := generateTestES256KKey(t) // secp256k1 key 1149 1150 method := "POST" 1151 uri := "https://api.example.com/resource" 1152 iat := time.Now() 1153 jti := uuid.New().String() 1154 1155 // Build claims 1156 claims := map[string]interface{}{ 1157 "jti": jti, 1158 "iat": iat.Unix(), 1159 "htm": method, 1160 "htu": uri, 1161 } 1162 1163 // Build header claiming ES256 but using secp256k1 key 1164 header := map[string]interface{}{ 1165 "typ": "dpop+jwt", 1166 "alg": "ES256", // Claim ES256 1167 "jwk": key.jwk, // But use secp256k1 key 1168 } 1169 1170 headerJSON, _ := json.Marshal(header) 1171 claimsJSON, _ := json.Marshal(claims) 1172 1173 headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) 1174 claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) 1175 1176 signingInput := headerB64 + "." + claimsB64 1177 signature, err := key.privateKey.HashAndSign([]byte(signingInput)) 1178 if err != nil { 1179 t.Fatalf("Failed to sign: %v", err) 1180 } 1181 1182 proof := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature) 1183 1184 _, err = verifier.VerifyDPoPProof(proof, method, uri) 1185 if err == nil { 1186 t.Error("Expected error for ES256 algorithm with secp256k1 curve, got nil") 1187 } 1188 if err != nil && !contains(err.Error(), "requires curve P-256") { 1189 t.Errorf("Expected curve mismatch error, got: %v", err) 1190 } 1191} 1192 1193// === exp/nbf Validation Tests === 1194 1195func TestVerifyDPoPProof_ExpiredWithExpClaim(t *testing.T) { 1196 verifier := NewDPoPVerifier() 1197 key := generateTestES256Key(t) 1198 1199 method := "POST" 1200 uri := "https://api.example.com/resource" 1201 iat := time.Now().Add(-2 * time.Minute) 1202 exp := time.Now().Add(-1 * time.Minute) // Expired 1 minute ago 1203 jti := uuid.New().String() 1204 1205 claims := &DPoPClaims{ 1206 RegisteredClaims: jwt.RegisteredClaims{ 1207 ID: jti, 1208 IssuedAt: jwt.NewNumericDate(iat), 1209 ExpiresAt: jwt.NewNumericDate(exp), 1210 }, 1211 HTTPMethod: method, 1212 HTTPURI: uri, 1213 } 1214 1215 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1216 token.Header["typ"] = "dpop+jwt" 1217 token.Header["jwk"] = key.jwk 1218 1219 proof, err := token.SignedString(key.privateKey) 1220 if err != nil { 1221 t.Fatalf("Failed to create test proof: %v", err) 1222 } 1223 1224 _, err = verifier.VerifyDPoPProof(proof, method, uri) 1225 if err == nil { 1226 t.Error("Expected error for expired proof with exp claim, got nil") 1227 } 1228 if err != nil && !contains(err.Error(), "expired") { 1229 t.Errorf("Expected expiration error, got: %v", err) 1230 } 1231} 1232 1233func TestVerifyDPoPProof_NotYetValidWithNbfClaim(t *testing.T) { 1234 verifier := NewDPoPVerifier() 1235 key := generateTestES256Key(t) 1236 1237 method := "POST" 1238 uri := "https://api.example.com/resource" 1239 iat := time.Now() 1240 nbf := time.Now().Add(5 * time.Minute) // Not valid for another 5 minutes 1241 jti := uuid.New().String() 1242 1243 claims := &DPoPClaims{ 1244 RegisteredClaims: jwt.RegisteredClaims{ 1245 ID: jti, 1246 IssuedAt: jwt.NewNumericDate(iat), 1247 NotBefore: jwt.NewNumericDate(nbf), 1248 }, 1249 HTTPMethod: method, 1250 HTTPURI: uri, 1251 } 1252 1253 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1254 token.Header["typ"] = "dpop+jwt" 1255 token.Header["jwk"] = key.jwk 1256 1257 proof, err := token.SignedString(key.privateKey) 1258 if err != nil { 1259 t.Fatalf("Failed to create test proof: %v", err) 1260 } 1261 1262 _, err = verifier.VerifyDPoPProof(proof, method, uri) 1263 if err == nil { 1264 t.Error("Expected error for not-yet-valid proof with nbf claim, got nil") 1265 } 1266 if err != nil && !contains(err.Error(), "not valid before") { 1267 t.Errorf("Expected not-before error, got: %v", err) 1268 } 1269} 1270 1271func TestVerifyDPoPProof_ValidWithExpClaimInFuture(t *testing.T) { 1272 verifier := NewDPoPVerifier() 1273 key := generateTestES256Key(t) 1274 1275 method := "POST" 1276 uri := "https://api.example.com/resource" 1277 iat := time.Now() 1278 exp := time.Now().Add(5 * time.Minute) // Valid for 5 more minutes 1279 jti := uuid.New().String() 1280 1281 claims := &DPoPClaims{ 1282 RegisteredClaims: jwt.RegisteredClaims{ 1283 ID: jti, 1284 IssuedAt: jwt.NewNumericDate(iat), 1285 ExpiresAt: jwt.NewNumericDate(exp), 1286 }, 1287 HTTPMethod: method, 1288 HTTPURI: uri, 1289 } 1290 1291 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1292 token.Header["typ"] = "dpop+jwt" 1293 token.Header["jwk"] = key.jwk 1294 1295 proof, err := token.SignedString(key.privateKey) 1296 if err != nil { 1297 t.Fatalf("Failed to create test proof: %v", err) 1298 } 1299 1300 result, err := verifier.VerifyDPoPProof(proof, method, uri) 1301 if err != nil { 1302 t.Fatalf("VerifyDPoPProof failed for valid proof with exp in future: %v", err) 1303 } 1304 1305 if result == nil { 1306 t.Error("Expected non-nil result for valid proof") 1307 } 1308}