Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(oauth): prevent session iteration drift causing invalid refresh token

Update ATP sessions in place instead of creating new iteration rows.
This fixes "Invalid refresh token" errors that occurred when client
tokens drifted from the current ATP session iteration.

Changes:
- Add update_tokens function to oauth_atp_sessions repository
- Replace increment_iteration with update_session_tokens in bridge
- Remove session_iteration update from atproto_auth refresh flow
- Make oauth_atp_sessions.get ignore iteration parameter
- Set session_iteration to constant Some(0) in new tokens

+260 -39
+5
CHANGELOG.md
··· 11 11 - Add `scope` parameter to `QuicksliceClientOptions` for setting default OAuth scope 12 12 - Add `scope` option to `loginWithRedirect()` for per-login scope override 13 13 14 + ## v0.17.4 15 + 16 + ### Fixed 17 + - Fix "Invalid refresh token" error caused by session iteration drift after ATP token refresh 18 + 14 19 ## v0.17.3 15 20 16 21 ### Fixed
+195
dev-docs/plans/2025-12-15-fix-session-iteration-drift.md
··· 1 + # Fix Session Iteration Drift 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix "Invalid refresh token" error that occurs after several hours when the client's session_iteration drifts from the current ATP session iteration. 6 + 7 + **Tech Stack:** Gleam, SQLite/PostgreSQL 8 + 9 + --- 10 + 11 + ## Problem 12 + 13 + When ATP tokens refresh, a new session row is created with `iteration + 1`. The current client access token gets updated to point to the new iteration, but the client refresh token retains the old iteration. When the client later refreshes its tokens, the new tokens inherit the stale iteration and look up an old ATP session with an invalidated refresh token. 14 + 15 + **Failure sequence:** 16 + 1. User logs in → ATP session `iteration=1` → client tokens with `session_iteration=1` 17 + 2. ATP access token expires → server refreshes → creates `iteration=2` → updates client access token 18 + 3. Client access token expires → client refreshes → new tokens created from refresh token with `session_iteration=1` 19 + 4. New client access token looks up ATP session `iteration=1` → finds old, invalidated refresh token 20 + 5. Tries to refresh with stale token → "Invalid refresh token" 21 + 22 + --- 23 + 24 + ## Solution 25 + 26 + Update ATP sessions in place instead of creating new iterations. The iteration mechanism adds complexity without benefit. 27 + 28 + **Architecture change:** 29 + - Current: `session_id + iteration` = primary key, each refresh creates new row 30 + - New: `session_id` = lookup key, refresh updates existing row 31 + 32 + --- 33 + 34 + ## Tasks 35 + 36 + ### Task 1: Add update_tokens function to oauth_atp_sessions repository 37 + 38 + **File:** `server/src/database/repositories/oauth_atp_sessions.gleam` 39 + 40 + Add new function: 41 + 42 + ```gleam 43 + pub fn update_tokens( 44 + exec: Executor, 45 + session_id: String, 46 + access_token: String, 47 + refresh_token: String, 48 + created_at: Int, 49 + expires_at: Int, 50 + ) -> Result(Nil, DbError) { 51 + let sql = "UPDATE oauth_atp_session SET 52 + access_token = " <> placeholder(exec, 1) <> ", 53 + refresh_token = " <> placeholder(exec, 2) <> ", 54 + access_token_created_at = " <> placeholder(exec, 3) <> ", 55 + access_token_expires_at = " <> placeholder(exec, 4) <> " 56 + WHERE session_id = " <> placeholder(exec, 5) 57 + 58 + executor.exec(exec, sql, [ 59 + Text(access_token), 60 + Text(refresh_token), 61 + DbInt(created_at), 62 + DbInt(expires_at), 63 + Text(session_id), 64 + ]) 65 + } 66 + ``` 67 + 68 + **Verify:** `gleam build` 69 + 70 + --- 71 + 72 + ### Task 2: Change bridge.gleam to update in place 73 + 74 + **File:** `server/src/lib/oauth/atproto/bridge.gleam` 75 + 76 + Replace `increment_iteration` function (lines 589-614) with `update_session_tokens`: 77 + 78 + ```gleam 79 + fn update_session_tokens( 80 + conn: Executor, 81 + session: OAuthAtpSession, 82 + access_token: String, 83 + refresh_token: String, 84 + expires_in: Int, 85 + ) -> Result(OAuthAtpSession, BridgeError) { 86 + let now = token_generator.current_timestamp() 87 + let expires_at = now + expires_in 88 + 89 + case oauth_atp_sessions.update_tokens( 90 + conn, 91 + session.session_id, 92 + access_token, 93 + refresh_token, 94 + now, 95 + expires_at, 96 + ) { 97 + Ok(_) -> Ok(OAuthAtpSession( 98 + ..session, 99 + access_token: Some(access_token), 100 + refresh_token: Some(refresh_token), 101 + access_token_created_at: Some(now), 102 + access_token_expires_at: Some(expires_at), 103 + )) 104 + Error(err) -> Error(StorageError("Failed to update session: " <> string.inspect(err))) 105 + } 106 + } 107 + ``` 108 + 109 + Update the call site in `refresh_tokens` (around line 222) to call `update_session_tokens` instead of `increment_iteration`. 110 + 111 + **Verify:** `gleam build` 112 + 113 + --- 114 + 115 + ### Task 3: Remove session_iteration update from atproto_auth.gleam 116 + 117 + **File:** `server/src/atproto_auth.gleam` 118 + 119 + Remove lines 175-181 (the `update_session_iteration` call). The session is now updated in place, so there's no new iteration to track. 120 + 121 + ```gleam 122 + // DELETE THIS BLOCK: 123 + // Update the access token's session_iteration to point to the new ATP session 124 + let _ = 125 + oauth_access_tokens.update_session_iteration( 126 + conn, 127 + token, 128 + refreshed.iteration, 129 + ) 130 + ``` 131 + 132 + **Verify:** `gleam build` 133 + 134 + --- 135 + 136 + ### Task 4: Modify oauth_atp_sessions.get to ignore iteration 137 + 138 + **File:** `server/src/database/repositories/oauth_atp_sessions.gleam` 139 + 140 + Change `get` function to look up by `session_id` only. Either: 141 + - Remove iteration parameter, or 142 + - Keep parameter but ignore it in the query (for backwards compatibility during rollout) 143 + 144 + Recommended: Keep parameter, ignore in query: 145 + 146 + ```gleam 147 + pub fn get( 148 + exec: Executor, 149 + session_id: String, 150 + _iteration: Int, // Deprecated, ignored 151 + ) -> Result(Option(OAuthAtpSession), DbError) { 152 + // Query by session_id only, ORDER BY iteration DESC LIMIT 1 153 + // to get the latest if multiple exist during migration 154 + } 155 + ``` 156 + 157 + **Verify:** `gleam build && gleam test` 158 + 159 + --- 160 + 161 + ### Task 5: Set session_iteration to constant in token.gleam 162 + 163 + **File:** `server/src/handlers/oauth/token.gleam` 164 + 165 + When creating new access/refresh tokens, set `session_iteration` to `Some(0)` instead of copying from old tokens. Search for `session_iteration:` and update to: 166 + 167 + ```gleam 168 + session_iteration: Some(0), // Deprecated field 169 + ``` 170 + 171 + **Verify:** `gleam build && gleam test` 172 + 173 + --- 174 + 175 + ### Task 6: Database cleanup migration (optional, can defer) 176 + 177 + Clean up old iteration rows: 178 + 179 + ```sql 180 + -- Keep only the latest iteration per session_id 181 + DELETE FROM oauth_atp_session 182 + WHERE (session_id, iteration) NOT IN ( 183 + SELECT session_id, MAX(iteration) 184 + FROM oauth_atp_session 185 + GROUP BY session_id 186 + ); 187 + ``` 188 + 189 + --- 190 + 191 + ## Testing 192 + 193 + 1. Build: `gleam build` 194 + 2. Run tests: `gleam test` 195 + 3. Manual test: Login, wait for token expirations, verify no "Invalid refresh token" error
+1 -10
server/src/atproto_auth.gleam
··· 171 171 signing_key, 172 172 ) 173 173 { 174 - Ok(refreshed) -> { 175 - // Update the access token's session_iteration to point to the new ATP session 176 - let _ = 177 - oauth_access_tokens.update_session_iteration( 178 - conn, 179 - token, 180 - refreshed.iteration, 181 - ) 182 - Ok(refreshed) 183 - } 174 + Ok(refreshed) -> Ok(refreshed) 184 175 Error(err) -> Error(RefreshFailed(string.inspect(err))) 185 176 } 186 177 }
+31 -9
server/src/database/repositories/oauth_atp_sessions.gleam
··· 71 71 executor.exec(exec, sql, params) 72 72 } 73 73 74 - /// Get an ATP session by session_id and iteration 74 + /// Get an ATP session by session_id (iteration parameter deprecated, ignored) 75 75 pub fn get( 76 76 exec: Executor, 77 77 session_id: String, 78 - iteration: Int, 78 + _iteration: Int, 79 79 ) -> Result(Option(OAuthAtpSession), DbError) { 80 + // Query by session_id only, ORDER BY iteration DESC LIMIT 1 81 + // to get the latest if multiple exist during migration 80 82 let sql = 81 83 "SELECT session_id, iteration, did, session_created_at, atp_oauth_state, 82 84 signing_key_jkt, dpop_key, access_token, refresh_token, 83 85 access_token_created_at, access_token_expires_at, access_token_scopes, 84 86 session_exchanged_at, exchange_error 85 87 FROM oauth_atp_session 86 - WHERE session_id = " <> executor.placeholder(exec, 1) <> " AND iteration = " <> executor.placeholder( 87 - exec, 88 - 2, 89 - ) 88 + WHERE session_id = " <> executor.placeholder(exec, 1) <> " ORDER BY iteration DESC LIMIT 1" 90 89 91 - case 92 - executor.query(exec, sql, [Text(session_id), DbInt(iteration)], decoder()) 93 - { 90 + case executor.query(exec, sql, [Text(session_id)], decoder()) { 94 91 Ok(rows) -> 95 92 case list.first(rows) { 96 93 Ok(session) -> Ok(Some(session)) ··· 144 141 } 145 142 Error(err) -> Error(err) 146 143 } 144 + } 145 + 146 + /// Update tokens for an existing ATP session (in place, no new iteration) 147 + pub fn update_tokens( 148 + exec: Executor, 149 + session_id: String, 150 + access_token: String, 151 + refresh_token: String, 152 + created_at: Int, 153 + expires_at: Int, 154 + ) -> Result(Nil, DbError) { 155 + let sql = "UPDATE oauth_atp_session SET 156 + access_token = " <> executor.placeholder(exec, 1) <> ", 157 + refresh_token = " <> executor.placeholder(exec, 2) <> ", 158 + access_token_created_at = " <> executor.placeholder(exec, 3) <> ", 159 + access_token_expires_at = " <> executor.placeholder(exec, 4) <> " 160 + WHERE session_id = " <> executor.placeholder(exec, 5) 161 + 162 + executor.exec(exec, sql, [ 163 + Text(access_token), 164 + Text(refresh_token), 165 + DbInt(created_at), 166 + DbInt(expires_at), 167 + Text(session_id), 168 + ]) 147 169 } 148 170 149 171 /// Decode ATP session from database row
+4 -4
server/src/handlers/oauth/token.gleam
··· 304 304 client_id: cid, 305 305 user_id: Some(code.user_id), 306 306 session_id: code.session_id, 307 - session_iteration: code.session_iteration, 307 + session_iteration: Some(0), 308 308 scope: code.scope, 309 309 created_at: now, 310 310 expires_at: token_generator.expiration_timestamp( ··· 321 321 client_id: cid, 322 322 user_id: code.user_id, 323 323 session_id: code.session_id, 324 - session_iteration: code.session_iteration, 324 + session_iteration: Some(0), 325 325 scope: code.scope, 326 326 created_at: now, 327 327 expires_at: case ··· 513 513 client_id: cid, 514 514 user_id: Some(old_refresh_token.user_id), 515 515 session_id: old_refresh_token.session_id, 516 - session_iteration: old_refresh_token.session_iteration, 516 + session_iteration: Some(0), 517 517 scope: scope, 518 518 created_at: now, 519 519 expires_at: token_generator.expiration_timestamp( ··· 530 530 client_id: cid, 531 531 user_id: old_refresh_token.user_id, 532 532 session_id: old_refresh_token.session_id, 533 - session_iteration: old_refresh_token.session_iteration, 533 + session_iteration: Some(0), 534 534 scope: scope, 535 535 created_at: now, 536 536 expires_at: case
+24 -16
server/src/lib/oauth/atproto/bridge.gleam
··· 218 218 auth_server.issuer, 219 219 )) 220 220 221 - // Increment session iteration with new tokens 222 - increment_iteration( 221 + // Update session tokens in place 222 + update_session_tokens( 223 223 conn, 224 224 session, 225 225 token_response.access_token, ··· 586 586 } 587 587 } 588 588 589 - /// Increment session iteration with new tokens (for refresh) 590 - fn increment_iteration( 589 + /// Update session tokens in place (for refresh) 590 + fn update_session_tokens( 591 591 conn: Executor, 592 592 session: OAuthAtpSession, 593 593 access_token: String, ··· 597 597 let now = token_generator.current_timestamp() 598 598 let expires_at = now + expires_in 599 599 600 - let new_session = 601 - OAuthAtpSession( 602 - ..session, 603 - iteration: session.iteration + 1, 604 - access_token: Some(access_token), 605 - refresh_token: Some(refresh_token), 606 - access_token_created_at: Some(now), 607 - access_token_expires_at: Some(expires_at), 600 + case 601 + oauth_atp_sessions.update_tokens( 602 + conn, 603 + session.session_id, 604 + access_token, 605 + refresh_token, 606 + now, 607 + expires_at, 608 608 ) 609 - 610 - case oauth_atp_sessions.insert(conn, new_session) { 611 - Ok(_) -> Ok(new_session) 609 + { 610 + Ok(_) -> 611 + Ok( 612 + OAuthAtpSession( 613 + ..session, 614 + access_token: Some(access_token), 615 + refresh_token: Some(refresh_token), 616 + access_token_created_at: Some(now), 617 + access_token_expires_at: Some(expires_at), 618 + ), 619 + ) 612 620 Error(err) -> 613 - Error(StorageError("Failed to increment session: " <> string.inspect(err))) 621 + Error(StorageError("Failed to update session: " <> string.inspect(err))) 614 622 } 615 623 }