# Sign in with WAMP Build a working WAMP sign-in — register a client, run authorization code with PKCE, verify the tokens, refresh, and sign out, with a complete relying party you can run. Source: https://docs.vampikez.fun/identity/sign-in-with-wamp/ Register a client, sign a WAMP user into your application, verify their identity, and maintain your own session. The runnable local relying party below uses authorization code with PKCE. WAMP is an OpenID Connect provider with exactly one flow: **authorization code with PKCE**. Every token is signed **EdDSA** (Ed25519). There are no client secrets, no `client_credentials`, no device flow, no dynamic client registration, and no token introspection. Two values identify the deployment you integrate with. On this deployment they are: | Placeholder | Value here | What it is | |---|---|---| | `$ISSUER` | `https://account.vampikez.fun/oauth2` | The OIDC issuer. Always ends in `/oauth2`; every OIDC endpoint is discovered from it | | `$API` | `https://api.vampikez.fun` | The Account API origin, which serves `/auth/*` and `/api/*` — client registration lives here, not under the issuer | Everything below writes them as placeholders so a snippet copied into another deployment still reads correctly. ## Set up a client in one block Sign-in needs three things that already exist or that you create once: an organization you administer, an **app** in it, and an **OIDC client** under that app with sign-in turned on. Creating the app and enabling its client are self-service once you have the required organization permissions. The account and organization must already exist. Use your email, app slug, and redirect URI: ```bash export API=https://api.vampikez.fun export ISSUER=https://account.vampikez.fun/oauth2 export APP_SLUG=acme-bot export REDIRECT_URI=http://127.0.0.1:7777/callback # 1. A user access token for yourself. This is your own WAMP session, not an # OIDC token — the management API below accepts nothing else, and it lasts # 15 minutes: re-run this step if a later call answers 401. TOKEN=$(curl -sS -X POST "$API/auth/login" -H 'Content-Type: application/json' \ -d '{"email":"you@example.com","password":"…"}' | jq -r .accessToken) # 2. The organization that will publish the app and whose members sign in. ORG_ID=$(curl -sS "$API/api/organizations" -H "Authorization: Bearer $TOKEN" \ | jq -r '.organizations[0].id') # 3. The app. Skip if it exists; a taken slug answers 409 slug_taken. curl -sS -X POST "$API/api/apps" -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg o "$ORG_ID" --arg s "$APP_SLUG" \ '{orgId: $o, slug: $s, name: "Acme Bot"}')" | jq '.app.slug, .error' # 4. The OIDC client. This is the only place your redirect URIs are declared; # add your production https:// callback to the same list, up to 20 of them. CLIENT_ID=$(curl -sS -X POST "$API/api/apps/$APP_SLUG/oauth-clients" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d "$(jq -n --arg r "$REDIRECT_URI" '{ name: "Acme Web", applicationType: "web", tokenEndpointAuthMethod: "none", redirectUris: [$r], postLogoutRedirectUris: ["http://127.0.0.1:7777/"], allowedScopes: ["openid", "profile", "email", "offline_access", "wamp:organization"], allowRefreshTokens: true }')" | jq -r .client.clientId) # 5. Turn on "Sign in with WAMP" for the app's own organization. Until this # runs, consent shows an empty organization list and no code is issued. curl -sS -X POST "$API/api/apps/$APP_SLUG/oauth-clients/$CLIENT_ID/sign-in" \ -H "Authorization: Bearer $TOKEN" | jq . echo "CLIENT_ID=$CLIENT_ID" ``` Steps 1–2 need an account and an organization, and neither is self-service: accounts are created by accepting an organization invitation, organizations by an operator. See [Organizations and roles](/identity/organizations/). Step 3 needs `organization.applications.manage`; step 5 also needs `organization.products.manage`. Both are on the default admin role. Sign-in is enabled for the organization that **owns the app**: its members sign in, and their tokens carry that organization's `org_id`. The self-service call grants only that organization — letting members of a *different* organization sign in with your client needs the operator provisioning path. ## The flow 1. Your app redirects the browser to `$ISSUER/auth` with the client id, the redirect URI, the scopes, `state`, `nonce`, and a PKCE challenge. 2. Account Center runs login and a consent screen. Consent **requires** picking one organization: a grant's tenant is part of the grant, not a header you send later. Your app is not involved in this leg and must not try to drive it — the interaction API is same-origin-guarded to Account Center. 3. The browser comes back to your `redirect_uri` with `code`, `state` and `iss` — or with an OAuth `error` (`access_denied` if the user aborted). 4. Your server exchanges the code at `$ISSUER/token` within **60 seconds**, sending the PKCE verifier. 5. Your server verifies the ID token against the JWKS, reads profile and tenant claims from `$ISSUER/me`, and creates its own session. Take every endpoint URL from the discovery document rather than hardcoding paths: ```bash curl -s "$ISSUER/.well-known/openid-configuration" | jq . ``` | Discovery member | Path on this deployment | |---|---| | `authorization_endpoint` | `$ISSUER/auth` | | `token_endpoint` | `$ISSUER/token` | | `userinfo_endpoint` | `$ISSUER/me` | | `jwks_uri` | `$ISSUER/jwks` | | `revocation_endpoint` | `$ISSUER/token/revocation` | | `end_session_endpoint` | `$ISSUER/session/end` | | `wamp_global_logout_endpoint` | An absolute URL on Account Center, not under the issuer — a WAMP extension, described under [Signing out](#signing-out) | There is no `introspection_endpoint`, no `registration_endpoint` and no `device_authorization_endpoint`; those features are switched off. ### Authorization request parameters | Parameter | Required | Notes | |---|---|---| | `client_id` | yes | | | `response_type` | yes | `code` is the only supported value | | `redirect_uri` | yes | Must match a registered URI exactly | | `scope` | yes | Must include `openid`; every scope must be in the client's `allowedScopes` | | `code_challenge` | yes | PKCE is required for every client, public or not | | `code_challenge_method` | yes | `S256` only | | `prompt` | **yes, for a refresh token** | Send `prompt=consent`. See the trap below | | `state` | strongly recommended | Your CSRF binding; returned unchanged | | `nonce` | strongly recommended | Echoed into the ID token; compare it | | `resource` | **do not send** | Each client has exactly one legal resource identifier, applied automatically. Any other value fails with an unknown-resource-indicator error | | `request`, `request_uri` | not supported | Request objects and PAR are disabled | `offline_access` is **silently dropped** from the request unless `prompt=consent` is present. Nothing fails: consent completes, the token response is a valid `200`, and it simply has no `refresh_token` in it. Every sign-in that wants a durable session sends `prompt=consent`, which also means the organization picker appears on every sign-in — that is how a user switches organization. The authorization response carries `iss` (RFC 9207). Compare it to your configured issuer before you use the code, alongside the `state` check. ## A complete relying party One file, Node 22 or newer, one dependency: ```bash npm install jose ISSUER=https://account.vampikez.fun/oauth2 CLIENT_ID=wamp_… \ BASE_URL=http://127.0.0.1:7777 node server.mjs ``` ```js // server.mjs — sign in with WAMP, hold a session, refresh it, sign out. const ISSUER = process.env.ISSUER.replace(/\/$/, ''); const CLIENT_ID = process.env.CLIENT_ID; const BASE_URL = process.env.BASE_URL ?? 'http://127.0.0.1:7777'; const REDIRECT_URI = `${BASE_URL}/callback`; const SCOPE = 'openid profile email offline_access wamp:organization'; // Two server-side maps. `pending` holds in-flight authorization requests, // `sessions` holds signed-in users. Use Redis in production — and keep both // server-side: the code verifier and the refresh token must never reach the // browser. const pending = new Map(); const sessions = new Map(); const discovery = await fetch(`${ISSUER}/.well-known/openid-configuration`) .then((response) => response.json()); if (discovery.issuer !== ISSUER) throw new Error('discovery issuer mismatch'); const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri)); const base64url = (bytes) => bytes.toString('base64url'); const cookie = (req, name) => req.headers.cookie?.match(new RegExp(`(?:^|; )${name}=([^;]*)`))?.[1]; const safeEqual = (a, b) => { const left = Buffer.from(a); const right = Buffer.from(b); return left.length === right.length && timingSafeEqual(left, right); }; const fail = (res, status, message) => { res.writeHead(status, { 'content-type': 'text/plain' }); res.end(`sign-in failed: ${message}`); }; function startAuthorization(res) { const verifier = base64url(randomBytes(64)); const state = base64url(randomBytes(32)); const nonce = base64url(randomBytes(32)); pending.set(state, { verifier, nonce, expiresAt: Date.now() + 600_000 }); const url = new URL(discovery.authorization_endpoint); url.search = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: REDIRECT_URI, scope: SCOPE, state, nonce, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', // Required for offline_access: without it the provider drops the scope and // the token response comes back with no refresh_token. prompt: 'consent', }).toString(); res.writeHead(302, { location: url.toString(), 'set-cookie': `wamp_tx=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`, }); res.end(); } async function completeAuthorization(req, url, res) { const state = url.searchParams.get('state') ?? ''; const tx = pending.get(state); if (!tx || tx.expiresAt <= Date.now() || !safeEqual(state, cookie(req, 'wamp_tx') ?? '')) { return fail(res, 400, 'state mismatch'); } pending.delete(state); if (url.searchParams.get('error')) { return fail(res, 400, url.searchParams.get('error')); } // RFC 9207: this provider stamps the issuer on every authorization response. if (url.searchParams.get('iss') !== ISSUER) return fail(res, 400, 'issuer mismatch'); const tokens = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: url.searchParams.get('code') ?? '', redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: tx.verifier, }), }).then((response) => response.json()); if (tokens.error) return fail(res, 400, tokens.error); const { payload: claims } = await jwtVerify(tokens.id_token, jwks, { issuer: ISSUER, audience: CLIENT_ID, algorithms: ['EdDSA'], requiredClaims: ['sub', 'exp', 'iat'], }); if (claims.nonce !== tx.nonce) return fail(res, 400, 'nonce mismatch'); // The ID token authenticates the exchange and carries no profile claims. // Userinfo releases the claims of every granted scope. const profile = await fetch(discovery.userinfo_endpoint, { headers: { authorization: `Bearer ${tokens.access_token}` }, }).then((response) => response.json()); if (profile.error || profile.sub !== claims.sub || typeof profile.org_id !== 'string' || !profile.org_id) { return fail(res, 400, 'userinfo identity or organization mismatch'); } const sid = base64url(randomBytes(32)); sessions.set(sid, { // `sub` is pairwise — unique to this client — and the organization is part // of the grant. Key your own user rows on the pair, never on the email. userKey: `${claims.sub}@${profile.org_id}`, profile, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, }); res.writeHead(302, { location: '/me', 'set-cookie': [ `wamp_sid=${sid}; HttpOnly; SameSite=Lax; Path=/`, 'wamp_tx=; Path=/; Max-Age=0', ], }); res.end(); } /** Returns a live access token, rotating the stored refresh token. */ async function accessToken(session) { if (Date.now() < session.expiresAt - 30_000) return session.accessToken; const refreshed = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: session.refreshToken, client_id: CLIENT_ID, }), }).then((response) => response.json()); // invalid_grant is the provider's verdict that the grant is gone — a // membership, installation, client or app changed. Sign the user in again. if (refreshed.error) throw new Error(refreshed.error); session.accessToken = refreshed.access_token; session.refreshToken = refreshed.refresh_token ?? session.refreshToken; session.expiresAt = Date.now() + refreshed.expires_in * 1000; return session.accessToken; } async function signOut(session, res) { await fetch(discovery.revocation_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: session.refreshToken, token_type_hint: 'refresh_token', client_id: CLIENT_ID, }), }); const end = new URL(discovery.end_session_endpoint); end.search = new URLSearchParams({ client_id: CLIENT_ID, post_logout_redirect_uri: `${BASE_URL}/`, }).toString(); res.writeHead(302, { location: end.toString(), 'set-cookie': 'wamp_sid=; Path=/; Max-Age=0', }); res.end(); } createServer(async (req, res) => { const url = new URL(req.url, BASE_URL); const sid = cookie(req, 'wamp_sid') ?? ''; const session = sessions.get(sid); try { if (url.pathname === '/login') return startAuthorization(res); if (url.pathname === '/callback') return await completeAuthorization(req, url, res); if (url.pathname === '/logout' && session) { sessions.delete(sid); return await signOut(session, res); } if (url.pathname === '/me') { if (!session) { res.writeHead(302, { location: '/login' }); return res.end(); } await accessToken(session); // refreshes when close to expiry res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(session.profile, null, 2)); } res.writeHead(200, { 'content-type': 'text/html' }); res.end(session ? 'Sign out' : 'Sign in with WAMP'); } catch (error) { fail(res, 500, error.message); } }).listen(new URL(BASE_URL).port, '127.0.0.1'); ``` Five decisions in that file are the ones worth copying: - **The verifier, the nonce and the refresh token stay on the server.** A public client has no secret, so PKCE is the only thing binding the code to your app. - **`prompt=consent` on every authorization request**, or there is no refresh token and no organization picker. - **`state` is checked against a cookie as well as against the store**, so a code replayed into another visitor's browser is rejected. - **The ID token is verified and userinfo is bound to it.** Verify signature, `iss`, `aud`, `nonce`, and expiry; require userinfo's `sub` to match the ID token before using its organization or profile. - **The rotated refresh token is stored before it is used.** Rotation invalidates the previous one; losing the new one loses the session. This is a local demonstration: it keeps sessions in memory and listens on loopback HTTP. For deployment, use HTTPS and Secure cookies, expire stored transactions and sessions, serialize refreshes for each session, protect logout with a CSRF-checked POST, and handle non-2xx provider responses. Cache discovery so an identity-provider outage does not prevent your own startup. ## Native and desktop apps The same flow, with a loopback redirect instead of a hosted callback. Register the client with `applicationType: "native"` and `tokenEndpointAuthMethod: "none"`, then: - Listen on `127.0.0.1:0`, read the assigned port, and build `redirect_uri` as `http://127.0.0.1:/callback`. For a `native` client the **port is ignored when matching a loopback redirect URI**, so one registration covers every ephemeral port. The path is matched exactly, and the `redirect_uri` you send to the token endpoint must be byte-identical to the one you sent to the authorization endpoint. - Open the authorization URL in the system browser, not an embedded webview. - Accept exactly one callback, answer it with a small page telling the user to return to the app, and close the listener. Set `Connection: close` on that response: browsers reuse the loopback connection, and a keep-alive response can strand a completed sign-in until the socket times out. - Store the refresh token in the OS keychain, never in a plain file. A custom scheme (`com.acme.app:/oauth/callback`) works too, and is the only option on mobile. Both are subject to the redirect URI rules below. ## Protecting your own API The access token is a JWT (`typ: at+jwt`), signed EdDSA against the same JWKS, with `aud` equal to your `client_id`. Verify it at your own resource server — **introspection is disabled**, so there is no endpoint that will do it for you: ```js const { ISSUER, CLIENT_ID } = process.env; const jwks = createRemoteJWKSet(new URL(`${ISSUER}/jwks`)); export async function principal(authorization) { if (typeof authorization !== 'string' || !/^Bearer \S+$/i.test(authorization)) { throw new Error('Bearer access token required'); } const { payload } = await jwtVerify(authorization.slice('Bearer '.length), jwks, { issuer: ISSUER, audience: CLIENT_ID, algorithms: ['EdDSA'], typ: 'at+jwt', requiredClaims: ['sub', 'exp', 'iat'], }); // The audience is exact and single-valued; client_id restates it. Checking // both closes the gap if a future token ever carries an array audience. if (payload.aud !== CLIENT_ID || payload.client_id !== CLIENT_ID) { throw new Error('client/audience mismatch'); } if (typeof payload.org_id !== 'string' || !payload.org_id) { throw new Error('organization claim required'); } return { userKey: `${payload.sub}@${payload.org_id}`, orgId: payload.org_id, scopes: String(payload.scope ?? '').split(' ').filter(Boolean), }; } ``` That verification is offline and therefore blind to revocation for the lifetime of the token — 10 minutes by default. Revocation happens at refresh, which re-resolves the grant against live state. An OIDC access token from your client authorizes **your** resource server and `$ISSUER/me`. It is not a WAMP platform credential: `$API` routes reject it with `401 {"error": "Invalid or expired token"}`, and no endpoint exchanges it for one. If your backend also needs to call WAMP as itself, that is a separate credential — see [Apps and credentials](/identity/apps-and-credentials/). ## Scopes, claims and tokens Five scopes exist. Any other string is rejected as `invalid_scope` at client registration. | Scope | Claims released | Meaning | |---|---|---| | `openid` | `sub` | Required; added for you if you omit it | | `profile` | `name` | Display name. No `given_name`, `picture` or other profile claims exist | | `email` | `email`, `email_verified` | See the warning below | | `offline_access` | — | Not a claim; enables refresh tokens, and needs `prompt=consent` | | `wamp:organization` | `org_id`, `org_slug`, `membership_id`, `app_id`, `installation_id` | The tenant this grant is bound to, the user's membership in it, and your app and installation ids | The three token surfaces carry different things: | Surface | Contents | |---|---| | **ID token** | `sub`, `aud`, `iss`, `exp`, `iat`, `nonce`. Authentication only — no profile claims, by configuration | | **Access token** | The claims of every granted scope, plus `client_id`, `scope`, `grant_id`. Readable, so a resource server that verifies it locally needs no userinfo round trip | | **`$ISSUER/me`** | The claims of every granted scope. Bearer header only — access tokens in a query parameter are rejected | `sub` is **pairwise**: the same human has a different `sub` for every client, derived by an HMAC the server keeps secret. It is stable for your client and opaque. Never parse it, never expect an email or a UUID, and never use it to join users across two of your own clients. Key on `sub` plus `org_id`, because the same person in a different organization is a different grant. `email_verified` is hardcoded `false` everywhere it appears. WAMP has no email-verification flow at all. If your product requires a verified address, verify it yourself — and do not write a branch that waits for `true`, because it will never arrive. Fetch the JWKS from `jwks_uri` and cache it in your process rather than per request — `createRemoteJWKSet` above does that, refetching only when it meets an unknown `kid`. The endpoint is CORS-open, because it holds nothing but public keys. Reject any token whose `alg` is not `EdDSA`, including `none` and any `HS*`. ### Lifetimes | Artifact | Lifetime | Configurable | |---|---|---| | Authorization code | 60 seconds | No | | Access token | 600 seconds by default | By the operator | | ID token | Same as the access token | By the operator | | Refresh token | 30 days by default, rotating | By the operator | | Grant and browser session | Same as the refresh token | By the operator | Read `expires_in` rather than hardcoding any of these. ### One live grant per user, per client A grant's organization is immutable. When a user signs in again and picks a different organization, the provider **replaces** the grant: the previous one is destroyed and the refresh tokens issued under it stop working. A user therefore holds one live WAMP session per client at a time. Creating a second local session does not preserve the old WAMP grant: a new authorization replaces it. Do not promise simultaneous independently refreshable organizations through one client. Every refresh re-resolves the grant against live state: the organization must still be active, the membership still active, the client still active, the app not suspended, and the installation and its client approval still live. If any of that changed the refresh fails with `invalid_grant`. That is the revocation mechanism — there is nothing to poll. ## Signing out Two endpoints, for two different jobs. **RP-initiated logout** (`end_session_endpoint`) is the standard flow. WAMP shows an HTML confirmation page before ending the session, so this is a browser navigation, not a background request: ``` GET $ISSUER/session/end ?client_id=wamp_3f2a… &post_logout_redirect_uri=https://app.example.com/signed-out ``` The `post_logout_redirect_uri` must be registered on the client, or the request is rejected. **Global logout** is the non-standard `wamp_global_logout_endpoint` member in the discovery document. Use it when you want the whole browser signed out of WAMP, including Account Center's own session cookie, which the standard endpoint cannot clear because it is bound to a different host. It takes the same two parameters and redirects into `end_session` for you. To drop one token rather than a session, use the revocation endpoint, as the sample does before it ends the session: ```bash curl -X POST "$ISSUER/token/revocation" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d token= \ -d token_type_hint=refresh_token \ -d client_id=wamp_3f2a… ``` ## Client reference The client you created in the first block is the whole configuration surface. `clientId` is `wamp_` followed by 48 hex characters; readable ids such as `wamp-desktop` exist only for first-party clients provisioned by an operator. | Field | Rules | |---|---| | `name` | 1–120 characters | | `applicationType` | `native` or `web`, lowercase | | `tokenEndpointAuthMethod` | `none` or `private_key_jwt`. `native` **must** use `none`. `private_key_jwt` requires at least one unrevoked app key already registered, otherwise `signing_key_required` | | `redirectUris` | 1–20 URIs, each ≤2048 characters. Rules below | | `postLogoutRedirectUris` | Optional, ≤20, validated by the same rules | | `allowedScopes` | Optional subset of the five scopes. Defaults to all five. `openid` is always added if you omit it | | `allowRefreshTokens` | Optional, defaults to `true`. When `false`, `offline_access` is stripped from `allowedScopes` at registration | Redirect URI rules differ by application type and reject some things RFC 8252 permits: | Application type | Accepted | Rejected | |---|---|---| | `web` | `https://…`; `http://` on `localhost` or `127.0.0.1` | any other `http://` | | `native` | `http://127.0.0.1[:port]/…`; `https://` on a host you control; a reverse-domain custom scheme with an empty authority, e.g. `com.acme.app:/oauth/callback` | **`http://localhost`**; `https://localhost` and `https://127.0.0.1`; schemes without a dot such as `myapp:`; `about: blob: chrome: data: file: javascript:` | Fragments, usernames and passwords are rejected in any URI. Duplicates are collapsed. A rejected URI answers `400 {"success": false, "error": "invalid_redirect_uri"}` with no indication of which URI or which rule failed. `http://localhost:7777/callback` is **rejected** for a `native` client while `http://127.0.0.1:7777/callback` is accepted — only the literal loopback IP passes. Bind your loopback listener accordingly. A custom scheme must have **no authority component**: `com.acme.app:/oauth/callback` is accepted, `com.acme.app://oauth/callback` is rejected, because the double slash makes `oauth` a host. Managing clients afterwards, all with the same user access token as the setup block: | Call | Effect | |---|---| | `GET $API/api/apps/:slug/oauth-clients` | Lists the app's clients, each with `signInEnabled` | | `PATCH $API/api/apps/:slug/oauth-clients/:clientId` | Updates any of `name`, `redirectUris`, `postLogoutRedirectUris`, `allowedScopes`, `allowRefreshTokens`. At least one field, and unknown fields are rejected | | `DELETE $API/api/apps/:slug/oauth-clients/:clientId` | Revokes the client and its grants. `204`. No new token can be issued, and refresh stops working immediately — an access token already in flight stays verifiable offline until it expires | | `POST …/oauth-clients/:clientId/sign-in` | Turns sign-in on. Idempotent | | `DELETE …/oauth-clients/:clientId/sign-in` | Turns it off. The organization drops out of consent and live refresh tokens for it stop resolving | A `private_key_jwt` client authenticates at the token endpoint with two extra form fields instead of relying on the public-client path: ``` -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -d client_assertion= ``` The assertion is signed **EdDSA** with one of the app's registered public keys (`kid` in the header), with `iss` and `sub` set to your `client_id` and `aud` set to `$ISSUER`. Registering those keys is described in [Apps and credentials](/identity/apps-and-credentials/#app-identity). ## Common failures | What you see | Cause | Fix | |---|---|---| | Token response has no `refresh_token` | `prompt=consent` was missing, so `offline_access` was dropped from the request | Send `prompt=consent`. If that is already there, check `allowRefreshTokens` on the client | | An HTML "oops! something went wrong" page instead of a redirect | The authorization request failed before the client and redirect URI were validated — usually an unknown `client_id` | Check the `client_id`; errors after that point arrive as parameters on your `redirect_uri` | | `400 invalid_redirect_uri` when registering a native client | `http://localhost` — only `127.0.0.1` is accepted for `native` | Use `http://127.0.0.1:/…` | | `400 invalid_redirect_uri` for a custom scheme that looks right | The URI has an authority: `com.acme.app://cb` | Drop one slash: `com.acme.app:/cb` | | `400 invalid_redirect_uri` for `myapp:/cb` | A custom scheme must be reverse-domain and contain a dot | Use `com.example.myapp:/cb` | | `400 invalid_request` when registering | The body failed validation — an unknown field, a missing `tokenEndpointAuthMethod`, an over-long name | Compare against the client reference above | | `400 invalid_scope` when registering | A scope outside the five supported strings | Send only `openid`, `profile`, `email`, `offline_access`, `wamp:organization` | | `400 signing_key_required` when registering | `private_key_jwt` requested with no app key on file | Register a public key first, then create the client | | `400 invalid_auth_method` | `applicationType: "native"` with `private_key_jwt` | Native clients must use `none` | | The consent screen shows no organizations to pick | Sign-in is not on for this client in the user's organization, or the installation is limited to assigned members and this user is not one | `POST …/oauth-clients//sign-in`; check member assignment | | `400 scope_not_allowed` at consent | The authorize request asked for a scope outside the client's `allowedScopes` | Patch the client, or narrow the request | | `invalid_grant` at the token endpoint | The code is older than 60 seconds, already used, issued to a different `redirect_uri`, or the `code_verifier` does not match the challenge | Shorten the round trip; send the identical `redirect_uri`; check your verifier storage | | `invalid_grant` on refresh, for one user only | Their membership, the installation, the client approval or the app status changed — or they signed in again under a different organization | Re-run the authorization flow; there is no repair call | | An unknown-resource-indicator error | You sent a `resource` parameter | Omit it | | `404` at an introspection or registration endpoint | Both features are disabled | Verify JWTs against the JWKS; create clients through the app API | | `401 {"error": "Invalid or expired token"}` from an `$API` route | You sent an OIDC access token to a route that wants a WAMP user session | Use the user access token from the setup block — see [Apps and credentials](/identity/apps-and-credentials/) | Failures at the authorization endpoint arrive as OAuth error parameters on your `redirect_uri` once the client is known. Failures during login and consent are shown by Account Center. Failures at the token endpoint are standard OAuth JSON error bodies. Failures at `$API` routes use one of WAMP's three error shapes, listed in [Overview](/identity/overview/#before-your-first-call).