# 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).