OAuth Server hooks
OAuth Server has no role model or staff list of its own. Who may manage applications, and which roles may sign in to which application, come from the permissions and roles of Auth and Authorization. What is left for you to answer is what goes into a token, extra vetoes and audit logging. This page is for app developers, in server mode only. Set up the module first: Overview.
Hooks
Your app answers these Nitro hooks from a server plugin (server/plugins/*.ts).
| Hook | When it runs | What your listener does |
|---|---|---|
oauth:application:access | After the allowedRoles check passed. | Sets allowed = false, and optionally reason, to refuse. |
oauth:claims | An ID token, an access token or a userinfo response is built. | Changes claims. |
oauth:token:issued | Tokens were issued, at a code exchange or a refresh. | Logs or audits. |
oauth:token:revoked | A refresh token family was revoked. | Logs or audits. |
ctx.user or ctx.userId, never from the session on ctx.event. The session is empty for requests
that carry a token, and event is missing for background calls.Each hook receives one context object:
type ApplicationAccessContext = {
event?: H3Event // undefined for background calls such as listUserApplications(userId)
user: OAuthUser
application: OAuthApplication
roles: string[] // the user's effective roles
allowed: boolean // true when the role check passed
reason?: string // shown to the user as the 403 message
}
type ClaimsContext = {
user: OAuthUser
application: OAuthApplication
scope: string[] // granted scopes
roles: string[] // effective roles
tokenType: 'id_token' | 'access_token' | 'userinfo'
claims: Record<string, unknown> // registered claims are re-applied afterwards
}
type TokenIssuedContext = { userId: number, applicationId: string, scope: string[], grant: 'authorization_code' | 'refresh_token' }
type TokenRevokedContext = {
applicationId: string
userId: number | null
reason: 'revoked' | 'reuse_detected' | 'user_grants_revoked' | 'signed_out' | 'credentials_changed'
}
type OAuthUser = { id: number, name: string, email: string, avatar: string | null } & Record<string, unknown>
Who may sign in to an application
Each OAuth application lists the roles that may sign in to it, in allowedRoles. A user may sign in when they hold at
least one of them. An empty list lets every signed-in user in.
The roles are the user's effective roles: the roles assigned to
them, the default and auto roles, and everything those roles inherit. Authorization resolves them, so you write no hook.
A role that inherits member therefore passes an application that lists member.
oauthServer: {
clients: [{
id: 'mobile',
name: 'Example mobile',
redirectUris: ['com.example.app:/auth/callback'],
allowedRoles: ['member'], // admins inherit `member`, so they pass too
}],
}
allowedRoles locks out everybody who holds none of them. Without
Authorization nobody holds any role.To decide from other data, such as a suspended account, use the oauth:application:access hook
(below). To give a user a role from data you already have, such as an email allowlist, add
it through the authorization:roles hook of Authorization.
Roles are checked at the authorization request, on the consent page, on approval, at every token exchange and on
userinfo. So a user who loses a role is stopped at the next refresh.
Who is an admin
The staff routes that manage applications and scopes call requireOAuthAdmin(event). It answers 401 when nobody is
signed in. It answers 403 forbidden unless the user holds the oauth:manage permission,
and 403 session_required for a bearer token, since the permission is sessionOnly. The admin role is given it by
default. To let another role in, grant it:
authorization: {
roles: {
integrations: { label: 'Integrations', grant: ['oauth:manage'] },
},
},
Every user also holds oauth:read, which lets them see the applications they may sign in to.
Enriching tokens and userinfo
oauth:claims runs for every token and for userinfo. Use it to add a role, a custom scope's data or a per-application
value. When it runs, the module has already added the standard profile claims for the granted scopes. The permissions
claim of an access token comes from the granted scopes, not from this hook.
iss, sub,
aud, exp, iat, nbf, jti, nonce, auth_time, scope, client_id and permissions.export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('oauth:claims', async (ctx) => {
// Access tokens stay bare, so only the ID token and userinfo get identity claims.
if (ctx.tokenType === 'access_token') return
ctx.claims['https://frigear.nu/claims/role'] = ctx.roles[0] ?? 'user'
// A custom scope: list it in `oauthServer.scopes: ['groups']`, applications then request `scope=openid groups`
if (ctx.scope.includes('groups')) ctx.claims.groups = ctx.roles
// Per application
if (ctx.application.tags.includes('shop')) ctx.claims.customer_id = await customerIdFor(ctx.user.id)
// Remove something
if (!ctx.scope.includes('email')) delete ctx.claims.email
})
})
Preferences uses this hook to forward user preferences as claims.
Vetoing access and auditing
oauth:application:access runs after the allowedRoles check and can still refuse a user, for example a suspended account. Its
reason is shown to the user as the 403 message. oauth:token:issued and oauth:token:revoked only report, so use
them for audit logs.
export default defineNitroPlugin((nitro) => {
// After the role check: e.g. only users with a verified email, or a suspended flag
nitro.hooks.hook('oauth:application:access', (ctx) => {
if (ctx.user.suspended) {
ctx.allowed = false
ctx.reason = 'Your account is suspended.'
}
})
nitro.hooks.hook('oauth:token:issued', ({ userId, applicationId, grant }) => {
console.info(`[oauth] tokens issued to user ${userId} for ${applicationId} (${grant})`)
})
nitro.hooks.hook('oauth:token:revoked', ({ applicationId, userId, reason }) => {
console.warn(`[oauth] revoked ${applicationId}/${userId}: ${reason}`)
})
})
Removed hooks
These hooks no longer exist. A listener for one is never called.
| Removed hook | Use instead |
|---|---|
oauth:authorize | The oauth:manage permission, granted through roles. |
oauth:user:roles | Roles from Authorization. allowedRoles match the effective roles. |
oauth:scope:staff | Give the permission a scope stands for only to the roles that should have it. staffOnly is gone too. |
Migrate to the new access control walks through the move.
Next steps
- Create an OAuth application – set
allowedRoleson your first application. - Scopes – decide what applications may ask users for.
- Preferences – forward user preferences as claims without your own hook.
- Auth and scopes – how permissions, roles and scopes work across all modules.