A community based topic aggregation platform built on atproto
1package oauth 2 3import ( 4 "encoding/json" 5 "net/http" 6 "net/http/httptest" 7 "testing" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/require" 14) 15 16// TestHandleClientMetadata tests the client metadata endpoint 17func TestHandleClientMetadata(t *testing.T) { 18 // Create a test OAuth client configuration 19 config := &OAuthConfig{ 20 PublicURL: "https://coves.social", 21 Scopes: []string{"atproto"}, 22 DevMode: false, 23 AllowPrivateIPs: false, 24 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", // base64 encoded 32 bytes 25 } 26 27 // Create OAuth client with memory store 28 client, err := NewOAuthClient(config, oauth.NewMemStore()) 29 require.NoError(t, err) 30 31 // Create handler 32 handler := NewOAuthHandler(client, oauth.NewMemStore()) 33 34 // Create test request 35 req := httptest.NewRequest(http.MethodGet, "/oauth/client-metadata.json", nil) 36 req.Host = "coves.social" 37 rec := httptest.NewRecorder() 38 39 // Call handler 40 handler.HandleClientMetadata(rec, req) 41 42 // Check response 43 assert.Equal(t, http.StatusOK, rec.Code) 44 assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) 45 46 // Parse response 47 var metadata oauth.ClientMetadata 48 err = json.NewDecoder(rec.Body).Decode(&metadata) 49 require.NoError(t, err) 50 51 // Validate metadata 52 assert.Equal(t, "https://coves.social", metadata.ClientID) 53 assert.Contains(t, metadata.RedirectURIs, "https://coves.social/oauth/callback") 54 assert.Contains(t, metadata.GrantTypes, "authorization_code") 55 assert.Contains(t, metadata.GrantTypes, "refresh_token") 56 assert.True(t, metadata.DPoPBoundAccessTokens) 57 assert.Contains(t, metadata.Scope, "atproto") 58} 59 60// TestHandleJWKS tests the JWKS endpoint 61func TestHandleJWKS(t *testing.T) { 62 // Create a test OAuth client configuration (public client, no keys) 63 config := &OAuthConfig{ 64 PublicURL: "https://coves.social", 65 Scopes: []string{"atproto"}, 66 DevMode: false, 67 AllowPrivateIPs: false, 68 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 69 } 70 71 client, err := NewOAuthClient(config, oauth.NewMemStore()) 72 require.NoError(t, err) 73 74 handler := NewOAuthHandler(client, oauth.NewMemStore()) 75 76 // Create test request 77 req := httptest.NewRequest(http.MethodGet, "/oauth/jwks.json", nil) 78 rec := httptest.NewRecorder() 79 80 // Call handler 81 handler.HandleJWKS(rec, req) 82 83 // Check response 84 assert.Equal(t, http.StatusOK, rec.Code) 85 assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) 86 87 // Parse response 88 var jwks oauth.JWKS 89 err = json.NewDecoder(rec.Body).Decode(&jwks) 90 require.NoError(t, err) 91 92 // Public client should have empty JWKS 93 assert.NotNil(t, jwks.Keys) 94 assert.Equal(t, 0, len(jwks.Keys)) 95} 96 97// TestHandleLogin tests the login endpoint 98func TestHandleLogin(t *testing.T) { 99 config := &OAuthConfig{ 100 PublicURL: "https://coves.social", 101 Scopes: []string{"atproto"}, 102 DevMode: true, // Use dev mode to avoid real PDS calls 103 AllowPrivateIPs: true, // Allow private IPs in dev mode 104 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 105 } 106 107 client, err := NewOAuthClient(config, oauth.NewMemStore()) 108 require.NoError(t, err) 109 110 handler := NewOAuthHandler(client, oauth.NewMemStore()) 111 112 t.Run("missing identifier", func(t *testing.T) { 113 req := httptest.NewRequest(http.MethodGet, "/oauth/login", nil) 114 rec := httptest.NewRecorder() 115 116 handler.HandleLogin(rec, req) 117 118 assert.Equal(t, http.StatusBadRequest, rec.Code) 119 }) 120 121 t.Run("with handle parameter", func(t *testing.T) { 122 // This test would need a mock PDS server to fully test 123 // For now, we just verify the endpoint accepts the parameter 124 req := httptest.NewRequest(http.MethodGet, "/oauth/login?handle=user.bsky.social", nil) 125 rec := httptest.NewRecorder() 126 127 handler.HandleLogin(rec, req) 128 129 // In dev mode or with a real PDS, this would redirect 130 // Without a mock, it will fail to resolve the handle 131 // We're just testing that the handler processes the request 132 assert.NotEqual(t, http.StatusOK, rec.Code) // Should redirect or error 133 }) 134} 135 136// TestHandleMobileLogin tests the mobile login endpoint 137func TestHandleMobileLogin(t *testing.T) { 138 config := &OAuthConfig{ 139 PublicURL: "https://coves.social", 140 Scopes: []string{"atproto"}, 141 DevMode: true, 142 AllowPrivateIPs: true, 143 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 144 } 145 146 client, err := NewOAuthClient(config, oauth.NewMemStore()) 147 require.NoError(t, err) 148 149 handler := NewOAuthHandler(client, oauth.NewMemStore()) 150 151 t.Run("missing redirect_uri", func(t *testing.T) { 152 req := httptest.NewRequest(http.MethodGet, "/oauth/mobile/login?handle=user.bsky.social", nil) 153 rec := httptest.NewRecorder() 154 155 handler.HandleMobileLogin(rec, req) 156 157 assert.Equal(t, http.StatusBadRequest, rec.Code) 158 assert.Contains(t, rec.Body.String(), "redirect_uri") 159 }) 160 161 t.Run("invalid redirect_uri (https)", func(t *testing.T) { 162 req := httptest.NewRequest(http.MethodGet, "/oauth/mobile/login?handle=user.bsky.social&redirect_uri=https://example.com", nil) 163 rec := httptest.NewRecorder() 164 165 handler.HandleMobileLogin(rec, req) 166 167 assert.Equal(t, http.StatusBadRequest, rec.Code) 168 assert.Contains(t, rec.Body.String(), "invalid redirect_uri") 169 }) 170 171 t.Run("invalid redirect_uri (wrong path)", func(t *testing.T) { 172 req := httptest.NewRequest(http.MethodGet, "/oauth/mobile/login?handle=user.bsky.social&redirect_uri=coves-app://callback", nil) 173 rec := httptest.NewRecorder() 174 175 handler.HandleMobileLogin(rec, req) 176 177 assert.Equal(t, http.StatusBadRequest, rec.Code) 178 assert.Contains(t, rec.Body.String(), "invalid redirect_uri") 179 }) 180 181 t.Run("valid mobile redirect_uri (Universal Link)", func(t *testing.T) { 182 req := httptest.NewRequest(http.MethodGet, "/oauth/mobile/login?handle=user.bsky.social&redirect_uri=https://coves.social/app/oauth/callback", nil) 183 rec := httptest.NewRecorder() 184 185 handler.HandleMobileLogin(rec, req) 186 187 // Should fail to resolve handle but accept the parameters 188 // Check that cookie was set 189 cookies := rec.Result().Cookies() 190 var found bool 191 for _, cookie := range cookies { 192 if cookie.Name == "mobile_redirect_uri" { 193 found = true 194 break 195 } 196 } 197 // May or may not set cookie depending on error handling 198 _ = found 199 }) 200} 201 202// TestParseSessionToken tests that we no longer use parseSessionToken 203// (removed in favor of sealed tokens) 204func TestParseSessionToken(t *testing.T) { 205 // This test is deprecated - we now use sealed tokens instead of plain "did:sessionID" format 206 // See TestSealAndUnsealSessionData for the new approach 207 t.Skip("parseSessionToken removed - we now use sealed tokens for security") 208} 209 210// TestIsMobileRedirectURI tests mobile redirect URI validation with EXACT URI matching 211// Only Universal Links (HTTPS) are allowed - custom schemes are blocked for security 212func TestIsMobileRedirectURI(t *testing.T) { 213 tests := []struct { 214 uri string 215 expected bool 216 }{ 217 {"https://coves.social/app/oauth/callback", true}, // Universal Link - allowed 218 {"coves-app://oauth/callback", false}, // Custom scheme - blocked (insecure) 219 {"coves://oauth/callback", false}, // Custom scheme - blocked (insecure) 220 {"coves-app://callback", false}, // Custom scheme - blocked 221 {"coves://oauth", false}, // Custom scheme - blocked 222 {"myapp://oauth", false}, // Not in allowlist 223 {"https://example.com", false}, // Wrong domain 224 {"http://localhost", false}, // HTTP not allowed 225 {"", false}, 226 {"not-a-uri", false}, 227 } 228 229 for _, tt := range tests { 230 t.Run(tt.uri, func(t *testing.T) { 231 result := isAllowedMobileRedirectURI(tt.uri) 232 assert.Equal(t, tt.expected, result) 233 }) 234 } 235} 236 237// TestSealAndUnsealSessionData tests session data sealing/unsealing 238func TestSealAndUnsealSessionData(t *testing.T) { 239 config := &OAuthConfig{ 240 PublicURL: "https://coves.social", 241 Scopes: []string{"atproto"}, 242 DevMode: false, 243 AllowPrivateIPs: false, 244 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 245 } 246 247 client, err := NewOAuthClient(config, oauth.NewMemStore()) 248 require.NoError(t, err) 249 250 // Create test DID 251 did, err := testDID() 252 require.NoError(t, err) 253 254 sessionID := "test-session-123" 255 256 // Seal the session using the client method 257 sealed, err := client.SealSession(did.String(), sessionID, 24*time.Hour) 258 require.NoError(t, err) 259 assert.NotEmpty(t, sealed) 260 261 // Unseal the session using the client method 262 unsealed, err := client.UnsealSession(sealed) 263 require.NoError(t, err) 264 require.NotNil(t, unsealed) 265 266 // Verify data matches 267 assert.Equal(t, did.String(), unsealed.DID) 268 assert.Equal(t, sessionID, unsealed.SessionID) 269 assert.Greater(t, unsealed.ExpiresAt, int64(0)) 270} 271 272// testDID creates a test DID for testing 273func testDID() (*syntax.DID, error) { 274 did, err := syntax.ParseDID("did:plc:test123abc456def789") 275 if err != nil { 276 return nil, err 277 } 278 return &did, nil 279}