OAuth Server

OAuth Server helpers

Create OAuth applications from code, define scopes, let applications call your API and sign one app in to every service.

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.

HelperWhat 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:

server/api/shop/connect.post.ts
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.

HelperWhat 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.
Call 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, outdatedScopes and normalizeWebsiteUrl;
  • the constants DEFAULT_SCOPES, OIDC_SCOPES and DEFAULT_CALLBACK_PATH;
  • the typed paths oauthServerApi and oidc, 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, email and offline_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 marked sessionOnly never travel in a token, so they are not scopes. A wildcard never covers a sensitive permission: those are named one by one.
  • Named scopes. One word that stands for a group of permissions, such as tickets for tickets:read and tickets: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.

nuxt.config.ts
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 sessionOnly permissions. Those are rejected when the app builds. A scope may have no permissions at all, as openid has none.
  • Permission names in scopes only describe. They must name a permission that exists, or the build fails. Adding permissions to 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 answer 409 managed_by_config, because config is read-only here.
  • Permissions are checked when you save. Each must exist and must not be sensitive or sessionOnly, or the save answers 400.
  • Growth asks users again. When an edit makes the scope stand for more permissions than before, its version goes up. A user who consented to the old version gets tokens without the scope until they approve it again.
  • Turning one off. Set isActive to false to stop accepting a scope without losing it. Deleting a scope makes new requests for it fail with invalid_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:

  1. It is known. An OIDC scope, a permission scope or a named scope.
  2. It is within the user's cap. See Capping scopes by role. No cap is set by default.
  3. It is not a wildcard, unless the application allows it. Turn on wildcardScopes for the application, or for a configured client.
  4. The user may delegate every permission it stands for. The user's roles, and the authorization:before hook, 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.

nuxt.config.ts
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 prepare it 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 with error=invalid_scope and your state. A request with no scope gets openid 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. scope is 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.

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 allowedRoles check and the oauth:application:access hook. Otherwise the call answers 401.
  • No account changes. Permissions that are sessionOnly, such as changing a password or a passkey, never reach a token. A route that needs one answers 403 session_required to 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.

nuxt.config.ts
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:

nuxt.config.ts
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.

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.

Resource indicators (RFC 8707), one audience per service, are not supported yet. Every service accepts every token of the shared audience and relies on the frozen 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 }
ComposableWhat 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.
oauthApplicationsApiThe 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.

With Menu, the module adds these items:

  • Applications (order 50) to the account menu: 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

Copyright © 2026