Session lifetimes per OAuth client
Status: accepted (2026-11-05), shipped in @frigear-nu/oauth-server 0.5. Scope: @frigear-nu/oauth-server,
@frigear-nu/auth (one hook), @frigear-nu/users (fires it), @frigear-nu/account (a page section), their docs.
Drafted from personal-finance, whose iOS app is the first consumer. How it works today:
Session lifetimes.
Context
OAuth Server issues refresh tokens with one lifetime for every application: refreshTokenTtl, default 30 days
(packages/oauth-server/src/module.ts, oauthServer.refreshTokenTtl). createRefreshToken stamps
expiresAt = now + refreshTokenTtl (runtime/server/utils/oauth-grants.ts), and every refresh rotates the token into a
new row with a fresh 30 days. So today the window is a sliding inactivity timeout: a user who opens the app once a
month never signs in again, and one who is away for 31 days always does.
That one number cannot fit every kind of client the server has:
| Client | Where the refresh token lives | What users expect |
|---|---|---|
First-party native app (Capacitor, com.example.app:/auth/callback) | Keychain / Keystore | Signed in until they sign out, like Reddit, Instagram or Gmail |
| Third-party web app (confidential, server-side) | The app's server | Weeks to months |
| Browser SPA (public, https redirect only) | localStorage, readable by any XSS | Hours to a day (Microsoft caps SPAs at 24 h) |
| MCP / CLI / integrations | A config file or a vault | Months, revoked by hand |
Raising the global setting to six months gives every SPA a six-month credential in localStorage. Keeping it at 30 days
signs mobile users out after a holiday. What large providers do instead is a lifetime per client plus an inactivity and
an absolute limit (Auth0 and Okta expose both knobs; Google expires an unused refresh token after about six months), and
they lean on revocation, not expiry, to end long sessions.
What we already have, and keep: access tokens stay short (accessTokenTtl, 15 minutes); refresh tokens rotate, and
reusing a rotated one revokes its whole family (rotateRefreshToken); an admin can revoke every grant of an
application (POST /api/admin/applications/:id/revoke). What is missing: a lifetime per application, a hard cap, and a
way for a user to end their own sessions or have them ended when their password changes.
Decision
1. A refresh-token policy per application
Two limits, both in seconds:
idleTtl: how long a refresh token lives unused. Every rotation starts a new window (today's behaviour).maxAge: how long a sign-in lives in total, counted from the originalauthTime, which the refresh token row already carries across rotations.null= no cap.
A new row gets expiresAt = min(now + idleTtl, authTime + maxAge). rotateRefreshToken already refuses an expired row,
so nothing else in the token endpoint changes. Because the policy is read when a token is created, a changed
policy reaches existing sessions on their next refresh; no backfill.
Where the policy comes from, first match wins:
- The application: two nullable columns on
oauth_applications,refreshIdleTtlandrefreshMaxAge(null= inherit). Editable in the admin slideover as Session length: Default, 1 day, 7 days, 30 days, 90 days, 6 months, 1 year, plus an optional Sign in again after cap. - A configured client:
oauthServer.clients[].refreshToken: { idleTtl?, maxAge? }, managed by config like its other fields (409managed_by_configon edit). - The global defaults:
oauthServer.refreshToken: { idleTtl: 30 days, maxAge: null }.refreshTokenTtlstays as a deprecated alias forrefreshToken.idleTtl, so existing apps behave exactly as today.
oauthServer: {
refreshToken: { idleTtl: 60 * 60 * 24 * 30 }, // everyone else: 30 days idle
clients: [{
id: 'eu.hareland.bills',
name: 'Household bills',
redirectUris: ['eu.hareland.bills:/auth/callback'],
refreshToken: { idleTtl: 60 * 60 * 24 * 180 }, // the iOS app: 6 months idle, no hard cap
}],
}
2. Guardrails by client kind
A long lifetime is only safe where the token is stored safely. The server infers the kind from what it already knows: a native application is a public application whose redirect URIs are all custom-scheme or loopback; a browser application is a public application with an https redirect URI.
- Upper bound for everyone:
idleTtlandmaxAgeat most 2 years. No "never expires": a forgotten device should eventually stop working by itself. - Browser clients: above 7 days idle, the admin form shows a warning and the config validator logs one, naming XSS. A
deliberate
allowLongBrowserSessions: trueon the client silences it. - Native and confidential clients: no warning.
3. Ending long sessions
Long sessions need ways to end them before they expire. All three use the existing revokeFamily and fire the existing
oauth:token:revoked hook.
- Users see and end their own sessions.
GET /api/oauth-server/me/sessionslists the caller's live refresh-token families grouped per application, as the large identity providers do: application name and icon, first signed in (authTime), last active (the newest row'screatedAt, since each refresh writes a row) and the number of live families. (It first listed one entry per family, which showed an application that re-authorized on the same device several times, with nothing to tell the entries apart.)DELETE /api/oauth-server/me/sessions/:id(the application id) revokes that application's families, andDELETE /api/oauth-server/me/sessionsrevokes them all (the caller is a browser session, never one of the families). The routes follow the module's URL convention (/api/<module>/me/...) and are session only, like everything that changes how a user signs in.@frigear-nu/accountshows them as Signed-in apps on the Security page, when OAuth Server is installed. - Credential changes end every session. Auth defines a new Nitro hook,
auth:credentials:changed({ userId, reason: 'password' | 'password-reset' | 'email' }), fired throughnotifyAuthCredentialsChangedafter a password reset (Auth) and a password change, an email change and an email revert (Users, which owns those routes). OAuth Server listens and revokes all of that user's families. An optionrevokeOnCredentialChange(defaulttrue) turns it off. - Admins keep revoke-all per application. A per-user revoke on the admin user page is left for a later change;
revokeUserGrants(userId)already does it from code.
A device label ("iPhone, Safari") is left out of this change. The token request carries no reliable device name, and a
User-Agent string from a webview says little. A later change can add an optional device_name parameter that native
clients send at the code exchange.
4. Clients
Auth in client mode needs nothing: it already refreshes on demand and signs out when a refresh fails. The token
response gains the non-standard but common refresh_token_expires_in (seconds until the new row's expiresAt), which
clients may use to warn before a forced sign-in; Auth ignores it for now.
Consequences
- Migration: two nullable integer columns on
oauth_applications(sqlite and postgresql schemas). Every host generates and commits a migration, as withisListedin 0.4.1. No data changes:nullinherits the global policy, which defaults to today's 30 days idle. - Release:
@frigear-nu/oauth-server0.5.0 (schema and options),@frigear-nu/authminor (the hook),@frigear-nu/users(fires it) and@frigear-nu/accountminor (Signed-in apps). Each works without the others: without the hook nothing revokes on a password change, without Account the API is still there. - Security: a stolen refresh token from a native app now lives up to
idleTtlif the thief keeps refreshing it. Rotation still detects a second user of the same token and kills the family, and the user can end it from Security. Access tokens stay at 15 minutes, so resource servers that cannot revoke are unaffected. - Load: none. The policy is a read on the application row, which the token endpoint already loads.
- A policy that shrinks: a sign-in already past a new, shorter
maxAgeis refused at its next refresh, even if its current token was issued a minute ago, rather than handed a token that is born expired.
Alternatives considered
- Raise the global lifetime. One line, but it gives browser SPAs the same six months. Rejected.
- Lifetime by
clientTypeonly (public / confidential). Public covers both native apps (safe storage) and SPAs (localStorage), the two extremes. Rejected in favour of a per-application value with guardrails by kind. - Non-expiring tokens for first-party apps. What large consumer apps effectively do, but they have device management, anomaly detection and support staff behind it. A 2-year ceiling with sliding renewal gives the same experience for anyone who opens the app at all.
- Re-authentication with biometrics instead. Complementary, not a replacement. It belongs in
@frigear-nu/mobileas an app lock and does not change server lifetimes.
Tests
createRefreshTokenuses application, then configured client, then global policy;maxAgecaps a sliding window.- Rotation after a policy change gives the new row the new lifetime.
- A token past
authTime + maxAgeis refused even if it was rotated a minute ago. refreshTokenTtlalone still produces today's 30-day behaviour.- Browser-kind client above 7 days warns;
allowLongBrowserSessionssilences it; values above 2 years are refused. - Sessions API lists only the caller's families, revokes one / all others, and fires
oauth:token:revoked. auth:credentials:changedrevokes every family of that user and none of anyone else's.
Decisions
Decision records - why a part of the workspace works the way it does, what else was considered, and what it costs.
One permission model
Why abilities, role-name checks, RBAC scopes and per-module authorize hooks become one model of permissions, roles, scopes and policies, how a check is evaluated, and what fails closed.