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_codeandrefresh_tokengrants exist. There is no implicit, password orclient_credentials. - PKCE is mandatory and accepts only
S256, including for confidential clients.plainis rejected. stateis required (8 to 512 characters).- The
redirect_urimust be identical to a registered one: no wildcard, no prefix, no normalization. subis the user’s immutable internal ID (a UUID). It is never an e-mail or phone number: your app’s account key is theiss+subpair.- 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
codelasts 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.
/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:
| Field | Rule |
|---|---|
name | 1 to 80 characters |
redirect_uris | 1 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: |
scopes | from the catalog below; the app may later request only a subset |
team_id | optional; 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
| Scope | What it delivers |
|---|---|
openid | turns on OIDC mode: id_token and access to /oauth/userinfo (sub) |
profile | name |
email | email and email_verified |
offline_access | issues 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
- Generate the transaction secrets. Create
code_verifier(32 random bytes in base64url = 43 characters),code_challenge = BASE64URL(SHA256(code_verifier)), astateand anonce, all from a CSPRNG. Keepcode_verifier,nonceandstatetied to the browser (HttpOnly cookie plus server-side storage). - 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. - Receive the callback at your
redirect_uriwithcode,stateandiss. Check thatstatematches the browser’s and thatissis the expected issuer (RFC 9207). On refusal,errorarrives instead ofcode. - Exchange the
codeatPOST /oauth/tokenwith thecode_verifier. - Validate the
id_tokenand only then open your app’s session. - Optional: call
/oauth/userinfowith theaccess_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
| Parameter | Required | Rule |
|---|---|---|
response_type | yes | only code |
client_id, redirect_uri | yes | an error here does not redirect: it answers 400 JSON (prevents open redirect) |
scope | yes | space-separated; include openid for OIDC; each scope must be on the client |
state | yes | 8 to 512 characters |
code_challenge | yes | 43 characters (SHA-256 in base64url) |
code_challenge_method | yes | only S256 |
nonce | recommended | up to 512 characters; comes back inside the id_token |
prompt | no | none (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:
- Read the header and require
alg = EdDSA. Reject anything else, includingnone. - Look up the key by
kidin the JWKS (jwks_urifrom discovery,/.well-known/jwks.json) and verify the signature. If thekidis new, reread the JWKS once: keys rotate. - Check
issequals the issuer,audequals yourclient_id,expis in the future (tolerate small clock skew) andnonceequals the one you stored. - Use
iss+subas the identity.
| Claim | Present when |
|---|---|
iss, sub, aud, azp, exp, iat | always (valid for 10 minutes) |
nonce | if you sent it in the request |
name | profile scope |
email, email_verified | email 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":"..."}.
error | HTTP | Common cause |
|---|---|---|
invalid_request | 400 | missing parameter, unregistered redirect_uri, short state, missing PKCE |
invalid_client | 401 | unknown client_id or wrong client_secret |
invalid_grant | 400 | invalid, expired or reused code or refresh; wrong verifier |
unsupported_grant_type | 400 | any grant other than authorization_code and refresh_token |
invalid_scope | 400 | unknown scope, not on the client, or larger than granted |
access_denied | redirect | the person denied consent |
login_required, consent_required | redirect | prompt=none without a session or without consent |
enrollment_required | 403 | restricted session: enroll the factors |
invalid_token, insufficient_scope | 401, 403 | /oauth/userinfo with a bad token or without openid |
rate_limited | 429 | too 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
plainor implicit, and do not put theclient_secretin a browser or mobile app: those arepublicclients. - Do not skip the
statecheck (login CSRF) or thenoncecheck (id_tokenreplay). - Do not accept an
id_tokenwithout verifying signature,iss,audandexp; do not accept thealgthe 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
localStorageor 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.