Vesopa Vesopa

Vesopa OAuth for developers

Vesopa OAuth is a standard OpenID Connect provider. If your language has a certified OIDC library, point it at the issuer below and it will configure itself — there is nothing of ours to learn. Everything here is curl, so you can see exactly what goes over the wire.

The only URL you need

Discovery
curl https://auth.vesopa.com/.well-known/openid-configuration

Every endpoint, the supported algorithms and the signing keys are described there. Hard-coding any of the other URLs is how an integration breaks the day one of them moves.

Issuerhttps://auth.vesopa.com
Authorization/oauth/authorize
Token/oauth/token
User info/oauth/userinfo
Keys/jwks.json
Revoke/oauth/revoke
Introspect/oauth/introspect
Log out/oauth/logout

What we support, and what we refuse

Grant
authorization_codeYes — with PKCE, always.
refresh_tokenYes — rotated on every use.
client_credentialsYes — machine to machine only.
implicitNo. It leaks tokens through the URL.
passwordNo. It teaches applications to collect passwords, and it cannot do a second factor.

PKCE is required of every client, including confidential ones with a secret. It costs you two extra fields and closes authorisation-code interception for everybody. code_challenge_method must be S256; plain is refused.

Signing somebody in, in four steps

1. Make a PKCE pair

VERIFIER=$(openssl rand -base64 60 | tr -d '=+/' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 \
  | openssl base64 | tr '+/' '-_' | tr -d '=')

Keep the verifier; you will need it in step 3. Never send it in step 2.

2. Send the person to Vesopa

https://auth.vesopa.com/oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyour.app%2Fcallback
  &response_type=code
  &scope=openid%20profile%20email
  &state=RANDOM_PER_REQUEST
  &nonce=RANDOM_PER_REQUEST
  &code_challenge=$CHALLENGE
  &code_challenge_method=S256

They come back to your redirect_uri with ?code=…&state=…. Check the state matches the one you sent — that check is what stops somebody else's authorisation code being fed to your callback. The redirect_uri must match one you registered, character for character; we do not forgive a trailing slash.

3. Swap the code for tokens

curl -X POST https://auth.vesopa.com/oauth/token \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="https://your.app/callback" \
  -d code_verifier="$VERIFIER" \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET"

Public clients — a single-page app, a desktop app — send no client_secret. A secret shipped inside something the user holds is not a secret, and we will refuse one if your application is registered as public.

Response
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
  "token_type": "Bearer",
  "expires_in": 600,
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
  "scope": "openid profile email"
}

4. Read who they are

curl https://auth.vesopa.com/oauth/userinfo \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Better still, verify the id_token yourself against /jwks.json and skip the round trip. Tokens are RS256 and carry a kid; cache the key set and re-fetch when you meet a kid you do not know.

Scopes

openidRequired. Gets you an ID token.
profileName, picture, date of birth, locale.
emailEmail address and whether it is confirmed.
phonePhone number and whether it is confirmed.
rolesThe person's roles in your application only.
offline_accessA refresh token. Ask only if you need to act while they are away.

Asking for a scope you were not granted is not an error — it is dropped, and the rest of the request succeeds. The scope in the response tells you what you actually got, so read it rather than assuming.

Refresh tokens rotate. Handle it properly.

curl -X POST https://auth.vesopa.com/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN" \
  -d client_id="$CLIENT_ID"

Every refresh returns a new refresh token, and the old one is spent. Store the new one before you do anything else. If a spent token is presented again we assume it was stolen and revoke the whole chain — because we cannot tell a thief from a client that kept a copy.

We make one allowance: the same device retrying within a few seconds, after a dropped response, gets 409 and should simply try once more rather than treating it as a failure.

Roles and permissions

A role says what a person may do inside your application. Ask for the roles scope and they arrive in the access token:

{
  "sub": "01M21A5GMCA3838KSRAAXMFRJA",
  "aud": "your-client-id",
  "scope": "openid profile roles",
  "roles": ["till.operator"],
  "amr": ["otp"],
  "acr": "aal1"
}

You only ever see roles from your own application — never another developer's, and never Vesopa's. Role changes reach you when the access token is next refreshed, within ten minutes; call /oauth/introspect if you need to know sooner.

Two claims worth using

amr says how somebody proved themselves — pwd, otp, sms, webauthn — and acr summarises how strong that is. If an action in your app deserves more than a password, send acr_values=aal2 on the authorisation request and we will ask for the missing factor without signing the person out.

Errors you will actually meet

invalid_grantThe code expired, was used already, or the code_verifier does not match. Codes last sixty seconds and work once.
invalid_clientWrong client id or secret — or a secret sent by a client registered as public.
invalid_requestUsually a missing code_challenge. PKCE is not optional here.
access_deniedThe person said no, or has no access to your application.
A page instead of a redirectYour client_id or redirect_uri is not registered. We will not redirect to an address we cannot vouch for.

Webhooks

Signing somebody in tells you who they are at that moment. Webhooks tell you what happened afterwards — that they changed their name, disconnected you, or deleted their account and you must erase your copy of their data. Add an endpoint in the developer portal and pick the events you want.

user.createdSomebody used your application for the first time.
user.updatedA name, picture or date of birth changed.
user.deletedThe account was deleted. Erase your copy of their data.
user.suspendedAn administrator suspended the account.
identity.linkedAn email address, phone number or provider was added.
identity.unlinkedOne was removed — it may now belong to somebody else.
consent.revokedThe person disconnected your application. Stop using their tokens.
session.revokedThey signed out everywhere, or a session was ended for them.

What arrives

POST to your endpoint
POST /your/endpoint HTTP/1.1
Content-Type: application/json
Vesopa-Signature: t=1757370000,v1=6f1c…
Vesopa-Event: user.updated
Vesopa-Event-Id: 01M21Z4RP1MXNKM2HFESAV3H29
Vesopa-Delivery: 01M21Z4RP2QK8CJ4W0YT7BNXME
Vesopa-Attempt: 1

{
  "id": "01M21Z4RP1MXNKM2HFESAV3H29",
  "type": "user.updated",
  "created": 1757370000,
  "data": { "sub": "01M21YEY6BPPC048E6J3CR6MSB", "name": "Jane Bell" }
}

The sub inside data is the same subject your ID tokens carry — for a third-party application it is derived from the person and your application, so it matches what you already store and is meaningless to anybody else.

Check the signature. Every time.

An unverified webhook endpoint is a public API that writes to your database, and its address is not a secret — it is in our outbound connection logs, your access logs and probably a screenshot somewhere. The signature is HMAC-SHA256 over timestamp + "." + raw body, so you must hash the bytes you received, before any JSON parsing and re-serialising: a re-encoded body is a different string and will never match.

Node
const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=')),
  );

  // Reject anything old, or a genuine delivery captured today can be
  // replayed at your endpoint tomorrow. The signature alone does not
  // stop that — it covers the timestamp precisely so you can.
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!(age < 300)) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`, 'utf8')
    .digest('hex');

  // Constant time, so a comparison cannot be timed to guess the digest.
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(parts.v1, 'hex'),
  );
}

Delivery

Answer 2xx and quickly — anything else is a failure and we try again. Retries back off 10s → 1m → 5m → 15m → 1h → 3h → 6h → 12h, eight attempts in all; an endpoint that has failed twenty times in a row is switched off and we email you. Delivery is at least once, so the same event can arrive twice — make your handler idempotent on Vesopa-Event-Id, which is stable across every retry while Vesopa-Delivery changes.

Do the work after you answer. We wait ten seconds for a response, and an endpoint that rebuilds a search index before replying will time out, be retried, and rebuild it again.

Endpoints must be https:// and reachable from the public internet — an address that resolves to a private range is refused when you add it, because an endpoint pointing back inside our network would turn your webhook into a way of reading it. Redirects are not followed, for the same reason.

The portal shows every attempt with the status and the first part of the response body, and will replay any delivery on demand — so a deploy that was down for an hour costs nothing.

Getting a client id

Sign in and open the developer portal. Create an application, register your redirect URI, choose your scopes, and the client id and secret are issued straight away. Secrets can be rotated there too — add the new one, deploy, then revoke the old, so a rotation is never an outage.

Something not covered here? info@vesopasoftware.com.