OAuth providers
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: trueon 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
signUpisfalse. - 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.frigearon, theNUXT_OAUTH_OIDC_*credentials belong to Sign in with frigear.nu. The plainoidcprovider then needs listing too, for exampleoidc: { label: 'Company SSO' }. GET /api/auth/providersreturns the buttons for the request:[{ key, to, label?, icon?, external? }].useLogin().oauthProvidersfills 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_tokenstable, one row per user and provider.keepTokenson 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.
deleteUserAuthDataremoves them with the user. A re-keyed user (forceId) keeps them.
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 config | Env var | Purpose |
|---|---|---|
previewDeployment | NUXT_PREVIEW_DEPLOYMENT | Turns the sign-in on without credentials. Set it yourself, for example from a CI marker. |
frigear.sso.url | NUXT_FRIGEAR_SSO_URL | The issuer origin. Defaults to the frigear.nu origin. |
frigear.sso.clientId | NUXT_FRIGEAR_SSO_CLIENT_ID | The id of your OAuth application at frigear.nu. |
frigear.sso.clientSecret | NUXT_FRIGEAR_SSO_CLIENT_SECRET | Its client secret. |
frigear.sso.scopes | NUXT_FRIGEAR_SSO_SCOPES | Scopes every sign-in asks for, space separated. Default openid profile email. |
frigear.sso.stepUpScopes | NUXT_FRIGEAR_SSO_STEP_UP_SCOPES | Extra scopes a sign-in may ask for with /auth/frigear?scope=.... Default none. |
frigear.forceId | NUXT_FRIGEAR_FORCE_ID | Make the local users.id equal the frigear.nu id. Default false. |
frigear.roleMap | NUXT_FRIGEAR_ROLE_MAP | frigear.nu role to local role, as an object (JSON in the env var). Default {}: no role is synced. |
frigear.defaultRole | NUXT_FRIGEAR_DEFAULT_ROLE | The 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.keepTokens | NUXT_FRIGEAR_KEEP_TOKENS | Keep the frigear.nu tokens. Defaults to oauth.frigear.keepTokens. |
public.authentication.frigearSso | NUXT_PUBLIC_AUTHENTICATION_FRIGEAR_SSO | Whether 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
statethat 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 samesub; otherwise the sign-in fails with401. - The
id_tokenis verified against the provider'sjwks_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-serverissuer sends it fromemailVerifiedAt). 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 with409, and an admin has to sort the two out. - On a re-key, rows that reference
users.idfollow. A different user that holds the id moves to the next free id. - The optional
color_modeclaim (light,darkorsystem) is kept on the session asfrigear.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.
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' },
},
},
})
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.
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 config | Env var | Purpose |
|---|---|---|
authTokensKey | NUXT_AUTH_TOKENS_KEY | Encrypts 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_tokenstable. - Identity – where Sign in with frigear.nu fits among the apps.