OAuth 2.0 / OIDC endpoints
External apps use the Authorization Code + PKCE flow for SSO. It is the only way to obtain application-bound JWTs.
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET | /.well-known/openid-configuration | OIDC discovery document. | Public |
GET | /api/v1/oauth/jwks | All usable access-key public keys as JWKS. ?kid=<fingerprint> narrows to all usable keys of that key's application. | Public |
GET | /api/v1/oauth/authorize | Validate an authorization request for the UI consent page. | Public |
POST | /api/v1/oauth/authorize | Issue an authorization code after the Auth UI session approves the request. | Auth UI bearer token |
GET | /api/v1/oauth/logout | Validate a post-logout redirect URI against the access key's Allowed URLs. | Public |
POST | /api/v1/oauth/token | Exchange authorization code + PKCE verifier, or rotate a refresh token. | Public / server-to-server (X-App-Public-Key) |
POST | /api/v1/oauth/revoke | Revoke a refresh token during external-app logout. | Public / server-to-server (X-App-Public-Key) |
GET | /api/v1/oauth/userinfo | Return the OIDC user profile for a valid access token. | Bearer access token |
There is no OAuth client_id. The client identifies itself with its
access key everywhere:
- Browser redirects (
/oauth/authorize,/oauth/logout) carrykey_id=<key fingerprint>— the SHA-256 fingerprint of the DER-encoded public key, base64url without padding. - Server-to-server calls (
/oauth/token,/oauth/revoke) carry the public key itself, in theX-App-Public-Keyheader or anapp_public_keyfield.
Authorization requests are sent to the UI browser endpoint configured as
OAUTH_AUTHORIZATION_ENDPOINT (default
http://localhost:3000/oauth/authorize):
GET {authViewUrl}/oauth/authorize?response_type=code&key_id=<key fingerprint>&redirect_uri=https%3A%2F%2Fhrm.example.com%2Fcallback&scope=openid%20profile%20email&state=<state>&nonce=<nonce>&code_challenge=<challenge>&code_challenge_method=S256
Logout redirects use the same identifier:
GET {authViewUrl}/oauth/logout?post_logout_redirect_uri=https%3A%2F%2Fhrm.example.com%2Fsigned-out&key_id=<key fingerprint>
The token endpoint is the API endpoint:
POST /api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
X-App-Public-Key: -----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----
grant_type=authorization_code&redirect_uri=https%3A%2F%2Fhrm.example.com%2Fcallback&code=<code>&code_verifier=<verifier>
The presented key must belong to the same application as the authorization code or refresh token being exchanged — tokens survive key rotation but never cross applications.
Token response covering
The access_token, id_token, and refresh_token response fields are
returned with a lightweight cvr1 AES-GCM cover derived from the access key
public key supplied in X-App-Public-Key. External app backends must uncover
those values with the same public key before JWT verification/use.
After uncovering, access and ID tokens are RS256 JWTs signed with the
presented access key's private key. The JWT header kid equals the access
key's public-key fingerprint and matches the key published by
/api/v1/oauth/jwks?kid=<fingerprint>.
The redirect_uri and post_logout_redirect_uri are validated against the
access key's Allowed URLs. The user's company is resolved server-side from
the user's active memberships subscribed to the requested application —
clients do not pick the company.
Allowed URLs
Every access key carries a list of Allowed URLs, set when the key is created on the support console's Access keys page. It is the only list the API will redirect a signed-in user to for requests made with that key.
Format — one URL per entry
The list is stored as text and split on whitespace or commas. Put each URL on its own line (or separate them with commas):
https://smartfarm-ui.caprover.truecode.africa
http://localhost:3005
Both forms parse to the same two entries:
https://smartfarm-ui.caprover.truecode.africa, http://localhost:3005
Each entry must be an absolute http:// or https:// URL with a host, and no
query string and no #fragment. Trailing slashes are stripped on save.
Do not paste an escaped \n between URLs. If the backslash is lost in
transit the list becomes a single glued entry such as
https://smartfarm-ui.caprover.truecode.africa/nhttp://localhost:3005 — a
syntactically valid URL, so it saves without error, but neither real origin is
registered and every sign-in fails the return URL check. After creating the
key, open it and confirm you see one URL per line.
How a redirect is matched
An incoming redirect_uri is allowed when some entry matches on both:
- Origin — exact. Scheme, host, and port must all be equal. Ports default
to 443 for
httpsand 80 forhttp, sohttps://app.example.comandhttps://app.example.com:443are the same origin, whilehttp://localhost:3005andhttp://localhost:3000are not. - Path — prefix. The redirect path must equal the entry's path or sit
below it. An entry with no path (path
/) matches every path on that origin.
| Allowed URL | redirect_uri | Result |
|---|---|---|
http://localhost:3005 | http://localhost:3005/api/authy/callback | Allowed — entry path is / |
http://localhost:3005/api/authy | http://localhost:3005/api/authy/callback | Allowed — below the entry path |
http://localhost:3005/api/authy/callback | http://localhost:3005/signed-out | Rejected — different path |
http://localhost:3005 | http://localhost:3000/api/authy/callback | Rejected — different port |
https://smartfarm-ui.caprover.truecode.africa | http://smartfarm-ui.caprover.truecode.africa/cb | Rejected — different scheme |
Registering the bare origin is usually what you want: it covers the callback, the post-logout URL, and any future route without needing a replacement key.
Local development
http:// and localhost are accepted — there is no special-casing that blocks
them. To run a local build against a deployed Authfy, add your dev origin
alongside the production one, matching the port your dev server actually uses:
https://smartfarm-ui.caprover.truecode.africa
http://localhost:3005
Be aware of what this means: anyone who can start an authorization request
with that key_id can have the code delivered to a listener on their own
machine. If that is not acceptable for a production application, create a
separate development access key instead — with only the local origin in its
Allowed URLs — and point the local .env at its public key.
Token creation rules
GETandPOST /api/v1/oauth/authorizecreate the authorization code.POST /api/v1/oauth/token(withgrant_type=authorization_codeorgrant_type=refresh_token) returns the access token, ID token, and refresh token.- The token endpoint requires the access key public key in
X-App-Public-Key(or anapp_public_keyform/JSON field). The backend resolves the key to its application, confirms the key is usable and belongs to the same application as the code or refresh token, and signs the tokens with that key's private key (JWT headerkid= the key's fingerprint). - A key is usable only while it is
ACTIVE, inside its optionalstartsAt/expiresAtwindow, and its application isACTIVE. Expired or revoked keys stop working immediately — including verification of outstanding tokens they signed. Because an application can hold many keys, rotation is zero-downtime: create a replacement key, reconfigure the client, then revoke the old key. POST /api/v1/auth/loginandPOST /api/v1/auth/refreshdo not acceptX-App-Public-Keyand never produce application JWTs.
The authfy npm package wires this whole flow — including
PKCE, the token cover, silent refresh, and logout — into a Next.js app with
two files.