Skip to content

Instantly share code, notes, and snippets.

@zzstoatzz
Created December 13, 2025 21:29
Show Gist options
  • Select an option

  • Save zzstoatzz/a9ec1a8a864ac29bd9eb8b28577b69e7 to your computer and use it in GitHub Desktop.

Select an option

Save zzstoatzz/a9ec1a8a864ac29bd9eb8b28577b69e7 to your computer and use it in GitHub Desktop.
quickslice OAuth integration experience - missing sub claim in token response

quickslice OAuth integration: our experience

what we were trying to do

build a simple status app frontend that:

  1. uses quickslice as the backend (GraphQL API, OAuth, Jetstream ingestion)
  2. authenticates users via AT Protocol OAuth
  3. lets users set/view statuses stored as io.zzstoatzz.status.record records

we used the official quickslice-client-js SDK from the CDN to handle the OAuth flow (PKCE + DPoP).

what happened

the OAuth flow appeared to complete successfully:

  • user enters handle, clicks login
  • redirects to PDS for authorization
  • PDS redirects back with ?code=...
  • SDK exchanges code for tokens (200 OK response from /oauth/token)
  • ...but user ends up back at the login screen

no errors in the console. the token exchange succeeded. but client.getUser() returned null, which triggered the "not authenticated" UI path.

what we were confused by

we spent a while chasing red herrings:

  • thought it might be CORS (it wasn't - redirects don't involve CORS)
  • thought it might be the PDS (it wasn't - works fine with other apps)
  • thought tokens might be in sessionStorage vs localStorage (they use both, but that wasn't the issue)
  • checked browser storage repeatedly - it was empty after the OAuth flow

the SDK's handleRedirectCallback() returned true (success), but nothing was stored.

what we found

after cloning the quickslice source and reading the SDK code, we traced the issue:

SDK side (quickslice-client-js/src/auth/tokens.ts):

export function storeTokens(tokens: {...}): void {
  storage.set(STORAGE_KEYS.accessToken, tokens.access_token);
  if (tokens.refresh_token) {
    storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token);
  }
  // userDid is only set if tokens.sub exists
  if (tokens.sub) {
    storage.set(STORAGE_KEYS.userDid, tokens.sub);
  }
}

Server side (server/src/handlers/oauth/token.gleam):

fn token_response(
  access_token: String,
  token_type: String,
  expires_in: Int,
  refresh_token: Option(String),
  scope: Option(String),
) -> wisp.Response {
  let base_fields = [
    #("access_token", json.string(access_token)),
    #("token_type", json.string(token_type)),
    #("expires_in", json.int(expires_in)),
  ]
  // ... no "sub" field
}

the token endpoint returns access_token, token_type, expires_in, refresh_token, and scope - but not sub.

the SDK expects sub (the user's DID) in the token response. without it, userDid never gets stored, so getUser() returns null.

what we had to do

we modified token.gleam to include sub in the token response:

fn token_response(
  access_token: String,
  token_type: String,
  expires_in: Int,
  refresh_token: Option(String),
  scope: Option(String),
  sub: String,  // added this parameter
) -> wisp.Response {
  let base_fields = [
    #("access_token", json.string(access_token)),
    #("token_type", json.string(token_type)),
    #("expires_in", json.int(expires_in)),
    #("sub", json.string(sub)),  // added this field
  ]
  // ...
}

and updated both call sites to pass the user's DID:

  • handle_authorization_code: pass code.user_id
  • handle_refresh_token: pass old_refresh_token.user_id

after this change, the OAuth flow works correctly.

notes

  • the sub claim is standard in OAuth 2.0 token responses (RFC 9068) to identify the resource owner
  • the data was already available server-side (code.user_id / refresh_token.user_id), it just wasn't being included in the response
  • this might be an oversight, or there might be a reason it was omitted that we're not aware of

environment

  • quickslice server running locally with loopback mode enabled
  • quickslice-client-js SDK from CDN
  • simple static site with client-side routing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment