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