···
4
-
"Coves/internal/api/handlers/oauth"
5
-
"Coves/internal/atproto/identity"
14
-
oauthCore "Coves/internal/core/oauth"
16
-
"github.com/lestrrat-go/jwx/v2/jwk"
19
-
// TestOAuthClientMetadata tests the /oauth/client-metadata.json endpoint
20
-
func TestOAuthClientMetadata(t *testing.T) {
24
-
expectedClientID string
25
-
expectedJWKSURI string
26
-
expectedRedirect string
29
-
name: "localhost development",
30
-
appviewURL: "http://localhost:8081",
31
-
expectedClientID: "http://localhost?redirect_uri=http://localhost:8081/oauth/callback&scope=atproto%20transition:generic",
32
-
expectedJWKSURI: "", // No JWKS URI for localhost
33
-
expectedRedirect: "http://localhost:8081/oauth/callback",
36
-
name: "production HTTPS",
37
-
appviewURL: "https://coves.social",
38
-
expectedClientID: "https://coves.social/oauth/client-metadata.json",
39
-
expectedJWKSURI: "https://coves.social/oauth/jwks.json",
40
-
expectedRedirect: "https://coves.social/oauth/callback",
44
-
for _, tt := range tests {
45
-
t.Run(tt.name, func(t *testing.T) {
47
-
if err := os.Setenv("APPVIEW_PUBLIC_URL", tt.appviewURL); err != nil {
48
-
t.Fatalf("Failed to set APPVIEW_PUBLIC_URL: %v", err)
51
-
if err := os.Unsetenv("APPVIEW_PUBLIC_URL"); err != nil {
52
-
t.Logf("Failed to unset APPVIEW_PUBLIC_URL: %v", err)
57
-
req := httptest.NewRequest("GET", "/oauth/client-metadata.json", nil)
58
-
w := httptest.NewRecorder()
61
-
oauth.HandleClientMetadata(w, req)
63
-
// Check status code
64
-
if w.Code != http.StatusOK {
65
-
t.Fatalf("expected status 200, got %d", w.Code)
69
-
var metadata oauth.ClientMetadata
70
-
if err := json.NewDecoder(w.Body).Decode(&metadata); err != nil {
71
-
t.Fatalf("failed to decode response: %v", err)
75
-
if metadata.ClientID != tt.expectedClientID {
76
-
t.Errorf("expected client_id %q, got %q", tt.expectedClientID, metadata.ClientID)
80
-
if metadata.JwksURI != tt.expectedJWKSURI {
81
-
t.Errorf("expected jwks_uri %q, got %q", tt.expectedJWKSURI, metadata.JwksURI)
84
-
// Verify redirect URI
85
-
if len(metadata.RedirectURIs) != 1 || metadata.RedirectURIs[0] != tt.expectedRedirect {
86
-
t.Errorf("expected redirect_uris [%q], got %v", tt.expectedRedirect, metadata.RedirectURIs)
89
-
// Verify OAuth spec compliance
90
-
if metadata.ClientName != "Coves" {
91
-
t.Errorf("expected client_name 'Coves', got %q", metadata.ClientName)
93
-
if metadata.TokenEndpointAuthMethod != "private_key_jwt" {
94
-
t.Errorf("expected token_endpoint_auth_method 'private_key_jwt', got %q", metadata.TokenEndpointAuthMethod)
96
-
if metadata.TokenEndpointAuthSigningAlg != "ES256" {
97
-
t.Errorf("expected token_endpoint_auth_signing_alg 'ES256', got %q", metadata.TokenEndpointAuthSigningAlg)
99
-
if !metadata.DpopBoundAccessTokens {
100
-
t.Error("expected dpop_bound_access_tokens to be true")
106
-
// TestOAuthJWKS tests the /oauth/jwks.json endpoint
107
-
func TestOAuthJWKS(t *testing.T) {
108
-
// Use the test JWK from .env.dev
109
-
testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
111
-
tests := []struct {
117
-
name: "valid plain JWK",
119
-
expectSuccess: true,
122
-
name: "missing JWK",
124
-
expectSuccess: false,
128
-
for _, tt := range tests {
129
-
t.Run(tt.name, func(t *testing.T) {
131
-
if tt.envValue != "" {
132
-
if err := os.Setenv("OAUTH_PRIVATE_JWK", tt.envValue); err != nil {
133
-
t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err)
136
-
if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil {
137
-
t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err)
143
-
req := httptest.NewRequest("GET", "/oauth/jwks.json", nil)
144
-
w := httptest.NewRecorder()
147
-
oauth.HandleJWKS(w, req)
149
-
// Check status code
150
-
if tt.expectSuccess {
151
-
if w.Code != http.StatusOK {
152
-
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
156
-
var jwksResp struct {
157
-
Keys []map[string]interface{} `json:"keys"`
159
-
if err := json.NewDecoder(w.Body).Decode(&jwksResp); err != nil {
160
-
t.Fatalf("failed to decode JWKS: %v", err)
163
-
// Verify we got a public key
164
-
if len(jwksResp.Keys) != 1 {
165
-
t.Fatalf("expected 1 key, got %d", len(jwksResp.Keys))
168
-
key := jwksResp.Keys[0]
169
-
if key["kty"] != "EC" {
170
-
t.Errorf("expected kty 'EC', got %v", key["kty"])
172
-
if key["alg"] != "ES256" {
173
-
t.Errorf("expected alg 'ES256', got %v", key["alg"])
175
-
if key["kid"] != "oauth-client-key" {
176
-
t.Errorf("expected kid 'oauth-client-key', got %v", key["kid"])
179
-
// Verify private key is NOT exposed
180
-
if _, hasPrivate := key["d"]; hasPrivate {
181
-
t.Error("SECURITY: private key 'd' should not be in JWKS!")
185
-
if w.Code == http.StatusOK {
186
-
t.Fatalf("expected error status, got 200")
193
-
// TestOAuthLoginHandler tests the OAuth login initiation
194
-
func TestOAuthLoginHandler(t *testing.T) {
195
-
// Skip if running in CI without database
196
-
if os.Getenv("SKIP_INTEGRATION") == "true" {
197
-
t.Skip("Skipping integration test")
200
-
// Setup test database
201
-
db := setupTestDB(t)
203
-
if err := db.Close(); err != nil {
204
-
t.Logf("Failed to close database: %v", err)
208
-
// Create session store
209
-
sessionStore := oauthCore.NewPostgresSessionStore(db)
211
-
// Create identity resolver (mock for now - we'll test with real PDS separately)
212
-
// For now, just test the handler structure and validation
214
-
tests := []struct {
216
-
requestBody map[string]interface{}
221
-
name: "missing handle",
222
-
requestBody: map[string]interface{}{
225
-
envJWK: `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`,
226
-
expectedStatus: http.StatusBadRequest,
229
-
name: "invalid handle format",
230
-
requestBody: map[string]interface{}{
231
-
"handle": "no-dots-invalid",
233
-
envJWK: `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`,
234
-
expectedStatus: http.StatusBadRequest,
237
-
name: "missing OAuth JWK",
238
-
requestBody: map[string]interface{}{
239
-
"handle": "alice.bsky.social",
242
-
expectedStatus: http.StatusInternalServerError,
246
-
for _, tt := range tests {
247
-
t.Run(tt.name, func(t *testing.T) {
249
-
if tt.envJWK != "" {
250
-
if err := os.Setenv("OAUTH_PRIVATE_JWK", tt.envJWK); err != nil {
251
-
t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err)
254
-
if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil {
255
-
t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err)
259
-
if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil {
260
-
t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err)
264
-
// Create mock identity resolver for validation tests
265
-
mockResolver := &mockIdentityResolver{}
268
-
handler := oauth.NewLoginHandler(mockResolver, sessionStore)
271
-
bodyBytes, marshalErr := json.Marshal(tt.requestBody)
272
-
if marshalErr != nil {
273
-
t.Fatalf("Failed to marshal request body: %v", marshalErr)
275
-
req := httptest.NewRequest("POST", "/oauth/login", bytes.NewReader(bodyBytes))
276
-
req.Header.Set("Content-Type", "application/json")
277
-
w := httptest.NewRecorder()
280
-
handler.HandleLogin(w, req)
282
-
// Check status code
283
-
if w.Code != tt.expectedStatus {
284
-
t.Errorf("expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String())
290
-
// TestOAuthCallbackHandler tests the OAuth callback handling
291
-
func TestOAuthCallbackHandler(t *testing.T) {
292
-
// Skip if running in CI without database
293
-
if os.Getenv("SKIP_INTEGRATION") == "true" {
294
-
t.Skip("Skipping integration test")
297
-
// Setup test database
298
-
db := setupTestDB(t)
300
-
if err := db.Close(); err != nil {
301
-
t.Logf("Failed to close database: %v", err)
305
-
// Create session store
306
-
sessionStore := oauthCore.NewPostgresSessionStore(db)
308
-
testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
310
-
tests := []struct {
311
-
queryParams map[string]string
316
-
name: "missing code",
317
-
queryParams: map[string]string{
318
-
"state": "test-state",
319
-
"iss": "https://bsky.social",
321
-
expectedStatus: http.StatusBadRequest,
324
-
name: "missing state",
325
-
queryParams: map[string]string{
326
-
"code": "test-code",
327
-
"iss": "https://bsky.social",
329
-
expectedStatus: http.StatusBadRequest,
332
-
name: "missing issuer",
333
-
queryParams: map[string]string{
334
-
"code": "test-code",
335
-
"state": "test-state",
337
-
expectedStatus: http.StatusBadRequest,
340
-
name: "OAuth error parameter",
341
-
queryParams: map[string]string{
342
-
"error": "access_denied",
343
-
"error_description": "User denied access",
345
-
expectedStatus: http.StatusBadRequest,
349
-
for _, tt := range tests {
350
-
t.Run(tt.name, func(t *testing.T) {
352
-
if err := os.Setenv("OAUTH_PRIVATE_JWK", testJWK); err != nil {
353
-
t.Fatalf("Failed to set OAUTH_PRIVATE_JWK: %v", err)
356
-
if err := os.Unsetenv("OAUTH_PRIVATE_JWK"); err != nil {
357
-
t.Logf("Failed to unset OAUTH_PRIVATE_JWK: %v", err)
362
-
handler := oauth.NewCallbackHandler(sessionStore)
364
-
// Build query string
365
-
req := httptest.NewRequest("GET", "/oauth/callback", nil)
366
-
q := req.URL.Query()
367
-
for k, v := range tt.queryParams {
370
-
req.URL.RawQuery = q.Encode()
372
-
w := httptest.NewRecorder()
375
-
handler.HandleCallback(w, req)
377
-
// Check status code
378
-
if w.Code != tt.expectedStatus {
379
-
t.Errorf("expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String())
385
-
// mockIdentityResolver is a mock for testing
386
-
type mockIdentityResolver struct{}
388
-
func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) {
389
-
// Return a mock resolved identity
390
-
return &identity.Identity{
391
-
DID: "did:plc:test123",
392
-
Handle: identifier,
393
-
PDSURL: "https://test.pds.example",
397
-
func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) {
398
-
return "did:plc:test123", "https://test.pds.example", nil
401
-
func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) {
402
-
return &identity.DIDDocument{
404
-
Service: []identity.Service{
406
-
ID: "#atproto_pds",
407
-
Type: "AtprotoPersonalDataServer",
408
-
ServiceEndpoint: "https://test.pds.example",
414
-
func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error {
418
-
// TestJWKParsing tests that we can parse JWKs correctly
419
-
func TestJWKParsing(t *testing.T) {
420
-
testJWK := `{"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"}`
423
-
key, err := jwk.ParseKey([]byte(testJWK))
425
-
t.Fatalf("failed to parse JWK: %v", err)
428
-
// Verify it's an EC key
429
-
if key.KeyType() != "EC" {
430
-
t.Errorf("expected key type 'EC', got %v", key.KeyType())
433
-
// Verify we can get the public key
434
-
pubKey, err := key.PublicKey()
436
-
t.Fatalf("failed to get public key: %v", err)
439
-
// Verify public key doesn't have private component
440
-
pubKeyJSON, marshalErr := json.Marshal(pubKey)
441
-
if marshalErr != nil {
442
-
t.Fatalf("failed to marshal public key: %v", marshalErr)
444
-
var pubKeyMap map[string]interface{}
445
-
if unmarshalErr := json.Unmarshal(pubKeyJSON, &pubKeyMap); unmarshalErr != nil {
446
-
t.Fatalf("failed to unmarshal public key: %v", unmarshalErr)
449
-
if _, hasPrivate := pubKeyMap["d"]; hasPrivate {
450
-
t.Error("SECURITY: public key should not contain private 'd' component!")