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}