OAuth Resource
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 thepermissionsthe 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.
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.
export default defineNuxtConfig({
modules: ['@nuxthub/core', '@frigear-nu/oauth-resource'],
oauthResource: {
issuer: 'https://frigear.nu',
audience: 'frigear-services',
},
})
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.
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:
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.
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.
| Option | Type | Default | Purpose |
|---|---|---|---|
issuer | string | https://frigear.nu | The iss that tokens must carry. |
audience | string | string[] | frigear-services | The aud that tokens must carry, any one of them. |
jwksUrl | string | <issuer>/.well-known/jwks.json | Where the issuer's signing keys are read. |
userinfoUrl | string | <issuer>/oauth/userinfo | Where an unknown user's profile is read. |
corsOrigins | string[] | capacitor://localhost, https://localhost | Exact origins whose webviews may call the API with a bearer token. |
corsPaths | string[] | /api/ | Path prefixes the CORS headers apply to. |
forceId | boolean | runtimeConfig.frigear.forceId | Use the issuer's user ID (sub) as the local user ID. |
jwksCacheTtl | number | 600 | Seconds the signing keys are cached. |
introspection.clientId | string | unset | The confidential client of the issuer that asks. Setting it turns introspection on. |
introspection.clientSecret | string | unset | The secret of that client. Set it through env. |
introspection.endpoint | string | <issuer>/oauth/introspect | Where the token is introspected. |
introspection.cacheTtl | number | 30 | Seconds 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.
@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
typisat+jwt; - it is signed with RS256 by a key in the issuer's JWKS;
issis the issuer, andaudcontains the audience;exphas not passed;- it has
subandclient_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:
- The introspection answer, when introspection is on.
- The
permissionsclaim the issuer froze in the token. - 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.
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.
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
subdiffers from the token's, answers401. - 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
cacheTtlseconds, 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.
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
- One app for every service – set up the issuer side: the shared audience and the scopes.
- Authorization – permissions, roles and the
authorization:beforehook. - Mobile starter – run a mobile app that signs in and calls a service.
- Auth and scopes – see what your routes get from
authorize. - Protect a route – require a signed-in user and a permission in a route.