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.
Prerequisites
Section titled “Prerequisites”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-client1.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.
-
Exchange the token. Send the user’s access token as
subject_tokenand the Chat API resource identifier asresource, never the Audience. Ask forchat: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 grantclientSecret: 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 audiencescope: '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;}}Terminal window curl -s -X POST "$CCS_ISSUER_URL/oauth/token" \-u "$CCS_CHAT_SYNC_CLIENT_ID:$CCS_CHAT_SYNC_CLIENT_SECRET" \--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \--data-urlencode "subject_token=$USER_TOKEN" \--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \--data-urlencode "resource=$CHAT_API_RESOURCE" \--data-urlencode "scope=chat:access"{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 600, "scope": "chat:access" } -
Expose it as a route. Put it behind your normal access-token check and answer
{ access_token, expires_in }withCache-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;}}); -
Let the app cache the token. The app renews it a minute before
expires_inand passes agetAccessTokenbacked by this route to the Chat libraries. See Get the Chat token in your app.
What you get
Section titled “What you get”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.
Caching
Section titled “Caching”- 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.
Errors
Section titled “Errors”| 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.
Verify
Section titled “Verify”Call your route with a signed-in user’s token, then use the result against Chat:
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.
Troubleshooting
Section titled “Troubleshooting”| 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. |