build a simple status app frontend that:
- uses quickslice as the backend (GraphQL API, OAuth, Jetstream ingestion)
- authenticates users via AT Protocol OAuth
- lets users set/view statuses stored as
io.zzstoatzz.status.recordrecords
we used the official quickslice-client-js SDK from the CDN to handle the OAuth flow (PKCE + DPoP).
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.
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.
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.
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: passcode.user_idhandle_refresh_token: passold_refresh_token.user_id
after this change, the OAuth flow works correctly.
- the
subclaim 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
- quickslice server running locally with loopback mode enabled
- quickslice-client-js SDK from CDN
- simple static site with client-side routing