Skip to content

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.

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:

Terminal window
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. Step 3 needs organization.applications.manage; step 5 also needs organization.products.manage. Both are on the default admin role.

  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:

Terminal window
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.

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.

One file, Node 22 or newer, one dependency:

Terminal window
npm install jose
ISSUER=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=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.

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:<port>/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.

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.

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*.

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.

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.

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:

Terminal window
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…

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.

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.