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// 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// Only Universal Links (HTTPS) are allowed - custom schemes are blocked for security 175func TestIsMobileRedirectURI(t *testing.T) { 176 tests := []struct { 177 uri string 178 expected bool 179 }{ 180 {"https://coves.social/app/oauth/callback", true}, // Universal Link - allowed 181 {"coves-app://oauth/callback", false}, // Custom scheme - blocked (insecure) 182 {"coves://oauth/callback", false}, // Custom scheme - blocked (insecure) 183 {"coves-app://callback", false}, // Custom scheme - blocked 184 {"coves://oauth", false}, // Custom scheme - blocked 185 {"myapp://oauth", false}, // Not in allowlist 186 {"https://example.com", false}, // Wrong domain 187 {"http://localhost", false}, // HTTP not allowed 188 {"", false}, 189 {"not-a-uri", false}, 190 } 191 192 for _, tt := range tests { 193 t.Run(tt.uri, func(t *testing.T) { 194 result := isAllowedMobileRedirectURI(tt.uri) 195 assert.Equal(t, tt.expected, result) 196 }) 197 } 198} 199 200// TestSealAndUnsealSessionData tests session data sealing/unsealing 201func TestSealAndUnsealSessionData(t *testing.T) { 202 config := &OAuthConfig{ 203 PublicURL: "https://coves.social", 204 Scopes: []string{"atproto"}, 205 DevMode: false, 206 AllowPrivateIPs: false, 207 SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 208 } 209 210 client, err := NewOAuthClient(config, oauth.NewMemStore()) 211 require.NoError(t, err) 212 213 // Create test DID 214 did, err := testDID() 215 require.NoError(t, err) 216 217 sessionID := "test-session-123" 218 219 // Seal the session using the client method 220 sealed, err := client.SealSession(did.String(), sessionID, 24*time.Hour) 221 require.NoError(t, err) 222 assert.NotEmpty(t, sealed) 223 224 // Unseal the session using the client method 225 unsealed, err := client.UnsealSession(sealed) 226 require.NoError(t, err) 227 require.NotNil(t, unsealed) 228 229 // Verify data matches 230 assert.Equal(t, did.String(), unsealed.DID) 231 assert.Equal(t, sessionID, unsealed.SessionID) 232 assert.Greater(t, unsealed.ExpiresAt, int64(0)) 233} 234 235// testDID creates a test DID for testing 236func testDID() (*syntax.DID, error) { 237 did, err := syntax.ParseDID("did:plc:test123abc456def789") 238 if err != nil { 239 return nil, err 240 } 241 return &did, nil 242}