OAuth Server routes and clients
This page lists every route OAuth Server registers and walks through one sign-in. It then covers how public OAuth applications and configured clients behave, and the tables and translations the module adds. It is for app developers and for anyone connecting an app to the issuer, in server mode only. Set up the module first: Overview.
Routes
Routes the module registers, and who may call them.
Paths are typed in @frigear-nu/oauth-server/api and @frigear-nu/oauth-server/utils. Use oauthServerApi for the
module API, such as oauthServerApi.admin.application(id). Use oidc for the standard endpoints, such as oidc.token.
Use them instead of string literals.
OAuth and OpenID Connect endpoints
These endpoints keep their standard paths and answer in the shape the specifications define.
| Method | Path | Who may call | Status | Purpose |
|---|---|---|---|---|
GET | /.well-known/openid-configuration | anyone | 200 | The discovery document. |
GET | /.well-known/jwks.json | anyone | 200 | The public signing keys. |
GET | /oauth/authorize | anyone; signed-out people go to the login page | 302 | Checks the authorization request, then redirects to the consent page. |
POST | /oauth/authorize | session only | 200 | The consent decision. Answers { redirectUrl }. |
POST | /oauth/token | the application's credentials | 200 | The authorization_code and refresh_token grants. |
POST | /oauth/revoke | the application's credentials | 200 | Revokes a refresh token and the rest of its family (RFC 7009). |
GET | /oauth/userinfo | a bearer access token | 200 | The user's claims, filtered by scope. |
POST | /oauth/introspect | a confidential application's credentials | 200 | Tells whether an access token is active and what it may do now (RFC 7662). |
GET /oauth/authorize answers 400 for an invalid request and 403 when the user may not use the application. A
scope the server does not know redirects back to the application with error=invalid_scope and the state.
POST /oauth/authorize answers with the callback URL, carrying code and state, or error=access_denied. The body
must be JSON (Content-Type: application/json), otherwise it answers 415. The consent page and the /oauth/*
responses carry Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY, so they cannot be framed.
/oauth/token and /oauth/revoke take the client secret with HTTP Basic, or client_id and client_secret in the
body. A public application sends its client_id only. Both reply
{ error, error_description } (RFC 6749 §5.2) to every failure, including a malformed body (400 invalid_request).
/oauth/token adds refresh_token_expires_in to its response: the seconds until the new refresh token expires
(see Session lifetimes). Its scope is what the user granted, which can be less than what the
application asked for.
Module API
These routes serve the consent screen, the user's applications and sign-ins, and the admin screen.
| Method | Path | Who may call | Status | Purpose |
|---|---|---|---|---|
GET | /api/oauth-server/consent | session only | 200 | Data for the consent screen (ConsentDto). The query is the authorization request. |
GET | /api/oauth-server/me/applications | session only | 200 | The applications the user may sign in to (UserApplicationDto[]). |
GET | /api/oauth-server/me/sessions | session only | 200 | The applications the user is signed in to (OAuthSessionDto[]), one entry per application, most recently active first. |
DELETE | /api/oauth-server/me/sessions/:id | session only | 204 | Signs the user out of one application on every device. :id is the application id. 404 not_found when they have no live sign-in in it. |
DELETE | /api/oauth-server/me/sessions | session only | 204 | Signs the user out of every application. |
GET | /api/admin/oauth-server/applications | staff (session + oauth:manage) | 200 | One page of applications. |
POST | /api/admin/oauth-server/applications | staff | 201 | Creates an application. Returns CreatedApplicationDto, with the secret shown once. |
GET | /api/admin/oauth-server/applications/:id | staff | 200 | One application (AdminApplicationDto). |
PATCH | /api/admin/oauth-server/applications/:id | staff | 200 | Updates an application and returns it. Disabling it signs everyone out. |
DELETE | /api/admin/oauth-server/applications/:id | staff | 204 | Deletes an application with its codes and refresh tokens. |
POST | /api/admin/oauth-server/applications/:id/secret | staff | 200 | Replaces the client secret. Returns { clientSecret } once. |
POST | /api/admin/oauth-server/applications/:id/revoke | staff | 200 | Signs everyone out of the application and returns it. |
GET | /api/admin/oauth-server/scopes | staff | 200 | Every named scope: the read-only ones of config and modules (managed: true), then the database ones (AdminOAuthScopeDto[]). Not paginated. |
POST | /api/admin/oauth-server/scopes | staff | 201 | Creates a database scope: { name, permissions, label?, description?, icon?, isActive? }. |
GET | /api/admin/oauth-server/scopes/:name | staff | 200 | One scope, also a managed one. |
PATCH | /api/admin/oauth-server/scopes/:name | staff | 200 | Updates a database scope. Its version goes up when it stands for more permissions than before. |
DELETE | /api/admin/oauth-server/scopes/:name | staff | 204 | Deletes a database scope. |
GET /api/admin/oauth-server/applications returns { items, total, page, pageSize } of AdminApplicationDto. It
takes page, pageSize (at most 100), q (name or client ID), sort (createdAt, name or priority) and
order.
PATCH and DELETE answer 409 managed_by_config for a config-owned field of a
configured client. DELETE answers it for any configured client.
Staff means a user with the oauth:manage permission, which is sessionOnly: a bearer token answers
403 session_required.
The scopes routes change only database scopes. POST, PATCH and DELETE answer 409 managed_by_config for a
scope that config or a module defines, or an OpenID Connect scope. A body with an unknown, sensitive or sessionOnly
permission answers 400. See Scopes.
POST …/secret answers 400 bad_request for a public application.
The module API fails with the toolkit errors. They are 401,
403 forbidden (without oauth:manage), 403 session_required, 404 not_found, 400 bad_request, and h3's 400 validation error
for a malformed body or query.
How a sign-in works
An application sends the user to the issuer, the user signs in and approves, and the application exchanges a code for tokens:
application ──▶ GET /oauth/authorize?client_id&redirect_uri&response_type=code&state&code_challenge&code_challenge_method=S256[&scope&nonce]
not signed in ──▶ <login page>?redirect=/oauth/authorize?… ──▶ back here
role/access check (403 if denied) ──▶ /authorize?… (consent page)
user approves ──▶ POST /oauth/authorize {…, approved: true} ──▶ redirect to the application's callback with ?code&state
application ──▶ POST /oauth/token (code + code_verifier + client credentials) ──▶ access_token, id_token, refresh_token
- PKCE and a secret. PKCE (Proof Key for Code Exchange) with S256 is required for every application. Applications are confidential by default, with a secret shown once when created. See Public applications for the type without one.
- Redirect URIs are derived, not free-form. An application accepts
websiteUrlpluscallbackPath, its explicitredirectUris, and itspreviewUrlPattern. - Preview patterns look like
https://*.vercel.apporhttps://myapp-*.vercel.app: https, one*in the first hostname label, then at least two literal labels, an optional port and path prefix. The*matches one DNS label ofa-z,0-9and-. A redirect URI is matched on its parsed parts (scheme, host, port, path), must serialise back to itself, and may not hold a query, fragment, userinfo,%,\or non-ASCII characters. Saving any other pattern is a 400. Public applications cannot have a pattern (400): with no secret, anyone who can deploy to the pattern's domain could redeem their codes. A pattern already stored on a public application is ignored; list its redirect URIs instead. httpsis required, except forlocalhost. Public applications may also list native redirect URIs.- Refresh tokens rotate. Presenting a refresh token that was already used counts as theft, and revokes its whole family. Tokens stay bound to their application: another application cannot refresh or revoke them.
- Access is checked again on every token exchange,
userinfocall and API call. Disabling an application or removing a role also stops refreshes and API calls. - Access tokens are bare. They carry
sub,scope,permissions,client_id,aud,iss,exp,iatandjti, with the headertyp: at+jwt.permissionsis what the granted scopes stand for, frozen when the token was issued. See The frozen permissions claim. - Identity claims (
name,picture,email) live in the ID token anduserinfo, per granted scope.emailcomes withemail_verified,trueonce the user'semailVerifiedAtis set. - Scopes. An unknown scope is answered with
invalid_scope, as a redirect to the application. A request with noscopegetsopenid profile email. A scope the user may not grant is shown as unavailable on the consent screen and left out of the token. Refreshes check scopes again, silently. See Scopes.
Token introspection
POST /oauth/introspect lets a service ask the issuer whether an access token is still good and what it may do now
(RFC 7662). The frozen permissions claim in the token cannot change. The
introspection answer can: it is checked against the user's roles when you ask. OAuth Resource
uses it when you turn it on.
curl -s https://id.example.com/oauth/introspect \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$ACCESS_TOKEN" -d token_type_hint=access_token
{
"active": true,
"scope": "openid tickets",
"client_id": "app_ab12",
"sub": "42",
"exp": 1760000000,
"iat": 1759999100,
"iss": "https://id.example.com",
"aud": "frigear-services",
"token_type": "Bearer",
"permissions": ["tickets:read"]
}
- Who may ask. A confidential application, with HTTP Basic or
client_idandclient_secretin the body. A public application proves nothing with itsclient_id, so it answers401 invalid_client. The body is form encoded, and JSON is accepted too. - Only for its own tokens. The token must have been issued to the caller (its
client_id), or the caller's client ID must be in the token'saud. For any other token the answer is{ "active": false }. So is the answer for an invalid, expired, foreign or refresh token. Nothing says why. - Live. The application must still be active and the user must still pass its
allowedRolescheck and theoauth:application:accesshook, as for an API call. Otherwise the token is inactive. permissionsnow. The permissions frozen in the token, limited to what the user may still delegate. A user who lost a role sees the token shrink. A user who gained one does not widen it.- No caching. The response carries
Cache-Control: no-store. Discovery lists the endpoint asintrospection_endpoint.
accessTokenAudience such as frigear-services, the aud is not a client ID. A service can then only
introspect tokens issued to its own client. To let a resource service introspect every token, add its client ID to
accessTokenAudience, for example ['frigear-services', 'app_resource'].Public applications
A native app or a single-page app cannot keep a secret, since anything compiled into the app is public. Register it as a public OAuth application. Identity explains when to pick which type. This section covers how a public application behaves.
Set clientType: 'public' with createApplication, or pick Client type in the admin form.
- No client secret.
/oauth/tokenand/oauth/revokeidentify it byclient_idalone. Discovery listsnoneintoken_endpoint_auth_methods_supported.createApplicationreturnssecret: null. - PKCE and refresh token rotation protect it, as described in How a sign-in works.
- A
client_secretis refused. Sending one to a public application answers401 invalid_client, because it means the app was set up as the wrong type. An emptyclient_secretfield is accepted. - The website URL is optional. A native app often has none. Without one, the consent screen shows no website link,
and only the listed
redirectUrisare accepted. At least one is then required.
Unlisted applications
Some applications are opened only by the user, such as a mobile app. Turn off Show in the users' application list
for them. In code that is isListed: false, or listed: false for a configured client.
An unlisted application is left out of the applications list, the
oauth-server:mine bootstrap key and the my-oauth-applications MCP tool. Signing in works as before. The admin table
marks it Unlisted.
Native redirect URIs
A public application may add custom-scheme redirect URIs to redirectUris. The app receives the code on them after
the system browser step. Two kinds work:
- a private-use scheme, preferably the reverse of a domain you control, such as
com.example.app:/callback(RFC 8252 §7.1); - a webview origin, such as
capacitor://localhost/auth/callback.
They are matched exactly: no prefixes, wildcards or query strings. These are rejected when the application is saved:
- web and dangerous schemes, such as
javascript:,data:,file:andblob:; - credentials and fragments in the URI;
- any custom scheme on a confidential application.
https redirects, such as universal links or app links, work for both types.
https universal link
or app link as the redirect where the platform offers one.Calling from a browser on another origin
The webview of a Capacitor app runs at capacitor://localhost on iOS and https://localhost on Android. So its
requests to this server are cross-origin. List those origins in cors.origins. To open your own API to them too, list
its path prefixes in cors.paths:
export default defineNuxtConfig({
oauthServer: {
cors: { origins: ['capacitor://localhost', 'https://localhost'], paths: ['/api/'] },
},
})
- Which endpoints.
/oauth/token,/oauth/revoke,/oauth/userinfo, the/.well-knowndocuments and the routes undercors.pathsanswer CORS requests, and only for listed origins. - Never
/oauth/authorize. It is a top-level navigation with the user's cookie session. It stays closed even when a prefix covers it. - No cookies. No
Access-Control-Allow-Credentialsis sent, so cookies never travel cross-origin. The app authenticates with its client ID and bearer tokens. Session-only routes, such as account settings and admin, answer401to cross-origin calls. - Exact origins. An origin is
scheme://host[:port].*is rejected when the app starts. - Prefix matching.
/api/matches/apiand everything below it, not/apis. - Headers on your API.
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS,Access-Control-Allow-Headers: authorization, content-type, accept, andAccess-Control-Expose-Headers: www-authenticateso the app can read why a token was refused. Preflights are answered204.
The headers come from applyBearerCors in
@frigear-nu/module-kit/server. You can use it for a
middleware of your own.
Configured clients
An app that ships with its client_id compiled in, such as a mobile app or a single-page app, needs that ID to be the
same in every environment. Define it in clients instead of the admin area, and it gets a fixed ID. Such an
application is a configured client.
export default defineNuxtConfig({
oauthServer: {
cors: { paths: ['/api/'] },
clients: [{
id: 'mobile',
name: 'Example mobile',
redirectUris: ['com.example.app:/auth/callback'],
// Only accepted under `nuxt dev`: the app's dev server in a desktop browser.
devRedirectUris: ['http://localhost:3001/auth/callback'],
// The webviews' origins (the redirect URIs have none to derive).
corsOrigins: ['capacitor://localhost', 'https://localhost'],
}],
},
})
| Field | Type | Default | Purpose |
|---|---|---|---|
id | string | required | The client_id. |
name | string | required | Shown on the consent screen and in the admin area. |
type | 'public' | 'public' | Only public applications can be configured. |
redirectUris | string[] | required | Exact redirect URIs: https (http for localhost) or native. |
devRedirectUris | string[] | [] | Added to redirectUris under nuxt dev only. |
websiteUrl | string | null | derived | The application's origin. |
listed | boolean | true | Shown in the users' applications list. false makes it unlisted. |
description, icon, tags | As for any application. | ||
allowedRoles | string[] | [] | Roles that may sign in, matched against the user's effective roles. Empty means every signed-in user. |
wildcardScopes | boolean | false | May ask for wildcard scopes (*, tickets:*). Users still grant only what they may do. |
refreshToken | { idleTtl?, maxAge? } | the global refreshToken | This client's session lifetime, in seconds. |
allowLongBrowserSessions | boolean | false | Silences the warning for a browser client above 7 days idle. |
corsOrigins | string[] | false | derived | Origins added to cors.origins. |
id is 3 to 64 letters, digits, ., _ or -, starting with a letter or digit. It may not start with app_,
the prefix of created applications.
type is always 'public', since a secret does not belong in config.
websiteUrl defaults to the origin of the first web redirect URI. A native-only configured client has none
(null).
corsOrigins defaults to the origins of the redirect URIs that have one, such as https://app.example.com,
http://localhost:3001 or capacitor://localhost. false adds none.
Validation and runtime override
The list is checked when the app starts. An invalid ID, redirect URI or origin is a build error.
It is checked again at runtime, where NUXT_OAUTH_SERVER_CLIENTS (JSON) can replace it. The CORS origins are added at
runtime too, so a configured client the override removes takes its origins with it.
An invalid override is logged once. Application lookups then answer 500 not_configured. CORS falls back to
cors.origins, and same-origin requests are untouched.
Config is the source of truth
Each server instance creates missing configured clients in oauth_applications on the first OAuth request. Every
lookup then reads the name, website, redirect URIs, roles, tags, listing and session lifetime from that instance's
config, not the row.
So a rolling deploy, a preview or nuxt dev on the same database never overwrite each other, and production never
accepts devRedirectUris. A native-only configured client is left out of the user's
applications list.
In the admin area
Configured clients carry a Managed in nuxt.config badge, and managed: true in AdminApplicationDto.
- What staff can change. Staff can disable a configured client, and change its priority and login URL.
- Disabling sticks.
isActiveis set only when the application is created, so a disabled one stays disabled. - What they cannot change. Changing a config-owned field or deleting the application answers
409 managed_by_config. Sending the current value is fine.
Conflicts and removal
An ID that already belongs to a confidential application is left alone. The configured client is ignored: it is not managed, adds no CORS origins and stays editable as usual. The server log shows a warning.
A configured client you remove from clients is not deleted, so its users stay signed in. The server warns once. The
application becomes an ordinary one that you can edit or delete in the admin area.
Session lifetimes
How long a user stays signed in to an application depends on its refresh tokens. Access tokens stay short
(accessTokenTtl, 15 minutes by default). Two limits apply, both in seconds:
idleTtlis how long a refresh token lives unused. Every refresh starts a fresh window, so an app that is opened regularly stays signed in.maxAgeis how long a sign-in lives in total, counted from when the user signed in.nullmeans no cap.
A new refresh token expires at min(now + idleTtl, authTime + maxAge). The first setting that exists wins:
- The application's Session length and Sign in again after in the admin area (
refreshIdleTtlandrefreshMaxAgein the admin API;nullinherits). - For a configured client, its
refreshToken. Config owns it, so editing it answers409 managed_by_config. - The server's
refreshTokenoption: 30 days idle and no cap by default.
export default defineNuxtConfig({
oauthServer: {
refreshToken: { idleTtl: 60 * 60 * 24 * 30 }, // everyone else: 30 days unused
clients: [{
id: 'com.example.app',
name: 'Example',
redirectUris: ['com.example.app:/auth/callback'],
refreshToken: { idleTtl: 60 * 60 * 24 * 180 }, // the native app: signed in while opened every six months
}],
},
})
The policy is read whenever a refresh token is created. A changed policy reaches existing sign-ins at their next
refresh, and a sign-in already past a new, shorter maxAge is refused then (400 invalid_grant).
allowLongBrowserSessions: true.The server sorts applications by what it already knows, shown as kind in AdminApplicationDto: native is a
public application whose redirect URIs are all custom-scheme or loopback, browser is any other public
application, and confidential is a server.
Ending sign-ins
Long sign-ins need ways to end early. Each one fires oauth:token:revoked.
- The user sees the applications they are signed in to as Signed-in apps on the
Account security page, one entry per application, and can sign out of one (on every
device) or of all of them (reason
signed_out). Theme/sessionsroutes above serve that list. It is per application, not per sign-in: an application that signs in again without revoking its old token leaves that sign-in live until it expires, and nothing about the device is stored to tell the two apart. - A credential change ends every sign-in of the user when Auth fires
auth:credentials:changed: a password change or reset, an email change, or its revert (reasoncredentials_changed). Turn it off withrevokeOnCredentialChange: false. - Staff disable the application or sign everyone out of it (
POST …/applications/:id/revoke). Your own code can callrevokeUserGrants(userId).
type OAuthSessionDto = {
id: string // the application id
application: { id: string, name: string, icon: string | null }
signedInAt: string // the earliest live sign-in
lastActiveAt: string // the last refresh, on any device
expiresAt: string // when the last live sign-in ends
sessions: number // live sign-ins (devices, browsers, app installs)
}
The application side
To connect an application, register it in the admin area or with createApplication. Then configure it with the
client ID (app_…) and the client secret. The whole flow with curl:
# 1. PKCE pair
VERIFIER=$(openssl rand -base64 48 | tr -d '=+/' | cut -c1-64)
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
# 2. Open in a browser, sign in and approve. You are redirected to the callback with ?code=…&state=…
echo "https://id.example.com/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=https://app.example.com/auth/frigear&response_type=code&state=xyz&scope=openid%20profile%20email&nonce=n1&code_challenge=$CHALLENGE&code_challenge_method=S256"
# 3. Exchange the code
curl -s https://id.example.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code -d code="$CODE" -d code_verifier="$VERIFIER" \
-d redirect_uri=https://app.example.com/auth/frigear
# 4. Refresh (the returned refresh_token replaces the old one)
curl -s https://id.example.com/oauth/token -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token -d refresh_token="$REFRESH_TOKEN"
# 5. Userinfo
curl -s https://id.example.com/oauth/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"
Verify the ID token with any JOSE library against the JWKS. Check iss, aud (the client
ID) and nonce:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const jwks = createRemoteJWKSet(new URL('https://id.example.com/.well-known/jwks.json'))
const { payload } = await jwtVerify(idToken, jwks, { issuer: 'https://id.example.com', audience: clientId })
access_token, an id_token and a refresh_token. Step 5 answers
with the user's claims for the granted scopes.Database
Tables the module adds. Generate and commit a migration after installing.
The module registers four tables through registerDatabaseTable. It leaves users untouched. The tables store the
user ID without a foreign key.
| Table | Holds |
|---|---|
oauth_applications | Name, clientType, website and login URL, preview pattern, extra redirect URIs, allowedRoles, tags, wildcardScopes, priority, isActive, isListed, the session lifetime (refreshIdleTtl, refreshMaxAge) and the hashed secret. |
oauth_scopes | The named scopes staff define: name (the key), label, description, icon, permissions, version and isActive. |
oauth_authorization_codes | Single-use codes (hash only), the PKCE challenge, the nonce, the versions of the database scopes consented to and a 10 minute lifetime. |
oauth_refresh_tokens | Hashed refresh tokens, grouped in families for rotation and reuse detection, with the scope versions consented to. |
oauth_applications keeps the website empty for a public application without one. A public application stores
the hash of a random value nobody sees, so the secret column stays NOT NULL.
Translations
Override any string in your app's i18n/locales/<code>.json.
The module registers da and en locale files under the oauthServer.* namespace. They cover the consent, admin, scopes and
applications pages, in the groups menu, admin, form, credentials, applications, scopes and authorize. Every string
on those pages comes from them. See
overriding module strings.
Next steps
- Create an OAuth application – register an app and connect it, step by step.
- Client mode – sign a single-page or Capacitor app in as a public application.
- OAuth Resource – let your services accept the access tokens this issuer signs.
- Scopes – what applications may ask users for.
- Hooks – add claims, veto access and audit tokens.