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
Section titled “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:
export API=https://api.vampikez.funexport ISSUER=https://account.vampikez.fun/oauth2export APP_SLUG=acme-botexport 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. Step 3
needs organization.applications.manage; step 5 also needs
organization.products.manage. Both are on the default admin role.
The flow
Section titled “The flow”- Your app redirects the browser to
$ISSUER/authwith the client id, the redirect URI, the scopes,state,nonce, and a PKCE challenge. - 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.
- The browser comes back to your
redirect_uriwithcode,stateandiss— or with an OAutherror(access_deniedif the user aborted). - Your server exchanges the code at
$ISSUER/tokenwithin 60 seconds, sending the PKCE verifier. - 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:
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 |
There is no introspection_endpoint, no registration_endpoint and no
device_authorization_endpoint; those features are switched off.
Authorization request parameters
Section titled “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 |
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
Section titled “A complete relying party”One file, Node 22 or newer, one dependency:
npm install joseISSUER=https://account.vampikez.fun/oauth2 CLIENT_ID=wamp_… \ BASE_URL=http://127.0.0.1:7777 node server.mjs// server.mjs — sign in with WAMP, hold a session, refresh it, sign out.import { createServer } from 'node:http';import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';import { createRemoteJWKSet, jwtVerify } from 'jose';
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 ? '<a href="/logout">Sign out</a>' : '<a href="/login">Sign in with WAMP</a>'); } 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=consenton every authorization request, or there is no refresh token and no organization picker.stateis 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’ssubto 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
Section titled “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 buildredirect_uriashttp://127.0.0.1:<port>/callback. For anativeclient the port is ignored when matching a loopback redirect URI, so one registration covers every ephemeral port. The path is matched exactly, and theredirect_uriyou 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: closeon 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
Section titled “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:
import { createRemoteJWKSet, jwtVerify } from 'jose';
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.
Scopes, claims and tokens
Section titled “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.
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
Section titled “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
Section titled “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
Section titled “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-outThe 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:
curl -X POST "$ISSUER/token/revocation" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d token=<access or refresh token> \ -d token_type_hint=refresh_token \ -d client_id=wamp_3f2a…Client reference
Section titled “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.
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=<JWT>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.
Common failures
Section titled “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:<port>/… |
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/<clientId>/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 |
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.