OAuth Server helpers
This page shows how to work with OAuth applications from your own code: create them on the server, define and cap the scopes they may ask for, let them call your API as the user, and sign one mobile app in to several services. It is for app developers and module authors, in server mode only. Set up the module first: Overview.
Manage applications from server code
Use these helpers when an OAuth application should be created or changed by your own route, not in the admin area. They are auto-imported in Nitro.
| Helper | What it does |
|---|---|
requireOAuthAdmin(event) | Lets only users with the oauth:manage permission through, by cookie session. Returns { userId }, or throws 401, 403 forbidden or 403 session_required. |
getApplication(id) | Reads an application. It never includes the secret hash. |
requireApplication(id) | getApplication, or a 404 not_found. |
createApplication(input, ownerId?) | Creates an application and returns { application, secret }. |
updateApplication(id, patch) | Changes an application, validated like the admin API. |
deleteApplication(id) | Deletes an application, validated like the admin API. |
rotateApplicationSecret(id) | Replaces the secret. The old one stops working and refresh tokens are revoked. |
toAdminApplicationDto(application, activeGrants?) | The admin DTO of an application. |
applicationCallbackUrl(application) | The redirect URI the application must accept: websiteUrl plus callbackPath. |
authenticateClient(event, body) | Checks who sent a token or revoke request. |
listUserApplications(userId, event?) | The active applications a user may sign in to, highest priority first. |
userCanAccessApplication(userId, application, event?) | The allowedRoles check plus the oauth:application:access hook. |
resolveUserRoles(userId, event?) | The user's effective roles from Authorization, inheritance included. |
revokeUserGrants(userId, applicationId?) | Ends a user's refresh tokens, for all applications or one. |
revokeApplicationGrants(applicationId) | Signs everyone out of an application. |
getOAuthUser(userId) | The user without the password hash, with the columns your app added. |
createApplication shows the secret only in its return value. It is null for a
public application.
updateApplication and deleteApplication answer 409 managed_by_config for a
configured client, as the admin API does.
rotateApplicationSecret throws 400 for a public application, which has no secret.
authenticateClient checks the secret of a confidential application. A public one sends its client_id only.
revokeUserGrants ends sign-ins at once. A user who loses a role is stopped at the next refresh anyway, so call it
when that is too slow.
This route lets staff create an application:
export default defineEventHandler(async (event) => {
const { userId } = await requireOAuthAdmin(event)
const { application, secret } = await createApplication({
name: 'Shop',
websiteUrl: 'https://shop.example.com',
allowedRoles: ['member'], // effective roles, so a role that inherits `member` may sign in too
tags: ['shop'],
}, userId)
return { clientId: application.id, clientSecret: secret }
})
interface ApplicationInput {
clientType?: 'confidential' | 'public' // default confidential; fixed after creation
name: string
websiteUrl?: string | null // an origin without a path; https (http only for localhost). Optional for public applications
description?: string | null
icon?: string | null
priority?: number
loginUrl?: string | null
previewUrlPattern?: string | null // e.g. https://*.vercel.app
redirectUris?: string[] // extra exact redirect URIs; public applications may list custom-scheme URIs
allowedRoles?: string[] // effective roles, inheritance included; empty = every signed-in user
tags?: string[]
wildcardScopes?: boolean // default false; may the application ask for wildcard scopes (`*`, `tickets:*`)?
isActive?: boolean
isListed?: boolean // default true; false keeps it out of the users' applications list
}
interface OAuthApplication extends Omit<Required<ApplicationInput>, 'isActive' | 'isListed'> {
id: string // `app_` + random id: the OAuth `client_id`
websiteUrl: string | null
ownerId: number | null
isActive: boolean
isListed: boolean
createdAt: Date
updatedAt: Date
}
OAuthApplication never includes the secret hash. The admin API returns AdminApplicationDto. That is an
OAuthApplication with ISO date strings, activeGrants? and callbackUrl, which is null without a website.
Creating an application returns CreatedApplicationDto. It adds clientId and clientSecret, which is null for a
public application.
Configured clients in your own queries
Configured clients live in nuxt.config.ts,
with a copy in the database. These helpers keep the two in step when you query oauth_applications yourself.
| Helper | What it does |
|---|---|
ensureOAuthConfiguredApplications() | Creates the missing configured clients in the database, once per server instance. |
withOAuthConfiguredValues(row) | Applies the config-owned fields of a configured client to a row. |
isOAuthManagedApplication(application) | Whether config manages the application: a configured id and a public row. |
getOAuthConfiguredClients() | The validated clients, as OAuthNormalizedClient[]. |
isOAuthConfiguredClient(id) | Whether an application is one of the configured clients. |
ensureOAuthConfiguredApplications() before you query oauth_applications yourself, and pass each row through
withOAuthConfiguredValues. The lookups above already do both.Imports outside Nitro
The pure helpers, the typed route paths and all types are importable from @frigear-nu/oauth-server/utils:
- the helpers
matchesAllowedRoles,validateRedirectUri,parseScope,resolveScope,outdatedScopesandnormalizeWebsiteUrl; - the constants
DEFAULT_SCOPES,OIDC_SCOPESandDEFAULT_CALLBACK_PATH; - the typed paths
oauthServerApiandoidc, also exported from@frigear-nu/oauth-server/api. See Routes.
Scopes
A scope is what an application asks a user to allow. It is also how an application's token is limited: a token can do only what its scopes stand for. Auth and scopes explains the vocabulary. This section covers the scopes the issuer accepts and how it decides which ones a user may grant.
The issuer accepts three kinds of scope:
- OpenID Connect scopes.
openid,profile,emailandoffline_access. They stand for claims, not for permissions. - Permission scopes. Every permission a token may hold, such as
tickets:read, and its wildcard (tickets:*,*). Permissions markedsessionOnlynever travel in a token, so they are not scopes. A wildcard never covers asensitivepermission: those are named one by one. - Named scopes. One word that stands for a group of permissions, such as
ticketsfortickets:readandtickets:write. They come from three places: your config, a module, or the database.
scopes_supported in the discovery document lists all of them. A request for any other scope is answered with
invalid_scope.
Named scopes in config
Name a scope in oauthServer.scopes and list the permissions it stands for. The consent screen shows the label, the
description and the icon, and groups the scope under its service.
oauthServer: {
scopes: [
{
name: 'tickets',
label: 'Your tickets',
description: 'See your tickets and buy new ones',
icon: 'i-lucide-ticket',
permissions: ['tickets:read', 'tickets:write'],
},
// A permission scope only needs a description on the consent screen
{ name: 'vouchers:read', label: 'See your vouchers', service: 'Vouchers' },
],
}
A module offers a named scope from its setup(). This is the same registry, served to API tokens
too:
import { addScope } from '@frigear-nu/auth/utils'
addScope(nuxt, { name: 'inventory', label: 'The inventory', service: 'Inventory', permissions: ['inventory:read'] })
- A named scope has no sensitive or
sessionOnlypermissions. Those are rejected when the app builds. A scope may have no permissions at all, asopenidhas none. - Permission names in
scopesonly describe. They must name a permission that exists, or the build fails. Addingpermissionsto one is an error. - One name, one scope. Two entries with the same name are merged: their permissions are added together and the later label and description win. So you can reword a module's scope.
Named scopes in the database
Staff can define a named scope without a deploy. Open OAuth scopes in the admin area (/admin/oauth/scopes, with
Admin), or call the scopes API.
A database scope is stored in oauth_scopes and has a name, a label, a description, an icon and its permissions.
- A name is one lower-case word. Letters, digits,
.,_and-. It cannot be an OIDC scope or the name of a config or module scope. Those answer409 managed_by_config, because config is read-only here. - Permissions are checked when you save. Each must exist and must not be
sensitiveorsessionOnly, or the save answers400. - Growth asks users again. When an edit makes the scope stand for more permissions than before, its
versiongoes up. A user who consented to the old version gets tokens without the scope until they approve it again. - Turning one off. Set
isActivetofalseto stop accepting a scope without losing it. Deleting a scope makes new requests for it fail withinvalid_scope, and existing sign-ins lose it at their next refresh.
Config and module scopes show in the admin list with a Managed badge. Anyone with oauth:manage can read them,
and nobody can edit them there.
// Your own staff route
await createOAuthScope({ name: 'reports', label: 'Your reports', permissions: ['reports:read'] })
await updateOAuthScope('reports', { permissions: ['reports:read', 'reports:export'] }) // version goes up
Which scopes a user may grant
A scope reaches an application only when the signed-in user may hand it over. A scope is grantable when all of these hold:
- It is known. An OIDC scope, a permission scope or a named scope.
- It is within the user's cap. See Capping scopes by role. No cap is set by default.
- It is not a wildcard, unless the application allows it. Turn on
wildcardScopesfor the application, or for a configured client. - The user may delegate every permission it stands for. The user's roles, and the
authorization:beforehook, are checked. Policies are not, because they decide on a record and a scope has none.
An application can never get more than its user holds. For example, users:read is held by staff only, so a plain user
who approves scope=openid users:read gets a token with openid only, and the consent screen shows users:read as
unavailable.
Capping scopes by role
Without a cap, a user may hand over everything they may do. settings.roleScopes is a stricter ceiling: it names the
most each role may grant to any application.
oauthServer: {
settings: {
roleScopes: {
admin: ['*'],
user: ['openid', 'profile', 'email', 'tickets'],
},
},
}
- Unset means no cap. Only the user's permissions limit what they grant.
- Set means a whitelist. A user's cap is the union of the lists of all their effective roles, inheritance included.
A role that is not listed adds nothing, so a user with only unlisted roles may grant nothing, not even
openid. - Entries are scopes, permissions or wildcards.
*covers every non-sensitive permission and the OIDC scopes. A sensitive permission is inside the cap only when an entry names it. - Role names are checked at build time. A name that is not a configured role fails the build. During
nuxt prepareit logs a warning instead.
The cap and the user's permissions both apply. The cap can only narrow what the user may do, never widen it.
What the user sees and what the application gets
- At
/oauth/authorize. A scope this server does not know sends the user straight back to the application witherror=invalid_scopeand yourstate. A request with noscopegetsopenid profile email. - On the consent screen. Scopes the user may grant are listed, grouped by
service. Scopes the user may not grant, because they lack the permission or exceed their cap, show under Not available to your account. Approving drops them, and the application still gets a token. - In the token response.
scopeis exactly what was granted, not what was asked for. An application reads it to see what it got. - On refresh. Scopes are checked again, silently. A user who lost a permission, or whose role no longer allows a
scope, gets tokens without it. The refresh does not fail with
invalid_scope. The refresh token keeps the scope, so it comes back when the user gets the permission back.
The frozen permissions claim
An access token carries a permissions claim next to scope. It is what the granted scopes stand for, limited to what the
user may delegate at the moment the token is issued. It is then frozen:
{
"sub": "42",
"client_id": "app_ab12",
"scope": "openid tickets",
"permissions": ["tickets:read", "tickets:write"]
}
Your API uses the frozen set to decide whether a token may do something, so it never has to look at scopes, named
scopes or the oauth_scopes table. A user who loses a permission after the token was issued is stopped on every
request, because the user's roles are checked as well. A user who gains one does not widen an old token.
The claim is a reserved claim: oauth:claims cannot change it. A token issued before permissions were frozen has no claim.
Your API then expands its scope against the user's current permissions. The same frozen set is stored for API tokens.
API access
An OAuth application can call this app's API as the user who signed in to it. It sends the access token as
Authorization: Bearer <access_token>. Routes that call authorize(event, permission) (see
Auth) then see that user. principal.client.id names the application.
A route allows the call only when the permission is in the token's frozen permissions and the user's roles still
grant it. Otherwise it answers 403 insufficient_scope.
How scopes read on the consent screen
openid, profile and email have built-in, translated labels. Describe your own scopes in oauthServer.scopes, as
Named scopes in config shows. Without a description, a permission scope shows the
description it was registered with through addPermission, and any other scope shows its name.
What an access token cannot do
- Live checks. On every call the application must still be active. The user must still exist and pass the
application's
allowedRolescheck and theoauth:application:accesshook. Otherwise the call answers401. - No account changes. Permissions that are
sessionOnly, such as changing a password or a passkey, never reach a token. A route that needs one answers403 session_requiredto an access token. - Access tokens only. ID tokens (
typ: JWT) are not accepted as credentials.
One app for every service
One mobile app can sign in once at this issuer and call several services that accept its access tokens with OAuth Resource. Identity shows the whole flow. This section covers the issuer's config.
Set issuer (or NUXT_OAUTH_SERVER_ISSUER) to the public origin in production. Without it the issuer is the origin of the
request, X-Forwarded-Host and X-Forwarded-Proto are ignored, and a warning is logged once outside development.
oauthServer: {
issuer: 'https://frigear.nu',
// Every access token is for all services, and short-lived: services cannot revoke it.
accessTokenAudience: 'frigear-services',
accessTokenTtl: 600,
scopes: [
{ name: 'vouchers:read', description: 'See your vouchers', service: 'Vouchers' },
{ name: 'vouchers:write', description: 'Redeem your vouchers', service: 'Vouchers' },
{ name: 'shop:order', description: 'Place orders in the shop', service: 'Shop' },
{ name: 'inventory:read', description: 'See the inventory', service: 'Inventory' },
],
cors: { origins: ['capacitor://localhost', 'https://localhost'] },
}
The scope registry
Every service's permissions must exist on the issuer. Register them with addPermission (or authentication.permissions)
and describe them in oauthServer.scopes, as Scopes shows. scopes also names groups of them. Entries with the
same name are merged, so you can reword a module's scope.
Keeping scopes for staff
There is no staff-only flag on a scope. A scope can only be granted by a user who holds every permission it stands for,
so give the permission only to the roles that should have it. Here inventory:read is held by the staff role alone:
authorization: {
roles: {
staff: { label: 'Staff', grant: ['inventory:read'] },
},
},
oauthServer: {
scopes: [{ name: 'inventory:read', description: 'See the inventory', service: 'Inventory' }],
},
Everyone else loses the scope at consent, at the code exchange and at every refresh. Their token simply lacks it, and the
service answers 403 insufficient_scope. Use roleScopes to also cap what a role may hand to
an application.
Consent and claims
The consent screen groups the requested scopes by service, with the account's own scopes first. The mobile app's role
guards see a role only when the ID token carries one. Add it with the
oauth:claims hook. Access tokens carry no
profile. A service reads it from userinfo when it first sees a user, so the app asks for email.
permissions. A service that must
see changes at once can ask the issuer through introspection.App
The module's composables power the admin screen and the user's applications page. Use them to build your own.
const { query } = useTableQuery({ sort: { id: 'createdAt', desc: true } })
const admin = await useOAuthApplications(query) // admin CRUD and one page of the list (applications, total, loading, refreshing, error, refresh)
const mine = await useMyApplications() // the signed-in user's applications: { applications, loading, error, refresh }
| Composable | What it does |
|---|---|
useOAuthApplications(query?) | Admin create, update and delete, plus one page of the application list. |
useMyApplications() | The applications the signed-in user may sign in to. |
useOAuthApplicationActions() | The admin actions for one application, and its row menu. |
oauthApplicationsApi | The same endpoints as plain calls. |
All of them are auto-imported. They are also exported from @frigear-nu/oauth-server/composables.
useOAuthApplications is awaitable, like useMyApplications(), so the admin page renders its list on the server.
The list is paginated: pass useTableQuery().query, with the parameters of the admin list
route. Read applications (the items of the page) and
total. It has no default data, so applications is empty until loaded and after an error. Check loading and
error first.
useOAuthApplicationActions owns create and edit (a FormSlideover opened through useOverlay()), remove
and signOutEveryone (confirm dialogs), toggle, rotateSecret, copyClientId and menu(application) for the row
menu. Creating an application or replacing its secret shows the credentials once, in a modal.
oauthApplicationsApi resolves remove with null and revokeGrants with the application.
Pages
The admin, consent and applications pages follow the UX patterns. The
edit form of an application lives in the URL, /admin/oauth?application=<id>, so you can link to it from ⌘K or
another page.
Menu items
With Menu, the module adds these items:
- Applications (order 50) to the
accountmenu: the account tile and the left navigation. - OAuth applications (order 40) to the Settings group of the admin sidebar (
admin.nav.settings), when the admin page is on. - OAuth scopes (order 41) next to it, when the scopes page is on.
Both admin items show only to users with oauth:manage. They are only links: the pages and the API check the permission
again, and it needs a cookie session.
Next steps
- Hooks – add claims, veto access and audit tokens.
- Authorization – roles and permissions behind
allowedRolesandroleScopes. - OAuth Resource – set up a service that accepts these access tokens.
- Routes and clients – look up every endpoint and how a sign-in works.
- Create an OAuth application – register an app in the admin area or in config.