Create an OAuth application
An app can sign its users in at your issuer only after it is registered there. That
registration is an OAuth application. It gives the app a client_id, the redirect URIs
the issuer accepts and, optionally, the roles that may sign in. This page registers one at an issuer that runs
OAuth Server, and picks its type.
The other side of the sign-in depends on the app:
- A browser or mobile app uses Auth in client mode.
- A web app with its own server uses Auth in server mode.
Pick the application type
An application is confidential or public. The type is fixed when the application is created. Why the two exist is on Public and confidential applications.
| Your app | Type | Why |
|---|---|---|
| A web app with its own server, such as Nuxt with Sign in with frigear.nu | Confidential | The secret stays on your server. It is sent on every token request, next to PKCE (Proof Key for Code Exchange). |
| A single-page app (SPA), a Capacitor or mobile app, a desktop app or a CLI | Public | Anything shipped to a browser or a device can be extracted, so a secret there is not a secret. PKCE protects the code exchange, and refresh token rotation protects the refresh token. |
| Machine to machine, with no user | Not supported | /oauth/token takes only the authorization_code and refresh_token grants. Every token belongs to a signed-in user, and there is no client_credentials grant. |
- PKCE with S256 is mandatory for both types. A request without
code_challenge_method=S256is a400. - A public application has no secret at all. The server stores a throwaway hash and never shows it.
- A
client_secretsent to a public application is refused, with401 invalid_client. It is not ignored. - A confidential application without its secret gets
401 invalid_clienton every token and revoke request. - Client mode is public by design. A production build that embeds
NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRETfails. - The type cannot be changed afterwards. Register a new application instead.
Create it in the admin area
Use the admin area for a confidential application, or for a public one that does not need the same client_id in
every environment.
- The admin page:
oauthServer.settings.pages.admin: true, with Admin installed. - A user whose role holds the
oauth:managepermission (admins do by default), or a before hook listener that allows it. Nobody else can manage applications. Access control fails closed.
- Open OAuth applications in the Settings group of the admin sidebar (
/admin/oauth). Choose Add application. - Pick the Client type: Confidential or Public.
- Fill in the Name, which the consent screen shows.
- Fill in the Website URL: the origin of the app, without a path, such as
https://app.example.com. Usehttponly for localhost. A confidential application needs it. A public application needs it or at least one redirect URI, so a native app can leave it empty and list its callback under Redirect URIs. - Fill in the Redirect URIs. See Redirect URIs.
- Optional: a Preview URL pattern, such as
https://*.vercel.app. It accepts the callback path on preview deployments, where*is one hostname label. - Set the Allowed roles. Leave them empty to let every signed-in user in, or list the roles that may. They are
the roles of
authorization.roles(including inherited andautoones), plus any your app adds in theauthorization:roleshook. - Optional: Login URL, Description, Icon, Priority and Tags. The Login URL is where the Open button of the user's application list goes, and it defaults to the website URL. Priority orders that list.
- Choose Add application.
app_…) and, for a confidential application, the Client secret. The secret is
shown only this once. Store it before you close the modal. See Secrets./admin/oauth. Its edit form opens at
/admin/oauth?application=<id>.A row's menu has Edit application, Copy client ID, Disable application, New client secret, Sign
everyone out and Delete application. Disable application sets isActive to false.
Create it in nuxt.config
oauthServer.clients defines public applications in config. These are
configured clients. Use them when:
- the app ships with its
client_idcompiled in, such as a mobile app, an SPA or a starter, and the id must be the same in every environment; - the registration should be versioned with the app.
Ids are free-form, such as mobile or com.example.app. app_… is reserved for applications created in the admin
area.
export default defineNuxtConfig({
oauthServer: {
cors: { paths: ['/api/'] },
clients: [
{
// Mobile (Capacitor): custom-scheme redirect, webview origins for CORS.
id: 'bills-mobile',
name: 'Bills (mobile)',
redirectUris: ['nu.frigear.bills:/auth/callback'],
// Accepted under `nuxt dev` only.
devRedirectUris: ['http://localhost:3001/auth/callback'],
corsOrigins: ['capacitor://localhost', 'https://localhost'],
},
{
// Web SPA: the origin is taken from the redirect URI.
id: 'bills-web',
name: 'Bills (web)',
redirectUris: ['https://bills.example.com/auth/callback'],
devRedirectUris: ['http://localhost:3000/auth/callback'],
},
],
},
})
type accepts only 'public', because a secret does not belong in
config, and an invalid entry fails the build. Register confidential applications in the admin area, or with
createApplication.- Other fields.
websiteUrl,description,icon,allowedRoles,tagsandcorsOrigins. See CORS. The full table is on Configured clients. - Runtime override.
NUXT_OAUTH_SERVER_CLIENTS, as JSON, replaces the list at runtime. - The stored row. The row is created on the first OAuth request, and the config wins on every read.
- In the admin area. The application shows Managed in nuxt.config. Staff can disable it and change its priority
and login URL. Any other change, or a delete, is
409 managed_by_config. - Turning it back. Remove the entry from config to turn it into an ordinary application.
- Id clashes. An id that already belongs to a confidential application is ignored, with a warning in the server log.
Redirect URIs
The server accepts a redirect_uri only when it is an exact string match of one of these. No prefixes, wildcards,
query strings or fragments.
- The website URL plus
callbackPath, which defaults to/auth/frigear. This applies to applications created in the admin area. Configured clients do not get it implicitly: list every URI. - An entry of Redirect URIs (
redirectUris, at most 20). It ishttps, orhttpforlocalhost,127.0.0.1and[::1]. Loopback is matched exactly, so list the port you use. - The Preview URL pattern, for web redirects.
Native redirects are for public applications only. A confidential application is limited to https and loopback.
- A native redirect is a private-use scheme with one slash, such as
nu.frigear.bills:/auth/callback, with the app id in lowercase. - A webview origin also works, such as
capacitor://localhost/auth/callback. - Web schemes,
javascript:,data:,file:, credentials and fragments are rejected.
| App | Register |
|---|---|
| Auth client mode in a browser | <origin>/auth/callback (its callbackPath default), plus http://localhost:3000/auth/callback for development |
| Auth client mode in a native app | <native.scheme in lowercase>:/auth/callback |
| Sign in with frigear.nu, in Auth | <origin>/auth/frigear: the website URL plus the server's default callbackPath, so no extra entry |
devRedirectUris exist for configured clients only. For an application created in the admin area, add the localhost
URI to Redirect URIs.
Scopes, consent and roles
An access token is a scope of what the user may do, never more. Four things decide what an application gets:
| Layer | What it does |
|---|---|
| What exists | OpenID Connect scopes (openid, profile, email), named scopes (oauthServer.scopes, a module's addScope, and scopes you add in the admin area), and permissions or resource:* wildcards. |
| What the user may grant | Every permission the scope expands to must pass the user's roles and the before hook (canGrantScope). A scope that does not is shown as unavailable on the consent screen and left out of the token. |
| What the role caps | oauthServer.settings.roleScopes lists the most each role may grant to a client. |
| What the application may ask for | Wildcards (*, tickets:*) only when the application has wildcardScopes on. |
- Named scopes. A scope is one word (
tickets) that stands for permissions. Define it in config, or add it in the admin area (Scopes, in the Settings group). A named scope never holds a sensitive orsessionOnlypermission.nuxt.config.ts (the issuer)oauthServer: { scopes: [ { name: 'tickets', label: 'Your tickets', permissions: ['tickets:read', 'tickets:write'] }, ], }
Scopes defined in config or by a module are read-only in the admin area (409 managed_by_config). When a stored scope grows, its version is bumped and users are asked to consent again. - Unknown scopes. A scope the server does not know is
invalid_scopeat/oauth/authorize, redirected back to the application once its redirect URI is trusted. On a refresh nothing is rejected: scopes the user can no longer grant are dropped silently. Noscopemeansopenid profile email. - The role cap.
roleScopesmaps a role to the scopes, permissions or wildcards it may grant. A user's cap is the union over all their roles, inheritance included, and a role that is not listed contributes nothing. Unset, there is no cap beyond the user's permissions. Names must be configured roles; the build checks them.nuxt.config.ts (the issuer)oauthServer: { settings: { roleScopes: { admin: ['*'], user: ['openid', 'profile', 'tickets:read', 'tickets:write'], }, }, }
It is a ceiling, not a default: a role listed there still only grants what the user could do. - Wildcard scopes. An application gets
*orresource:*only withwildcardScopes: trueon it (default off). Sensitive permissions (:pii) are never covered by a wildcard, andsessionOnlypermissions are never offered as scopes at all. - The
permissionsclaim. The access token carries the scopes, expanded and intersected with the user's permissions at issue. A resource server reads this claim (OAuth Resource) instead of looking up roles it does not have. A token never gains permissions later; it only loses them if the user does. - Introspection. A resource server that cannot verify tokens locally calls
POST /oauth/introspect(RFC 7662) with its client credentials. It authenticates as a confidential application and only gets an answer for tokens issued to it or for its audience: anything else is{ "active": false }. The answer holds the livepermissions, so a role change shows at once, not at expiry. - Consent. Every sign-in shows the consent screen (
/authorize) with the requested scopes. It cannot be skipped per application. - Allowed roles. An application with roles refuses users who hold none of them: with
403at the authorization request, and withaccess_deniedat the token endpoint. Roles are resolved through the role provider, so assigned, inherited andautoroles all count. The check runs again on every refresh. So removing a role, or disabling the application, stops the next refresh. - Refresh tokens. The server always issues one with a user grant. There is no
offline_accessscope: it is dropped as unsupported, so asking for it is harmless. Each refresh replaces the token, and using an old one revokes its whole family. - Lifetimes.
accessTokenTtl(default 900 s) andcodeTtl(600 s) are server options. How long a refresh token lives is set per application (Session length in the admin area), else by the server'srefreshTokenoption (30 days unused). The token response includesrefresh_token_expires_in. See Session lifetimes.
Secrets
A confidential application's secret is shown once: in the modal after creating the application, and after New client secret. Only a hash is stored, so a lost secret is replaced, not recovered.
NUXT_FRIGEAR_SSO_CLIENT_SECRET. Never put it in runtimeConfig.public, a committed file, or a browser or mobile
bundle.| Action | What it does |
|---|---|
| New client secret | Rotates the secret (POST /api/admin/oauth-server/applications/:id/secret). The old secret stops working at once, and every refresh token of the application is revoked, so everyone is signed out. A public application has no secret to rotate (400). |
| Sign everyone out | Revokes all refresh tokens and keeps the secret. Use it after a leak of tokens. |
| Disable application | Blocks new sign-ins and refreshes. |
| Delete application | Also removes the application's codes and tokens. |
Access tokens already issued stay valid until they expire, after 15 minutes by default. To end one user's sessions,
call revokeUserGrants(userId, applicationId?). See the
OAuth Server helpers.
CORS for browser and Capacitor clients
Native redirects need no cross-origin requests (CORS). Two kinds of app do call the server from a browser on another origin, so CORS applies to them:
- the webview of a Capacitor app:
capacitor://localhoston iOS, andhttps://localhoston Android; - an SPA on another origin.
| Setting | What it does |
|---|---|
oauthServer.cors.origins | Exact origins, never *, that may call /oauth/token, /oauth/revoke, /oauth/userinfo and the /.well-known documents. NUXT_OAUTH_SERVER_CORS_ORIGINS takes a comma separated list. |
A configured client's corsOrigins | The client's own origins. By default, those of its redirect URIs that have one: a native nu.frigear.bills:/… has none. false adds none. For a mobile app, list the two webview origins here. |
oauthServer.cors.paths | Path prefixes of your own API, such as ['/api/'], that the same origins may call with a bearer token. |
- An application created in the admin area adds no origins. List them in
cors.origins. /oauth/authorizeis never opened to CORS. It is a navigation, with cookies.- Cookies never travel cross-origin. So session-only routes answer
401to these apps.
Connect the app
A public application, in a browser or mobile app. Point Auth in client mode at the issuer. There is no secret option.
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@frigear-nu/auth'],
authentication: {
mode: 'client',
client: {
issuer: 'https://id.example.com',
clientId: 'bills-mobile', // or app_… for an admin-created application
scopes: ['openid', 'profile', 'email'], // must contain 'openid'
discovery: false, // the Frigear OAuth server's own paths
native: { scheme: 'nu.frigear.bills' }, // nu.frigear.bills:/auth/callback
},
},
})
A confidential application, in a web app with its own server. Use Sign in with frigear.nu (oauth.frigear), with
the Client ID and secret from the modal. Create the application with the app's origin as the Website URL.
<origin>/auth/frigear is then accepted without a Redirect URIs entry.
NUXT_FRIGEAR_SSO_URL=https://id.example.com
NUXT_FRIGEAR_SSO_CLIENT_ID=app_…
NUXT_FRIGEAR_SSO_CLIENT_SECRET=…
With both set, GET /auth/frigear signs users in at the issuer. The options are on Auth.
A service that only receives the tokens, such as an API behind the app, is not an OAuth application. It verifies the access token with OAuth Resource, and needs no application of its own.
For other issuers, and the full checklist of what the app needs, see Use your own OAuth server.
Troubleshooting
| Symptom | Likely cause |
|---|---|
401 invalid_client at /oauth/token | A wrong client_id; a confidential application without its secret, or with an old one after rotation; or a secret sent to a public application. Remove the secret from a public application's app. |
| The authorize page says "The redirect_uri is not allowed for this application" | Not an exact match. Compare scheme, case, port, trailing slash and path (appid:/auth/callback, one slash). A native URI on a confidential application is never accepted. |
| "Unknown or disabled application" | A wrong client_id, or the application is disabled. |
invalid_grant on a refresh | The refresh token was already used, was revoked or expired. Reuse happens when two tabs or devices share one, or a response was lost after rotation. Revocation comes from Sign everyone out or a rotated secret. The whole family is revoked: sign in again. |
invalid_grant: redirect_uri does not match | The token request's redirect_uri differs from the authorization request's, or the code was reused or expired (codeTtl). |
invalid_scope at /oauth/authorize | The request names a scope the server does not know. Define it in oauthServer.scopes or the admin area, or ask for a permission that is registered. |
| A scope missing from the token, or unavailable on the consent screen | The user's roles do not grant it, it lies outside their roleScopes cap, or it is a wildcard for an application without wildcardScopes. |
access_denied at the token endpoint, or 403 at authorize | The user lacks an allowed role, the application is disabled, or oauth:application:access vetoed. |
409 managed_by_config in the admin area | The application is a configured client. Edit oauthServer.clients. |
500 not_configured | oauthServer.clients, or NUXT_OAUTH_SERVER_CLIENTS, is invalid. The server log names the problem. |
| A browser CORS error on the token request or the API | The origin is not in cors.origins or the client's corsOrigins, or the API path is not under cors.paths. The authorization endpoint is never CORS-enabled, by design. |
| A confidential application in a mobile app or SPA | Do not. Register a public application: a secret in the bundle is public, and Auth's client mode fails the build for it. |
Next steps
- Signing keys – set up the key the issuer signs this application's tokens with.
- Routes and clients – look up every route and configured client field.
- Access control – how permissions, roles and scopes fit together.
- Identity – see how the app, the issuer and the resource server relate.