OAuth Server

OAuth Server routes and clients

Every route OAuth Server registers, how a sign-in runs, and how public and configured applications behave.

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.

MethodPathWho may callStatusPurpose
GET/.well-known/openid-configurationanyone200The discovery document.
GET/.well-known/jwks.jsonanyone200The public signing keys.
GET/oauth/authorizeanyone; signed-out people go to the login page302Checks the authorization request, then redirects to the consent page.
POST/oauth/authorizesession only200The consent decision. Answers { redirectUrl }.
POST/oauth/tokenthe application's credentials200The authorization_code and refresh_token grants.
POST/oauth/revokethe application's credentials200Revokes a refresh token and the rest of its family (RFC 7009).
GET/oauth/userinfoa bearer access token200The user's claims, filtered by scope.
POST/oauth/introspecta confidential application's credentials200Tells 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.

MethodPathWho may callStatusPurpose
GET/api/oauth-server/consentsession only200Data for the consent screen (ConsentDto). The query is the authorization request.
GET/api/oauth-server/me/applicationssession only200The applications the user may sign in to (UserApplicationDto[]).
GET/api/oauth-server/me/sessionssession only200The applications the user is signed in to (OAuthSessionDto[]), one entry per application, most recently active first.
DELETE/api/oauth-server/me/sessions/:idsession only204Signs 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/sessionssession only204Signs the user out of every application.
GET/api/admin/oauth-server/applicationsstaff (session + oauth:manage)200One page of applications.
POST/api/admin/oauth-server/applicationsstaff201Creates an application. Returns CreatedApplicationDto, with the secret shown once.
GET/api/admin/oauth-server/applications/:idstaff200One application (AdminApplicationDto).
PATCH/api/admin/oauth-server/applications/:idstaff200Updates an application and returns it. Disabling it signs everyone out.
DELETE/api/admin/oauth-server/applications/:idstaff204Deletes an application with its codes and refresh tokens.
POST/api/admin/oauth-server/applications/:id/secretstaff200Replaces the client secret. Returns { clientSecret } once.
POST/api/admin/oauth-server/applications/:id/revokestaff200Signs everyone out of the application and returns it.
GET/api/admin/oauth-server/scopesstaff200Every named scope: the read-only ones of config and modules (managed: true), then the database ones (AdminOAuthScopeDto[]). Not paginated.
POST/api/admin/oauth-server/scopesstaff201Creates a database scope: { name, permissions, label?, description?, icon?, isActive? }.
GET/api/admin/oauth-server/scopes/:namestaff200One scope, also a managed one.
PATCH/api/admin/oauth-server/scopes/:namestaff200Updates a database scope. Its version goes up when it stands for more permissions than before.
DELETE/api/admin/oauth-server/scopes/:namestaff204Deletes 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 websiteUrl plus callbackPath, its explicit redirectUris, and its previewUrlPattern.
  • Preview patterns look like https://*.vercel.app or https://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 of a-z, 0-9 and -. 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.
  • https is required, except for localhost. 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, userinfo call 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, iat and jti, with the header typ: at+jwt. permissions is 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 and userinfo, per granted scope. email comes with email_verified, true once the user's emailVerifiedAt is set.
  • Scopes. An unknown scope is answered with invalid_scope, as a redirect to the application. A request with no scope gets openid 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_id and client_secret in the body. A public application proves nothing with its client_id, so it answers 401 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's aud. 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 allowedRoles check and the oauth:application:access hook, as for an API call. Otherwise the token is inactive.
  • permissions now. 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 as introspection_endpoint.
With a shared 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.

The type is fixed when the application is created. You cannot change a confidential application into a public one later, or back.
  • No client secret. /oauth/token and /oauth/revoke identify it by client_id alone. Discovery lists none in token_endpoint_auth_methods_supported. createApplication returns secret: null.
  • PKCE and refresh token rotation protect it, as described in How a sign-in works.
  • A client_secret is refused. Sending one to a public application answers 401 invalid_client, because it means the app was set up as the wrong type. An empty client_secret field 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 redirectUris are 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: and blob:;
  • 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.

Any app on the device can claim a custom scheme. PKCE keeps the code itself safe, but prefer an 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:

nuxt.config.ts
export default defineNuxtConfig({
  oauthServer: {
    cors: { origins: ['capacitor://localhost', 'https://localhost'], paths: ['/api/'] },
  },
})
  • Which endpoints. /oauth/token, /oauth/revoke, /oauth/userinfo, the /.well-known documents and the routes under cors.paths answer 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-Credentials is 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, answer 401 to cross-origin calls.
  • Exact origins. An origin is scheme://host[:port]. * is rejected when the app starts.
  • Prefix matching. /api/ matches /api and 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, and Access-Control-Expose-Headers: www-authenticate so the app can read why a token was refused. Preflights are answered 204.

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.

nuxt.config.ts
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'],
    }],
  },
})
FieldTypeDefaultPurpose
idstringrequiredThe client_id.
namestringrequiredShown on the consent screen and in the admin area.
type'public''public'Only public applications can be configured.
redirectUrisstring[]requiredExact redirect URIs: https (http for localhost) or native.
devRedirectUrisstring[][]Added to redirectUris under nuxt dev only.
websiteUrlstring | nullderivedThe application's origin.
listedbooleantrueShown in the users' applications list. false makes it unlisted.
description, icon, tagsAs for any application.
allowedRolesstring[][]Roles that may sign in, matched against the user's effective roles. Empty means every signed-in user.
wildcardScopesbooleanfalseMay ask for wildcard scopes (*, tickets:*). Users still grant only what they may do.
refreshToken{ idleTtl?, maxAge? }the global refreshTokenThis client's session lifetime, in seconds.
allowLongBrowserSessionsbooleanfalseSilences the warning for a browser client above 7 days idle.
corsOriginsstring[] | falsederivedOrigins 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. isActive is 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:

  • idleTtl is how long a refresh token lives unused. Every refresh starts a fresh window, so an app that is opened regularly stays signed in.
  • maxAge is how long a sign-in lives in total, counted from when the user signed in. null means no cap.

A new refresh token expires at min(now + idleTtl, authTime + maxAge). The first setting that exists wins:

  1. The application's Session length and Sign in again after in the admin area (refreshIdleTtl and refreshMaxAge in the admin API; null inherits).
  2. For a configured client, its refreshToken. Config owns it, so editing it answers 409 managed_by_config.
  3. The server's refreshToken option: 30 days idle and no cap by default.
nuxt.config.ts
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).

Both values are capped at two years, so a forgotten device stops working by itself. A browser application keeps its refresh token where any XSS can read it, so the admin area warns above 7 days idle. A configured client gets the warning in the server log unless it sets 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). The me/sessions routes 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 (reason credentials_changed). Turn it off with revokeOnCredentialChange: false.
  • Staff disable the application or sign everyone out of it (POST …/applications/:id/revoke). Your own code can call revokeUserGrants(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 })
What you should see: step 3 answers with an 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.

TableHolds
oauth_applicationsName, 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_scopesThe named scopes staff define: name (the key), label, description, icon, permissions, version and isActive.
oauth_authorization_codesSingle-use codes (hash only), the PKCE challenge, the nonce, the versions of the database scopes consented to and a 10 minute lifetime.
oauth_refresh_tokensHashed 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.

Client secrets, codes and refresh tokens are stored only as SHA-256 hashes.

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.
Copyright © 2026