Skip to content

Issue Chat tokens to your users

Give your web or mobile app a Chat token for the signed-in user without a second login. Your backend swaps the user’s token for your API for a User token for Chat, using Token exchange (RFC 8693), and your app fetches it from a route of yours.

User scope: chat:access
  • A backend client with the token-exchange grant and an exchange permission for chat:access. See Enable token exchange.
  • @central-code-station/ccs-identity-client 1.5.0 or later. Any language can send the raw request instead; see Other languages.
  • Your app holds the user’s access token for your own API, and your backend already verifies it.
  1. Exchange the token. Send the user’s access token as subject_token and the Chat API resource identifier as resource, never the Audience. Ask for chat:access.

    issue-chat-token.ts
    import { CcsIdentityClient, CcsOAuthError } from '@central-code-station/ccs-identity-client';
    const identity = new CcsIdentityClient({
    issuerUrl: process.env.CCS_ISSUER_URL!,
    clientId: process.env.CCS_CHAT_SYNC_CLIENT_ID!, // the backend client, with the token-exchange grant
    clientSecret: process.env.CCS_CHAT_SYNC_CLIENT_SECRET!,
    });
    export interface ChatToken {
    access_token: string;
    expires_in: number;
    }
    export class ChatTokenError extends Error {
    constructor(readonly status: number, readonly code: string) {
    super(code);
    }
    }
    // Call this from GET /chat-token, behind your normal access-token check.
    // userAccessToken is the signed-in user's own token for your API, already verified.
    export async function issueChatToken(userAccessToken: string): Promise<ChatToken> {
    try {
    const { access_token, expires_in } = await identity.exchangeToken({
    subjectToken: userAccessToken,
    resource: process.env.CHAT_API_RESOURCE!, // the identifier, never the audience
    scope: 'chat:access',
    });
    return { access_token, expires_in };
    } catch (error) {
    // invalid_grant: the user's token expired or the session ended. access_denied: no access.
    if (error instanceof CcsOAuthError) throw new ChatTokenError(401, error.error);
    throw error;
    }
    }
  2. Expose it as a route. Put it behind your normal access-token check and answer { access_token, expires_in } with Cache-Control: no-store.

    app.get('/chat-token', requireUser, async (req, res) => {
    try {
    res.set('Cache-Control', 'no-store').json(await issueChatToken(bearerOf(req)));
    } catch (error) {
    if (error instanceof ChatTokenError) return res.status(error.status).json({ error: error.code });
    throw error;
    }
    });
  3. Let the app cache the token. The app renews it a minute before expires_in and passes a getAccessToken backed by this route to the Chat libraries. See Get the Chat token in your app.

The token is an ordinary user token: sub is the user, aud is chat-api, scope is chat:access, and client_id is your backend client. It also carries act, the delegation marker. It lives at most 10 minutes, and no longer than the user’s own token. There is no refresh token.

Chat treats it like any user token: it checks the user’s Chat permissions and conversation membership on every call. A token issued to your application’s client works only in your application’s context. Anything else answers 403 client_context_mismatch.

  • Do not exchange on every request. The Identity Server rate-limits exchanges per client and per user, so let the app cache the token and call the route about once per token lifetime.
  • If your backend caches tokens, key them by user, expire them before expires_in, and never share one between users or log it.
  • Logging out, disabling the user or removing them from the tenant stops further exchanges. Tokens already issued expire within minutes.
Identity Server error Meaning What your route does
invalid_grant The user’s own token expired, was revoked, or the session ended. Answer 401. The app renews its own token and asks again.
access_denied The user is disabled, not a tenant member, or lost access to the application. Answer 401 or 403. Do not retry.
unauthorized_client, invalid_target, invalid_scope, unsupported_grant_type Your setup, not the user. Answer 500 and alert. See Enable token exchange.

The subject token must be your own API’s access token. An ID token, or a token for the identity-userinfo audience, is refused with invalid_grant.

Call your route with a signed-in user’s token, then use the result against Chat:

Terminal window
curl -s "$CHAT_API_URL/tenants/$CCS_TENANT_ID/applications/$CCS_APPLICATION_ID/me/capabilities" \
-H "Authorization: Bearer $CHAT_USER_TOKEN"

A 200 response lists the user’s roles and Chat permissions. Decode the token and check aud and scope as shown in Local development.

Symptom Cause and fix
401 invalid_token from Chat The token’s aud is not chat-api, it has expired, or it lacks chat:access. Exchange with the resource identifier and scope=chat:access.
403 client_context_mismatch The URL names another tenant or application than your backend client’s.
403 missing_capability The user’s role lacks the Chat permission. See Grant permissions to roles.
The route returns 401 invalid_grant for every user The app sends an ID token, or a token for another audience, as the subject.