Axuz Developers

Login with Axuz

Implement "Sign in with Axuz" in your app with OAuth 2.1 and OpenID Connect, PKCE S256, refresh rotation and id_token validation, with a complete Node example.

“Login with Axuz” lets your app identify people by their Axuz account without touching passwords, MFA or passkeys: Axuz authenticates, the person consents, and your app receives a signed id_token and, if requested, an access_token to call APIs. The protocol is Authorization Code with PKCE S256 (OAuth 2.1) plus OpenID Connect.

Decisions that apply to every app

  • Only the authorization_code and refresh_token grants exist. There is no implicit, password or client_credentials.
  • PKCE is mandatory and accepts only S256, including for confidential clients. plain is rejected.
  • state is required (8 to 512 characters).
  • The redirect_uri must be identical to a registered one: no wildcard, no prefix, no normalization.
  • sub is the user’s immutable internal ID (a UUID). It is never an e-mail or phone number: your app’s account key is the iss + sub pair.
  • Only a full session at Axuz authorizes (password, e-mail and TOTP or passkey). People with a restricted session are sent to enroll their factors.
  • The code lasts 60 seconds and is single-use; reusing it revokes the tokens it already produced.

1. Register the client

Each app has a client_id. The public type (SPA, native app) has no secret and proves itself with PKCE alone; confidential (server) also receives a client_secret.

POST/oauth/clients

Registration requires a full session and a trusted Origin (CSRF protection), so it is done with the account’s browser session, not a personal token. There is no dedicated screen for managing clients yet.

curl

curl -sS -X POST https://auth-api.axuz.net/oauth/clients \
  --cookie "access_token=$AXUZ_SESSION_JWT" \
  -H "Origin: https://auth.axuz.net" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My App",
    "client_type": "confidential",
    "redirect_uris": ["http://localhost:3000/callback", "https://app.example.test/callback"],
    "scopes": ["openid", "profile", "email", "offline_access"]
  }'

The 201 response carries client_id, client_type, redirect_uris, scopes, verified and, only for confidential, the client_secret, shown exactly once (only its hash is stored). Registration rules:

FieldRule
name1 to 80 characters
redirect_uris1 to 10; https, or http only on loopback (localhost, 127.0.0.1, [::1]), or a custom scheme on public/native clients; never a fragment, wildcard, javascript: or data:
scopesfrom the catalog below; the app may later request only a subset
team_idoptional; requires being owner or admin of the team

Each person may have up to 20 active clients (409 beyond that). Sensitive scopes (forms:write, tasks:write, files:write, webhooks:manage, mail:send, me:write, me:profile) require a verified client, approved by an Axuz operator; in self-service they are rejected. GET, PATCH and DELETE /oauth/clients/{client_id} and POST /oauth/clients/{client_id}/rotate-secret also exist (the old secret dies immediately). Deleting a client revokes all its grants.

Scopes

ScopeWhat it delivers
openidturns on OIDC mode: id_token and access to /oauth/userinfo (sub)
profilename
emailemail and email_verified
offline_accessissues a refresh token
forms:*, tasks:*, files:*, webhooks:manage, mail:send, me:*API access, mirroring the public catalog GET /v1/scopes

2. The flow, step by step

  1. Generate the transaction secrets. Create code_verifier (32 random bytes in base64url = 43 characters), code_challenge = BASE64URL(SHA256(code_verifier)), a state and a nonce, all from a CSPRNG. Keep code_verifier, nonce and state tied to the browser (HttpOnly cookie plus server-side storage).
  2. Redirect the person to the provider’s GET /oauth/authorize. If they have no session, the provider sends them to login; if they have not consented yet, to the consent screen; otherwise it comes straight back.
  3. Receive the callback at your redirect_uri with code, state and iss. Check that state matches the browser’s and that iss is the expected issuer (RFC 9207). On refusal, error arrives instead of code.
  4. Exchange the code at POST /oauth/token with the code_verifier.
  5. Validate the id_token and only then open your app’s session.
  6. Optional: call /oauth/userinfo with the access_token.

Step 2: authorization request

GET https://auth-api.axuz.net/oauth/authorize
  ?response_type=code
  &client_id=CLIENT_ID
  &redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback
  &scope=openid%20profile%20email
  &state=RANDOM_STATE
  &nonce=RANDOM_NONCE
  &code_challenge=CHALLENGE_43_CHARS
  &code_challenge_method=S256
ParameterRequiredRule
response_typeyesonly code
client_id, redirect_uriyesan error here does not redirect: it answers 400 JSON (prevents open redirect)
scopeyesspace-separated; include openid for OIDC; each scope must be on the client
stateyes8 to 512 characters
code_challengeyes43 characters (SHA-256 in base64url)
code_challenge_methodyesonly S256
noncerecommendedup to 512 characters; comes back inside the id_token
promptnonone (no UI: returns login_required or consent_required by redirect) or consent (forces the screen)

Other errors come back by redirect to your redirect_uri with error, error_description, state and iss.

Step 4: code exchange

POST/oauth/token

The body is application/x-www-form-urlencoded. A confidential client authenticates with client_secret_basic (Authorization: Basic header) or client_secret_post (fields in the body); a public client sends only the client_id.

curl

curl -sS -X POST https://auth-api.axuz.net/oauth/token \
  -u "$AXUZ_CLIENT_ID:$AXUZ_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d "code=$CODE" \
  --data-urlencode "redirect_uri=http://localhost:3000/callback" \
  -d "code_verifier=$CODE_VERIFIER"

Node

const res = await fetch('https://auth-api.axuz.net/oauth/token', {
  method: 'POST',
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: 'http://localhost:3000/callback',
    client_id: process.env.AXUZ_CLIENT_ID,
    client_secret: process.env.AXUZ_CLIENT_SECRET, // omit for a public client
    code_verifier,
  }),
});
const tokens = await res.json();

The 200 response (with Cache-Control: no-store) has access_token, token_type: "Bearer", expires_in (600), scope, id_token (with openid) and refresh_token (only with offline_access). Any failure in the exchange (expired code, already used, different redirect_uri, wrong verifier) answers the same invalid_grant message, on purpose.

3. Validate the id_token

The id_token is a three-part JWT signed with EdDSA (Ed25519). Before trusting it:

  1. Read the header and require alg = EdDSA. Reject anything else, including none.
  2. Look up the key by kid in the JWKS (jwks_uri from discovery, /.well-known/jwks.json) and verify the signature. If the kid is new, reread the JWKS once: keys rotate.
  3. Check iss equals the issuer, aud equals your client_id, exp is in the future (tolerate small clock skew) and nonce equals the one you stored.
  4. Use iss + sub as the identity.
ClaimPresent when
iss, sub, aud, azp, exp, iatalways (valid for 10 minutes)
nonceif you sent it in the request
nameprofile scope
email, email_verifiedemail scope

The access_token is a typ: at+jwt JWT (RFC 9068) valid for 10 minutes for Axuz APIs (aud: axuz-api); do not confuse it with the id_token or use it as proof of login. It can be revoked at any time, so APIs that accept it verify it on the server.

4. Userinfo

GET/oauth/userinfo
curl -sS https://auth-api.axuz.net/oauth/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"

Returns {"sub":"...","name":"...","email":"...","email_verified":true} according to the scopes. Without the openid scope the response is 403 insufficient_scope; an invalid token gives 401 invalid_token with WWW-Authenticate. POST is also accepted. Compare sub with the id_token’s.

5. Refresh with rotation

With offline_access you receive an opaque refresh token. Renew at POST /oauth/token with grant_type=refresh_token and refresh_token; you may send scope with a subset of what was granted (never widens, otherwise invalid_scope).

  • Each refresh is single-use: the response carries a new one, and you must discard the old one.
  • Reusing an already-exchanged refresh is treated as a leak and revokes the whole grant (every token of that authorization).
  • Sliding validity of 30 days, with an absolute cap of 90 days since consent; after that the person must consent again.

6. Revoke and introspect

POST/oauth/revoke

POST /oauth/revoke (RFC 7009) takes token (access or refresh) and the client’s authentication, and revokes the token’s whole grant. An unknown, expired or other-client token also answers 200, so existence is not revealed. The person can withdraw consent at DELETE /oauth/consents/{client_id}, which revokes their grants on that app. POST /oauth/introspect (RFC 7662) exists only for confidential clients and sees only the client’s own tokens.

7. Discovery

GET /.well-known/openid-configuration lists issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, revocation_endpoint, introspection_endpoint, scopes_supported, code_challenge_methods_supported: ["S256"], id_token_signing_alg_values_supported: ["EdDSA"], token_endpoint_auth_methods_supported (client_secret_basic, client_secret_post, none) and authorization_response_iss_parameter_supported: true. OIDC libraries configure themselves from it; the document’s issuer must equal the one you configured.

OAuth errors

Protocol errors follow RFC 6749: JSON {"error":"...","error_description":"..."}.

errorHTTPCommon cause
invalid_request400missing parameter, unregistered redirect_uri, short state, missing PKCE
invalid_client401unknown client_id or wrong client_secret
invalid_grant400invalid, expired or reused code or refresh; wrong verifier
unsupported_grant_type400any grant other than authorization_code and refresh_token
invalid_scope400unknown scope, not on the client, or larger than granted
access_deniedredirectthe person denied consent
login_required, consent_requiredredirectprompt=none without a session or without consent
enrollment_required403restricted session: enroll the factors
invalid_token, insufficient_scope401, 403/oauth/userinfo with a bad token or without openid
rate_limited429too many failures (client or IP); respect Retry-After

Complete Node example

The documentation repository ships a minimal, runnable server at examples/login-with-axuz/server.mjs (Node 22 or newer, no dependencies) and a test against a simulated provider at tests/examples/login-with-axuz.test.ts. It implements /login (PKCE, state, nonce, discovery) and /callback (checks state against the browser cookie and iss, exchanges the code, validates the EdDSA signature and claims of the id_token, calls userinfo) and prints the result.

export AXUZ_ISSUER=https://auth-api.axuz.net
export AXUZ_CLIENT_ID=YOUR_CLIENT_ID
export AXUZ_CLIENT_SECRET=YOUR_SECRET   # empty for a public client
export AXUZ_REDIRECT_URI=http://localhost:3000/callback
node examples/login-with-axuz/server.mjs
# open http://localhost:3000/login

The core of /login is this:

import { createHash, randomBytes } from 'node:crypto';

const b64u = (b) => Buffer.from(b).toString('base64url');
const verifier = b64u(randomBytes(32));            // 43 characters
const challenge = b64u(createHash('sha256').update(verifier).digest());
const state = b64u(randomBytes(16));
const nonce = b64u(randomBytes(16));
// keep verifier, nonce and state (HttpOnly cookie + server) and redirect:
const url = new URL(`${process.env.AXUZ_ISSUER}/oauth/authorize`);
url.search = new URLSearchParams({
  response_type: 'code', client_id: process.env.AXUZ_CLIENT_ID,
  redirect_uri: process.env.AXUZ_REDIRECT_URI, scope: 'openid profile email',
  state, nonce, code_challenge: challenge, code_challenge_method: 'S256',
}).toString();

Security: what not to do

  • Do not use plain or implicit, and do not put the client_secret in a browser or mobile app: those are public clients.
  • Do not skip the state check (login CSRF) or the nonce check (id_token replay).
  • Do not accept an id_token without verifying signature, iss, aud and exp; do not accept the alg the token asks for.
  • Do not use e-mail as identity: it changes and a person may have several. Use iss + sub.
  • Do not store tokens in localStorage or logs; keep refresh tokens on the server only.
  • Do not register a generic redirect_uri: the more exact, the less attack surface.

Older OAuth server (axuz_api)

axuz_api already exposes GET /oauth/authorize, POST /oauth/token, POST /oauth/revoke, GET /oauth/userinfo, /.well-known/openid-configuration, /.well-known/jwks.json and registration at /v1/oauth/apps, documented in the OAuth reference. It differs from the new IdP: tokens are opaque (axot_, one hour of validity, 30-day refresh), the code lasts 10 minutes, consent is per team (it requires the axuz_active_team cookie, and sub is derived from the user and team pair, with axuz_user_id and axuz_team_id separate), and PKCE is also S256 only. In addition, the openid scope is not in that server’s catalog: registering an app with openid today answers 400 unknown scope, so its OIDC mode is not usable by third-party apps until that is fixed. For a new “Login with Axuz”, plan against the axuz_auth_api IdP.

Updated