A community based topic aggregation platform built on atproto
1package oauth
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "time"
8)
9
10// PostgresSessionStore implements SessionStore using PostgreSQL
11type PostgresSessionStore struct {
12 db *sql.DB
13}
14
15// NewPostgresSessionStore creates a new PostgreSQL-backed session store
16func NewPostgresSessionStore(db *sql.DB) SessionStore {
17 return &PostgresSessionStore{db: db}
18}
19
20// SaveRequest stores a temporary OAuth request state
21func (s *PostgresSessionStore) SaveRequest(req *OAuthRequest) error {
22 query := `
23 INSERT INTO oauth_requests (
24 state, did, handle, pds_url, pkce_verifier,
25 dpop_private_jwk, dpop_authserver_nonce, auth_server_iss, return_url
26 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
27 `
28
29 _, err := s.db.Exec(
30 query,
31 req.State,
32 req.DID,
33 req.Handle,
34 req.PDSURL,
35 req.PKCEVerifier,
36 req.DPoPPrivateJWK,
37 req.DPoPAuthServerNonce,
38 req.AuthServerIss,
39 req.ReturnURL,
40 )
41
42 if err != nil {
43 return fmt.Errorf("failed to save OAuth request: %w", err)
44 }
45
46 return nil
47}
48
49// GetRequestByState retrieves an OAuth request by state parameter
50func (s *PostgresSessionStore) GetRequestByState(state string) (*OAuthRequest, error) {
51 query := `
52 SELECT
53 state, did, handle, pds_url, pkce_verifier,
54 dpop_private_jwk, dpop_authserver_nonce, auth_server_iss,
55 COALESCE(return_url, ''), created_at
56 FROM oauth_requests
57 WHERE state = $1
58 `
59
60 var req OAuthRequest
61 err := s.db.QueryRow(query, state).Scan(
62 &req.State,
63 &req.DID,
64 &req.Handle,
65 &req.PDSURL,
66 &req.PKCEVerifier,
67 &req.DPoPPrivateJWK,
68 &req.DPoPAuthServerNonce,
69 &req.AuthServerIss,
70 &req.ReturnURL,
71 &req.CreatedAt,
72 )
73
74 if err == sql.ErrNoRows {
75 return nil, fmt.Errorf("OAuth request not found for state: %s", state)
76 }
77 if err != nil {
78 return nil, fmt.Errorf("failed to get OAuth request: %w", err)
79 }
80
81 return &req, nil
82}
83
84// GetAndDeleteRequest atomically retrieves and deletes an OAuth request to prevent replay attacks
85// This ensures the state parameter can only be used once
86func (s *PostgresSessionStore) GetAndDeleteRequest(state string) (*OAuthRequest, error) {
87 query := `
88 DELETE FROM oauth_requests
89 WHERE state = $1
90 RETURNING
91 state, did, handle, pds_url, pkce_verifier,
92 dpop_private_jwk, dpop_authserver_nonce, auth_server_iss,
93 COALESCE(return_url, ''), created_at
94 `
95
96 var req OAuthRequest
97 err := s.db.QueryRow(query, state).Scan(
98 &req.State,
99 &req.DID,
100 &req.Handle,
101 &req.PDSURL,
102 &req.PKCEVerifier,
103 &req.DPoPPrivateJWK,
104 &req.DPoPAuthServerNonce,
105 &req.AuthServerIss,
106 &req.ReturnURL,
107 &req.CreatedAt,
108 )
109
110 if err == sql.ErrNoRows {
111 return nil, fmt.Errorf("OAuth request not found or already used: %s", state)
112 }
113 if err != nil {
114 return nil, fmt.Errorf("failed to get and delete OAuth request: %w", err)
115 }
116
117 return &req, nil
118}
119
120// DeleteRequest removes an OAuth request (cleanup after callback)
121func (s *PostgresSessionStore) DeleteRequest(state string) error {
122 query := `DELETE FROM oauth_requests WHERE state = $1`
123
124 _, err := s.db.Exec(query, state)
125 if err != nil {
126 return fmt.Errorf("failed to delete OAuth request: %w", err)
127 }
128
129 return nil
130}
131
132// SaveSession stores a new OAuth session (upsert on DID)
133func (s *PostgresSessionStore) SaveSession(session *OAuthSession) error {
134 query := `
135 INSERT INTO oauth_sessions (
136 did, handle, pds_url, access_token, refresh_token,
137 dpop_private_jwk, dpop_authserver_nonce, dpop_pds_nonce,
138 auth_server_iss, expires_at
139 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
140 ON CONFLICT (did) DO UPDATE SET
141 handle = EXCLUDED.handle,
142 pds_url = EXCLUDED.pds_url,
143 access_token = EXCLUDED.access_token,
144 refresh_token = EXCLUDED.refresh_token,
145 dpop_private_jwk = EXCLUDED.dpop_private_jwk,
146 dpop_authserver_nonce = EXCLUDED.dpop_authserver_nonce,
147 dpop_pds_nonce = EXCLUDED.dpop_pds_nonce,
148 auth_server_iss = EXCLUDED.auth_server_iss,
149 expires_at = EXCLUDED.expires_at,
150 updated_at = CURRENT_TIMESTAMP
151 `
152
153 _, err := s.db.Exec(
154 query,
155 session.DID,
156 session.Handle,
157 session.PDSURL,
158 session.AccessToken,
159 session.RefreshToken,
160 session.DPoPPrivateJWK,
161 session.DPoPAuthServerNonce,
162 session.DPoPPDSNonce,
163 session.AuthServerIss,
164 session.ExpiresAt,
165 )
166
167 if err != nil {
168 return fmt.Errorf("failed to save OAuth session: %w", err)
169 }
170
171 return nil
172}
173
174// GetSession retrieves an OAuth session by DID
175func (s *PostgresSessionStore) GetSession(did string) (*OAuthSession, error) {
176 query := `
177 SELECT
178 did, handle, pds_url, access_token, refresh_token,
179 dpop_private_jwk,
180 COALESCE(dpop_authserver_nonce, ''),
181 COALESCE(dpop_pds_nonce, ''),
182 auth_server_iss, expires_at, created_at, updated_at
183 FROM oauth_sessions
184 WHERE did = $1
185 `
186
187 var session OAuthSession
188 err := s.db.QueryRow(query, did).Scan(
189 &session.DID,
190 &session.Handle,
191 &session.PDSURL,
192 &session.AccessToken,
193 &session.RefreshToken,
194 &session.DPoPPrivateJWK,
195 &session.DPoPAuthServerNonce,
196 &session.DPoPPDSNonce,
197 &session.AuthServerIss,
198 &session.ExpiresAt,
199 &session.CreatedAt,
200 &session.UpdatedAt,
201 )
202
203 if err == sql.ErrNoRows {
204 return nil, fmt.Errorf("session not found for DID: %s", did)
205 }
206 if err != nil {
207 return nil, fmt.Errorf("failed to get OAuth session: %w", err)
208 }
209
210 return &session, nil
211}
212
213// UpdateSession updates an existing OAuth session
214func (s *PostgresSessionStore) UpdateSession(session *OAuthSession) error {
215 query := `
216 UPDATE oauth_sessions SET
217 handle = $2,
218 pds_url = $3,
219 access_token = $4,
220 refresh_token = $5,
221 dpop_private_jwk = $6,
222 dpop_authserver_nonce = $7,
223 dpop_pds_nonce = $8,
224 auth_server_iss = $9,
225 expires_at = $10,
226 updated_at = CURRENT_TIMESTAMP
227 WHERE did = $1
228 `
229
230 result, err := s.db.Exec(
231 query,
232 session.DID,
233 session.Handle,
234 session.PDSURL,
235 session.AccessToken,
236 session.RefreshToken,
237 session.DPoPPrivateJWK,
238 session.DPoPAuthServerNonce,
239 session.DPoPPDSNonce,
240 session.AuthServerIss,
241 session.ExpiresAt,
242 )
243
244 if err != nil {
245 return fmt.Errorf("failed to update OAuth session: %w", err)
246 }
247
248 rows, err := result.RowsAffected()
249 if err != nil {
250 return fmt.Errorf("failed to check rows affected: %w", err)
251 }
252 if rows == 0 {
253 return fmt.Errorf("session not found for DID: %s", session.DID)
254 }
255
256 return nil
257}
258
259// DeleteSession removes an OAuth session (logout)
260func (s *PostgresSessionStore) DeleteSession(did string) error {
261 query := `DELETE FROM oauth_sessions WHERE did = $1`
262
263 _, err := s.db.Exec(query, did)
264 if err != nil {
265 return fmt.Errorf("failed to delete OAuth session: %w", err)
266 }
267
268 return nil
269}
270
271// RefreshSession updates access and refresh tokens after a token refresh
272func (s *PostgresSessionStore) RefreshSession(did, newAccessToken, newRefreshToken string, expiresAt time.Time) error {
273 query := `
274 UPDATE oauth_sessions SET
275 access_token = $2,
276 refresh_token = $3,
277 expires_at = $4,
278 updated_at = CURRENT_TIMESTAMP
279 WHERE did = $1
280 `
281
282 result, err := s.db.Exec(query, did, newAccessToken, newRefreshToken, expiresAt)
283 if err != nil {
284 return fmt.Errorf("failed to refresh OAuth session: %w", err)
285 }
286
287 rows, err := result.RowsAffected()
288 if err != nil {
289 return fmt.Errorf("failed to check rows affected: %w", err)
290 }
291 if rows == 0 {
292 return fmt.Errorf("session not found for DID: %s", did)
293 }
294
295 return nil
296}
297
298// UpdateAuthServerNonce updates the DPoP nonce for the auth server token endpoint
299func (s *PostgresSessionStore) UpdateAuthServerNonce(did, nonce string) error {
300 query := `
301 UPDATE oauth_sessions SET
302 dpop_authserver_nonce = $2,
303 updated_at = CURRENT_TIMESTAMP
304 WHERE did = $1
305 `
306
307 _, err := s.db.Exec(query, did, nonce)
308 if err != nil {
309 return fmt.Errorf("failed to update auth server nonce: %w", err)
310 }
311
312 return nil
313}
314
315// UpdatePDSNonce updates the DPoP nonce for PDS requests
316func (s *PostgresSessionStore) UpdatePDSNonce(did, nonce string) error {
317 query := `
318 UPDATE oauth_sessions SET
319 dpop_pds_nonce = $2,
320 updated_at = CURRENT_TIMESTAMP
321 WHERE did = $1
322 `
323
324 _, err := s.db.Exec(query, did, nonce)
325 if err != nil {
326 return fmt.Errorf("failed to update PDS nonce: %w", err)
327 }
328
329 return nil
330}
331
332// CleanupExpiredRequests removes OAuth requests older than 30 minutes
333// Should be called periodically (e.g., via cron job or background goroutine)
334func (s *PostgresSessionStore) CleanupExpiredRequests(ctx context.Context) error {
335 query := `DELETE FROM oauth_requests WHERE created_at < NOW() - INTERVAL '30 minutes'`
336
337 _, err := s.db.ExecContext(ctx, query)
338 if err != nil {
339 return fmt.Errorf("failed to cleanup expired requests: %w", err)
340 }
341
342 return nil
343}
344
345// CleanupExpiredSessions removes OAuth sessions that have been expired for > 7 days
346// Gives users time to refresh their tokens before permanent deletion
347func (s *PostgresSessionStore) CleanupExpiredSessions(ctx context.Context) error {
348 query := `DELETE FROM oauth_sessions WHERE expires_at < NOW() - INTERVAL '7 days'`
349
350 _, err := s.db.ExecContext(ctx, query)
351 if err != nil {
352 return fmt.Errorf("failed to cleanup expired sessions: %w", err)
353 }
354
355 return nil
356}