Identity

OAuth Resource

Let a separate service accept the access tokens of an issuer, so one mobile app signs in once and calls every service.

Your mobile app talks to several services, such as vouchers, a shop and an inventory. People should sign in once, not once per service. OAuth Resource lets a service accept the access tokens that an issuer gave the app, so the service needs no sign-in of its own. If the service should be the place where people sign in, use OAuth Server instead.

The module is @frigear-nu/oauth-resource. Identity shows where it fits.

What you get

  • Server routes. None of its own. Your routes that call authorize(event, permission) accept the issuer's access tokens and get the usual Auth principal. What the token may do is the permissions the issuer put in it, or its live introspection answer. On by default.
  • Users. A user the service has not seen is created from the issuer's profile the first time a token arrives. On by default.
  • Introspection. Optional: ask the issuer what a token may do now, instead of trusting the claim in it.
  • Cross-origin calls. CORS headers on /api/ for the webviews of a Capacitor app. On by default.
  • Pages, composables and emails. None.
ServerClientMobile
Installed for you:Auth, Users and Module Kit. You add:@nuxthub/core. Optional: nothing.

Quick setup

Install the module

pnpm add @frigear-nu/oauth-resource @nuxthub/core

Configure it in nuxt.config.ts

The defaults accept frigear.nu's tokens. Point issuer and audience at any issuer whose access tokens follow RFC 9068, the JSON Web Token profile for access tokens.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxthub/core', '@frigear-nu/oauth-resource'],
  oauthResource: {
    issuer: 'https://frigear.nu',
    audience: 'frigear-services',
  },
})
Without this step the issuer drops your service's scopes as unsupported. Tokens then lack them, and every route that requires one answers 403.

Register the service's permissions at the issuer and here

The issuer has to know the permissions, or it drops them as unknown. Register them there with addPermission, and describe them in its oauthServer.scopes, each with a service name, so the consent screen groups them under your service. See Scopes. Register the same permissions in this service too: a route that checks an unknown permission answers 403.

nuxt.config.ts of the service
authentication: {
  permissions: [{ name: 'vouchers:read', label: 'See your vouchers' }],
},

Check a permission in a route

Routes check the permission they need, exactly as for API tokens or sessions:

server/api/vouchers/index.get.ts
export default defineEventHandler(async (event) => {
  const { user } = await authorize(event, 'vouchers:read')
  return listVouchers(user.id)
})

A signed-in session still passes when the service's own roles grant the permission, so its web pages keep working.

What you should see: a call with a valid access token whose permissions include vouchers:read answers 200. A token without it answers 403 insufficient_scope, with WWW-Authenticate: Bearer error="insufficient_scope". An invalid, expired or foreign token answers 401 (error="invalid_token").

Options

Options go under the oauthResource key.

OptionTypeDefaultPurpose
issuerstringhttps://frigear.nuThe iss that tokens must carry.
audiencestring | string[]frigear-servicesThe aud that tokens must carry, any one of them.
jwksUrlstring<issuer>/.well-known/jwks.jsonWhere the issuer's signing keys are read.
userinfoUrlstring<issuer>/oauth/userinfoWhere an unknown user's profile is read.
corsOriginsstring[]capacitor://localhost, https://localhostExact origins whose webviews may call the API with a bearer token.
corsPathsstring[]/api/Path prefixes the CORS headers apply to.
forceIdbooleanruntimeConfig.frigear.forceIdUse the issuer's user ID (sub) as the local user ID.
jwksCacheTtlnumber600Seconds the signing keys are cached.
introspection.clientIdstringunsetThe confidential client of the issuer that asks. Setting it turns introspection on.
introspection.clientSecretstringunsetThe secret of that client. Set it through env.
introspection.endpointstring<issuer>/oauth/introspectWhere the token is introspected.
introspection.cacheTtlnumber30Seconds an answer is cached, never past the token's expiry.

audience matches the issuer's accessTokenAudience.

corsOrigins takes exact origins only, never *.

forceId is explained under Users.

introspection is explained under Introspection. The secret belongs in NUXT_OAUTH_RESOURCE_INTROSPECTION_CLIENT_SECRET, not in nuxt.config.ts.

Runtime config

Every option is also runtimeConfig.oauthResource.<option>. Set it per environment with NUXT_OAUTH_RESOURCE_<OPTION>, with lists comma separated.

The module was renamed from @frigear-nu/frigear-resource in 0.1.6. The config key is now oauthResource (was frigearResource), the runtime config is runtimeConfig.oauthResource, and the env vars are NUXT_OAUTH_RESOURCE_* (were NUXT_FRIGEAR_RESOURCE_*).

Server

The module adds no routes. It plugs into Auth, so your existing routes accept the issuer's tokens.

How a token is checked

The module adds a token resolver to Auth (addAuthResolver, order 50). It looks at every JSON Web Token (JWT) and accepts it only when all of these hold:

  • its header typ is at+jwt;
  • it is signed with RS256 by a key in the issuer's JWKS;
  • iss is the issuer, and aud contains the audience;
  • exp has not passed;
  • it has sub and client_id.

The keys are cached for jwksCacheTtl. A token with an unknown kid means the issuer rotated its key, so the keys are fetched again, at most every 30 seconds.

Any other token is left to the next resolver, such as the service's own API tokens, and ends in 401. ID tokens are never accepted.

The principal is { kind: 'oauth', user, scopes, permissions, foreign: true, client: { id } }. scopes is the token's scope, and client.id is the app's client_id.

What the token may do

The token is foreign: it comes from another issuer, so the service does not ask its own roles what the user may do. principal.permissions replaces the role check. It is the first of these that applies:

  1. The introspection answer, when introspection is on.
  2. The permissions claim the issuer froze in the token.
  3. The token's scope, expanded against this service's permissions, for a token without the claim (an older issuer).

A malformed permissions claim is no claim: the token is refused.

A check then runs the same way as for any caller. An unknown permission is 403 forbidden. A sessionOnly permission is 403 session_required, since a token never holds one. A permission the token lacks is 403 insufficient_scope. The authorization:before hook still runs, so it can deny a token. Policies on the permission refine it too. Nothing the user's roles at this service hold can widen the token.

Without introspection, the service cannot revoke an access token or see a role change. A token lives as long as the issuer says, so keep accessTokenTtl at 5 to 15 minutes there. A user who loses access at the issuer stops getting new tokens at the next refresh. Turn on introspection when that is too slow.

Introspection

With introspection, the service asks the issuer what the token may do now, by POST /oauth/introspect (RFC 7662), and uses the answer instead of the frozen claim. A user who lost a role at the issuer loses it here within cacheTtl.

nuxt.config.ts
export default defineNuxtConfig({
  oauthResource: {
    issuer: 'https://frigear.nu',
    audience: 'frigear-services',
    introspection: { clientId: 'app_resource', cacheTtl: 30 },
  },
})

Set NUXT_OAUTH_RESOURCE_INTROSPECTION_CLIENT_SECRET to the client's secret.

  • The client. A confidential application at the issuer. The tokens must be issued to it, or its client ID must be in the tokens' audience. Add it to the issuer's accessTokenAudience. See Token introspection.
  • Inactive means refused. A token the issuer calls inactive, or one whose sub differs from the token's, answers 401.
  • Fail closed. When the issuer cannot be reached, the token is refused and the failure is not cached. The next request asks again.
  • Cached briefly. Answers are kept per token for cacheTtl seconds, at most until the token expires.

Users

The user is the token's sub, the user ID at the issuer.

  • With forceId, the local user has the same ID, so tokens and sign-ins find the same user. This is recommended. It is the default when Sign in with frigear.nu uses it.
  • Without it, users are matched by email. The issuer's ID is remembered per server instance.

Either way an existing user is matched by email only when userinfo has email_verified: true, and a local user holding the ID under another email is never taken over; the token is refused instead.

A user the service has not seen is created the first time a token arrives. Known users are not written to on requests.

The app must ask for the email scope. The module reads the profile (email, name, picture) from the issuer's userinfo endpoint for a user it has not seen, and without forceId once per server instance. A profile without an email gets the token refused.

CORS

A webview of the mobile app runs on capacitor://localhost on iOS or https://localhost on Android. So its calls to the service are cross-origin.

The module answers preflights and adds the CORS headers for the configured origins on corsPaths. It never sends Access-Control-Allow-Credentials, so a cross-origin call is always a bearer call. The headers come from the shared applyBearerCors, the same helper behind OAuth Server's cors.paths.

Next steps

Copyright © 2026