A community based topic aggregation platform built on atproto
at main 8.1 kB view raw
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// TestHandleLogin tests the login endpoint 61func TestHandleLogin(t *testing.T) { 62 config := &OAuthConfig{ 63 PublicURL: "https://coves.social", 64 Scopes: []string{"atproto"}, 65 DevMode: true, // Use dev mode to avoid real PDS calls 66 AllowPrivateIPs: true, // Allow private IPs in dev mode 67 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 68 } 69 70 client, err := NewOAuthClient(config, oauth.NewMemStore()) 71 require.NoError(t, err) 72 73 handler := NewOAuthHandler(client, oauth.NewMemStore()) 74 75 t.Run("missing identifier", func(t *testing.T) { 76 req := httptest.NewRequest(http.MethodGet, "/oauth/login", nil) 77 rec := httptest.NewRecorder() 78 79 handler.HandleLogin(rec, req) 80 81 assert.Equal(t, http.StatusBadRequest, rec.Code) 82 }) 83 84 t.Run("with handle parameter", func(t *testing.T) { 85 // This test would need a mock PDS server to fully test 86 // For now, we just verify the endpoint accepts the parameter 87 req := httptest.NewRequest(http.MethodGet, "/oauth/login?handle=user.bsky.social", nil) 88 rec := httptest.NewRecorder() 89 90 handler.HandleLogin(rec, req) 91 92 // In dev mode or with a real PDS, this would redirect 93 // Without a mock, it will fail to resolve the handle 94 // We're just testing that the handler processes the request 95 assert.NotEqual(t, http.StatusOK, rec.Code) // Should redirect or error 96 }) 97} 98 99// TestHandleMobileLogin tests the mobile login endpoint 100func TestHandleMobileLogin(t *testing.T) { 101 config := &OAuthConfig{ 102 PublicURL: "https://coves.social", 103 Scopes: []string{"atproto"}, 104 DevMode: true, 105 AllowPrivateIPs: true, 106 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 107 } 108 109 client, err := NewOAuthClient(config, oauth.NewMemStore()) 110 require.NoError(t, err) 111 112 handler := NewOAuthHandler(client, oauth.NewMemStore()) 113 114 t.Run("missing redirect_uri", func(t *testing.T) { 115 req := httptest.NewRequest(http.MethodGet, "/oauth/mobile/login?handle=user.bsky.social", nil) 116 rec := httptest.NewRecorder() 117 118 handler.HandleMobileLogin(rec, req) 119 120 assert.Equal(t, http.StatusBadRequest, rec.Code) 121 assert.Contains(t, rec.Body.String(), "redirect_uri") 122 }) 123 124 t.Run("invalid redirect_uri (https)", func(t *testing.T) { 125 req := httptest.NewRequest(http.MethodGet, "/oauth/mobile/login?handle=user.bsky.social&redirect_uri=https://example.com", nil) 126 rec := httptest.NewRecorder() 127 128 handler.HandleMobileLogin(rec, req) 129 130 assert.Equal(t, http.StatusBadRequest, rec.Code) 131 assert.Contains(t, rec.Body.String(), "invalid redirect_uri") 132 }) 133 134 t.Run("invalid redirect_uri (wrong path)", func(t *testing.T) { 135 req := httptest.NewRequest(http.MethodGet, "/oauth/mobile/login?handle=user.bsky.social&redirect_uri=coves-app://callback", nil) 136 rec := httptest.NewRecorder() 137 138 handler.HandleMobileLogin(rec, req) 139 140 assert.Equal(t, http.StatusBadRequest, rec.Code) 141 assert.Contains(t, rec.Body.String(), "invalid redirect_uri") 142 }) 143 144 t.Run("valid mobile redirect_uri (Universal Link)", func(t *testing.T) { 145 req := httptest.NewRequest(http.MethodGet, "/oauth/mobile/login?handle=user.bsky.social&redirect_uri=https://coves.social/app/oauth/callback", nil) 146 rec := httptest.NewRecorder() 147 148 handler.HandleMobileLogin(rec, req) 149 150 // Should fail to resolve handle but accept the parameters 151 // Check that cookie was set 152 cookies := rec.Result().Cookies() 153 var found bool 154 for _, cookie := range cookies { 155 if cookie.Name == "mobile_redirect_uri" { 156 found = true 157 break 158 } 159 } 160 // May or may not set cookie depending on error handling 161 _ = found 162 }) 163} 164 165// TestParseSessionToken tests that we no longer use parseSessionToken 166// (removed in favor of sealed tokens) 167func TestParseSessionToken(t *testing.T) { 168 // This test is deprecated - we now use sealed tokens instead of plain "did:sessionID" format 169 // See TestSealAndUnsealSessionData for the new approach 170 t.Skip("parseSessionToken removed - we now use sealed tokens for security") 171} 172 173// TestIsMobileRedirectURI tests mobile redirect URI validation with EXACT URI matching 174// Per atproto spec, custom schemes must match client_id hostname in reverse-domain order 175func TestIsMobileRedirectURI(t *testing.T) { 176 tests := []struct { 177 uri string 178 expected bool 179 }{ 180 // Custom scheme per atproto spec (reverse domain of coves.social) 181 {"social.coves:/callback", true}, 182 {"social.coves://callback", true}, 183 {"social.coves:/oauth/callback", true}, 184 {"social.coves://oauth/callback", true}, 185 // Universal Link - allowed (strongest security) 186 {"https://coves.social/app/oauth/callback", true}, 187 // Wrong custom schemes - not reverse-domain of coves.social 188 {"coves-app://oauth/callback", false}, 189 {"coves://oauth/callback", false}, 190 {"coves.social://callback", false}, // Not reversed 191 {"myapp://oauth", false}, 192 // Wrong domain/scheme 193 {"https://example.com", false}, 194 {"http://localhost", false}, 195 {"", false}, 196 {"not-a-uri", false}, 197 } 198 199 for _, tt := range tests { 200 t.Run(tt.uri, func(t *testing.T) { 201 result := isAllowedMobileRedirectURI(tt.uri) 202 assert.Equal(t, tt.expected, result) 203 }) 204 } 205} 206 207// TestSealAndUnsealSessionData tests session data sealing/unsealing 208func TestSealAndUnsealSessionData(t *testing.T) { 209 config := &OAuthConfig{ 210 PublicURL: "https://coves.social", 211 Scopes: []string{"atproto"}, 212 DevMode: false, 213 AllowPrivateIPs: false, 214 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 215 } 216 217 client, err := NewOAuthClient(config, oauth.NewMemStore()) 218 require.NoError(t, err) 219 220 // Create test DID 221 did, err := testDID() 222 require.NoError(t, err) 223 224 sessionID := "test-session-123" 225 226 // Seal the session using the client method 227 sealed, err := client.SealSession(did.String(), sessionID, 24*time.Hour) 228 require.NoError(t, err) 229 assert.NotEmpty(t, sealed) 230 231 // Unseal the session using the client method 232 unsealed, err := client.UnsealSession(sealed) 233 require.NoError(t, err) 234 require.NotNil(t, unsealed) 235 236 // Verify data matches 237 assert.Equal(t, did.String(), unsealed.DID) 238 assert.Equal(t, sessionID, unsealed.SessionID) 239 assert.Greater(t, unsealed.ExpiresAt, int64(0)) 240} 241 242// testDID creates a test DID for testing 243func testDID() (*syntax.DID, error) { 244 did, err := syntax.ParseDID("did:plc:test123abc456def789") 245 if err != nil { 246 return nil, err 247 } 248 return &did, nil 249}