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// 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}