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

feat: add sub claim to OAuth token response

The token endpoint now returns the user's DID as a 'sub' claim
in the token response. This is required by AT Protocol OAuth
clients to identify the authenticated user.

- Add sub parameter to token_response function
- Pass user_id from authorization code grant
- Pass user_id from refresh token grant
- Add tests for both grant types

Fixes: missing sub claim causing client SDK getUser() to return null

+483 -1
+338
dev-docs/plans/2025-12-13-add-sub-claim-to-token-response.md
··· 1 + # Add `sub` Claim to OAuth Token Response 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Return the user's DID as a `sub` claim in OAuth token responses so client SDKs can identify the authenticated user. 6 + 7 + **Architecture:** Modify the `token_response` function to accept an optional `sub` parameter and include it in the JSON response. Update both call sites (authorization code grant and refresh token grant) to pass the user's DID. 8 + 9 + **Tech Stack:** Gleam, wisp HTTP framework 10 + 11 + --- 12 + 13 + ## Task 1: Add Test for `sub` Claim in Token Response 14 + 15 + **Files:** 16 + - Modify: `server/test/oauth/token_test.gleam` 17 + 18 + **Step 1: Write the failing test** 19 + 20 + Add a new test at the end of the file that verifies the `sub` claim is present in a successful token response. This requires setting up a complete authorization code flow. 21 + 22 + ```gleam 23 + pub fn token_response_includes_sub_claim_test() { 24 + let assert Ok(exec) = test_helpers.create_test_db() 25 + let assert Ok(_) = test_helpers.create_all_tables(exec) 26 + 27 + // Create a public client 28 + let test_client = 29 + types.OAuthClient( 30 + client_id: "test-client", 31 + client_secret: None, 32 + client_name: "Test Client", 33 + redirect_uris: ["https://example.com/callback"], 34 + grant_types: [types.AuthorizationCode, types.RefreshToken], 35 + response_types: [types.Code], 36 + scope: Some("atproto"), 37 + token_endpoint_auth_method: types.AuthNone, 38 + client_type: types.Public, 39 + created_at: 0, 40 + updated_at: 0, 41 + metadata: "{}", 42 + access_token_expiration: 3600, 43 + refresh_token_expiration: 2_592_000, 44 + require_redirect_exact: True, 45 + registration_access_token: None, 46 + jwks: None, 47 + ) 48 + let assert Ok(_) = oauth_clients.insert(exec, test_client) 49 + 50 + // Create an authorization code with a DID as user_id 51 + let test_code = 52 + types.OAuthAuthorizationCode( 53 + code: "test-auth-code", 54 + client_id: "test-client", 55 + user_id: "did:plc:testuser123", 56 + session_id: Some("test-session"), 57 + session_iteration: Some(0), 58 + redirect_uri: "https://example.com/callback", 59 + scope: Some("atproto"), 60 + code_challenge: Some("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"), 61 + code_challenge_method: Some("S256"), 62 + created_at: 0, 63 + expires_at: test_helpers.future_timestamp(600), 64 + used: False, 65 + dpop_jkt: None, 66 + ) 67 + let assert Ok(_) = oauth_authorization_codes.insert(exec, test_code) 68 + 69 + // Exchange code for token 70 + let req = 71 + simulate.request(http.Post, "/oauth/token") 72 + |> simulate.header("content-type", "application/x-www-form-urlencoded") 73 + |> simulate.string_body( 74 + "grant_type=authorization_code&client_id=test-client&code=test-auth-code&redirect_uri=https://example.com/callback&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", 75 + ) 76 + 77 + let response = token.handle(req, exec, "http://localhost:8080") 78 + 79 + response.status |> should.equal(200) 80 + 81 + case response.body { 82 + wisp.Text(response_body) -> { 83 + // Verify sub claim is present with the user's DID 84 + response_body |> string.contains("\"sub\"") |> should.be_true 85 + response_body |> string.contains("did:plc:testuser123") |> should.be_true 86 + } 87 + _ -> should.fail() 88 + } 89 + } 90 + ``` 91 + 92 + **Step 2: Add required import** 93 + 94 + Add `oauth_authorization_codes` to the imports at the top of the test file: 95 + 96 + ```gleam 97 + import database/repositories/oauth_authorization_codes 98 + ``` 99 + 100 + **Step 3: Run test to verify it fails** 101 + 102 + Run: `cd server && gleam test` 103 + 104 + Expected: Test fails because `sub` is not in the response. 105 + 106 + **Step 4: Commit** 107 + 108 + ```bash 109 + git add server/test/oauth/token_test.gleam 110 + git commit -m "test: add failing test for sub claim in token response" 111 + ``` 112 + 113 + --- 114 + 115 + ## Task 2: Add `sub` Parameter to `token_response` Function 116 + 117 + **Files:** 118 + - Modify: `server/src/handlers/oauth/token.gleam:757-785` 119 + 120 + **Step 1: Update function signature** 121 + 122 + Change the `token_response` function to accept a `sub` parameter: 123 + 124 + ```gleam 125 + fn token_response( 126 + access_token: String, 127 + token_type: String, 128 + expires_in: Int, 129 + refresh_token: Option(String), 130 + scope: Option(String), 131 + sub: Option(String), 132 + ) -> wisp.Response { 133 + let base_fields = [ 134 + #("access_token", json.string(access_token)), 135 + #("token_type", json.string(token_type)), 136 + #("expires_in", json.int(expires_in)), 137 + ] 138 + 139 + let with_refresh = case refresh_token { 140 + Some(rt) -> list.append(base_fields, [#("refresh_token", json.string(rt))]) 141 + None -> base_fields 142 + } 143 + 144 + let with_scope = case scope { 145 + Some(s) -> list.append(with_refresh, [#("scope", json.string(s))]) 146 + None -> with_refresh 147 + } 148 + 149 + let with_sub = case sub { 150 + Some(s) -> list.append(with_scope, [#("sub", json.string(s))]) 151 + None -> with_scope 152 + } 153 + 154 + wisp.response(200) 155 + |> wisp.set_header("content-type", "application/json") 156 + |> wisp.set_header("cache-control", "no-store") 157 + |> wisp.set_header("pragma", "no-cache") 158 + |> wisp.set_body(wisp.Text(json.to_string(json.object(with_sub)))) 159 + } 160 + ``` 161 + 162 + **Step 2: Verify build fails** 163 + 164 + Run: `cd server && gleam build` 165 + 166 + Expected: Build fails because call sites don't pass the new `sub` argument. 167 + 168 + --- 169 + 170 + ## Task 3: Update Authorization Code Grant Call Site 171 + 172 + **Files:** 173 + - Modify: `server/src/handlers/oauth/token.gleam:370-376` 174 + 175 + **Step 1: Pass `code.user_id` as `sub`** 176 + 177 + Update the `token_response` call in the authorization code grant handler: 178 + 179 + ```gleam 180 + token_response( 181 + access_token_value, 182 + token_type_str, 183 + client.access_token_expiration, 184 + Some(refresh_token_value), 185 + code.scope, 186 + Some(code.user_id), 187 + ) 188 + ``` 189 + 190 + **Step 2: Verify build still fails** 191 + 192 + Run: `cd server && gleam build` 193 + 194 + Expected: Build still fails because refresh token grant call site not updated yet. 195 + 196 + --- 197 + 198 + ## Task 4: Update Refresh Token Grant Call Site 199 + 200 + **Files:** 201 + - Modify: `server/src/handlers/oauth/token.gleam:583-589` 202 + 203 + **Step 1: Pass `old_refresh_token.user_id` as `sub`** 204 + 205 + Update the `token_response` call in the refresh token grant handler: 206 + 207 + ```gleam 208 + token_response( 209 + new_access_token_value, 210 + token_type_str, 211 + client.access_token_expiration, 212 + Some(new_refresh_token_value), 213 + scope, 214 + Some(old_refresh_token.user_id), 215 + ) 216 + ``` 217 + 218 + **Step 2: Build and run tests** 219 + 220 + Run: `cd server && gleam build && gleam test` 221 + 222 + Expected: Build succeeds, all tests pass including the new `sub` claim test. 223 + 224 + **Step 3: Commit** 225 + 226 + ```bash 227 + git add server/src/handlers/oauth/token.gleam 228 + git commit -m "feat: add sub claim to OAuth token response 229 + 230 + The token endpoint now returns the user's DID as a 'sub' claim 231 + in the token response. This is required by AT Protocol OAuth 232 + clients to identify the authenticated user. 233 + 234 + Fixes: missing sub claim causing client SDK getUser() to return null" 235 + ``` 236 + 237 + --- 238 + 239 + ## Task 5: Add Test for `sub` Claim in Refresh Token Response 240 + 241 + **Files:** 242 + - Modify: `server/test/oauth/token_test.gleam` 243 + 244 + **Step 1: Write the test** 245 + 246 + Add a test to verify `sub` is also present when refreshing tokens: 247 + 248 + ```gleam 249 + pub fn refresh_token_response_includes_sub_claim_test() { 250 + let assert Ok(exec) = test_helpers.create_test_db() 251 + let assert Ok(_) = test_helpers.create_all_tables(exec) 252 + 253 + // Create a public client 254 + let test_client = 255 + types.OAuthClient( 256 + client_id: "test-client", 257 + client_secret: None, 258 + client_name: "Test Client", 259 + redirect_uris: ["https://example.com/callback"], 260 + grant_types: [types.AuthorizationCode, types.RefreshToken], 261 + response_types: [types.Code], 262 + scope: Some("atproto"), 263 + token_endpoint_auth_method: types.AuthNone, 264 + client_type: types.Public, 265 + created_at: 0, 266 + updated_at: 0, 267 + metadata: "{}", 268 + access_token_expiration: 3600, 269 + refresh_token_expiration: 2_592_000, 270 + require_redirect_exact: True, 271 + registration_access_token: None, 272 + jwks: None, 273 + ) 274 + let assert Ok(_) = oauth_clients.insert(exec, test_client) 275 + 276 + // Create a refresh token with a DID as user_id 277 + let test_refresh_token = 278 + types.OAuthRefreshToken( 279 + token: "test-refresh-token", 280 + access_token: "old-access-token", 281 + client_id: "test-client", 282 + user_id: "did:plc:refreshuser456", 283 + session_id: Some("test-session"), 284 + session_iteration: Some(0), 285 + scope: Some("atproto"), 286 + created_at: 0, 287 + expires_at: None, 288 + revoked: False, 289 + ) 290 + let assert Ok(_) = oauth_refresh_tokens.insert(exec, test_refresh_token) 291 + 292 + // Refresh the token 293 + let req = 294 + simulate.request(http.Post, "/oauth/token") 295 + |> simulate.header("content-type", "application/x-www-form-urlencoded") 296 + |> simulate.string_body( 297 + "grant_type=refresh_token&client_id=test-client&refresh_token=test-refresh-token", 298 + ) 299 + 300 + let response = token.handle(req, exec, "http://localhost:8080") 301 + 302 + response.status |> should.equal(200) 303 + 304 + case response.body { 305 + wisp.Text(response_body) -> { 306 + // Verify sub claim is present with the user's DID 307 + response_body |> string.contains("\"sub\"") |> should.be_true 308 + response_body |> string.contains("did:plc:refreshuser456") |> should.be_true 309 + } 310 + _ -> should.fail() 311 + } 312 + } 313 + ``` 314 + 315 + **Step 2: Run tests** 316 + 317 + Run: `cd server && gleam test` 318 + 319 + Expected: All tests pass. 320 + 321 + **Step 3: Commit** 322 + 323 + ```bash 324 + git add server/test/oauth/token_test.gleam 325 + git commit -m "test: verify sub claim in refresh token response" 326 + ``` 327 + 328 + --- 329 + 330 + ## Summary 331 + 332 + This fix adds a `sub` claim containing the user's DID to OAuth token responses. The change is minimal: 333 + 334 + 1. Add `sub: Option(String)` parameter to `token_response` function 335 + 2. Include `sub` in JSON response when present 336 + 3. Pass `code.user_id` (authorization code grant) or `old_refresh_token.user_id` (refresh token grant) to the function 337 + 338 + The user ID is already the DID (set in `atp_callback.gleam` from `session.did`), so no additional lookups are needed.
+9 -1
server/src/handlers/oauth/token.gleam
··· 373 373 client.access_token_expiration, 374 374 Some(refresh_token_value), 375 375 code.scope, 376 + Some(code.user_id), 376 377 ) 377 378 } 378 379 } ··· 586 587 client.access_token_expiration, 587 588 Some(new_refresh_token_value), 588 589 scope, 590 + Some(old_refresh_token.user_id), 589 591 ) 590 592 } 591 593 } ··· 760 762 expires_in: Int, 761 763 refresh_token: Option(String), 762 764 scope: Option(String), 765 + sub: Option(String), 763 766 ) -> wisp.Response { 764 767 let base_fields = [ 765 768 #("access_token", json.string(access_token)), ··· 777 780 None -> with_refresh 778 781 } 779 782 783 + let with_sub = case sub { 784 + Some(s) -> list.append(with_scope, [#("sub", json.string(s))]) 785 + None -> with_scope 786 + } 787 + 780 788 wisp.response(200) 781 789 |> wisp.set_header("content-type", "application/json") 782 790 |> wisp.set_header("cache-control", "no-store") 783 791 |> wisp.set_header("pragma", "no-cache") 784 - |> wisp.set_body(wisp.Text(json.to_string(json.object(with_scope)))) 792 + |> wisp.set_body(wisp.Text(json.to_string(json.object(with_sub)))) 785 793 }
+136
server/test/oauth/token_test.gleam
··· 1 + import database/repositories/oauth_authorization_code 1 2 import database/repositories/oauth_clients 2 3 import database/repositories/oauth_refresh_tokens 3 4 import database/types ··· 237 238 // Should NOT be 401 (auth passed, will fail on invalid code instead) 238 239 response.status |> should.not_equal(401) 239 240 } 241 + 242 + pub fn token_response_includes_sub_claim_test() { 243 + let assert Ok(exec) = test_helpers.create_test_db() 244 + let assert Ok(_) = test_helpers.create_all_tables(exec) 245 + 246 + // Create a confidential client (doesn't require DPoP) 247 + let test_client = 248 + types.OAuthClient( 249 + client_id: "test-client", 250 + client_secret: Some("test-secret"), 251 + client_name: "Test Client", 252 + redirect_uris: ["https://example.com/callback"], 253 + grant_types: [types.AuthorizationCode, types.RefreshToken], 254 + response_types: [types.Code], 255 + scope: Some("atproto"), 256 + token_endpoint_auth_method: types.ClientSecretPost, 257 + client_type: types.Confidential, 258 + created_at: 0, 259 + updated_at: 0, 260 + metadata: "{}", 261 + access_token_expiration: 3600, 262 + refresh_token_expiration: 2_592_000, 263 + require_redirect_exact: True, 264 + registration_access_token: None, 265 + jwks: None, 266 + ) 267 + let assert Ok(_) = oauth_clients.insert(exec, test_client) 268 + 269 + // Create an authorization code with a DID as user_id 270 + let test_code = 271 + types.OAuthAuthorizationCode( 272 + code: "test-auth-code", 273 + client_id: "test-client", 274 + user_id: "did:plc:testuser123", 275 + session_id: Some("test-session"), 276 + session_iteration: Some(0), 277 + redirect_uri: "https://example.com/callback", 278 + scope: Some("atproto"), 279 + code_challenge: Some("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"), 280 + code_challenge_method: Some(types.S256), 281 + nonce: None, 282 + created_at: 0, 283 + expires_at: 9_999_999_999, 284 + used: False, 285 + ) 286 + let assert Ok(_) = oauth_authorization_code.insert(exec, test_code) 287 + 288 + // Exchange code for token (include client_secret for confidential client) 289 + let req = 290 + simulate.request(http.Post, "/oauth/token") 291 + |> simulate.header("content-type", "application/x-www-form-urlencoded") 292 + |> simulate.string_body( 293 + "grant_type=authorization_code&client_id=test-client&client_secret=test-secret&code=test-auth-code&redirect_uri=https://example.com/callback&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", 294 + ) 295 + 296 + let response = token.handle(req, exec, "http://localhost:8080") 297 + 298 + response.status |> should.equal(200) 299 + 300 + case response.body { 301 + wisp.Text(response_body) -> { 302 + // Verify sub claim is present with the user's DID 303 + response_body |> string.contains("\"sub\"") |> should.be_true 304 + response_body |> string.contains("did:plc:testuser123") |> should.be_true 305 + } 306 + _ -> should.fail() 307 + } 308 + } 309 + 310 + pub fn refresh_token_response_includes_sub_claim_test() { 311 + let assert Ok(exec) = test_helpers.create_test_db() 312 + let assert Ok(_) = test_helpers.create_all_tables(exec) 313 + 314 + // Create a confidential client (doesn't require DPoP) 315 + let test_client = 316 + types.OAuthClient( 317 + client_id: "test-client", 318 + client_secret: Some("test-secret"), 319 + client_name: "Test Client", 320 + redirect_uris: ["https://example.com/callback"], 321 + grant_types: [types.AuthorizationCode, types.RefreshToken], 322 + response_types: [types.Code], 323 + scope: Some("atproto"), 324 + token_endpoint_auth_method: types.ClientSecretPost, 325 + client_type: types.Confidential, 326 + created_at: 0, 327 + updated_at: 0, 328 + metadata: "{}", 329 + access_token_expiration: 3600, 330 + refresh_token_expiration: 2_592_000, 331 + require_redirect_exact: True, 332 + registration_access_token: None, 333 + jwks: None, 334 + ) 335 + let assert Ok(_) = oauth_clients.insert(exec, test_client) 336 + 337 + // Create a refresh token with a DID as user_id 338 + let test_refresh_token = 339 + types.OAuthRefreshToken( 340 + token: "test-refresh-token", 341 + access_token: "old-access-token", 342 + client_id: "test-client", 343 + user_id: "did:plc:refreshuser456", 344 + session_id: Some("test-session"), 345 + session_iteration: Some(0), 346 + scope: Some("atproto"), 347 + created_at: 0, 348 + expires_at: None, 349 + revoked: False, 350 + ) 351 + let assert Ok(_) = oauth_refresh_tokens.insert(exec, test_refresh_token) 352 + 353 + // Refresh the token (include client_secret for confidential client) 354 + let req = 355 + simulate.request(http.Post, "/oauth/token") 356 + |> simulate.header("content-type", "application/x-www-form-urlencoded") 357 + |> simulate.string_body( 358 + "grant_type=refresh_token&client_id=test-client&client_secret=test-secret&refresh_token=test-refresh-token", 359 + ) 360 + 361 + let response = token.handle(req, exec, "http://localhost:8080") 362 + 363 + response.status |> should.equal(200) 364 + 365 + case response.body { 366 + wisp.Text(response_body) -> { 367 + // Verify sub claim is present with the user's DID 368 + response_body |> string.contains("\"sub\"") |> should.be_true 369 + response_body 370 + |> string.contains("did:plc:refreshuser456") 371 + |> should.be_true 372 + } 373 + _ -> should.fail() 374 + } 375 + }