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}