···
4
-
"Coves/internal/atproto/auth"
4
+
"Coves/internal/atproto/oauth"
···
18
-
"github.com/golang-jwt/jwt/v5"
19
-
"github.com/google/uuid"
15
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
16
+
"github.com/bluesky-social/indigo/atproto/syntax"
22
-
// mockJWKSFetcher is a test double for JWKSFetcher
23
-
type mockJWKSFetcher struct {
19
+
// mockOAuthClient is a test double for OAuthClient
20
+
type mockOAuthClient struct {
25
+
func newMockOAuthClient() *mockOAuthClient {
26
+
// Create a 32-byte seal secret for testing
27
+
secret := []byte("test-secret-key-32-bytes-long!!")
28
+
return &mockOAuthClient{
27
-
func (m *mockJWKSFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
29
-
return nil, fmt.Errorf("mock fetch failure")
33
+
func (m *mockOAuthClient) UnsealSession(token string) (*oauth.SealedSession, error) {
34
+
if m.shouldFailSeal {
35
+
return nil, fmt.Errorf("mock unseal failure")
31
-
// Return nil - we won't actually verify signatures in Phase 1 tests
38
+
// For testing, we'll decode a simple format: base64(did|sessionID|expiresAt)
39
+
// In production this would be AES-GCM encrypted
40
+
// Using pipe separator to avoid conflicts with colon in DIDs
41
+
decoded, err := base64.RawURLEncoding.DecodeString(token)
43
+
return nil, fmt.Errorf("invalid token encoding: %w", err)
46
+
parts := strings.Split(string(decoded), "|")
47
+
if len(parts) != 3 {
48
+
return nil, fmt.Errorf("invalid token format")
52
+
_, _ = fmt.Sscanf(parts[2], "%d", &expiresAt)
55
+
if expiresAt <= time.Now().Unix() {
56
+
return nil, fmt.Errorf("token expired")
59
+
return &oauth.SealedSession{
61
+
SessionID: parts[1],
62
+
ExpiresAt: expiresAt,
66
+
// Helper to create a test sealed token
67
+
func (m *mockOAuthClient) createTestToken(did, sessionID string, ttl time.Duration) string {
68
+
expiresAt := time.Now().Add(ttl).Unix()
69
+
payload := fmt.Sprintf("%s|%s|%d", did, sessionID, expiresAt)
70
+
return base64.RawURLEncoding.EncodeToString([]byte(payload))
73
+
// mockOAuthStore is a test double for ClientAuthStore
74
+
type mockOAuthStore struct {
75
+
sessions map[string]*oauthlib.ClientSessionData
78
+
func newMockOAuthStore() *mockOAuthStore {
79
+
return &mockOAuthStore{
80
+
sessions: make(map[string]*oauthlib.ClientSessionData),
35
-
// createTestToken creates a test JWT with the given DID
36
-
func createTestToken(did string) string {
37
-
claims := jwt.MapClaims{
39
-
"iss": "https://test.pds.local",
41
-
"exp": time.Now().Add(1 * time.Hour).Unix(),
42
-
"iat": time.Now().Unix(),
84
+
func (m *mockOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauthlib.ClientSessionData, error) {
85
+
key := did.String() + ":" + sessionID
86
+
session, ok := m.sessions[key]
88
+
return nil, fmt.Errorf("session not found")
45
-
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
46
-
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
93
+
func (m *mockOAuthStore) SaveSession(ctx context.Context, session oauthlib.ClientSessionData) error {
94
+
key := session.AccountDID.String() + ":" + session.SessionID
95
+
m.sessions[key] = &session
99
+
func (m *mockOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
100
+
key := did.String() + ":" + sessionID
101
+
delete(m.sessions, key)
105
+
func (m *mockOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauthlib.AuthRequestData, error) {
106
+
return nil, fmt.Errorf("not implemented")
109
+
func (m *mockOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauthlib.AuthRequestData) error {
110
+
return fmt.Errorf("not implemented")
113
+
func (m *mockOAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
114
+
return fmt.Errorf("not implemented")
50
-
// TestRequireAuth_ValidToken tests that valid tokens are accepted with DPoP scheme (Phase 1)
117
+
// TestRequireAuth_ValidToken tests that valid sealed tokens are accepted
func TestRequireAuth_ValidToken(t *testing.T) {
52
-
fetcher := &mockJWKSFetcher{}
53
-
middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true
119
+
client := newMockOAuthClient()
120
+
store := newMockOAuthStore()
122
+
// Create a test session
123
+
did := syntax.DID("did:plc:test123")
124
+
sessionID := "session123"
125
+
session := &oauthlib.ClientSessionData{
127
+
SessionID: sessionID,
128
+
AccessToken: "test_access_token",
129
+
HostURL: "https://pds.example.com",
131
+
_ = store.SaveSession(context.Background(), *session)
133
+
middleware := NewOAuthAuthMiddleware(client, store)
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify DID was extracted and injected into context
60
-
did := GetUserDID(r)
61
-
if did != "did:plc:test123" {
62
-
t.Errorf("expected DID 'did:plc:test123', got %s", did)
140
+
extractedDID := GetUserDID(r)
141
+
if extractedDID != "did:plc:test123" {
142
+
t.Errorf("expected DID 'did:plc:test123', got %s", extractedDID)
65
-
// Verify claims were injected
66
-
claims := GetJWTClaims(r)
68
-
t.Error("expected claims to be non-nil")
145
+
// Verify OAuth session was injected
146
+
oauthSession := GetOAuthSession(r)
147
+
if oauthSession == nil {
148
+
t.Error("expected OAuth session to be non-nil")
71
-
if claims.Subject != "did:plc:test123" {
72
-
t.Errorf("expected claims.Subject 'did:plc:test123', got %s", claims.Subject)
151
+
if oauthSession.SessionID != sessionID {
152
+
t.Errorf("expected session ID '%s', got %s", sessionID, oauthSession.SessionID)
155
+
// Verify access token is available
156
+
accessToken := GetUserAccessToken(r)
157
+
if accessToken != "test_access_token" {
158
+
t.Errorf("expected access token 'test_access_token', got %s", accessToken)
w.WriteHeader(http.StatusOK)
78
-
token := createTestToken("did:plc:test123")
164
+
token := client.createTestToken("did:plc:test123", sessionID, time.Hour)
req := httptest.NewRequest("GET", "/test", nil)
80
-
req.Header.Set("Authorization", "DPoP "+token)
166
+
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
// TestRequireAuth_MissingAuthHeader tests that missing Authorization header is rejected
func TestRequireAuth_MissingAuthHeader(t *testing.T) {
96
-
fetcher := &mockJWKSFetcher{}
97
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
182
+
client := newMockOAuthClient()
183
+
store := newMockOAuthStore()
184
+
middleware := NewOAuthAuthMiddleware(client, store)
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called")
···
114
-
// TestRequireAuth_InvalidAuthHeaderFormat tests that non-DPoP tokens are rejected (including Bearer)
201
+
// TestRequireAuth_InvalidAuthHeaderFormat tests that non-Bearer tokens are rejected
func TestRequireAuth_InvalidAuthHeaderFormat(t *testing.T) {
116
-
fetcher := &mockJWKSFetcher{}
117
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
203
+
client := newMockOAuthClient()
204
+
store := newMockOAuthStore()
205
+
middleware := NewOAuthAuthMiddleware(client, store)
{"Basic auth", "Basic dGVzdDp0ZXN0"},
124
-
{"Bearer scheme", "Bearer some-token"},
212
+
{"DPoP scheme", "DPoP some-token"},
{"Invalid format", "InvalidFormat"},
···
147
-
// TestRequireAuth_BearerRejectionErrorMessage verifies that Bearer tokens are rejected
148
-
// with a helpful error message guiding users to use DPoP scheme
149
-
func TestRequireAuth_BearerRejectionErrorMessage(t *testing.T) {
150
-
fetcher := &mockJWKSFetcher{}
151
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
153
-
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154
-
t.Error("handler should not be called")
157
-
req := httptest.NewRequest("GET", "/test", nil)
158
-
req.Header.Set("Authorization", "Bearer some-token")
159
-
w := httptest.NewRecorder()
161
-
handler.ServeHTTP(w, req)
235
+
// TestRequireAuth_CaseInsensitiveScheme verifies that Bearer scheme matching is case-insensitive
236
+
func TestRequireAuth_CaseInsensitiveScheme(t *testing.T) {
237
+
client := newMockOAuthClient()
238
+
store := newMockOAuthStore()
163
-
if w.Code != http.StatusUnauthorized {
164
-
t.Errorf("expected status 401, got %d", w.Code)
240
+
// Create a test session
241
+
did := syntax.DID("did:plc:test123")
242
+
sessionID := "session123"
243
+
session := &oauthlib.ClientSessionData{
245
+
SessionID: sessionID,
246
+
AccessToken: "test_access_token",
248
+
_ = store.SaveSession(context.Background(), *session)
167
-
// Verify error message guides user to use DPoP
168
-
body := w.Body.String()
169
-
if !strings.Contains(body, "Expected: DPoP") {
170
-
t.Errorf("error message should guide user to use DPoP, got: %s", body)
174
-
// TestRequireAuth_CaseInsensitiveScheme verifies that DPoP scheme matching is case-insensitive
175
-
// per RFC 7235 which states HTTP auth schemes are case-insensitive
176
-
func TestRequireAuth_CaseInsensitiveScheme(t *testing.T) {
177
-
fetcher := &mockJWKSFetcher{}
178
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
180
-
// Create a valid JWT for testing
181
-
validToken := createValidJWT(t, "did:plc:test123", time.Hour)
250
+
middleware := NewOAuthAuthMiddleware(client, store)
251
+
token := client.createTestToken("did:plc:test123", sessionID, time.Hour)
187
-
{"lowercase", "dpop"},
188
-
{"uppercase", "DPOP"},
189
-
{"mixed_case", "DpOp"},
190
-
{"standard", "DPoP"},
257
+
{"lowercase", "bearer"},
258
+
{"uppercase", "BEARER"},
259
+
{"mixed_case", "BeArEr"},
260
+
{"standard", "Bearer"},
for _, tc := range testCases {
···
req := httptest.NewRequest("GET", "/test", nil)
202
-
req.Header.Set("Authorization", tc.scheme+" "+validToken)
272
+
req.Header.Set("Authorization", tc.scheme+" "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
215
-
// TestRequireAuth_MalformedToken tests that malformed JWTs are rejected
216
-
func TestRequireAuth_MalformedToken(t *testing.T) {
217
-
fetcher := &mockJWKSFetcher{}
218
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
285
+
// TestRequireAuth_InvalidToken tests that malformed sealed tokens are rejected
286
+
func TestRequireAuth_InvalidToken(t *testing.T) {
287
+
client := newMockOAuthClient()
288
+
store := newMockOAuthStore()
289
+
middleware := NewOAuthAuthMiddleware(client, store)
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called")
req := httptest.NewRequest("GET", "/test", nil)
225
-
req.Header.Set("Authorization", "DPoP not-a-valid-jwt")
296
+
req.Header.Set("Authorization", "Bearer not-a-valid-sealed-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
235
-
// TestRequireAuth_ExpiredToken tests that expired tokens are rejected
306
+
// TestRequireAuth_ExpiredToken tests that expired sealed tokens are rejected
func TestRequireAuth_ExpiredToken(t *testing.T) {
237
-
fetcher := &mockJWKSFetcher{}
238
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
308
+
client := newMockOAuthClient()
309
+
store := newMockOAuthStore()
311
+
// Create a test session
312
+
did := syntax.DID("did:plc:test123")
313
+
sessionID := "session123"
314
+
session := &oauthlib.ClientSessionData{
316
+
SessionID: sessionID,
317
+
AccessToken: "test_access_token",
319
+
_ = store.SaveSession(context.Background(), *session)
321
+
middleware := NewOAuthAuthMiddleware(client, store)
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called for expired token")
244
-
// Create expired token
245
-
claims := jwt.MapClaims{
246
-
"sub": "did:plc:test123",
247
-
"iss": "https://test.pds.local",
248
-
"scope": "atproto",
249
-
"exp": time.Now().Add(-1 * time.Hour).Unix(), // Expired 1 hour ago
250
-
"iat": time.Now().Add(-2 * time.Hour).Unix(),
253
-
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
254
-
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
327
+
// Create expired token (expired 1 hour ago)
328
+
token := client.createTestToken("did:plc:test123", sessionID, -time.Hour)
req := httptest.NewRequest("GET", "/test", nil)
257
-
req.Header.Set("Authorization", "DPoP "+tokenString)
331
+
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
267
-
// TestRequireAuth_MissingDID tests that tokens without DID are rejected
268
-
func TestRequireAuth_MissingDID(t *testing.T) {
269
-
fetcher := &mockJWKSFetcher{}
270
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
341
+
// TestRequireAuth_SessionNotFound tests that tokens with non-existent sessions are rejected
342
+
func TestRequireAuth_SessionNotFound(t *testing.T) {
343
+
client := newMockOAuthClient()
344
+
store := newMockOAuthStore()
345
+
middleware := NewOAuthAuthMiddleware(client, store)
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called")
276
-
// Create token without sub claim
277
-
claims := jwt.MapClaims{
279
-
"iss": "https://test.pds.local",
280
-
"scope": "atproto",
281
-
"exp": time.Now().Add(1 * time.Hour).Unix(),
282
-
"iat": time.Now().Unix(),
351
+
// Create token for session that doesn't exist in store
352
+
token := client.createTestToken("did:plc:nonexistent", "session999", time.Hour)
354
+
req := httptest.NewRequest("GET", "/test", nil)
355
+
req.Header.Set("Authorization", "Bearer "+token)
356
+
w := httptest.NewRecorder()
358
+
handler.ServeHTTP(w, req)
360
+
if w.Code != http.StatusUnauthorized {
361
+
t.Errorf("expected status 401, got %d", w.Code)
365
+
// TestRequireAuth_DIDMismatch tests that session DID must match token DID
366
+
func TestRequireAuth_DIDMismatch(t *testing.T) {
367
+
client := newMockOAuthClient()
368
+
store := newMockOAuthStore()
370
+
// Create a session with different DID than token
371
+
did := syntax.DID("did:plc:different")
372
+
sessionID := "session123"
373
+
session := &oauthlib.ClientSessionData{
375
+
SessionID: sessionID,
376
+
AccessToken: "test_access_token",
378
+
// Store with key that matches token DID
379
+
key := "did:plc:test123:" + sessionID
380
+
store.sessions[key] = session
285
-
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
286
-
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
382
+
middleware := NewOAuthAuthMiddleware(client, store)
384
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
385
+
t.Error("handler should not be called when DID mismatches")
388
+
token := client.createTestToken("did:plc:test123", sessionID, time.Hour)
req := httptest.NewRequest("GET", "/test", nil)
289
-
req.Header.Set("Authorization", "DPoP "+tokenString)
391
+
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
299
-
// TestOptionalAuth_WithToken tests that OptionalAuth accepts valid DPoP tokens
401
+
// TestOptionalAuth_WithToken tests that OptionalAuth accepts valid Bearer tokens
func TestOptionalAuth_WithToken(t *testing.T) {
301
-
fetcher := &mockJWKSFetcher{}
302
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
403
+
client := newMockOAuthClient()
404
+
store := newMockOAuthStore()
406
+
// Create a test session
407
+
did := syntax.DID("did:plc:test123")
408
+
sessionID := "session123"
409
+
session := &oauthlib.ClientSessionData{
411
+
SessionID: sessionID,
412
+
AccessToken: "test_access_token",
414
+
_ = store.SaveSession(context.Background(), *session)
416
+
middleware := NewOAuthAuthMiddleware(client, store)
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify DID was extracted
309
-
did := GetUserDID(r)
310
-
if did != "did:plc:test123" {
311
-
t.Errorf("expected DID 'did:plc:test123', got %s", did)
423
+
extractedDID := GetUserDID(r)
424
+
if extractedDID != "did:plc:test123" {
425
+
t.Errorf("expected DID 'did:plc:test123', got %s", extractedDID)
w.WriteHeader(http.StatusOK)
317
-
token := createTestToken("did:plc:test123")
431
+
token := client.createTestToken("did:plc:test123", sessionID, time.Hour)
req := httptest.NewRequest("GET", "/test", nil)
319
-
req.Header.Set("Authorization", "DPoP "+token)
433
+
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
// TestOptionalAuth_WithoutToken tests that OptionalAuth allows requests without tokens
func TestOptionalAuth_WithoutToken(t *testing.T) {
335
-
fetcher := &mockJWKSFetcher{}
336
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
449
+
client := newMockOAuthClient()
450
+
store := newMockOAuthStore()
451
+
middleware := NewOAuthAuthMiddleware(client, store)
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···
// TestOptionalAuth_InvalidToken tests that OptionalAuth continues without auth on invalid token
func TestOptionalAuth_InvalidToken(t *testing.T) {
368
-
fetcher := &mockJWKSFetcher{}
369
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
483
+
client := newMockOAuthClient()
484
+
store := newMockOAuthStore()
485
+
middleware := NewOAuthAuthMiddleware(client, store)
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···
req := httptest.NewRequest("GET", "/test", nil)
385
-
req.Header.Set("Authorization", "DPoP not-a-valid-jwt")
501
+
req.Header.Set("Authorization", "Bearer not-a-valid-sealed-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
···
409
-
// TestGetJWTClaims_NotAuthenticated tests that GetJWTClaims returns nil when not authenticated
410
-
func TestGetJWTClaims_NotAuthenticated(t *testing.T) {
525
+
// TestGetOAuthSession_NotAuthenticated tests that GetOAuthSession returns nil when not authenticated
526
+
func TestGetOAuthSession_NotAuthenticated(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
412
-
claims := GetJWTClaims(req)
528
+
session := GetOAuthSession(req)
415
-
t.Errorf("expected nil claims, got %+v", claims)
530
+
if session != nil {
531
+
t.Errorf("expected nil session, got %+v", session)
419
-
// TestGetDPoPProof_NotAuthenticated tests that GetDPoPProof returns nil when no DPoP was verified
420
-
func TestGetDPoPProof_NotAuthenticated(t *testing.T) {
535
+
// TestGetUserAccessToken_NotAuthenticated tests that GetUserAccessToken returns empty when not authenticated
536
+
func TestGetUserAccessToken_NotAuthenticated(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
422
-
proof := GetDPoPProof(req)
538
+
token := GetUserAccessToken(req)
425
-
t.Errorf("expected nil proof, got %+v", proof)
541
+
t.Errorf("expected empty token, got %s", token)
429
-
// TestRequireAuth_WithDPoP_SecurityModel tests the correct DPoP security model:
430
-
// Token MUST be verified first, then DPoP is checked as an additional layer.
431
-
// DPoP is NOT a fallback for failed token verification.
432
-
func TestRequireAuth_WithDPoP_SecurityModel(t *testing.T) {
433
-
// Generate an ECDSA key pair for DPoP
434
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
436
-
t.Fatalf("failed to generate key: %v", err)
545
+
// TestSetTestUserDID tests the testing helper function
546
+
func TestSetTestUserDID(t *testing.T) {
547
+
ctx := context.Background()
548
+
ctx = SetTestUserDID(ctx, "did:plc:testuser")
550
+
did, ok := ctx.Value(UserDIDKey).(string)
552
+
t.Error("DID not found in context")
554
+
if did != "did:plc:testuser" {
555
+
t.Errorf("expected 'did:plc:testuser', got %s", did)
439
-
// Calculate JWK thumbprint for cnf.jkt
440
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
441
-
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
443
-
t.Fatalf("failed to calculate thumbprint: %v", err)
559
+
// TestExtractBearerToken tests the Bearer token extraction logic
560
+
func TestExtractBearerToken(t *testing.T) {
561
+
tests := []struct {
567
+
{"valid bearer", "Bearer token123", "token123", true},
568
+
{"lowercase bearer", "bearer token123", "token123", true},
569
+
{"uppercase bearer", "BEARER token123", "token123", true},
570
+
{"mixed case", "BeArEr token123", "token123", true},
571
+
{"empty header", "", "", false},
572
+
{"wrong scheme", "DPoP token123", "", false},
573
+
{"no token", "Bearer", "", false},
574
+
{"no space", "Bearertoken123", "", false},
575
+
{"extra spaces", "Bearer token123 ", "token123", true},
446
-
t.Run("DPoP_is_NOT_fallback_for_failed_verification", func(t *testing.T) {
447
-
// SECURITY TEST: When token verification fails, DPoP should NOT be used as fallback
448
-
// This prevents an attacker from forging a token with their own cnf.jkt
578
+
for _, tt := range tests {
579
+
t.Run(tt.name, func(t *testing.T) {
580
+
token, ok := extractBearerToken(tt.authHeader)
581
+
if ok != tt.expectOK {
582
+
t.Errorf("expected ok=%v, got %v", tt.expectOK, ok)
584
+
if token != tt.expectToken {
585
+
t.Errorf("expected token '%s', got '%s'", tt.expectToken, token)
450
-
// Create a DPoP-bound access token (unsigned - will fail verification)
451
-
claims := auth.Claims{
452
-
RegisteredClaims: jwt.RegisteredClaims{
453
-
Subject: "did:plc:attacker",
454
-
Issuer: "https://external.pds.local",
455
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
456
-
IssuedAt: jwt.NewNumericDate(time.Now()),
459
-
Confirmation: map[string]interface{}{
591
+
// TestRequireAuth_ValidCookie tests that valid session cookies are accepted
592
+
func TestRequireAuth_ValidCookie(t *testing.T) {
593
+
client := newMockOAuthClient()
594
+
store := newMockOAuthStore()
464
-
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
465
-
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
596
+
// Create a test session
597
+
did := syntax.DID("did:plc:test123")
598
+
sessionID := "session123"
599
+
session := &oauthlib.ClientSessionData{
601
+
SessionID: sessionID,
602
+
AccessToken: "test_access_token",
603
+
HostURL: "https://pds.example.com",
605
+
_ = store.SaveSession(context.Background(), *session)
467
-
// Create valid DPoP proof (attacker has the private key)
468
-
dpopProof := createDPoPProof(t, privateKey, "GET", "https://test.local/api/endpoint")
607
+
middleware := NewOAuthAuthMiddleware(client, store)
470
-
// Mock fetcher that fails (simulating external PDS without JWKS)
471
-
fetcher := &mockJWKSFetcher{shouldFail: true}
472
-
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false
609
+
handlerCalled := false
610
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
611
+
handlerCalled = true
474
-
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
475
-
t.Error("SECURITY VULNERABILITY: handler was called despite token verification failure")
478
-
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
479
-
req.Header.Set("Authorization", "DPoP "+tokenString)
480
-
req.Header.Set("DPoP", dpopProof)
481
-
w := httptest.NewRecorder()
483
-
handler.ServeHTTP(w, req)
485
-
// MUST reject - token verification failed, DPoP cannot substitute for signature verification
486
-
if w.Code != http.StatusUnauthorized {
487
-
t.Errorf("SECURITY: expected 401 for unverified token, got %d", w.Code)
613
+
// Verify DID was extracted and injected into context
614
+
extractedDID := GetUserDID(r)
615
+
if extractedDID != "did:plc:test123" {
616
+
t.Errorf("expected DID 'did:plc:test123', got %s", extractedDID)
491
-
t.Run("DPoP_required_when_cnf_jkt_present_in_verified_token", func(t *testing.T) {
492
-
// When token has cnf.jkt, DPoP header MUST be present
493
-
// This test uses skipVerify=true to simulate a verified token
495
-
claims := auth.Claims{
496
-
RegisteredClaims: jwt.RegisteredClaims{
497
-
Subject: "did:plc:test123",
498
-
Issuer: "https://test.pds.local",
499
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
500
-
IssuedAt: jwt.NewNumericDate(time.Now()),
503
-
Confirmation: map[string]interface{}{
619
+
// Verify OAuth session was injected
620
+
oauthSession := GetOAuthSession(r)
621
+
if oauthSession == nil {
622
+
t.Error("expected OAuth session to be non-nil")
508
-
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
509
-
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
511
-
// NO DPoP header - should fail when skipVerify is false
512
-
// Note: with skipVerify=true, DPoP is not checked
513
-
fetcher := &mockJWKSFetcher{}
514
-
middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true for parsing
516
-
handlerCalled := false
517
-
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
518
-
handlerCalled = true
519
-
w.WriteHeader(http.StatusOK)
522
-
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
523
-
req.Header.Set("Authorization", "DPoP "+tokenString)
525
-
w := httptest.NewRecorder()
527
-
handler.ServeHTTP(w, req)
529
-
// With skipVerify=true, DPoP is not checked, so this should succeed
530
-
if !handlerCalled {
531
-
t.Error("handler should be called when skipVerify=true")
625
+
if oauthSession.SessionID != sessionID {
626
+
t.Errorf("expected session ID '%s', got %s", sessionID, oauthSession.SessionID)
536
-
// TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback is the key security test.
537
-
// It ensures that DPoP cannot be used as a fallback when token signature verification fails.
538
-
func TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback(t *testing.T) {
539
-
// Generate a key pair (attacker's key)
540
-
attackerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
541
-
jwk := ecdsaPublicKeyToJWK(&attackerKey.PublicKey)
542
-
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
544
-
// Create a FORGED token claiming to be the victim
545
-
claims := auth.Claims{
546
-
RegisteredClaims: jwt.RegisteredClaims{
547
-
Subject: "did:plc:victim_user", // Attacker claims to be victim
548
-
Issuer: "https://untrusted.pds",
549
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
550
-
IssuedAt: jwt.NewNumericDate(time.Now()),
553
-
Confirmation: map[string]interface{}{
554
-
"jkt": thumbprint, // Attacker uses their own key
558
-
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
559
-
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
561
-
// Attacker creates a valid DPoP proof with their key
562
-
dpopProof := createDPoPProof(t, attackerKey, "POST", "https://api.example.com/protected")
564
-
// Fetcher fails (external PDS without JWKS)
565
-
fetcher := &mockJWKSFetcher{shouldFail: true}
566
-
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false - REAL verification
568
-
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
569
-
t.Fatalf("CRITICAL SECURITY FAILURE: Request authenticated as %s despite forged token!",
629
+
w.WriteHeader(http.StatusOK)
573
-
req := httptest.NewRequest("POST", "https://api.example.com/protected", nil)
574
-
req.Header.Set("Authorization", "DPoP "+tokenString)
575
-
req.Header.Set("DPoP", dpopProof)
632
+
token := client.createTestToken("did:plc:test123", sessionID, time.Hour)
633
+
req := httptest.NewRequest("GET", "/test", nil)
634
+
req.AddCookie(&http.Cookie{
635
+
Name: "coves_session",
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
580
-
// MUST reject - the token signature was never verified
581
-
if w.Code != http.StatusUnauthorized {
582
-
t.Errorf("SECURITY VULNERABILITY: Expected 401, got %d. Token was not properly verified!", w.Code)
642
+
if !handlerCalled {
643
+
t.Error("handler was not called")
586
-
// TestVerifyDPoPBinding_UsesForwardedProto ensures we honor the external HTTPS
587
-
// scheme when TLS is terminated upstream and X-Forwarded-Proto is present.
588
-
func TestVerifyDPoPBinding_UsesForwardedProto(t *testing.T) {
589
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
591
-
t.Fatalf("failed to generate key: %v", err)
594
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
595
-
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
597
-
t.Fatalf("failed to calculate thumbprint: %v", err)
600
-
claims := &auth.Claims{
601
-
RegisteredClaims: jwt.RegisteredClaims{
602
-
Subject: "did:plc:test123",
603
-
Issuer: "https://test.pds.local",
604
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
605
-
IssuedAt: jwt.NewNumericDate(time.Now()),
608
-
Confirmation: map[string]interface{}{
613
-
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
614
-
defer middleware.Stop()
616
-
externalURI := "https://api.example.com/protected/resource"
617
-
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
619
-
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
620
-
req.Host = "api.example.com"
621
-
req.Header.Set("X-Forwarded-Proto", "https")
623
-
// Pass a fake access token - ath verification will pass since we don't include ath in the DPoP proof
624
-
fakeAccessToken := "fake-access-token-for-testing"
625
-
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken)
627
-
t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err)
630
-
if proof == nil || proof.Claims == nil {
631
-
t.Fatal("expected DPoP proof to be returned")
646
+
if w.Code != http.StatusOK {
647
+
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
635
-
// TestVerifyDPoPBinding_UsesForwardedHost ensures we honor X-Forwarded-Host header
636
-
// when behind a TLS-terminating proxy that rewrites the Host header.
637
-
func TestVerifyDPoPBinding_UsesForwardedHost(t *testing.T) {
638
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
640
-
t.Fatalf("failed to generate key: %v", err)
651
+
// TestRequireAuth_HeaderPrecedenceOverCookie tests that Authorization header takes precedence over cookie
652
+
func TestRequireAuth_HeaderPrecedenceOverCookie(t *testing.T) {
653
+
client := newMockOAuthClient()
654
+
store := newMockOAuthStore()
643
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
644
-
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
646
-
t.Fatalf("failed to calculate thumbprint: %v", err)
656
+
// Create two test sessions
657
+
did1 := syntax.DID("did:plc:header")
658
+
sessionID1 := "session_header"
659
+
session1 := &oauthlib.ClientSessionData{
661
+
SessionID: sessionID1,
662
+
AccessToken: "header_token",
663
+
HostURL: "https://pds.example.com",
665
+
_ = store.SaveSession(context.Background(), *session1)
649
-
claims := &auth.Claims{
650
-
RegisteredClaims: jwt.RegisteredClaims{
651
-
Subject: "did:plc:test123",
652
-
Issuer: "https://test.pds.local",
653
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
654
-
IssuedAt: jwt.NewNumericDate(time.Now()),
657
-
Confirmation: map[string]interface{}{
667
+
did2 := syntax.DID("did:plc:cookie")
668
+
sessionID2 := "session_cookie"
669
+
session2 := &oauthlib.ClientSessionData{
671
+
SessionID: sessionID2,
672
+
AccessToken: "cookie_token",
673
+
HostURL: "https://pds.example.com",
675
+
_ = store.SaveSession(context.Background(), *session2)
662
-
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
663
-
defer middleware.Stop()
677
+
middleware := NewOAuthAuthMiddleware(client, store)
665
-
// External URI that the client uses
666
-
externalURI := "https://api.example.com/protected/resource"
667
-
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
679
+
handlerCalled := false
680
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
681
+
handlerCalled = true
669
-
// Request hits internal service with internal hostname, but X-Forwarded-Host has public hostname
670
-
req := httptest.NewRequest("GET", "http://internal-service:8080/protected/resource", nil)
671
-
req.Host = "internal-service:8080" // Internal host after proxy
672
-
req.Header.Set("X-Forwarded-Proto", "https")
673
-
req.Header.Set("X-Forwarded-Host", "api.example.com") // Original public host
683
+
// Should get header DID, not cookie DID
684
+
extractedDID := GetUserDID(r)
685
+
if extractedDID != "did:plc:header" {
686
+
t.Errorf("expected header DID 'did:plc:header', got %s", extractedDID)
675
-
fakeAccessToken := "fake-access-token-for-testing"
676
-
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken)
678
-
t.Fatalf("expected DPoP verification to succeed with X-Forwarded-Host, got %v", err)
689
+
w.WriteHeader(http.StatusOK)
681
-
if proof == nil || proof.Claims == nil {
682
-
t.Fatal("expected DPoP proof to be returned")
692
+
headerToken := client.createTestToken("did:plc:header", sessionID1, time.Hour)
693
+
cookieToken := client.createTestToken("did:plc:cookie", sessionID2, time.Hour)
686
-
// TestVerifyDPoPBinding_UsesStandardForwardedHeader tests RFC 7239 Forwarded header parsing
687
-
func TestVerifyDPoPBinding_UsesStandardForwardedHeader(t *testing.T) {
688
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
690
-
t.Fatalf("failed to generate key: %v", err)
695
+
req := httptest.NewRequest("GET", "/test", nil)
696
+
req.Header.Set("Authorization", "Bearer "+headerToken)
697
+
req.AddCookie(&http.Cookie{
698
+
Name: "coves_session",
699
+
Value: cookieToken,
701
+
w := httptest.NewRecorder()
693
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
694
-
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
696
-
t.Fatalf("failed to calculate thumbprint: %v", err)
703
+
handler.ServeHTTP(w, req)
699
-
claims := &auth.Claims{
700
-
RegisteredClaims: jwt.RegisteredClaims{
701
-
Subject: "did:plc:test123",
702
-
Issuer: "https://test.pds.local",
703
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
704
-
IssuedAt: jwt.NewNumericDate(time.Now()),
707
-
Confirmation: map[string]interface{}{
705
+
if !handlerCalled {
706
+
t.Error("handler was not called")
712
-
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
713
-
defer middleware.Stop()
716
-
externalURI := "https://api.example.com/protected/resource"
717
-
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
719
-
// Request with standard Forwarded header (RFC 7239)
720
-
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
721
-
req.Host = "internal-service"
722
-
req.Header.Set("Forwarded", "for=192.0.2.60;proto=https;host=api.example.com")
724
-
fakeAccessToken := "fake-access-token-for-testing"
725
-
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken)
727
-
t.Fatalf("expected DPoP verification to succeed with Forwarded header, got %v", err)
731
-
t.Fatal("expected DPoP proof to be returned")
709
+
if w.Code != http.StatusOK {
710
+
t.Errorf("expected status 200, got %d", w.Code)
735
-
// TestVerifyDPoPBinding_ForwardedMixedCaseAndQuotes tests RFC 7239 edge cases:
736
-
// mixed-case keys (Proto vs proto) and quoted values (host="example.com")
737
-
func TestVerifyDPoPBinding_ForwardedMixedCaseAndQuotes(t *testing.T) {
738
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
740
-
t.Fatalf("failed to generate key: %v", err)
714
+
// TestRequireAuth_MissingBothHeaderAndCookie tests that missing both auth methods is rejected
715
+
func TestRequireAuth_MissingBothHeaderAndCookie(t *testing.T) {
716
+
client := newMockOAuthClient()
717
+
store := newMockOAuthStore()
718
+
middleware := NewOAuthAuthMiddleware(client, store)
743
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
744
-
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
746
-
t.Fatalf("failed to calculate thumbprint: %v", err)
720
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
721
+
t.Error("handler should not be called")
749
-
claims := &auth.Claims{
750
-
RegisteredClaims: jwt.RegisteredClaims{
751
-
Subject: "did:plc:test123",
752
-
Issuer: "https://test.pds.local",
753
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
754
-
IssuedAt: jwt.NewNumericDate(time.Now()),
757
-
Confirmation: map[string]interface{}{
724
+
req := httptest.NewRequest("GET", "/test", nil)
725
+
// No Authorization header and no cookie
726
+
w := httptest.NewRecorder()
762
-
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
763
-
defer middleware.Stop()
765
-
// External URI that the client uses
766
-
externalURI := "https://api.example.com/protected/resource"
767
-
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
769
-
// Request with RFC 7239 Forwarded header using:
770
-
// - Mixed-case keys: "Proto" instead of "proto", "Host" instead of "host"
771
-
// - Quoted value: Host="api.example.com" (legal per RFC 7239 section 4)
772
-
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
773
-
req.Host = "internal-service"
774
-
req.Header.Set("Forwarded", `for=192.0.2.60;Proto=https;Host="api.example.com"`)
728
+
handler.ServeHTTP(w, req)
776
-
fakeAccessToken := "fake-access-token-for-testing"
777
-
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, fakeAccessToken)
779
-
t.Fatalf("expected DPoP verification to succeed with mixed-case/quoted Forwarded header, got %v", err)
783
-
t.Fatal("expected DPoP proof to be returned")
730
+
if w.Code != http.StatusUnauthorized {
731
+
t.Errorf("expected status 401, got %d", w.Code)
787
-
// TestVerifyDPoPBinding_AthValidation tests access token hash (ath) claim validation
788
-
func TestVerifyDPoPBinding_AthValidation(t *testing.T) {
789
-
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
791
-
t.Fatalf("failed to generate key: %v", err)
735
+
// TestRequireAuth_InvalidCookie tests that malformed cookie tokens are rejected
736
+
func TestRequireAuth_InvalidCookie(t *testing.T) {
737
+
client := newMockOAuthClient()
738
+
store := newMockOAuthStore()
739
+
middleware := NewOAuthAuthMiddleware(client, store)
794
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
795
-
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
797
-
t.Fatalf("failed to calculate thumbprint: %v", err)
741
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
742
+
t.Error("handler should not be called")
800
-
claims := &auth.Claims{
801
-
RegisteredClaims: jwt.RegisteredClaims{
802
-
Subject: "did:plc:test123",
803
-
Issuer: "https://test.pds.local",
804
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
805
-
IssuedAt: jwt.NewNumericDate(time.Now()),
808
-
Confirmation: map[string]interface{}{
813
-
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
814
-
defer middleware.Stop()
816
-
accessToken := "real-access-token-12345"
818
-
t.Run("ath_matches_access_token", func(t *testing.T) {
819
-
// Create DPoP proof with ath claim matching the access token
820
-
dpopProof := createDPoPProofWithAth(t, privateKey, "GET", "https://api.example.com/resource", accessToken)
822
-
req := httptest.NewRequest("GET", "https://api.example.com/resource", nil)
823
-
req.Host = "api.example.com"
825
-
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof, accessToken)
827
-
t.Fatalf("expected verification to succeed with matching ath, got %v", err)
830
-
t.Fatal("expected proof to be returned")
745
+
req := httptest.NewRequest("GET", "/test", nil)
746
+
req.AddCookie(&http.Cookie{
747
+
Name: "coves_session",
748
+
Value: "not-a-valid-sealed-token",
834
-
t.Run("ath_mismatch_rejected", func(t *testing.T) {
835
-
// Create DPoP proof with ath for a DIFFERENT token
836
-
differentToken := "different-token-67890"
837
-
dpopProof := createDPoPProofWithAth(t, privateKey, "POST", "https://api.example.com/resource", differentToken)
839
-
req := httptest.NewRequest("POST", "https://api.example.com/resource", nil)
840
-
req.Host = "api.example.com"
750
+
w := httptest.NewRecorder()
842
-
// Try to use with the original access token - should fail
843
-
_, err := middleware.verifyDPoPBinding(req, claims, dpopProof, accessToken)
845
-
t.Fatal("SECURITY: expected verification to fail when ath doesn't match access token")
847
-
if !strings.Contains(err.Error(), "ath") {
848
-
t.Errorf("error should mention ath mismatch, got: %v", err)
853
-
// TestMiddlewareStop tests that the middleware can be stopped properly
854
-
func TestMiddlewareStop(t *testing.T) {
855
-
fetcher := &mockJWKSFetcher{}
856
-
middleware := NewAtProtoAuthMiddleware(fetcher, false)
858
-
// Stop should not panic and should clean up resources
752
+
handler.ServeHTTP(w, req)
861
-
// Calling Stop again should also be safe (idempotent-ish)
862
-
// Note: The underlying DPoPVerifier.Stop() closes a channel, so this might panic
863
-
// if not handled properly. We test that at least one Stop works.
754
+
if w.Code != http.StatusUnauthorized {
755
+
t.Errorf("expected status 401, got %d", w.Code)
866
-
// TestOptionalAuth_DPoPBoundToken_NoDPoPHeader tests that OptionalAuth treats
867
-
// tokens with cnf.jkt but no DPoP header as unauthenticated (potential token theft)
868
-
func TestOptionalAuth_DPoPBoundToken_NoDPoPHeader(t *testing.T) {
869
-
// Generate a key pair for DPoP binding
870
-
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
871
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
872
-
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
759
+
// TestOptionalAuth_WithCookie tests that OptionalAuth accepts valid session cookies
760
+
func TestOptionalAuth_WithCookie(t *testing.T) {
761
+
client := newMockOAuthClient()
762
+
store := newMockOAuthStore()
874
-
// Create a DPoP-bound token (has cnf.jkt)
875
-
claims := auth.Claims{
876
-
RegisteredClaims: jwt.RegisteredClaims{
877
-
Subject: "did:plc:user123",
878
-
Issuer: "https://test.pds.local",
879
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
880
-
IssuedAt: jwt.NewNumericDate(time.Now()),
883
-
Confirmation: map[string]interface{}{
764
+
// Create a test session
765
+
did := syntax.DID("did:plc:test123")
766
+
sessionID := "session123"
767
+
session := &oauthlib.ClientSessionData{
769
+
SessionID: sessionID,
770
+
AccessToken: "test_access_token",
888
-
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
889
-
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
772
+
_ = store.SaveSession(context.Background(), *session)
891
-
// Use skipVerify=true to simulate a verified token
892
-
// (In production, skipVerify would be false and VerifyJWT would be called)
893
-
// However, for this test we need skipVerify=false to trigger DPoP checking
894
-
// But the fetcher will fail, so let's use skipVerify=true and verify the logic
895
-
// Actually, the DPoP check only happens when skipVerify=false
774
+
middleware := NewOAuthAuthMiddleware(client, store)
897
-
t.Run("with_skipVerify_false", func(t *testing.T) {
898
-
// This will fail at JWT verification level, but that's expected
899
-
// The important thing is the code path for DPoP checking
900
-
fetcher := &mockJWKSFetcher{shouldFail: true}
901
-
middleware := NewAtProtoAuthMiddleware(fetcher, false)
902
-
defer middleware.Stop()
776
+
handlerCalled := false
777
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
778
+
handlerCalled = true
904
-
handlerCalled := false
905
-
var capturedDID string
906
-
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
907
-
handlerCalled = true
908
-
capturedDID = GetUserDID(r)
909
-
w.WriteHeader(http.StatusOK)
912
-
req := httptest.NewRequest("GET", "/test", nil)
913
-
req.Header.Set("Authorization", "DPoP "+tokenString)
914
-
// Deliberately NOT setting DPoP header
915
-
w := httptest.NewRecorder()
917
-
handler.ServeHTTP(w, req)
919
-
// Handler should be called (optional auth doesn't block)
920
-
if !handlerCalled {
921
-
t.Error("handler should be called")
780
+
// Verify DID was extracted
781
+
extractedDID := GetUserDID(r)
782
+
if extractedDID != "did:plc:test123" {
783
+
t.Errorf("expected DID 'did:plc:test123', got %s", extractedDID)
924
-
// But since JWT verification fails, user should not be authenticated
925
-
if capturedDID != "" {
926
-
t.Errorf("expected empty DID when verification fails, got %s", capturedDID)
930
-
t.Run("with_skipVerify_true_dpop_not_checked", func(t *testing.T) {
931
-
// When skipVerify=true, DPoP is not checked (Phase 1 mode)
932
-
fetcher := &mockJWKSFetcher{}
933
-
middleware := NewAtProtoAuthMiddleware(fetcher, true)
934
-
defer middleware.Stop()
786
+
w.WriteHeader(http.StatusOK)
936
-
handlerCalled := false
937
-
var capturedDID string
938
-
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
939
-
handlerCalled = true
940
-
capturedDID = GetUserDID(r)
941
-
w.WriteHeader(http.StatusOK)
944
-
req := httptest.NewRequest("GET", "/test", nil)
945
-
req.Header.Set("Authorization", "DPoP "+tokenString)
947
-
w := httptest.NewRecorder()
949
-
handler.ServeHTTP(w, req)
951
-
if !handlerCalled {
952
-
t.Error("handler should be called")
955
-
// With skipVerify=true, DPoP check is bypassed - token is trusted
956
-
if capturedDID != "did:plc:user123" {
957
-
t.Errorf("expected DID when skipVerify=true, got %s", capturedDID)
789
+
token := client.createTestToken("did:plc:test123", sessionID, time.Hour)
790
+
req := httptest.NewRequest("GET", "/test", nil)
791
+
req.AddCookie(&http.Cookie{
792
+
Name: "coves_session",
795
+
w := httptest.NewRecorder()
962
-
// TestDPoPReplayProtection tests that the same DPoP proof cannot be used twice
963
-
func TestDPoPReplayProtection(t *testing.T) {
964
-
// This tests the NonceCache functionality
965
-
cache := auth.NewNonceCache(5 * time.Minute)
797
+
handler.ServeHTTP(w, req)
968
-
jti := "unique-proof-id-123"
970
-
// First use should succeed
971
-
if !cache.CheckAndStore(jti) {
972
-
t.Error("First use of jti should succeed")
975
-
// Second use should fail (replay detected)
976
-
if cache.CheckAndStore(jti) {
977
-
t.Error("SECURITY: Replay attack not detected - same jti accepted twice")
799
+
if !handlerCalled {
800
+
t.Error("handler was not called")
980
-
// Different jti should succeed
981
-
if !cache.CheckAndStore("different-jti-456") {
982
-
t.Error("Different jti should succeed")
803
+
if w.Code != http.StatusOK {
804
+
t.Errorf("expected status 200, got %d", w.Code)
986
-
// Helper: createDPoPProof creates a DPoP proof JWT for testing
987
-
func createDPoPProof(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri string) string {
988
-
// Create JWK from public key
989
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
808
+
// TestOptionalAuth_InvalidCookie tests that OptionalAuth continues without auth on invalid cookie
809
+
func TestOptionalAuth_InvalidCookie(t *testing.T) {
810
+
client := newMockOAuthClient()
811
+
store := newMockOAuthStore()
812
+
middleware := NewOAuthAuthMiddleware(client, store)
991
-
// Create DPoP claims with UUID for jti to ensure uniqueness across tests
992
-
claims := auth.DPoPClaims{
993
-
RegisteredClaims: jwt.RegisteredClaims{
994
-
IssuedAt: jwt.NewNumericDate(time.Now()),
995
-
ID: uuid.New().String(),
997
-
HTTPMethod: method,
814
+
handlerCalled := false
815
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
816
+
handlerCalled = true
1001
-
// Create token with custom header
1002
-
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
1003
-
token.Header["typ"] = "dpop+jwt"
1004
-
token.Header["jwk"] = jwk
818
+
// Verify no DID is set (invalid cookie ignored)
819
+
did := GetUserDID(r)
821
+
t.Errorf("expected empty DID for invalid cookie, got %s", did)
1006
-
// Sign with private key
1007
-
signedToken, err := token.SignedString(privateKey)
1009
-
t.Fatalf("failed to sign DPoP proof: %v", err)
824
+
w.WriteHeader(http.StatusOK)
1012
-
return signedToken
827
+
req := httptest.NewRequest("GET", "/test", nil)
828
+
req.AddCookie(&http.Cookie{
829
+
Name: "coves_session",
830
+
Value: "not-a-valid-sealed-token",
832
+
w := httptest.NewRecorder()
1015
-
// Helper: createDPoPProofWithAth creates a DPoP proof JWT with ath (access token hash) claim
1016
-
func createDPoPProofWithAth(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri, accessToken string) string {
1017
-
// Create JWK from public key
1018
-
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
834
+
handler.ServeHTTP(w, req)
1020
-
// Calculate ath: base64url(SHA-256(access_token))
1021
-
hash := sha256.Sum256([]byte(accessToken))
1022
-
ath := base64.RawURLEncoding.EncodeToString(hash[:])
1024
-
// Create DPoP claims with ath
1025
-
claims := auth.DPoPClaims{
1026
-
RegisteredClaims: jwt.RegisteredClaims{
1027
-
IssuedAt: jwt.NewNumericDate(time.Now()),
1028
-
ID: uuid.New().String(),
1030
-
HTTPMethod: method,
1032
-
AccessTokenHash: ath,
836
+
if !handlerCalled {
837
+
t.Error("handler was not called")
1035
-
// Create token with custom header
1036
-
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
1037
-
token.Header["typ"] = "dpop+jwt"
1038
-
token.Header["jwk"] = jwk
1040
-
// Sign with private key
1041
-
signedToken, err := token.SignedString(privateKey)
1043
-
t.Fatalf("failed to sign DPoP proof: %v", err)
840
+
if w.Code != http.StatusOK {
841
+
t.Errorf("expected status 200, got %d", w.Code)
1046
-
return signedToken
1049
-
// Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map
1050
-
func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} {
1053
-
switch pubKey.Curve {
1054
-
case elliptic.P256():
1056
-
case elliptic.P384():
1058
-
case elliptic.P521():
1061
-
panic("unsupported curve")
845
+
// TestWriteAuthError_JSONEscaping tests that writeAuthError properly escapes messages
846
+
func TestWriteAuthError_JSONEscaping(t *testing.T) {
847
+
tests := []struct {
851
+
{"simple message", "Missing authentication"},
852
+
{"message with quotes", `Invalid "token" format`},
853
+
{"message with newlines", "Invalid\ntoken\nformat"},
854
+
{"message with backslashes", `Invalid \ token`},
855
+
{"message with special chars", `Invalid <script>alert("xss")</script> token`},
856
+
{"message with unicode", "Invalid token: \u2028\u2029"},
1064
-
// Encode coordinates
1065
-
xBytes := pubKey.X.Bytes()
1066
-
yBytes := pubKey.Y.Bytes()
859
+
for _, tt := range tests {
860
+
t.Run(tt.name, func(t *testing.T) {
861
+
w := httptest.NewRecorder()
862
+
writeAuthError(w, tt.message)
1068
-
// Ensure proper byte length (pad if needed)
1069
-
keySize := (pubKey.Curve.Params().BitSize + 7) / 8
1070
-
xPadded := make([]byte, keySize)
1071
-
yPadded := make([]byte, keySize)
1072
-
copy(xPadded[keySize-len(xBytes):], xBytes)
1073
-
copy(yPadded[keySize-len(yBytes):], yBytes)
864
+
// Verify status code
865
+
if w.Code != http.StatusUnauthorized {
866
+
t.Errorf("expected status 401, got %d", w.Code)
1075
-
return map[string]interface{}{
1078
-
"x": base64.RawURLEncoding.EncodeToString(xPadded),
1079
-
"y": base64.RawURLEncoding.EncodeToString(yPadded),
869
+
// Verify content type
870
+
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
871
+
t.Errorf("expected Content-Type 'application/json', got %s", ct)
1083
-
// Helper: createValidJWT creates a valid unsigned JWT token for testing
1084
-
// This is used with skipVerify=true middleware where signature verification is skipped
1085
-
func createValidJWT(t *testing.T, subject string, expiry time.Duration) string {
1088
-
claims := auth.Claims{
1089
-
RegisteredClaims: jwt.RegisteredClaims{
1091
-
Issuer: "https://test.pds.local",
1092
-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
1093
-
IssuedAt: jwt.NewNumericDate(time.Now()),
874
+
// Verify response is valid JSON
875
+
var response map[string]string
876
+
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
877
+
t.Fatalf("response is not valid JSON: %v\nBody: %s", err, w.Body.String())
1098
-
// Create unsigned token (for skipVerify=true tests)
1099
-
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
1100
-
signedToken, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
1102
-
t.Fatalf("failed to create test JWT: %v", err)
881
+
if response["error"] != "AuthenticationRequired" {
882
+
t.Errorf("expected error 'AuthenticationRequired', got %s", response["error"])
884
+
if response["message"] != tt.message {
885
+
t.Errorf("expected message %q, got %q", tt.message, response["message"])
1105
-
return signedToken