Auth

OAuth providers

Let people sign in with GitHub, Google, frigear.nu or any OpenID Connect provider, and keep their tokens to call it.

This page shows how to turn on sign-in through an OAuth provider, including Sign in with frigear.nu, and how to keep the provider's tokens. For app developers; server mode only. Set up the module first: Overview.

Turn on a provider

Every nuxt-auth-utils provider signs in at GET /api/auth/<provider>. That covers GitHub, Google, Auth0, Okta, Keycloak and Microsoft, among others. oidc covers any other OpenID Connect provider.

A provider is off by default. It turns on, per request, once nuxt-auth-utils has its credentials. These are the environment variables nuxt-auth-utils documents for the provider:

NUXT_OAUTH_AUTH0_CLIENT_ID=...
NUXT_OAUTH_AUTH0_CLIENT_SECRET=...
NUXT_OAUTH_AUTH0_DOMAIN=example.eu.auth0.com

# Any OpenID Connect provider
NUXT_OAUTH_OIDC_CLIENT_ID=...
NUXT_OAUTH_OIDC_CLIENT_SECRET=...
NUXT_OAUTH_OIDC_OPENID_CONFIG=https://sso.example.com/.well-known/openid-configuration

Without credentials the route answers 404 and the sign-in page shows no button. Register <origin>/api/auth/<provider> as the callback URL at the provider.

List a provider in settings.oauth only to change it:

authentication: {
  settings: {
    oauth: {
      auth0: {
        label: 'Company SSO', // button label (default: the provider's name, translatable as `auth.oauth.<provider>.label`)
        icon: 'i-simple-icons-auth0', // iconify name
        signUp: false, // only sign in existing users; unknown emails go back to the sign-in page
        config: { scope: ['openid', 'profile', 'email'] }, // passed to the nuxt-auth-utils handler
        keepTokens: true, // keep its access and refresh tokens, see "Keeping provider tokens"
        trustEmail: false, // `true` trusts emails the provider does not flag as verified (see below)
      },
      github: false, // off, even with credentials
    },
  },
}
  • Users are matched by email, and only when the provider says the address is verified: email_verified: true (OIDC providers, Google, Auth0, Okta, ...), verified: true (Discord), or a GitHub address (GitHub only shows verified ones). Anything else, including a provider that sends no flag (Facebook, Microsoft, Spotify, ...), is sent back to the sign-in page with ?error=oauth-unverified: nothing is linked or created.
  • trustEmail: true on a provider accepts its emails without a flag. Set it only for a provider that never hands out an address its user does not own, since a sign-in then opens the local account with that email.
  • An unknown email gets a new account, marked verified, unless signUp is false.
  • A local account that was never verified (someone registered the address with a password or a passkey and never followed the mail) loses that password, its passkeys, API tokens and kept tokens at the first verified sign-in. The same happens on a sign-in link (magic link) to it. Its own verification mail keeps them.
  • With oauth.frigear on, the NUXT_OAUTH_OIDC_* credentials belong to Sign in with frigear.nu. The plain oidc provider then needs listing too, for example oidc: { label: 'Company SSO' }.
  • GET /api/auth/providers returns the buttons for the request: [{ key, to, label?, icon?, external? }]. useLogin().oauthProviders fills in the default labels and icons.

Keeping provider tokens

By default a sign-in keeps none of the provider's tokens. Set keepTokens: true on a provider (oauth.frigear too) to keep them. Your app can then call the provider as the user, also from a scheduled task or a bearer request, where there is no session.

  • Auth keeps the access token, the refresh token, the expiry and the granted scopes.
  • They live in the auth_oauth_tokens table, one row per user and provider. keepTokens on any provider adds that table, so generate a migration for it.
  • Every sign-in with the provider replaces the row. A step-up sign-in replaces the plain one.
  • The token columns are encrypted with AES-GCM (WebCrypto) and bound to the provider.
  • deleteUserAuthData removes them with the user. A re-keyed user (forceId) keeps them.
The encryption key is derived from NUXT_AUTH_TOKENS_KEY (runtimeConfig.authTokensKey), else from the session password. Changing it makes the kept tokens unreadable, and users have to sign in again.
// The caller's tokens (event), or a user's by id (a task syncing with an admin's token). `null` when none are kept.
const tokens = await getOAuthTokens(event, 'frigear') // { accessToken, refreshToken?, tokenType?, expiresAt, scopes }

// A usable access token: refreshed first when it expired, `null` when there is none, it lacks one of `scopes`,
// or it cannot be refreshed.
const token = await getOAuthAccessToken(adminId, 'frigear', { scopes: ['users:read'] })

await revokeOAuthTokens(userId, 'frigear') // forget them ("disconnect")

An expired token is refreshed with its refresh token at the provider's token endpoint. Auth finds that endpoint through OIDC discovery: frigear.nu's, or the openidConfig of an oidc-style provider (NUXT_OAUTH_<PROVIDER>_OPENID_CONFIG). Other providers' tokens are used until they expire. One refresh runs at a time per user and provider, because providers rotate refresh tokens and treat a replayed one as theft.

Sign in with frigear.nu

Sign in with frigear.nu lets people sign in with their frigear.nu account on pull request previews and in local development. Turn it on with settings.oauth.frigear. It registers GET /auth/frigear, an OIDC sign-in against frigear.nu.

It is decided per request. It is active when runtimeConfig.previewDeployment is true, or when runtimeConfig.frigear.sso has a client id and secret. Otherwise the route answers 404 and the button is hidden. Register <origin>/auth/frigear as the callback on frigear.nu.

Runtime configEnv varPurpose
previewDeploymentNUXT_PREVIEW_DEPLOYMENTTurns the sign-in on without credentials. Set it yourself, for example from a CI marker.
frigear.sso.urlNUXT_FRIGEAR_SSO_URLThe issuer origin. Defaults to the frigear.nu origin.
frigear.sso.clientIdNUXT_FRIGEAR_SSO_CLIENT_IDThe id of your OAuth application at frigear.nu.
frigear.sso.clientSecretNUXT_FRIGEAR_SSO_CLIENT_SECRETIts client secret.
frigear.sso.scopesNUXT_FRIGEAR_SSO_SCOPESScopes every sign-in asks for, space separated. Default openid profile email.
frigear.sso.stepUpScopesNUXT_FRIGEAR_SSO_STEP_UP_SCOPESExtra scopes a sign-in may ask for with /auth/frigear?scope=.... Default none.
frigear.forceIdNUXT_FRIGEAR_FORCE_IDMake the local users.id equal the frigear.nu id. Default false.
frigear.roleMapNUXT_FRIGEAR_ROLE_MAPfrigear.nu role to local role, as an object (JSON in the env var). Default {}: no role is synced.
frigear.defaultRoleNUXT_FRIGEAR_DEFAULT_ROLEThe local role for a missing or unmapped role claim. Default user.
frigear.syncRole–Set false to stop syncing roles while keeping roleMap. On whenever roleMap maps a role.
frigear.keepTokensNUXT_FRIGEAR_KEEP_TOKENSKeep the frigear.nu tokens. Defaults to oauth.frigear.keepTokens.
public.authentication.frigearSsoNUXT_PUBLIC_AUTHENTICATION_FRIGEAR_SSOWhether the build saw the credentials.

frigear.sso.scopes. openid is always added.

frigear.syncRole. Declare it in your runtimeConfig to override it. Without a roleMap it does nothing (the build prints a warning when it is true).

frigear.keepTokens. See Keeping provider tokens. oauth.frigear.keepTokens also adds the table, so turning frigear.keepTokens on at runtime only works with the table.

public.authentication.frigearSso. The button follows the route, through GET /api/auth/providers.

The credentials can also come from nuxt-auth-utils' NUXT_OAUTH_OIDC_CLIENT_ID, NUXT_OAUTH_OIDC_CLIENT_SECRET and NUXT_OAUTH_OIDC_OPENID_CONFIG.

What happens at sign-in:

  • A callback without a state that matches the browser's state cookie is sent back to the sign-in page with ?error=oauth-state. The same holds for every provider at /api/auth/<provider>.
  • The token response must carry an id_token, and userinfo must be about the same sub; otherwise the sign-in fails with 401.
  • The id_token is verified against the provider's jwks_uri, including issuer and audience. The keys are cached for an hour.
  • Users are matched by email only with email_verified: true (a @frigear-nu/oauth-server issuer sends it from emailVerifiedAt). An unverified address that an account here already has is sent back to the sign-in page with ?error=oauth-unverified. A free address gets a new account, verified only with the claim.
  • A matched account that was never verified loses the credentials set before (password, passkeys, API tokens, kept tokens) and is marked verified.
  • With forceId, Auth makes the local id match in one of three ways. It updates the user with that id and email, re-keys an existing user with the same (verified) email, or creates a user with that id. A local user that holds the id under another email is never taken over: the sign-in fails with 409, and an admin has to sort the two out.
  • On a re-key, rows that reference users.id follow. A different user that holds the id moves to the next free id.
  • The optional color_mode claim (light, dark or system) is kept on the session as frigear.colorMode. It is applied once per sign-in.

The role claim. With a roleMap, Auth sets the user's single sign-on roles at every sign-in. It reads the role claims of the verified id_token only, never userinfo: the role claim, the namespaced https://frigear.nu/claims/role, then the entries of roles. Every claimed role the map lists becomes a local role. A role the map does not list, or no claim at all, gives defaultRole (nothing when it is user, which every user holds anyway). The roles are written through the role provider with source = 'sso': they replace the user's earlier sso rows, so a role removed at frigear.nu is removed here at the next sign-in, and roles an admin assigned by hand are never touched. Without a roleMap no role is synced. It needs Authorization for the table that stores them; without it nothing is stored. These claim names follow the current frigear.nu and will change when this app becomes frigear.nu. On the issuer side, the host app adds the claim in the oauth:claims hook of OAuth Server.

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    frigear: {
      // Only these frigear.nu roles carry over; every other one, or none, becomes `user`.
      roleMap: { admin: 'admin', 'frigear-staff': 'manager' },
    },
  },
})
Map only the roles you trust frigear.nu (or the issuer behind NUXT_OAUTH_OIDC_*) to grant here. A role the map lists is granted to anyone the issuer gives it to.

Step-up. A sign-in asks for frigear.sso.scopes. To ask for more only when needed, such as an admin connecting for sync, allow the scope in stepUpScopes. Then send the user to /auth/frigear?scope=users:read, optionally with &redirect=/admin/sync. Requested scopes outside the allowlist are ignored. With keepTokens, the step-up sign-in's tokens replace the ones kept before.

frigear.nu decides what it grants. A scope for a permission the user does not hold, such as users:read, is dropped. Check the kept scopes rather than assuming the step-up worked.
// oauth: { frigear: { keepTokens: true } }, NUXT_FRIGEAR_SSO_STEP_UP_SCOPES=users:read
const token = await getOAuthAccessToken(event, 'frigear', { scopes: ['users:read'] })
if (!token) throw apiError(403, 'frigear_not_connected', 'Connect frigear.nu for sync: /auth/frigear?scope=users:read')
const users = await $fetch('https://frigear.nu/api/admin/users', { headers: { Authorization: `Bearer ${token}` } })

Runtime config

Runtime config for kept provider tokens.

Runtime configEnv varPurpose
authTokensKeyNUXT_AUTH_TOKENS_KEYEncrypts kept provider tokens. Falls back to a key derived from the session password.

Next steps

  • Auth reference – the sign-in routes and the auth_oauth_tokens table.
  • Identity – where Sign in with frigear.nu fits among the apps.
Copyright © 2026