Exchange token
Background
Shukah issues an opaque token. The auth service issues a Locate JWT and refuses anything else on the
/api/v1/clients/* routes that need a login: a Shukah token gets 401. Exchange token is the bridge
between the two. It exists only on the auth service. Shukah has no equivalent, so no Shukah API maps
to it.
During the migration the app can hold a token for one system and need to call the other. This endpoint turns the token it holds into one that the target system accepts.
Feature
Send the token you hold and the system you are about to call (target_platform: shukah or
locate). You do not need to know what kind of token you hold: the service decides from its shape.
Three dot-separated parts is a Locate JWT, and anything else is a Shukah token.
| Token you send | target_platform | What you get back |
|---|---|---|
| Locate JWT | locate | The same JWT, unchanged. It is not checked. |
| Shukah token | shukah | The same token, unchanged. It is not checked. |
| Locate JWT | shukah | A Shukah token stored for that client. The JWT is checked first. |
| Shukah token | locate | A new Locate JWT for the client that owns the Shukah token. |
The last row is the only one that writes. Each call creates a new Locate JWT, stores it, and pushes it to Shukah when the client has a Shukah id and the outbound push is enabled. The Locate tokens it stores are never deleted.
The auth service's draft flow chart (2026-07-08, see Sources under Details) says how the app should
use it. At start the app reads the auth_source client setting. If it has a saved token, it calls
exchange token with that token and target_platform set to auth_source. If it has none, it logs in
with OTP on the matching system. That flow chart is a draft, and it still shows the old
/maeda/api/exchange-token path.
The app does not do this in the snapshot of 2026-02-26. No code in the mobile app calls exchange
token, and the app has no auth-service base URL and no auth_source setting. So a user who holds a
Shukah token and calls Verify token on Locate gets 401, and the app
then deletes its stored token, which signs the user out. Whether the current app build, which is
newer than the snapshot, calls this endpoint: Not documented yet.
POST /api/v1/clients/exchange-token
Request
No Authorization header is needed or read: the token to convert goes in the JSON body.
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
token | body | string | yes | Must not be empty. Three dot-separated parts counts as a Locate JWT. Nothing else about the token is checked at this step. A non-empty value that is not a string fails with 401. | The token the app holds. |
target_platform | body | string | yes | Exactly "shukah" or "locate", in lower case. Anything else gives 400. | The system the app is about to call. |
Content-Type | header | string | yes | application/json. Without it the body is not read, and the call gives 400. | — |
Response
200 with the token for the target system:
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
status | integer | no | Always 200. | — |
data.token | string | no | A Locate JWT when target_platform is locate. A Shukah token when it is shukah. | The token for the target system. |
{
"status": 200,
"data": {
"token": "<locate-jwt>"
}
}
Captured on dev, 2026-10-01 (client 356259; shape only): a Shukah token sent with target_platform: "locate". The token value is a placeholder, because tokens are never stored in this repo.
{
"status": 200,
"data": {
"token": "<shukah-token>"
}
}
Captured on dev, 2026-10-01 (client 356259; shape only): that Locate JWT sent back with target_platform: "shukah". A Shukah token sent with target_platform: "shukah" came back unchanged.
A Locate JWT sent with target_platform: "shukah" can come back as {"status":200,"data":{}}, when the
stored Shukah token has no raw copy. Which environments this affects: Not documented yet (see Details).
Errors
| HTTP | Body | When |
|---|---|---|
400 | {"status":400,"message":"Invalid token or target_platform"} | token is missing or empty, or target_platform is not shukah or locate, or the request has no Content-Type: application/json. |
400 | {"status":400,"message":"Internal server error"} | The body is not valid JSON. Outside production the message is the parser's text, for example Unexpected end of JSON input, and a stack field is added. |
401 | {"status":401,"message":"Unauthorization"} | Locate JWT to shukah: the signature is invalid, the token is malformed, or the client has no active Shukah token. Shukah token to locate: no stored token matches, or the client it belongs to does not exist. Any unexpected failure inside the call also gives this 401, for example a database error, a token that is not a string, or a duplicate token. |
The first 400 body was captured on dev on 2026-10-01, with an empty JSON body and again with
target_platform set to nope. The 401 body is the one the code returns here; the capture run
did not trigger it on this route. The malformed-JSON 400 comes from an offline test of the service
code, not from dev.
Try it
This call writes: each time it turns a Shukah token into a Locate JWT it stores a new token and pushes it to Shukah. The service also writes the request body, token included, to its log. Do not send two exchanges for the same client within one second.
export AUTH_BASE_URL=https://dev.authentication.locate.sa
export SHUKAH_TOKEN='<shukah-token>' # see "Getting a test token" on the module overview
curl -s -X POST "$AUTH_BASE_URL/api/v1/clients/exchange-token" -H "Content-Type: application/json" -d "{\"token\":\"$SHUKAH_TOKEN\",\"target_platform\":\"locate\"}"
Expected: 200 and a Locate JWT in data.token. Keep it as $TOKEN for the other pages.
Watch out
- The unchanged cases do not check the token: any three-part string sent with
target_platform: "locate"returns200. To test a token, call Verify token. - A Shukah token sent with
target_platform: "locate"stores a new token and pushes it to Shukah each time. Two calls for the same client in the same second can make the second one fail with401(code reading, not reproduced). - In production mode only tokens that Shukah pushed live have a stored raw copy, and exchange needs it. Production behaviour: Not documented yet.
Details (for backend)
Only dev was checked: host https://dev.authentication.locate.sa, prefix /api/v1. The UAT and
production hosts and prefix: Not documented yet. Dev does not run with NODE_ENV=production: its
error bodies carry a stack field (captured on 2026-10-01 on the address-book, profile and setting
errors), and the handler adds stack only outside production (src/middlewares/error-handler.js:12-35).
So on dev token_raw is written, because it is stored only outside production (see "The token_bridge collection" below).
Route
- The route has no middleware: no
clientguard, and the handler is not wrapped inasyncHandler(src/routes/client.js:22). The handler has its own try/catch, and every exception inside it becomes401(src/controllers/client/exchange-token/index.js:10-96). - The body is read by
express.json()(src/index.js:42). A request withoutContent-Type: application/jsonleavesreq.bodyas{}, which gives the400above (library default, not captured). The library's default body limit of 100 kb applies. - The request body, token included, is written to the server log
(
src/controllers/client/exchange-token/index.js:32).
What each combination does
| Token in | target_platform | What happens | Source |
|---|---|---|---|
| JWT (three segments) | locate | Echoed back unchanged, with no verification. A forged three-part string also returns 200. | src/controllers/client/exchange-token/index.js:35-41 |
| Opaque | shukah | Echoed back unchanged, with no verification. | src/controllers/client/exchange-token/index.js:35-41 |
| JWT | shukah | verifyAccessToken runs, and a throw gives 401. Then it finds a token_bridge row for the client with source_system in ['shukah','locate'] and is_active: true, with no ordering: any active Shukah token is acceptable (decided 2026-09-09). None found gives 401. Otherwise it returns that row's raw token. 'locate' in the list can never match, because the enum is shukah or merchant. | src/controllers/client/exchange-token/index.js:44-67; src/models/token-bridge.js:5-23; docs/plans/2026-07-08-token-bridge-generic-and-image-urls.md:200-224 |
| Opaque | locate | It finds the row by raw token, with no is_active filter. None found gives 401. It loads the client with no status or is_deleted check. None found gives 401. It mints a JWT {client_id, phone}, inserts a token_bridge row (source_system: 'merchant'), pushes the JWT to Shukah (best effort), and answers 200. | src/controllers/client/exchange-token/index.js:70-89 |
- A client that is not active still gets a JWT in the last row, but the
clientguard on the other routes refuses it (src/middlewares/client.js:27-33,src/shared/constant/user.js:14). - If the matched row has no raw token, the key is dropped and the body is
{"status":200,"data":{}}(src/controllers/client/exchange-token/index.js:66; code reading). - The endpoint cannot validate a token: it only checks one when it has to look something up.
- Two opaque-to-
locatecalls for the same client in the same second mint identical JWTs, because the token has no random part andiathas one-second resolution. The second insert hits the uniquetoken_hashindex and the catch-all returns401. Code reading and an offline determinism check, not reproduced: Not documented yet whether it happens on dev.
The token_bridge collection and the push to Shukah
- A Locate JWT is stored in
token_bridgewithsource_system: 'merchant':client_id, the SHA-256 hash of the token astoken_hash,is_active: true,created_at, andtoken_rawonly whenNODE_ENVis notproduction(src/services/merchant-token-bridge.js:6-16,src/shared/helpers/jwt.js:12). - Shukah tokens (
source_system: 'shukah') come from the ETL of Shukah's token table, withtoken_rawonly whenNODE_ENVis notproduction(src/sync/mappers/token-bridge-mapper.js:7-25), and from the inbound pushPOST {PREFIX_API_V1}/admin/clients/tokens, which always stores the raw token (src/routes/admin.js:27,47,src/controllers/admin/create-token/handler.js:12-29,src/services/shukah-token-bridge.js:8-33). - Exchange reads
token_rawin both directions (src/controllers/client/exchange-token/index.js:66,72). Rows are never deleted or deactivated by the auth service for Locate tokens: each opaque-to-locatecall adds one row. Nothing revokes a Locate JWT. - The push is
POST {SHUKAH_HOST}/maeda/client/add-tokenwith{access_token, client_id: <shukah_id>}and the headersx-from-application: LOCATE_CLIENTandAuthorization: Bearer <SHUKAH_ADMIN_TOKEN>. It is best effort and awaited inline, with no timeout. It does nothing whenshukah_idis null, the sync is disabled, or the host or admin token is not set (src/services/shukah-sync.js:13-75,src/config/envConfig.js:19-33). This is how Shukah learns Locate JWTs (docs/plans/2026-07-16-shukah-token-outbound-push.md:10-28).
Where the docs and the code disagree (code wins)
- The service doc lists a
401for "JWT expired" (docs/api/client-auth-and-address-book.md:404). Locate JWTs have no expiry (src/shared/helpers/jwt.js:4-6), so that case cannot happen. - The draft flow chart and the 2026-07-06 design doc write this route as
POST /maeda/api/exchange-token. At commitc2d1c9f(2026-07-08) the service did mount it there, without the prefix. Commitd837492(2026-07-10) moved every client route underPREFIX_API_V1/clients, and the auth host now answers404for/maeda/*. - Two docs say the raw token is "never populated in prod"
(
docs/guides/shukah-migration-schema-reference.md:113,docs/plans/2026-07-08-token-bridge-generic-and-image-urls.md:106-108). The inbound push writer stores it unconditionally (src/services/shukah-token-bridge.js:16). Only the ETL and the Locate-JWT writer are gated to non-production (src/sync/mappers/token-bridge-mapper.js:22,src/services/merchant-token-bridge.js:14).
Not documented yet
- In production mode, which stored Shukah tokens carry a raw copy, and what the JWT-to-
shukahcall returns when it is missing. By the code it is{"status":200,"data":{}}. - Whether the outbound push to Shukah is enabled on dev (
SHUKAH_SYNC_ENABLED,SHUKAH_HOST,SHUKAH_ADMIN_TOKEN). - Whether two exchanges for one client in the same second really fail.
- Whether the current app build (newer than the 2026-02-26 snapshot) calls this endpoint.
Sources
locate-authentication-service@6757aa4:src/routes/client.js:22,src/controllers/client/exchange-token/index.js:10-96,src/index.js:42,src/shared/helpers/jwt.js:4-12,src/services/merchant-token-bridge.js:6-16,src/services/shukah-token-bridge.js:8-33,src/sync/mappers/token-bridge-mapper.js:7-25,src/models/token-bridge.js:5-23,src/routes/admin.js:27,47,src/controllers/admin/create-token/handler.js:12-29,src/services/shukah-sync.js:13-75,src/config/envConfig.js:19-33,src/middlewares/client.js:27-33,src/shared/constant/user.js:14,src/middlewares/error-handler.js:12-35,src/shared/constant/error-message.js:59locate-authentication-service@6757aa4docs:docs/api/client-auth-and-address-book.md§9 (lines 372-408; last changed 2026-07-27),docs/draft/flow-chart-shukah2locate.md:15-36,54,69-112(2026-07-08),docs/plans/2026-07-06-shukah-auth-api-local.md:8-11,26,docs/plans/2026-07-08-token-bridge-generic-and-image-urls.md:106-108,196-224(2026-09-09),docs/plans/2026-07-16-shukah-token-outbound-push.md:10-28,docs/guides/shukah-migration-schema-reference.md:113- Mobile snapshot
mobile/locate-client@e960c3a(branchdevelopment, 2026-02-26; the app's own code, not Locate): a search of the whole tree forexchange-token,api/v1/clients,authentication.locateandauth_sourcefinds nothing;src/entities/user/model/api/services/auth.service.ts:85-112(the stored token is deleted on a401at:101-103);src/entities/settings/model/api/dto/settings.dto.ts:112-126(the client-settings type has noauth_sourcefield) - Dev captures, 2026-10-01:
probe-locate-exchange-token,probe-auth-host-maeda-prefix, and the exchange-token calls recorded in the live-capture summary (shape only, tokens not stored)