Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

fix: complete AT Protocol token refresh implementation

- Add client_id to refresh token request body (required by PDS)
- Pass correct AT Protocol client_id (metadata URL) through call chain
- Update access token's session_iteration after successful refresh
- Add atp_client_id to server Context for DRY reuse

The PDS expects the AT Protocol client_id (metadata URL like
http://host/oauth-client-metadata.json), not the Quickslice OAuth
client_id. After refresh, the access token must point to the new
ATP session iteration to avoid "refresh token replayed" errors.

+241 -8
+94
dev-docs/plans/2025-12-10-fix-refresh-token-client-id.md
··· 1 + # Fix Refresh Token Missing client_id Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix token refresh failure by adding missing `client_id` to AT Protocol PDS refresh requests. 6 + 7 + **Architecture:** The `refresh_tokens` function in `bridge.gleam` builds a request body to send to the upstream AT Protocol PDS, but omits the required `client_id` parameter. The fix adds `client_id` to match how `exchange_code_for_tokens` builds its request body. 8 + 9 + **Tech Stack:** Gleam, AT Protocol OAuth 10 + 11 + --- 12 + 13 + ## Root Cause 14 + 15 + In `server/src/lib/oauth/atproto/bridge.gleam:204`, the refresh token request body is: 16 + 17 + ```gleam 18 + let body = "grant_type=refresh_token" <> "&refresh_token=" <> refresh_token 19 + ``` 20 + 21 + The `client_id` parameter is passed to the function but never included in the body. The AT Protocol PDS requires `client_id` and returns: 22 + 23 + ``` 24 + Client credentials missing: Required at body.client_id 25 + ``` 26 + 27 + --- 28 + 29 + ### Task 1: Add client_id to refresh token request body 30 + 31 + **Files:** 32 + - Modify: `server/src/lib/oauth/atproto/bridge.gleam:204` 33 + 34 + **Step 1: Edit the body construction** 35 + 36 + Change line 204 from: 37 + 38 + ```gleam 39 + let body = "grant_type=refresh_token" <> "&refresh_token=" <> refresh_token 40 + ``` 41 + 42 + To: 43 + 44 + ```gleam 45 + let body = 46 + "grant_type=refresh_token" 47 + <> "&refresh_token=" 48 + <> uri.percent_encode(refresh_token) 49 + <> "&client_id=" 50 + <> uri.percent_encode(client_id) 51 + ``` 52 + 53 + Note: Also adding `uri.percent_encode` for consistency with `exchange_code_for_tokens` (line 351-360). 54 + 55 + **Step 2: Build to verify syntax** 56 + 57 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 58 + Expected: Build succeeds with no errors 59 + 60 + **Step 3: Run existing tests** 61 + 62 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 63 + Expected: All tests pass (this is an internal function, existing tests should still pass) 64 + 65 + **Step 4: Commit** 66 + 67 + ```bash 68 + git add server/src/lib/oauth/atproto/bridge.gleam 69 + git commit -m "fix: add client_id to AT Protocol refresh token requests 70 + 71 + The refresh_tokens function was missing client_id in the request body 72 + sent to the upstream AT Protocol PDS. This caused token refresh to fail 73 + with 'Client credentials missing: Required at body.client_id'. 74 + 75 + Also added uri.percent_encode for consistency with exchange_code_for_tokens." 76 + ``` 77 + 78 + --- 79 + 80 + ### Task 2: Manual verification (optional) 81 + 82 + **Step 1: Test in browser** 83 + 84 + 1. Start the quickslice server 85 + 2. Log in via the JS client 86 + 3. Wait for token to expire (or manually clear `quickslice_access_token` from localStorage) 87 + 4. Perform a mutation that triggers token refresh 88 + 5. Verify mutation succeeds without "Token refresh failed" error 89 + 90 + --- 91 + 92 + ## Summary 93 + 94 + This is a one-line fix (plus formatting). The `client_id` parameter was already being passed to `refresh_tokens` but wasn't being included in the HTTP request body sent to the AT Protocol PDS.
+12 -2
server/src/atproto_auth.gleam
··· 115 115 did_cache: Subject(did_cache.Message), 116 116 token: String, 117 117 signing_key: Option(String), 118 + atp_client_id: String, 118 119 ) -> Result(AtprotoSession, AuthError) { 119 120 // Look up access token to get session_id and iteration 120 121 use access_token <- result.try(case oauth_access_tokens.get(conn, token) { ··· 166 167 conn, 167 168 did_cache, 168 169 atp_session, 169 - access_token.client_id, 170 + atp_client_id, 170 171 signing_key, 171 172 ) 172 173 { 173 - Ok(refreshed) -> Ok(refreshed) 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 184 Error(err) -> Error(RefreshFailed(string.inspect(err))) 175 185 } 176 186 }
+18
server/src/database/repositories/oauth_access_tokens.gleam
··· 119 119 } 120 120 } 121 121 122 + /// Update session iteration for an access token (after ATP token refresh) 123 + pub fn update_session_iteration( 124 + conn: sqlight.Connection, 125 + token_value: String, 126 + new_iteration: Int, 127 + ) -> Result(Nil, sqlight.Error) { 128 + let sql = 129 + "UPDATE oauth_access_token SET session_iteration = ? WHERE token = ?" 130 + 131 + use _ <- result.try(sqlight.query( 132 + sql, 133 + on: conn, 134 + with: [sqlight.int(new_iteration), sqlight.text(token_value)], 135 + expecting: decode.dynamic, 136 + )) 137 + Ok(Nil) 138 + } 139 + 122 140 /// Revoke an access token 123 141 pub fn revoke( 124 142 conn: sqlight.Connection,
+4
server/src/graphql_gleam.gleam
··· 40 40 db: sqlight.Connection, 41 41 did_cache: Subject(did_cache.Message), 42 42 signing_key: option.Option(String), 43 + atp_client_id: String, 43 44 plc_url: String, 44 45 domain_authority: String, 45 46 ) -> Result(schema.Schema, String) { ··· 381 382 db: db, 382 383 did_cache: did_cache, 383 384 signing_key: signing_key, 385 + atp_client_id: atp_client_id, 384 386 plc_url: plc_url, 385 387 collection_ids: collection_ids, 386 388 external_collection_ids: external_collection_ids, ··· 490 492 auth_token: Result(String, Nil), 491 493 did_cache: Subject(did_cache.Message), 492 494 signing_key: option.Option(String), 495 + atp_client_id: String, 493 496 plc_url: String, 494 497 ) -> Result(String, String) { 495 498 // Get domain authority from database ··· 503 506 db, 504 507 did_cache, 505 508 signing_key, 509 + atp_client_id, 506 510 plc_url, 507 511 domain_authority, 508 512 ))
+25 -2
server/src/handlers/graphql.gleam
··· 27 27 db: sqlight.Connection, 28 28 did_cache: Subject(did_cache.Message), 29 29 signing_key: option.Option(String), 30 + atp_client_id: String, 30 31 plc_url: String, 31 32 ) -> wisp.Response { 32 33 case req.method { 33 - http.Post -> handle_graphql_post(req, db, did_cache, signing_key, plc_url) 34 - http.Get -> handle_graphql_get(req, db, did_cache, signing_key, plc_url) 34 + http.Post -> 35 + handle_graphql_post( 36 + req, 37 + db, 38 + did_cache, 39 + signing_key, 40 + atp_client_id, 41 + plc_url, 42 + ) 43 + http.Get -> 44 + handle_graphql_get( 45 + req, 46 + db, 47 + did_cache, 48 + signing_key, 49 + atp_client_id, 50 + plc_url, 51 + ) 35 52 _ -> method_not_allowed_response() 36 53 } 37 54 } ··· 41 58 db: sqlight.Connection, 42 59 did_cache: Subject(did_cache.Message), 43 60 signing_key: option.Option(String), 61 + atp_client_id: String, 44 62 plc_url: String, 45 63 ) -> wisp.Response { 46 64 // Extract Authorization header (optional for queries, required for mutations) ··· 64 82 auth_token, 65 83 did_cache, 66 84 signing_key, 85 + atp_client_id, 67 86 plc_url, 68 87 ) 69 88 } ··· 82 101 db: sqlight.Connection, 83 102 did_cache: Subject(did_cache.Message), 84 103 signing_key: option.Option(String), 104 + atp_client_id: String, 85 105 plc_url: String, 86 106 ) -> wisp.Response { 87 107 // Extract Authorization header (optional for queries, required for mutations) ··· 101 121 auth_token, 102 122 did_cache, 103 123 signing_key, 124 + atp_client_id, 104 125 plc_url, 105 126 ) 106 127 Error(_) -> bad_request_response("Missing 'query' parameter") ··· 114 135 auth_token: Result(String, Nil), 115 136 did_cache: Subject(did_cache.Message), 116 137 signing_key: option.Option(String), 138 + atp_client_id: String, 117 139 plc_url: String, 118 140 ) -> wisp.Response { 119 141 // Use the new pure Gleam GraphQL implementation ··· 125 147 auth_token, 126 148 did_cache, 127 149 signing_key, 150 + atp_client_id, 128 151 plc_url, 129 152 ) 130 153 {
+2
server/src/handlers/graphql_ws.gleam
··· 184 184 db: sqlight.Connection, 185 185 did_cache: Subject(did_cache.Message), 186 186 signing_key: Option(String), 187 + atp_client_id: String, 187 188 plc_url: String, 188 189 domain_authority: String, 189 190 ) -> response.Response(ResponseData) { ··· 198 199 db, 199 200 did_cache, 200 201 signing_key, 202 + atp_client_id, 201 203 plc_url, 202 204 domain_authority, 203 205 )
+2
server/src/lib/mcp/tools/graphql.gleam
··· 23 23 // No auth token for MCP queries 24 24 did_cache, 25 25 signing_key, 26 + "", 27 + // Empty atp_client_id - MCP queries don't do mutations that need ATP refresh 26 28 plc_url, 27 29 )) 28 30
+6 -1
server/src/lib/oauth/atproto/bridge.gleam
··· 201 201 )) 202 202 203 203 // Build refresh request 204 - let body = "grant_type=refresh_token" <> "&refresh_token=" <> refresh_token 204 + let body = 205 + "grant_type=refresh_token" 206 + <> "&refresh_token=" 207 + <> uri.percent_encode(refresh_token) 208 + <> "&client_id=" 209 + <> uri.percent_encode(client_id) 205 210 206 211 // Make token request 207 212 use token_response <- result.try(fetch_tokens(
+5
server/src/mutation_resolvers.gleam
··· 30 30 db: sqlight.Connection, 31 31 did_cache: Subject(did_cache.Message), 32 32 signing_key: option.Option(String), 33 + atp_client_id: String, 33 34 plc_url: String, 34 35 collection_ids: List(String), 35 36 external_collection_ids: List(String), ··· 360 361 ctx.did_cache, 361 362 token, 362 363 ctx.signing_key, 364 + ctx.atp_client_id, 363 365 ) 364 366 |> result.map_error(fn(err) { 365 367 case err { ··· 575 577 ctx.did_cache, 576 578 token, 577 579 ctx.signing_key, 580 + ctx.atp_client_id, 578 581 ) 579 582 |> result.map_error(fn(err) { 580 583 case err { ··· 764 767 ctx.did_cache, 765 768 token, 766 769 ctx.signing_key, 770 + ctx.atp_client_id, 767 771 ) 768 772 |> result.map_error(fn(err) { 769 773 case err { ··· 903 907 ctx.did_cache, 904 908 token, 905 909 ctx.signing_key, 910 + ctx.atp_client_id, 906 911 ) 907 912 |> result.map_error(fn(err) { 908 913 case err {
+15
server/src/server.gleam
··· 58 58 did_cache: process.Subject(did_cache.Message), 59 59 oauth_signing_key: option.Option(String), 60 60 oauth_loopback_mode: Bool, 61 + /// AT Protocol client_id for OAuth (metadata URL or loopback client_id) 62 + atp_client_id: String, 61 63 ) 62 64 } 63 65 ··· 378 380 let assert Ok(did_cache_subject) = did_cache.start() 379 381 logging.log(logging.Info, "[server] DID cache actor initialized") 380 382 383 + // Compute ATP client_id once (used for token refresh) 384 + let atp_client_id = case oauth_loopback_mode { 385 + True -> 386 + build_loopback_client_id( 387 + external_base_url <> "/oauth/atp/callback", 388 + "atproto transition:generic", 389 + ) 390 + False -> external_base_url <> "/oauth-client-metadata.json" 391 + } 392 + 381 393 let ctx = 382 394 Context( 383 395 db: db, ··· 387 399 did_cache: did_cache_subject, 388 400 oauth_signing_key: oauth_signing_key, 389 401 oauth_loopback_mode: oauth_loopback_mode, 402 + atp_client_id: atp_client_id, 390 403 ) 391 404 392 405 let handler = fn(req) { handle_request(req, ctx, static_directory) } ··· 426 439 ctx.db, 427 440 ctx.did_cache, 428 441 ctx.oauth_signing_key, 442 + ctx.atp_client_id, 429 443 config_repo.get_plc_directory_url(ctx.db), 430 444 domain_authority, 431 445 ) ··· 527 541 ctx.db, 528 542 ctx.did_cache, 529 543 ctx.oauth_signing_key, 544 + ctx.atp_client_id, 530 545 config_repo.get_plc_directory_url(ctx.db), 531 546 ) 532 547 ["graphiql"] ->
+3 -3
server/test/atproto_auth_test.gleam
··· 155 155 let assert Ok(cache) = did_cache.start() 156 156 157 157 let result = 158 - atproto_auth.get_atp_session(conn, cache, "no-session-token", None) 158 + atproto_auth.get_atp_session(conn, cache, "no-session-token", None, "") 159 159 160 160 result |> should.be_error 161 161 let assert Error(err) = result ··· 205 205 let assert Ok(cache) = did_cache.start() 206 206 207 207 let result = 208 - atproto_auth.get_atp_session(conn, cache, "has-session-token", None) 208 + atproto_auth.get_atp_session(conn, cache, "has-session-token", None, "") 209 209 210 210 result |> should.be_error 211 211 let assert Error(err) = result ··· 254 254 let assert Ok(cache) = did_cache.start() 255 255 256 256 let result = 257 - atproto_auth.get_atp_session(conn, cache, "error-session-token", None) 257 + atproto_auth.get_atp_session(conn, cache, "error-session-token", None, "") 258 258 259 259 result |> should.be_error 260 260 let assert Error(err) = result
+4
server/test/blob_integration_test.gleam
··· 126 126 db, 127 127 cache, 128 128 option.None, 129 + "", 129 130 "https://plc.directory", 130 131 ) 131 132 ··· 222 223 db, 223 224 cache, 224 225 option.None, 226 + "", 225 227 "https://plc.directory", 226 228 ) 227 229 ··· 293 295 db, 294 296 cache, 295 297 option.None, 298 + "", 296 299 "https://plc.directory", 297 300 ) 298 301 ··· 356 359 db, 357 360 cache, 358 361 option.None, 362 + "", 359 363 "https://plc.directory", 360 364 ) 361 365
+10
server/test/graphql_aggregation_integration_test.gleam
··· 267 267 db, 268 268 cache, 269 269 None, 270 + "", 270 271 "https://plc.directory", 271 272 ) 272 273 ··· 315 316 db, 316 317 cache, 317 318 None, 319 + "", 318 320 "https://plc.directory", 319 321 ) 320 322 ··· 360 362 db, 361 363 cache2, 362 364 None, 365 + "", 363 366 "https://plc.directory", 364 367 ) 365 368 ··· 389 392 db, 390 393 cache3, 391 394 None, 395 + "", 392 396 "https://plc.directory", 393 397 ) 394 398 ··· 419 423 db, 420 424 cache, 421 425 None, 426 + "", 422 427 "https://plc.directory", 423 428 ) 424 429 ··· 476 481 db, 477 482 cache, 478 483 None, 484 + "", 479 485 "https://plc.directory", 480 486 ) 481 487 ··· 518 524 db, 519 525 cache, 520 526 None, 527 + "", 521 528 "https://plc.directory", 522 529 ) 523 530 ··· 560 567 db, 561 568 cache, 562 569 None, 570 + "", 563 571 "https://plc.directory", 564 572 ) 565 573 ··· 658 666 db, 659 667 cache, 660 668 None, 669 + "", 661 670 "https://plc.directory", 662 671 ) 663 672 ··· 702 711 db, 703 712 cache, 704 713 None, 714 + "", 705 715 "https://plc.directory", 706 716 ) 707 717
+11
server/test/graphql_handler_integration_test.gleam
··· 177 177 db, 178 178 cache, 179 179 None, 180 + "", 180 181 "https://plc.directory", 181 182 ) 182 183 ··· 251 252 db, 252 253 cache, 253 254 None, 255 + "", 254 256 "https://plc.directory", 255 257 ) 256 258 ··· 293 295 db, 294 296 cache, 295 297 None, 298 + "", 296 299 "https://plc.directory", 297 300 ) 298 301 ··· 329 332 db, 330 333 cache, 331 334 None, 335 + "", 332 336 "https://plc.directory", 333 337 ) 334 338 ··· 369 373 db, 370 374 cache, 371 375 None, 376 + "", 372 377 "https://plc.directory", 373 378 ) 374 379 ··· 401 406 db, 402 407 cache, 403 408 None, 409 + "", 404 410 "https://plc.directory", 405 411 ) 406 412 ··· 501 507 db, 502 508 cache1, 503 509 None, 510 + "", 504 511 "https://plc.directory", 505 512 ) 506 513 ··· 553 560 db, 554 561 cache2, 555 562 None, 563 + "", 556 564 "https://plc.directory", 557 565 ) 558 566 ··· 622 630 db, 623 631 cache, 624 632 None, 633 + "", 625 634 "https://plc.directory", 626 635 ) 627 636 ··· 736 745 db, 737 746 cache, 738 747 None, 748 + "", 739 749 "https://plc.directory", 740 750 ) 741 751 ··· 852 862 db, 853 863 cache, 854 864 None, 865 + "", 855 866 "https://plc.directory", 856 867 ) 857 868
+4
server/test/graphql_introspection_did_join_test.gleam
··· 134 134 db, 135 135 cache, 136 136 option.None, 137 + "", 137 138 "https://plc.directory", 138 139 ) 139 140 ··· 222 223 db, 223 224 cache, 224 225 option.None, 226 + "", 225 227 "https://plc.directory", 226 228 ) 227 229 ··· 391 393 db, 392 394 cache, 393 395 option.None, 396 + "", 394 397 "https://plc.directory", 395 398 ) 396 399 ··· 515 518 db, 516 519 cache, 517 520 option.None, 521 + "", 518 522 "https://plc.directory", 519 523 ) 520 524
+4
server/test/graphql_total_count_test.gleam
··· 145 145 db, 146 146 cache, 147 147 option.None, 148 + "", 148 149 "https://plc.directory", 149 150 ) 150 151 ··· 253 254 db, 254 255 cache, 255 256 option.None, 257 + "", 256 258 "https://plc.directory", 257 259 ) 258 260 ··· 315 317 db, 316 318 cache, 317 319 option.None, 320 + "", 318 321 "https://plc.directory", 319 322 ) 320 323 ··· 390 393 db, 391 394 cache, 392 395 option.None, 396 + "", 393 397 "https://plc.directory", 394 398 ) 395 399
+10
server/test/join_integration_test.gleam
··· 233 233 Error(Nil), 234 234 cache, 235 235 option.None, 236 + "", 236 237 "https://plc.directory", 237 238 ) 238 239 ··· 330 331 Error(Nil), 331 332 cache, 332 333 option.None, 334 + "", 333 335 "https://plc.directory", 334 336 ) 335 337 ··· 444 446 Error(Nil), 445 447 cache, 446 448 option.None, 449 + "", 447 450 "https://plc.directory", 448 451 ) 449 452 ··· 569 572 Error(Nil), 570 573 cache, 571 574 option.None, 575 + "", 572 576 "https://plc.directory", 573 577 ) 574 578 ··· 698 702 Error(Nil), 699 703 cache, 700 704 option.None, 705 + "", 701 706 "https://plc.directory", 702 707 ) 703 708 ··· 837 842 Error(Nil), 838 843 cache, 839 844 option.None, 845 + "", 840 846 "https://plc.directory", 841 847 ) 842 848 ··· 986 992 Error(Nil), 987 993 cache, 988 994 option.None, 995 + "", 989 996 "https://plc.directory", 990 997 ) 991 998 ··· 1103 1110 Error(Nil), 1104 1111 cache, 1105 1112 option.None, 1113 + "", 1106 1114 "https://plc.directory", 1107 1115 ) 1108 1116 ··· 1238 1246 Error(Nil), 1239 1247 cache, 1240 1248 option.None, 1249 + "", 1241 1250 "https://plc.directory", 1242 1251 ) 1243 1252 ··· 1499 1508 Error(Nil), 1500 1509 cache, 1501 1510 option.None, 1511 + "", 1502 1512 "https://plc.directory", 1503 1513 ) 1504 1514
+5
server/test/nested_join_sortby_where_test.gleam
··· 204 204 Error(Nil), 205 205 cache, 206 206 option.None, 207 + "", 207 208 "https://plc.directory", 208 209 ) 209 210 ··· 349 350 Error(Nil), 350 351 cache, 351 352 option.None, 353 + "", 352 354 "https://plc.directory", 353 355 ) 354 356 ··· 469 471 Error(Nil), 470 472 cache, 471 473 option.None, 474 + "", 472 475 "https://plc.directory", 473 476 ) 474 477 ··· 598 601 Error(Nil), 599 602 cache, 600 603 option.None, 604 + "", 601 605 "https://plc.directory", 602 606 ) 603 607 ··· 767 771 Error(Nil), 768 772 cache, 769 773 option.None, 774 + "", 770 775 "https://plc.directory", 771 776 ) 772 777
+4
server/test/paginated_join_test.gleam
··· 236 236 Error(Nil), 237 237 cache, 238 238 option.None, 239 + "", 239 240 "https://plc.directory", 240 241 ) 241 242 ··· 354 355 Error(Nil), 355 356 cache, 356 357 option.None, 358 + "", 357 359 "https://plc.directory", 358 360 ) 359 361 ··· 470 472 Error(Nil), 471 473 cache, 472 474 option.None, 475 + "", 473 476 "https://plc.directory", 474 477 ) 475 478 ··· 587 590 Error(Nil), 588 591 cache, 589 592 option.None, 593 + "", 590 594 "https://plc.directory", 591 595 ) 592 596
+3
server/test/reverse_join_field_resolution_test.gleam
··· 351 351 Error(Nil), 352 352 cache, 353 353 option.None, 354 + "", 354 355 "https://plc.directory", 355 356 ) 356 357 ··· 432 433 Error(Nil), 433 434 cache, 434 435 option.None, 436 + "", 435 437 "https://plc.directory", 436 438 ) 437 439 ··· 593 595 Error(Nil), 594 596 cache, 595 597 option.None, 598 + "", 596 599 "https://plc.directory", 597 600 ) 598 601