diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d9d36e..44be6b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -17,7 +20,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -30,6 +33,12 @@ jobs: - run: pnpm typecheck - - run: pnpm lint + - name: Lint + run: | + pnpm lint + if ! git diff --exit-code; then + echo "::error::pnpm lint changed the files above. Run it locally and commit the result." + exit 1 + fi - run: pnpm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 30555b6..f4f78d0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,15 @@ name: Publish +# Publishes when a push to main changes a package version, or on demand. +# Only versions npm doesn't have yet go out, so any other edit to a package.json publishes nothing. +# Pull requests never trigger it, and a manual run from any branch but main is a dry run. on: + push: + branches: [main] + paths: + - packages/*/package.json workflow_dispatch: inputs: - packages: - description: "'all', or one pnpm filter such as @wilsoon/auth-machine" - type: string - default: all dry_run: description: "Run every check and pack the tarballs, but publish nothing" type: boolean @@ -17,17 +20,19 @@ concurrency: cancel-in-progress: false permissions: - contents: read - id-token: write + contents: write # pushes the release tags + id-token: write # npm provenance and trusted publishing jobs: publish: + # Forks have no business publishing, whatever their workflows say. + if: github.repository == 'Wilsoon7721/node-oidc-kit' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -35,33 +40,111 @@ jobs: cache: pnpm registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile --ignore-scripts + # Decides what to release, and refuses a release that looks unfinished: + # - auth-core, auth-react and auth-next must share one version (auth-machine is independent); + # - every version going out needs its entry in CHANGELOG.md. + - name: Find unpublished versions + id: plan + run: | + node <<'JS' + const fs = require("fs"); + const { execFileSync } = require("child_process"); + + const LOCKSTEP = ["@wilsoon/auth-core", "@wilsoon/auth-react", "@wilsoon/auth-next"]; + const ORDER = [...LOCKSTEP, "@wilsoon/auth-machine"]; + const changelog = fs.readFileSync("CHANGELOG.md", "utf8").split(/\r?\n/); + + const packages = fs.readdirSync("packages") + .map((dir) => JSON.parse(fs.readFileSync(`packages/${dir}/package.json`, "utf8"))) + .filter((pkg) => !pkg.private) + .sort((a, b) => ORDER.indexOf(a.name) - ORDER.indexOf(b.name)); + + const fail = (message) => { + console.log(`::error::${message}`); + process.exit(1); + }; + + const kit = new Set(packages.filter((pkg) => LOCKSTEP.includes(pkg.name)).map((pkg) => pkg.version)); + if (kit.size > 1) fail(`${LOCKSTEP.join(", ")} must share one version, but have ${[...kit].join(", ")}. Bump all three together.`); + + // Only a definite "not found" counts as unpublished. Any other failure stops the run rather than guessing. + const onNpm = (pkg) => { + try { + execFileSync("npm", ["view", `${pkg.name}@${pkg.version}`, "version", "--registry", "https://registry.npmjs.org"], { stdio: "pipe" }); + return true; + } catch (error) { + const output = `${error.stdout ?? ""}${error.stderr ?? ""}`; + if (/E404|404 Not Found/.test(output)) return false; + fail(`Could not ask npm about ${pkg.name}@${pkg.version}: ${output.trim().split("\n")[0]}`); + } + }; + + const pending = packages.filter((pkg) => !onNpm(pkg)); + for (const pkg of pending) { + const documented = LOCKSTEP.includes(pkg.name) + ? changelog.includes(`## ${pkg.version}`) + : changelog.some((line) => line.startsWith("#") && line.includes(pkg.name.split("/")[1]) && line.includes(pkg.version)); + if (!documented) fail(`${pkg.name}@${pkg.version} has no entry in CHANGELOG.md. Add one, or revert the version bump.`); + } - - run: pnpm build - - run: pnpm typecheck - - run: pnpm lint - - run: pnpm test + if (pending.length === 0) console.log("::notice::Every package version is already on npm. Nothing to publish."); + for (const pkg of pending) console.log(`::notice::Will publish ${pkg.name}@${pkg.version}`); + + // One tag per release: v for the kit, @ for anything versioned on its own. + const tags = new Set(pending.map((pkg) => (LOCKSTEP.includes(pkg.name) ? `v${pkg.version}` : `${pkg.name.split("/")[1]}@${pkg.version}`))); + + const out = fs.createWriteStream(process.env.GITHUB_OUTPUT, { flags: "a" }); + out.write(`pending=${pending.map((pkg) => `${pkg.name}@${pkg.version}`).join(" ")}\n`); + out.write(`tags=${[...tags].join(" ")}\n`); + out.end(); + JS + + - name: Build and check + if: steps.plan.outputs.pending != '' + run: | + pnpm install --frozen-lockfile --ignore-scripts + pnpm build + pnpm typecheck + pnpm lint + git diff --exit-code + pnpm test - name: Show what would be published + if: steps.plan.outputs.pending != '' env: - PACKAGES: ${{ inputs.packages }} + PENDING: ${{ steps.plan.outputs.pending }} run: | - if [ "$PACKAGES" = "all" ]; then - pnpm -r publish --dry-run --no-git-checks - else - pnpm --filter "$PACKAGES" publish --dry-run --no-git-checks - fi + for spec in $PENDING; do + pnpm --filter "${spec%@*}" publish --dry-run --no-git-checks + done + # A version with a pre-release part (3.1.0-beta.1) goes to the `next` tag, so `npm install` keeps getting the last stable one. - name: Publish - if: ${{ !inputs.dry_run }} + if: steps.plan.outputs.pending != '' && !inputs.dry_run && github.ref == 'refs/heads/main' env: - PACKAGES: ${{ inputs.packages }} + PENDING: ${{ steps.plan.outputs.pending }} # Empty when the repo has no NPM_TOKEN secret, which is the intended end state # once trusted publishing is confirmed working. NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - if [ "$PACKAGES" = "all" ]; then - pnpm -r publish --provenance --access public --no-git-checks - else - pnpm --filter "$PACKAGES" publish --provenance --access public --no-git-checks - fi + for spec in $PENDING; do + name="${spec%@*}" + version="${spec##*@}" + tag=latest + case "$version" in *-*) tag=next ;; esac + pnpm --filter "$name" publish --provenance --access public --no-git-checks --tag "$tag" + done + + - name: Tag the release + if: steps.plan.outputs.pending != '' && !inputs.dry_run && github.ref == 'refs/heads/main' + env: + TAGS: ${{ steps.plan.outputs.tags }} + run: | + for tag in $TAGS; do + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then + echo "::notice::Tag $tag already exists." + continue + fi + git tag "$tag" "$GITHUB_SHA" + git push origin "refs/tags/$tag" + done diff --git a/CHANGELOG.md b/CHANGELOG.md index 352b045..4c6a0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,140 @@ The three original packages (`@wilsoon/auth-core`, `@wilsoon/auth-react`, `@wilsoon/auth-next`) are versioned together. `@wilsoon/auth-machine` has no dependency on them and is versioned independently, starting at 0.x. +## 3.0.0 + +The core is now standards-only. Everything specific to one identity provider moved behind a +**provider profile**, which adapts claims, picks storage names and adds functions that are in +no spec, namespaced under the profile's name. A provider quirk no longer needs a kit release. + +### Migrating + +Install the WilsoonID profile, which ships separately from the kit: + +```sh +pnpm add @wilsoon/auth-provider-wilsoon +``` + +Then one line per app. Pass your provider's profile, and 2.x behaviour and storage names come back: + +```ts +import { wilsoon } from "@wilsoon/auth-provider-wilsoon"; + +new AuthClient({ ...config, profile: wilsoon() }); // 2.x behaviour, same storage keys +const client = createAuthClient({ ...config, profile: wilsoon() }); +await client.wilsoon.reauthorize(["passkey"]); // typed extensions +``` + +Do not annotate the config object as `AuthConfig` if you want `client.wilsoon` typed. The +annotation widens `profile` and loses its name; spread the profile in last, or use `satisfies`. + +The 2.x methods below still work on `AuthClient` when a profile provides them. They warn once +and forward to the profile, and are removed in 4.0. Without a profile they throw +`PROFILE_REQUIRED`. + +| 2.x | 3.0 | +|---|---| +| `client.createEscalation()`, `pollEscalation()`, `reauthorize()`, `verifyEscalationToken()` | `client.wilsoon.*`, or the portable `createStepUpRequest()` / `login({ acrValues, maxAge })` | +| `client.verifyPlatformSession()` | `client.wilsoon.verifyPlatformSession()`; `resolveSession()` still routes to it | +| `client.isSessionCurrent()` | `client.wilsoon.isSessionCurrent()`, or `enforce: "live"` in the middleware | +| `client.hydrateSession()` | `client.silentAuthorize()`; with a profile, `hydrateSession()` forwards to its cookie restore | +| `user.role === "admin"` | `hasRole(user, "admin")` / `hasPermission(user, ".")` | +| `requireSession(config, { roles: ["admin"] })` | `requireSession(config, { permissions: [".admin"] })` (`roles` still works, any-of) | +| `STORAGE_KEYS.tokens` | `client.storageKeys.tokens` | +| `AuthConfig.cookieDomain` | `cookieDomain` on `createAuthMiddleware` options or `ServerCookieStorage` | +| `AuthConfig.escalationEndpoint`, `resolveSessionVersion`, `platformSessionCacheSeconds` | the WilsoonID profile's options (still read as fallbacks) | + +### Changed - `@wilsoon/auth-core` + +- **Storage names default to `oidc_tokens`, `oidc_state`, `oidc_nonce`, `oidc_verifier`.** + `storagePrefix` changes the prefix. A profile can set exact names; the WilsoonID profile + keeps `wilsoon_id_tokens` and `wilsoon_auth_*`. An app that upgrades without a profile + signs its users out once. +- **`AuthenticatedUser.role` is optional and deprecated.** Users gain `roles`, `permissions` + and `sid`; `roles` and `permissions` are empty unless `rolesClaim`, `permissionsClaim` or + a profile fills them. `sessionVersion` is deprecated and set only by the WilsoonID profile. +- **An unrecognised role no longer fails the login.** 2.x threw on any role outside + `admin`/`user`. It now lands in `roles` and never maps onto a known `role`. +- **Machine detection without a profile is RFC 9068 only** (`sub === client_id`). The + `token_use: "client"` check moved to the WilsoonID profile. `isMachineToken()` is unchanged. +- **`resolveSession()` without a profile refuses a sibling client's session** with + `FOREIGN_SESSION`. Shared sessions need a profile that resolves them, `apiAudience` and a + `clientSecret`. +- `getLogoutUrl()` accepts no ID token and sends `client_id` instead (RP-Initiated Logout 1.0). +- Log prefix is `[node-oidc-kit]`. + +### Removed - `@wilsoon/auth-core` + +Everything deprecated before 3.0: + +- `CookieStorage`, a no-op since 1.2.0. Use `silentAuthorize()` in the browser, or a + server-side adapter such as `ServerCookieStorage`. +- `parseIdToken()`. Use `verifyIdToken()`, or `decodeIdTokenUnsafe()` for display only. +- `isTokenExpired()`. Use `isTokenNearExpiry()`. +- The `User` type. Use `AuthenticatedUser`. + +### Documentation + +- `INTEGRATION.md` is gone. Its framework recipes, troubleshooting table and pre-ship checklist + moved to a new Recipes chapter on the docs site, rewritten for 3.0. The 1.x migration notes + were dropped. + +### Added - `@wilsoon/auth-core` + +- `ProviderProfile`, `ProfileContext`, `defineProfile()` and `createAuthClient()`, which types + the profile's extensions under `client.`. +- `rolesClaim` / `permissionsClaim` (a dot path or a function), with `hasRole`, + `hasPermission`, `requireRole`, `requirePermission`, `readClaimPath`, `readClaimList`. These + cover Auth0 (`permissions`), Entra ID (`roles`) and Keycloak (`resource_access..roles`) + with no profile. +- The session's own access token adds its roles and permissions to the user, in + `handleCallback()` and `resolveSession()`. It counts only when it verifies, names this client + as `client_id` and has the same subject. WilsoonID carries `permissions` only there. +- `silentAuthorize()`: session restore through `prompt=none`, in a hidden iframe (default) or a + top-level redirect. `isSilentAuthError()` and `SILENT_AUTH_ERRORS` read `login_required` and + its siblings as "not signed in". +- `liveAccess(token)`: whether the provider still honours a token, and the user's permissions + right now. Uses the profile's hook, or plain introspection. +- `handleCallback()` checks an `iss` on the authorization response (RFC 9207), and requires it + when discovery advertises `authorization_response_iss_parameter_supported`. +- `client.storageKeys`, `client.profileName`, `DEFAULT_STORAGE_KEYS`, `storageKeysFor()`. +- `roles`, `permissions` and `sid` on `AccessTokenClaims`; `permissions` and `sid` on + `IntrospectionResponse`. + +### Changed - `@wilsoon/auth-react` + +- **`AuthProvider` restores sessions with `silentAuthorize()`** and falls back to the profile's + restore, such as WilsoonID's cookie, when that finds nothing. `restore="profile"` keeps the + 2.x behaviour; `restore="none"` turns restore off. The `redirectUri` page must allow being + framed by its own origin. +- Rendered inside the silent-sign-in iframe, `AuthProvider` does nothing, so the parent finishes + the exchange. +- Logout without an ID token uses the discovered `end_session_endpoint` with `client_id`, + instead of a hardcoded `/api/logout`. + +### Added - `@wilsoon/auth-react` + +- `profile`, `rolesClaim`, `permissionsClaim`, `storagePrefix`, `restore` and `silentTimeoutMs` + props. +- `useAuth()` exposes `client` and the profile's extensions under its name; + `useAuth()` types them. + +### Changed - `@wilsoon/auth-next` + +- The default session cookie name is the client's `storageKeys.tokens`. With the WilsoonID + profile that is still `wilsoon_id_tokens`. +- `roles` on the middleware and `requireSession()` is `string[]`, matched with `hasRole` (any-of). +- `ServerCookieStorage` treats a key ending in `tokens` as the long-lived token cookie, so any + prefix works. Pass `storageKeys` for a name that does not. + +### Added - `@wilsoon/auth-next` + +- `permissions` (all-of) on the middleware and `requireSession()`. +- `enforce: "live"` on the middleware. It asks the provider on every request, signs the user out + when the session is gone, and guards `permissions` on what the user holds now. If the provider + cannot be asked it answers 503 and keeps the cookie, so an outage signs nobody out. +- The client cache keys on the profile and claim selectors, so define them once at module level. + ## 2.3.0 One fix, in the Next.js middleware: it was ending the sessions it was meant to be keeping. diff --git a/INTEGRATION.md b/INTEGRATION.md deleted file mode 100644 index 44b5295..0000000 --- a/INTEGRATION.md +++ /dev/null @@ -1,1114 +0,0 @@ -# Integrating an application - guide for library v2.0 - -For wiring an application up to an OIDC provider with `@wilsoon/auth-core`, `@wilsoon/auth-react`, `@wilsoon/auth-next` or `@wilsoon/auth-machine`. - -Read [Is the flow different now?](#is-the-flow-different-now) first if you already have a working 1.x integration, then jump to the recipe for your stack. - ---- - -## Contents - -- [Is the flow different now?](#is-the-flow-different-now) -- [The one rule](#the-one-rule) -- [Two session models - pick yours first](#two-session-models--pick-yours-first) -- [Setup](#setup) -- [Recipe 1 - Next.js App Router](#recipe-1--nextjs-app-router) -- [Recipe 2 - React SPA](#recipe-2--react-spa) -- [Recipe 3 - Any other server framework](#recipe-3--any-other-server-framework-astro-express-hono-sveltekit) -- [Recipe 4 - API / resource server](#recipe-4--api--resource-server) -- [Recipe 5 - Astro on Cloudflare Workers](#recipe-5--astro-on-cloudflare-workers) -- [Do I have to change every `user.role` check?](#do-i-have-to-change-every-userrole-check) -- [Authorization: role, amr, step-up](#authorization-role-amr-step-up) -- [Recipe 6 - CLI sign-in with the device grant](#recipe-6--cli-sign-in-with-the-device-grant) -- [Recipe 8 - Step-up from a resource server](#recipe-8--step-up-from-a-resource-server) -- [Recipe 7 - Server-to-server with no user](#recipe-7--server-to-server-with-no-user) -- [Session revocation](#session-revocation) -- [Logout](#logout) -- [Troubleshooting by error code](#troubleshooting-by-error-code) -- [Checklist before you ship](#checklist-before-you-ship) - ---- - -## Is the flow different now? - -**The protocol flow is identical. The library calls you make at three of the steps changed, and -one new parameter is sent.** - -Same as before: authorization code + PKCE, the same endpoints, the same redirect to -`/authorize`, the same `?code=&state=` callback, the same `/api/token` exchange, the same -`/api/logout` for RP-initiated logout, the same HttpOnly `wilsoon_id_tokens` cookie set by -the provider. Nothing about your registered client, redirect URIs or secrets changes, and an -in-flight login started on 1.x still completes on 2.0. - -| Step | 1.x | 2.0 | Protocol change? | -| ---------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------- | -| Build the authorize URL | `createAuthorizeUrl()`, you store `state` + verifier | `createAuthorizeUrl()` stores `state`, `nonce`, verifier for you | Adds `nonce` (already supported by the IdP) | -| Redirect to `/authorize` | same | same | No | -| Callback `?code=&state=` | `validateState()` → `exchangeCodeForToken()` → `parseIdToken()` | **`handleCallback(url)`** does all of it, and verifies the token | No | -| Read the session server-side | `getSession()` → decoded claims | `getSession()` → **verified** claims | No | -| Gate on `role` / `amr` | decoded from the ID token (forgeable) or missing from userinfo | `verifyIdToken()` / `getSession()` / `requireSession()` | No | -| Refresh | `refreshAccessToken()`, you re-save | `refreshAccessToken()`, single-flight, saves rotated tokens | No | -| Logout | `getLogoutUrl()` → redirect | unchanged | No | - -So: **same conversation with the IdP, different (and much shorter) code on your side.** - -The full flow, for reference: - -```mermaid -sequenceDiagram - participant B as Browser - participant A as Your app - participant I as id.wilsoon.dev - - B->>A: GET /login - A->>A: createAuthorizeUrl() → stores state, nonce, PKCE verifier - A-->>B: 302 to /authorize?...&state&nonce&code_challenge - B->>I: Sign in (password / social / passkey, 2FA) - I-->>B: 302 to your /callback?code=...&state=... - B->>A: GET /callback?code&state - A->>A: handleCallback(url) - validates state - A->>I: POST /api/token (code + code_verifier) - I-->>A: access_token, id_token, refresh_token + HttpOnly cookie - A->>I: GET /api/jwks (cached) - A->>A: verify signature, iss, aud, exp, nonce - A-->>B: 302 to your app, session established -``` - ---- - -## The one rule - -**Authorize only on a verified token.** In practice that means one of: - -```ts -const user = await client.verifyIdToken(idToken); // any runtime -const { user } = await getSession(authConfig); // Next.js server -const user = await requireSession(authConfig, { roles: ["admin"] }); -const claims = await client.verifyAccessToken(token, { audience: API_AUDIENCE }); // your API -``` - -Anything else is either display-only or forgeable: - -| Source | `role` / `amr` / `sessionVersion` | Why | -| ----------------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------- | -| `verifyIdToken()`, `getSession()`, `handleCallback()` | ✅ present and verified | signature + `iss` + `aud` + `exp` + `nonce` checked against the IdP's JWKS | -| `getUser()`, `hydrateSession()` | ❌ not in the type | the userinfo endpoint returns profile claims only | -| `parseIdToken()`, `decodeIdTokenUnsafe()` | ⚠️ present but unverified | anyone can mint a JWT with `role: "admin"` and an empty signature | -| `useAuth().user` in the browser | only when `user.verified === true` | and even then it is for rendering, not access control | -| A `client_credentials` token | ❌ there is no user at all | its `sub` is a `client_id`; verify with `verifyMachineToken()` | - ---- - -## Two session models - pick yours first - -Everything else follows from this, so settle it before writing code. - -### Model A - platform session (what `*.wilsoon.dev` uses) - -One login serves every service. The identity provider writes a single HttpOnly -`wilsoon_id_tokens` cookie scoped to `.wilsoon.dev`, so `dash.wilsoon.dev` and -`go.wilsoon.dev` both receive it and neither prompts the user again. - -The consequence that matters: **that cookie holds the tokens of whichever service most recently -completed a code exchange.** Each service is a separate client, so the ID token inside it is -addressed (`aud`) to that service alone - verifying it anywhere else fails, correctly, because -a token addressed to another party is exactly what token substitution looks like. - -So in this model: - -| Token in the shared cookie | Who may authorize on it | Why | -| -------------------------- | ----------------------------------- | -------------------------------------------- | -| `id_token` | only the service named in its `aud` | per-service audience; that's the point of it | -| `access_token` | every first-party service | addressed to the shared platform audience | - -Identity therefore comes from the **access token**, and `role` / `amr` / `session_version` come -from **introspection** - which is live, so a role change or a "sign out everywhere" is honoured -immediately rather than at token expiry. This is structurally what Google does across its own -properties: shared session cookie, authorization decided per service on the server. - -```ts -// lib/auth-config.ts - a service in a shared-cookie platform -export const authConfig: AuthConfig = { - clientId: process.env.WILSOON_CLIENT_ID!, // this service's own client - clientSecret: process.env.WILSOON_CLIENT_SECRET!, // required: introspection is confidential - issuer: "https://id.wilsoon.dev", - redirectUri: "https://dash.wilsoon.dev/api/auth/callback", - apiAudience: "https://api.wilsoon.dev", // required for the platform path - platformSessionCacheSeconds: 10, // optional: one introspection per 10s -}; -``` - -With `apiAudience` and `clientSecret` set, `getSession()` handles both cases on its own: - -```ts -const { user } = await getSession(authConfig); -user.source; // 'id_token' right after this service's own callback, -// 'access_token' when the cookie came from a sibling service -``` - -Or directly, outside Next.js: - -```ts -const user = await client.verifyPlatformSession(tokens.access_token); -``` - -Middleware only pays for introspection when it has to: with no `roles`/`amr` policy it verifies -the access token locally, and introspects only when a policy needs the live claims. - -**The trade-off, stated plainly:** any service that can read that cookie can act as the user at -every other service. That is fine while every `*.wilsoon.dev` host is equally trusted and -operated by you. The day a lower-trust or third-party app gets a subdomain, move it to Model B - -give it its own host-only session cookie, and it will no longer be able to present the platform -token elsewhere. - -Cross-service introspection requires the calling application to be marked `first_party` in the -provider's `applications` table. A third-party client can still only introspect tokens issued to -itself. - -### Model B - per-application session (standard OIDC) - -Each app keeps its own session cookie (host-only, its own name) and authorizes on its own ID -token. Single sign-on still works - the provider's own session cookie makes the second login -silent - but no app can present another's token. Use this for anything you don't fully control. - -```ts -const { user } = await getSession(authConfig); // resolves via this app's own id_token -``` - -Nothing extra to configure; omit `apiAudience` and the platform path is simply never taken. - ---- - -## Setup - -```bash -npm install @wilsoon/auth-core # any runtime -npm install @wilsoon/auth-next @wilsoon/auth-react # Next.js App Router -npm install @wilsoon/auth-react # React SPA -``` - -Requires Node.js 18+ (or a browser/edge runtime with Web Crypto). - -One config object, shared everywhere: - -```ts -// lib/auth-config.ts -import type { AuthConfig } from "@wilsoon/auth-core"; - -export const authConfig: AuthConfig = { - clientId: process.env.WILSOON_CLIENT_ID!, - issuer: "https://id.wilsoon.dev", - redirectUri: `${process.env.APP_URL}/api/auth/callback`, - scope: ["openid", "profile", "email"], // add 'offline_access' if you need refresh tokens - - // Server-side only. Never ship a secret to the browser. - clientSecret: process.env.WILSOON_CLIENT_SECRET, - - // Only for an API verifying access tokens: - apiAudience: "https://api.wilsoon.dev", -}; -``` - -`redirectUri` must exactly match one of your registered redirect URIs. - ---- - -## Recipe 1 - Next.js App Router - -### Login and callback route handlers - -```ts -// app/api/auth/login/route.ts -import { cookies } from "next/headers"; -import { NextResponse } from "next/server"; -import { AuthClient } from "@wilsoon/auth-core"; -import { ServerCookieStorage } from "@wilsoon/auth-next"; -import { authConfig } from "@/lib/auth-config"; - -export async function GET() { - const client = new AuthClient(authConfig, new ServerCookieStorage(await cookies())); - - // Persists state, nonce and the PKCE verifier as HttpOnly cookies (10 min). - // Next.js applies those cookie writes to whatever response this handler returns. - const { url } = await client.createAuthorizeUrl(); - - return NextResponse.redirect(url); -} -``` - -```ts -// app/api/auth/callback/route.ts -import { cookies } from "next/headers"; -import { NextResponse } from "next/server"; -import { AuthClient, AuthError } from "@wilsoon/auth-core"; -import { ServerCookieStorage } from "@wilsoon/auth-next"; -import { authConfig } from "@/lib/auth-config"; - -export async function GET(request: Request) { - const client = new AuthClient(authConfig, new ServerCookieStorage(await cookies())); - - try { - // Validates state, exchanges the code with its PKCE verifier, verifies the ID token - // (signature, iss, aud, exp, nonce), then clears the transient cookies. - const { user } = await client.handleCallback(request.url); - - console.log(`signed in: ${user?.id} (${user?.role})`); - return NextResponse.redirect(new URL("/dashboard", request.url)); - } catch (error) { - const code = error instanceof AuthError ? error.code : "UNKNOWN"; - return NextResponse.redirect(new URL(`/login?error=${code}`, request.url)); - } -} -``` - -The provider also sets its own HttpOnly `wilsoon_id_tokens` cookie on `.wilsoon.dev` during -the exchange. If your app is **not** on a `wilsoon.dev` subdomain, persist the tokens -yourself with `handleCallback(request.url, { persistTokens: true })` so `getSession()` has -something to read. - -### Middleware - -```ts -// middleware.ts -import { createAuthMiddleware } from "@wilsoon/auth-next"; -import { authConfig } from "./lib/auth-config"; - -export const middleware = createAuthMiddleware({ - ...authConfig, - loginPath: "/api/auth/login", - unauthorizedPath: "/unauthorized", - // verify: true by default - checks the ID token per request - // roles: ['admin'], - // amr: ['fido'], -}); - -export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] }; -``` - -It verifies the session, refreshes an access token that is within 60s of expiry (writing the -rotated tokens back to the cookie), and clears the cookie rather than redirect-looping when a -session cannot be verified. - -### Server Components and Route Handlers - -```tsx -import { getSession, requireSession } from "@wilsoon/auth-next"; -import { authConfig } from "@/lib/auth-config"; - -export default async function Page() { - const { user } = await getSession(authConfig); // user is null unless verified - if (!user) redirect("/api/auth/login"); - - return ( -

- Hello {user.name}, you are {user.role}. -

- ); -} -``` - -```ts -// Guard clause style - throws instead of returning null. -const user = await requireSession(authConfig, { roles: ["admin"], amr: ["fido"] }); -``` - -Verifying clients are cached per config, so the JWKS is fetched once per runtime instance, -not once per render. - -### Client Components - -```tsx -"use client"; -import { AuthProvider, useAuth } from "@wilsoon/auth-next"; // re-exported from auth-react -``` - -See [Recipe 2](#recipe-2--react-spa) for the provider API. Remember client state is for -rendering; the server helpers above are what gate access. - ---- - -## Recipe 2 - React SPA - -```tsx -import { AuthProvider } from "@wilsoon/auth-react"; - - - -; -``` - -```tsx -const { user, isLoading, error, login, logout } = useAuth(); - -if (isLoading) return ; -if (!user) return ; - -return ( - <> - {/* Display claims are always available: */} - {user.name} - - {/* Authorization claims exist only on a verified session: */} - {user.verified && user.role === "admin" && } - -); -``` - -`user` is a discriminated union: - -| How the session started | `verified` | Has `role` / `authMethods` / `sessionVersion` | -| ---------------------------------------------- | ---------- | --------------------------------------------- | -| Returned from the OIDC callback (`?code=...`) | `true` | yes - the ID token was verified | -| Restored on page load from the HttpOnly cookie | `false` | no - userinfo does not return them | - -TypeScript will not let you read `role` without narrowing on `verified`, which is what stops -a hydrated session from silently yielding `undefined` where a role was expected. - -The provider handles the whole callback itself: it stores `state`/`nonce`/verifier in -`sessionStorage`, calls `handleCallback()`, strips the query string, and surfaces failures on -`error` (`STATE_MISMATCH`, `NONCE_MISMATCH`, `TOKEN_VERIFICATION_FAILED`, -`AUTHORIZATION_RESPONSE_ERROR`). - ---- - -## Recipe 3 - Any other server framework (Astro, Express, Hono, SvelteKit) - -Implement `AuthStorage` over your framework's cookies once, and the core client does the rest. - -```ts -import type { AuthStorage } from "@wilsoon/auth-core"; - -// `cookies` here is whatever your framework gives you. -export const cookieStorage = (cookies: any): AuthStorage => ({ - getItem: (key) => cookies.get(key)?.value ?? null, - setItem: (key, value) => - cookies.set(key, value, { - httpOnly: true, - secure: true, - sameSite: "lax", // must survive the redirect back from the IdP - path: "/", - maxAge: 600, // transient login state; use a longer age for the token blob - }), - removeItem: (key) => cookies.delete(key, { path: "/" }), -}); -``` - -```ts -// GET /login -const client = new AuthClient(authConfig, cookieStorage(cookies)); -const { url } = await client.createAuthorizeUrl(); -return redirect(url); - -// GET /callback -const client = new AuthClient(authConfig, cookieStorage(cookies)); -const { tokens, user } = await client.handleCallback(request.url); -// `user` is verified; persist `tokens` however your app keeps sessions. -``` - -If you would rather keep managing the transient values yourself: - -```ts -const { url, state, nonce, codeVerifier } = await client.createAuthorizeUrl({ persist: false }); -// ...store all three... -const { user } = await client.handleCallback(request.url, { - expected: { state, nonce, codeVerifier }, -}); -``` - -All three matter: without `state` there is no CSRF protection on the callback, without -`codeVerifier` the exchange cannot complete, and without `nonce` the ID token is not bound to -your request. `handleCallback()` refuses to run at all if it has neither storage nor -`expected`. - -Without storage, `createAuthorizeUrl()` warns once and returns the values instead of -persisting them - treat that warning as a bug in your wiring. - ---- - -## Recipe 4 - API / resource server - -Access tokens are minted with a **shared** audience (`https://api.wilsoon.dev`), so verifying -only the signature would let any client's token in. Pin the audience: - -```ts -import { AuthClient, ClaimValidationError, TokenVerificationError } from "@wilsoon/auth-core"; - -// A verify-only client needs no redirectUri - it never starts a login. -const client = new AuthClient({ - clientId: process.env.WILSOON_CLIENT_ID!, - issuer: "https://id.wilsoon.dev", - clientSecret: process.env.WILSOON_CLIENT_SECRET, // needed only for introspection - apiAudience: "https://api.wilsoon.dev", -}); - -export async function authenticate(request: Request) { - const bearer = request.headers.get("authorization")?.replace(/^Bearer /i, ""); - if (!bearer) return unauthorized(); - - try { - const claims = await client.verifyAccessToken(bearer, { requiredScopes: ["openid"] }); - return { userId: claims.subject, scopes: claims.scopes }; - } catch (error) { - if (error instanceof TokenVerificationError || error instanceof ClaimValidationError) return unauthorized(); - throw error; - } -} -``` - -Access tokens carry **no** `role` or `amr` - those live in the ID token. If your API needs -them and only ever sees an access token, introspect (confidential clients only): - -```ts -const info = await client.introspectToken(bearer); -if (!info.active) return unauthorized(); -if (info.role !== "admin") return forbidden(); -``` - -A first-party service can do both steps in one call, which also rejects a revoked session: - -```ts -const user = await client.verifyPlatformSession(bearer); // verify + introspect -if (user.role !== "admin") return forbidden(); -``` - -`redirectUri` is only needed to start a login or exchange a code, so a verify-only client can -omit it. - ---- - -## Recipe 5 - Astro on Cloudflare Workers - -The three files most apps share, ported to 2.0. Copy them as-is. - -**What actually changes in your app:** the _source_ of the user object, in one function. -`user.role` and every check built on it stay exactly as they are - see -[Do I have to change every `user.role` check?](#do-i-have-to-change-every-userrole-check). - -### `src/lib/auth/config.ts` - -`process.env` does not exist on Workers - secrets are runtime bindings, so thread them in from -`Astro.locals.runtime?.env` and fall back to `import.meta.env` for `astro dev`. Don't reach for -`require('cloudflare:workers')`; `require` is undefined in an ESM bundle. - -```ts -import type { AuthConfig } from "@wilsoon/auth-core"; - -export type EnvSource = Record | undefined; - -const read = (env: EnvSource, key: string): string | undefined => env?.[key] ?? (import.meta.env as unknown as Record)[key]; - -export function authConfig(origin: string, env?: EnvSource): AuthConfig { - const clientId = read(env, "WILSOON_CLIENT_ID"); - if (!clientId) throw new Error("Missing WILSOON_CLIENT_ID"); - - return { - clientId, - // Safe here: Astro API routes, middleware and .astro frontmatter are all server-side. - // Required for introspection, which is how a shared-cookie session resolves `role`. - clientSecret: read(env, "WILSOON_CLIENT_SECRET"), - issuer: read(env, "WILSOON_ISSUER") ?? "https://id.wilsoon.dev", - redirectUri: `${origin}/api/callback`, - scope: ["openid", "profile", "email", "offline_access"], - apiAudience: "https://api.wilsoon.dev", // needed for sibling-service sessions - platformSessionCacheSeconds: 10, // one introspection per 10s, not per request - }; -} -``` - -### `src/lib/auth/AstroCookieStorage.ts` - -The two kinds of value need different scopes. `state`, `nonce` and the PKCE verifier belong to -**one login attempt on one host** - scoping them to `.wilsoon.dev` means two services mid-login -overwrite each other, and the first callback back fails with `STATE_MISMATCH`. - -```ts -import { STORAGE_KEYS, type AuthStorage } from "@wilsoon/auth-core"; -import type { AstroCookies } from "astro"; - -const TRANSIENT_KEYS: string[] = [STORAGE_KEYS.state, STORAGE_KEYS.nonce, STORAGE_KEYS.codeVerifier]; - -export class AstroCookieStorage implements AuthStorage { - private readonly isLocal: boolean; - - constructor( - private cookies: AstroCookies, - origin: string, - private sharedDomain: string | null = ".wilsoon.dev", - ) { - const hostname = (() => { - try { - return origin.includes("://") ? new URL(origin).hostname : origin.split(":")[0]; - } catch { - return origin; - } - })(); - this.isLocal = hostname === "localhost" || hostname === "127.0.0.1"; - } - - private scope(key: string) { - const transient = TRANSIENT_KEYS.includes(key); - const shared = !transient && !this.isLocal && this.sharedDomain; - return { transient, domain: shared ? this.sharedDomain : undefined }; - } - - getItem(key: string): string | null { - return this.cookies.get(key)?.value ?? null; - } - - setItem(key: string, value: string): void { - const { transient, domain } = this.scope(key); - this.cookies.set(key, value, { - path: "/", - httpOnly: true, - sameSite: "lax", // must survive the redirect back from the provider - secure: !this.isLocal, - maxAge: transient ? 600 : 60 * 60 * 24 * 365, - ...(domain ? { domain } : {}), - }); - } - - removeItem(key: string): void { - const { domain } = this.scope(key); - // A domain-scoped cookie is only cleared when the same domain is named. - this.cookies.delete(key, { path: "/", ...(domain ? { domain } : {}) }); - } -} -``` - -### `src/lib/auth/utils.ts` - the one file that closes the hole - -```ts -import { AuthClient, type AuthenticatedUser } from "@wilsoon/auth-core"; -import type { AstroCookies } from "astro"; -import { AstroCookieStorage } from "./AstroCookieStorage"; -import { authConfig, type EnvSource } from "./config"; - -export function getAuthClient(cookies: AstroCookies, origin: string, env?: EnvSource): AuthClient { - return new AuthClient(authConfig(origin, env), new AstroCookieStorage(cookies, origin)); -} - -/** The verified user for this request, or null. Fails closed. */ -export async function getAuthenticatedUser(cookies: AstroCookies, origin: string, env?: EnvSource): Promise { - const client = getAuthClient(cookies, origin, env); - - const tokens = client.getStoredTokens(); - if (!tokens) return null; - - let current = tokens; - if (client.isTokenNearExpiry(tokens.access_token)) { - if (!tokens.refresh_token) { - client.clearStorage(); - return null; - } - try { - // The provider rotates refresh tokens; the library stores the rotated response itself. - current = await client.refreshAccessToken(tokens.refresh_token); - } catch { - client.clearStorage(); - return null; - } - } - - try { - // Our own ID token when the cookie is ours; the shared platform session otherwise. - return await client.resolveSession(current); - } catch { - return null; - } -} - -/** Cheap "is anyone signed in?" - verifies, resolves no claims, no introspection round trip. */ -export async function isAuthenticated(cookies: AstroCookies, origin: string, env?: EnvSource): Promise { - const client = getAuthClient(cookies, origin, env); - - const tokens = client.getStoredTokens(); - if (!tokens) return false; - - try { - if (client.isTokenNearExpiry(tokens.access_token)) { - if (!tokens.refresh_token) return false; - const refreshed = await client.refreshAccessToken(tokens.refresh_token); - await client.verifyAccessToken(refreshed.access_token); - return true; - } - await client.verifyAccessToken(tokens.access_token); - return true; - } catch { - return false; - } -} -``` - -`resolveSession()` is the framework-agnostic version of what `getSession()` does in Next.js. - -### `src/pages/auth.ts` - login - -```ts -import type { APIRoute } from "astro"; -import { getAuthClient } from "../lib/auth/utils"; - -const RETURN_TO_COOKIE = "wilsoon_auth_redirect"; - -export const GET: APIRoute = async ({ url, cookies, redirect, locals }) => { - const next = url.searchParams.get("next"); - if (!next) return new Response("Missing 'next' parameter", { status: 400 }); - - // Same-origin paths only, so `?next=` cannot become an open redirect. - if (!next.startsWith("/") || next.startsWith("//")) { - return new Response("Invalid 'next' parameter", { status: 400 }); - } - - const client = getAuthClient(cookies, url.origin, (locals as any).runtime?.env); - const { url: authorizeUrl } = await client.createAuthorizeUrl(); - - cookies.set(RETURN_TO_COOKIE, next, { - path: "/", - httpOnly: true, - sameSite: "lax", - secure: url.protocol === "https:", - maxAge: 600, - }); - - return redirect(authorizeUrl); -}; -``` - -### `src/pages/api/callback.ts` - -`handleCallback()` replaces `validateState()` then `exchangeCodeForToken()`, and adds signature, issuer, audience, expiry and `nonce` verification. - -```ts -import type { APIRoute } from "astro"; -import { AuthError } from "@wilsoon/auth-core"; -import { getAuthClient } from "../../lib/auth/utils"; - -const RETURN_TO_COOKIE = "wilsoon_auth_redirect"; - -export const GET: APIRoute = async ({ url, cookies, redirect, locals }) => { - const client = getAuthClient(cookies, url.origin, (locals as any).runtime?.env); - - const returnTo = cookies.get(RETURN_TO_COOKIE)?.value || "/"; - const clearReturnTo = () => cookies.delete(RETURN_TO_COOKIE, { path: "/" }); - - try { - // Validates state, exchanges the code with its PKCE verifier, verifies the ID token, - // clears the transient cookies, and stores the token response. - const { user } = await client.handleCallback(url.href, { persistTokens: true }); - - console.log(`signed in: ${user?.id} (${user?.role})`); - clearReturnTo(); - return redirect(returnTo); - } catch (error) { - const code = error instanceof AuthError ? (error.code ?? "AUTH_FAILED") : "AUTH_FAILED"; - console.error("Auth callback failed:", code, error); - clearReturnTo(); - - const errorUrl = new URL(returnTo, url.origin); - errorUrl.searchParams.set("auth_error", code.toLowerCase()); - return redirect(errorUrl.pathname + errorUrl.search); - } -}; -``` - -### `src/middleware.ts` - unchanged in shape - -```ts -import { defineMiddleware } from "astro:middleware"; -import { AMR, satisfiesAmr } from "@wilsoon/auth-core"; -import { getAuthenticatedUser } from "./lib/auth/utils"; - -export const onRequest = defineMiddleware(async (context, next) => { - if (!context.url.pathname.startsWith("/admin")) return next(); - - const isApi = context.url.pathname.startsWith("/api/"); - const user = await getAuthenticatedUser(context.cookies, context.url.origin, (context.locals as any).runtime?.env); - - if (!user) { - return isApi ? new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }) : context.redirect(`/auth?next=${encodeURIComponent(context.url.pathname)}`); - } - - if (user.role !== "admin") { - return isApi ? new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }) : new Response("Forbidden", { status: 403 }); - } - - // Optional: `mfa` alone is not phishing-resistant - the provider emits it for TOTP too. - if (!satisfiesAmr(user, [AMR.FIDO])) { - return isApi ? new Response(JSON.stringify({ error: "Step-up required" }), { status: 403 }) : context.redirect(`/auth?next=${encodeURIComponent(context.url.pathname)}&stepup=1`); - } - - context.locals.user = user; - return next(); -}); -``` - ---- - -## Do I have to change every `user.role` check? - -**No.** `user.role` is not the problem and never was - where the `user` came from is. - -```ts -// The vulnerability: role from an unverified decode. Anyone can mint that token. -const user = client.parseIdToken(tokens.id_token); -if (user.role !== "admin") deny(); // <- this line is fine - -// The fix: same line, verified source. -const user = await client.resolveSession(tokens); -if (user.role !== "admin") deny(); // <- unchanged -``` - -So per app, one function changes - `getAuthenticatedUser()`. Everything downstream is untouched: - -| Code | Change needed | -| ------------------------------------------------------------- | ---------------------------------------------------------------------- | -| `getAuthenticatedUser()` / `isAuthenticated()` | **Yes** - swap `parseIdToken()` for `resolveSession()` | -| `login.ts`, `callback.ts` | **Yes** - `createAuthorizeUrl()` persists, `handleCallback()` verifies | -| Storage class | **Yes** - transient keys host-only, 10 min | -| `middleware.ts` role and `amr` gates | No | -| `.astro` pages reading `user.role`, `user.name`, `user.email` | No | -| `context.locals.user` | No | - -Two small type notes when you compile: `AuthenticatedUser.email` is optional (the provider omits -it without the `email` scope), so `const e: string = user.email` needs a fallback; and -`getAuthenticatedUser()` now returns `AuthenticatedUser | null` rather than the deprecated `User` -alias - `role` is `'admin' | 'user'` in both, so comparisons compile unchanged. - ---- - -## Authorization: role, amr, step-up - -```ts -import { AMR, assertAmr, satisfiesAmr } from "@wilsoon/auth-core"; - -const user = await client.verifyIdToken(idToken); - -if (user.role !== "admin") return forbidden(); -if (!satisfiesAmr(user, [AMR.FIDO])) return stepUp(); -assertAmr(user, [AMR.MFA]); // throws ClaimValidationError -``` - -How the IdP composes `amr`: - -| Login | `amr` | -| --------------------------- | ------------------------ | -| Federated / social provider | `["ext", "social"]` | -| Passkey | `["mfa", "fido", "hw"]` | -| TOTP challenge completed | adds `"otp"` and `"mfa"` | - -**`mfa` does not imply a hardware factor** - it is emitted for passkeys _and_ for a social -login that cleared a TOTP challenge. Require `AMR.FIDO`/`AMR.HARDWARE` when you mean -phishing-resistant. - -To force a stronger login rather than just rejecting a weak one: - -```ts -await client.createAuthorizeUrl({ prompt: "reauthenticate", acrValues: ["mfa"] }); -// or in React: login({ prompt: 'reauthenticate', acrValues: ['mfa'] }) -``` - -Two things to add to that. First, `max_age` bounds how _old_ the authentication may be, and -`max_age=0` demands a fresh one outright: - -```ts -await client.createAuthorizeUrl({ acrValues: ["mfa"], maxAge: 300 }); -``` - -Second - and this is the part that is easy to miss - **the request is not the guarantee.** -An authorization server may ignore `acr_values` and `max_age` and still return a perfectly -valid token describing a weaker login. Check on the way back: - -```ts -const user = await client.verifyIdToken(idToken, { - requiredAcr: "urn:wilsoon:acr:passkey", - maxAuthAgeSeconds: 300, -}); -``` - -If a resource server is the thing demanding it, have it answer `401` with an RFC 9470 -challenge and let the client act on that - see -[Recipe 8](#recipe-8--step-up-from-a-resource-server), which works against any provider. - -**Escalation** is the provider-specific alternative: the session survives, the user proves -one extra thing, and you get back a token proving it happened. Reach for it when the client -has no redirect URI, or when you want to ask without showing anything to a user who already -qualifies. - -```ts -const result = await client.reauthorize(["passkey", "fido"], true, { - idTokenHint: tokens.id_token, // required for a public client - openUrl: (url) => redirect(url), // or print it, in a CLI -}); - -if (result.alreadySatisfied) { - // force: false and the session already qualified - the user saw nothing. -} -``` - -A denial throws `AuthorizationDeniedError` and an expiry throws -`AuthorizationExpiredError`, because those are different facts: in one the user answered, -in the other they never did. Hand `result.escalationToken` to a resource server rather than -a boolean - a boolean from your own code proves nothing to anybody else. - -And to require a _recent_ authentication: - -```ts -await client.verifyIdToken(idToken, { maxAuthAgeSeconds: 300 }); -``` - -`role` is validated at the boundary: an unrecognised value throws `ClaimValidationError` -rather than being asserted into `'admin' | 'user'`, and an absent one becomes the -least-privileged `'user'`. - ---- - -## Recipe 6 - CLI sign-in with the device grant - -For a client that can print a string but cannot host a redirect URI. The application must be -registered for the device grant, or the first call is refused with `unauthorized_client`. - -```ts -import { AuthClient } from "@wilsoon/auth-core"; - -const client = new AuthClient({ clientId: CLI_CLIENT_ID, issuer: OIDC_ISSUER }); - -const { user, tokens } = await client.authorizeDevice({ - scope: ["openid", "profile", "email", "offline_access"], - onUserCode: ({ userCode, verificationUri, verificationUriComplete }) => { - console.log(`\n Go to ${verificationUri}`); - console.log(` Enter code: ${userCode}\n`); - console.log(` Or open: ${verificationUriComplete}`); - }, -}); - -console.log(`Signed in as ${user?.email}`); -``` - -Show the code **and** the plain URI even when `verificationUriComplete` exists - the approval -page displays the code it resolved, so a user who followed a link can check it against what -the terminal printed. - -Request `offline_access` if the CLI should keep working after the access token expires, -which for a tool signed into once is usually the point. - -The `acr` on the resulting token is the acr of **the browser session that approved it** - the -device never sees a session, so it asserts nothing about the authentication itself. - ---- - -## Recipe 7 - Server-to-server with no user - -A scheduled job or one service calling another. There is no user anywhere in this exchange, -so it needs a different package: `@wilsoon/auth-machine`, which is server-only and currently -0.x. - -```bash -npm install @wilsoon/auth-machine -``` - -```ts -import { createMachineClient } from "@wilsoon/auth-machine"; - -const machine = createMachineClient({ - issuer: process.env.OIDC_ISSUER!, - clientId: process.env.OIDC_CLIENT_ID!, - clientSecret: process.env.OIDC_CLIENT_SECRET!, // confidential clients only -}); - -// Cached, single-flighted, renewed ahead of expiry, one retry on a 401. -const res = await machine.fetch("https://api.example.com/reports"); -``` - -On the **receiving** side, in your resource server, the important part: - -```ts -// Only machines may call this endpoint. -const caller = await client.verifyMachineToken(token, { audience: API_AUDIENCE }); -caller.clientId; // there is no user to read, by design - -// Or serve both, and branch: -const claims = await client.verifyAccessToken(token, { allowMachineTokens: true }); -if (claims.tokenUse === "client") return serveMachine(claims.subject); -``` - -Without `allowMachineTokens`, `verifyAccessToken()` throws `MachineTokenNotAllowedError`. -That default is the point: a machine token verifies identically to a user token, but its -`sub` is a `client_id`, so an endpoint written for users would otherwise treat a service as -a person and never notice. - -Three constraints come from the grant itself: it accepts **no scope**, issues **no refresh -token**, and its tokens **cannot be revoked** - rotating the secret stops new issuance but -leaves outstanding tokens valid until they expire. - ---- - -## Recipe 8 - Step-up from a resource server - -The portable way to demand stronger authentication for one action. Nothing here is specific -to any provider: it is OIDC Core's `acr_values` and `max_age`, plus RFC 9470's challenge -header. - -**On the API**, answer `401` - never `403`. The caller is being told to go and fetch -something, which is a different statement from being refused. - -```ts -import { buildAuthenticationChallenge, satisfiesAmr, AMR } from "@wilsoon/auth-core"; - -if (!satisfiesAmr(user, [AMR.FIDO])) { - res.setHeader( - "WWW-Authenticate", - buildAuthenticationChallenge({ - acrValues: "urn:wilsoon:acr:passkey", - maxAge: 300, - errorDescription: "A recent passkey assertion is required.", - }), - ); - return res.status(401).end(); -} -``` - -**In the client**, act on it: - -```ts -import { readAuthenticationChallenge, isStepUpChallenge } from "@wilsoon/auth-core"; - -const res = await fetch("/api/payouts", { method: "POST", body }); - -if (res.status === 401) { - const challenge = readAuthenticationChallenge(res.headers.get("www-authenticate")); - - if (isStepUpChallenge(challenge)) { - const { url } = await client.createStepUpRequest(challenge); - return redirect(url); // acr_values and max_age carried across - } - return redirect("/login"); // an ordinary 401: the token is no good -} -``` - -That `isStepUpChallenge()` branch matters. `insufficient_user_authentication` means the token -is valid and the user _is_ signed in - sending them through a plain login throws away a good -session for nothing. - -Only `acr_values` and `max_age` are carried from the challenge into the request. A `scope` -from the same header is parsed but never applied, since a resource server should not be able -to widen what your client asks for on the user's behalf. - -Then verify what came back, with `requiredAcr` and `maxAuthAgeSeconds`. Without that, a -provider that ignored the request hands you a step-up that only appears to have happened. - ---- - -## Session revocation - -When a user hits "sign out everywhere" (or an admin changes their role), the IdP increments -`users.session_version`. Tokens minted earlier stay cryptographically valid until they -expire, so an app that cares must check: - -```ts -// Confidential clients: ask the IdP directly (RFC 7662 introspection). -const user = await client.verifyIdToken(idToken); -if (!(await client.isSessionCurrent(user, { token: idToken }))) return reauthenticate(); -``` - -```ts -// Or resolve the version from your own backend / cache: -const client = new AuthClient({ - ...authConfig, - resolveSessionVersion: (userId) => myDb.getSessionVersion(userId), -}); -if (!(await client.isSessionCurrent(user))) return reauthenticate(); -``` - -It fails closed: with no resolver and no token it throws `SessionCheckUnavailableError` -rather than assuming the session is fine. Introspection also returns `active: false` on its -own for a superseded session, and gives you the live `role`. - -This costs a network call, so put it on privileged actions and long-lived sessions rather -than on every request. - ---- - -## Logout - -Redirect the user to the IdP's `end_session_endpoint` so it can clear its own session and the shared cookie: - -```ts -const url = await client.getLogoutUrl(tokens.id_token, "https://app.example.com/goodbye"); -// ...then clear your own session/cookies and redirect to `url`. -client.clearStorage(); -``` - -In React, `logout()` does this for you: - -```tsx - -``` - -Two things to know: - -- `post_logout_redirect_uri` must share an origin with one of your **registered redirect - URIs**, or the IdP bounces the user to its own `/auth` page with - `?error=open_redirect_protection`. -- Clear your app's session cookies yourself. The IdP clears its session and the - `wilsoon_id_tokens` cookie it owns; it cannot clear cookies your app set. - ---- - -## Troubleshooting by error code - -| Error / code | What happened | Fix | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | -| `ISSUER_MISMATCH` | The discovery document advertises a different issuer than you configured | Check your `OIDC_ISSUER` variable, or set `expectedIssuer` in your config | -| `TOKEN_VERIFICATION_FAILED`, message mentions the signature | Forged token, or the IdP rotated keys | Retry once (the library refetches the JWKS itself); if it persists, check `OIDC_PRIVATE_KEY`/`OIDC_PUBLIC_KEY` are a pair | -| `TOKEN_VERIFICATION_FAILED`, message mentions `aud` | ID token minted for another `client_id`, or an ID token passed to `verifyAccessToken` | Verify ID tokens with the client they were issued to; pass `access_token` to API verification | -| `TOKEN_VERIFICATION_FAILED`, message mentions `iss` | Token from a different IdP deployment | Align `issuer` / `OIDC_ISSUER` between app and IdP | -| `TOKEN_VERIFICATION_FAILED` - "Token has expired" | Clock skew or a genuinely old token | Check server clocks; raise `clockToleranceSeconds` only for real skew | -| `STATE_MISMATCH` | `state` missing or different - often the transient cookie was lost | Set the cookie's key to be `SameSite=Lax` (not `Strict`), correct `path`, and don't start multiple logins in parallel | -| `NONCE_MISMATCH` | The ID token was not bound to this authorization request | Make sure the same client instance/storage handles authorize and callback and don't replay a callback URL | -| `INVALID_CALLBACK` | No `code`, or the PKCE verifier is gone | The login attempt expired (10 min) - start again from your login route | -| `AUTHORIZATION_RESPONSE_ERROR` | The IdP returned `?error=` (e.g. `access_denied`) | Expected when a user declines consent; send them back to your login page | -| `STORAGE_UNAVAILABLE` | A storage-backed call with no usable storage, or a cookie write in a Server Component | Pass a storage implementation; move cookie writes into a Route Handler, Server Action or middleware | -| `CLAIM_INVALID` - "unrecognised role" | The IdP issued a role this library version doesn't model | The only valid roles in the original version is `user` and `admin`, do map it yourself if needed | -| `SESSION_CHECK_UNAVAILABLE` | `isSessionCurrent()` had no way to resolve the live version | Configure `resolveSessionVersion`, or pass `{ token }` with a `clientSecret` set | -| `INTROSPECTION_UNAVAILABLE` | No `clientSecret`, or the IdP advertises no introspection endpoint | Introspect from your server with a confidential client | -| `TOKEN_VERIFICATION_FAILED` - "no longer active" | The platform session was revoked, or superseded by a newer one | Send the user back through login | -| `CLAIM_INVALID` - "audience this resource server accepts" | A shared-cookie session but no `apiAudience` configured | Set `apiAudience` (Model A), or expect only your own ID token (Model B) | -| Session works right after login, then goes null | Model A without `apiAudience`/`clientSecret`: a sibling service overwrote the shared cookie | Configure the platform path - see [Two session models](#two-session-models--pick-yours-first) | -| `CRYPTO_UNAVAILABLE` | Runtime has no Web Crypto | Node.js 18+, a modern browser, or an edge runtime | -| `DISCOVERY_FAILED` | `/.well-known/openid-configuration` unreachable or incomplete | Check the issuer URL and network egress | -| `MACHINE_TOKEN_NOT_ALLOWED` | A `client_credentials` token reached a path that expects a user | Pass `allowMachineTokens: true` and branch on `tokenUse`, or use `verifyMachineToken()` | -| `NOT_A_MACHINE_TOKEN` | A user token was passed to `verifyMachineToken()` | Use `verifyAccessToken()` for user tokens | -| `ACCESS_DENIED` | The user refused a step-up or a device authorization | Expected; tell them what was declined rather than retrying | -| `EXPIRED_TOKEN` | The escalation or device code passed its deadline | Start the flow again. Only the server decides this - don't add a client-side timeout | -| `ESCALATION_FAILED` | Unknown method (`invalid_use`), unusable `id_token_hint`, or an unknown subject | Check `use` against what the provider supports, and pass an `idTokenHint` you were issued | -| `INVALID_CHALLENGE` | `createStepUpRequest()` got a header it could not parse, or one naming no `acr_values`/`max_age` | Check `isStepUpChallenge()` first; an ordinary 401 is answered with a plain login | -| `CLAIM_INVALID` - "does not satisfy" | The token's `acr` is weaker than `requiredAcr` demanded | The provider ignored or could not meet `acr_values`. This is the check working | -| `DEVICE_FLOW_FAILED` | Not registered for the grant, device code already redeemed, or no endpoint advertised | Enable the grant on the client; device codes are single use | - ---- - -## Checklist before you ship - -**Your application** - -- [ ] `clientSecret` is only ever read on the server. -- [ ] Every authorization decision goes through `verifyIdToken()` / `getSession()` / - `requireSession()` / `verifyAccessToken()`. -- [ ] `redirectUri` and any `post_logout_redirect_uri` origin are registered with the IdP. -- [ ] Transient login cookies are `HttpOnly`, `Secure`, `SameSite=Lax`. -- [ ] `offline_access` requested only if you actually refresh; refreshed tokens are persisted - (the library does it when it owns storage - the IdP rotates and revokes replayed tokens). -- [ ] Privileged actions check `satisfiesAmr(...)` and, where it matters, `isSessionCurrent(...)`. -- [ ] If you share the `.wilsoon.dev` cookie: `apiAudience` and `clientSecret` are set, and you - never authorize on an ID token whose `aud` is not yours. -- [ ] Logout clears your own session cookies as well as redirecting to the IdP. - -**The identity provider** (operators) - -- [ ] `OIDC_ISSUER` (or `NEXTAUTH_URL`) is the public URL of the deployment - tokens and the - discovery document must agree, or every client rejects every token. -- [ ] `OIDC_PRIVATE_KEY` / `OIDC_PUBLIC_KEY` are a matching pair; `/api/jwks` returns a key - whose `kid` is its RFC 7638 thumbprint. -- [ ] `OIDC_COOKIE_DOMAIN` set (or emptied for localhost) so the session cookie is accepted. -- [ ] After a key rotation, keep publishing the previous `kid` until the longest session has - expired, then set `OIDC_DROP_LEGACY_KID=true`. -- [ ] Introspection is reachable and only accepts confidential clients. diff --git a/README.md b/README.md index 9a86a98..270fb35 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Full documentation, with slightly more depth than this README, is at - [Pointing it at your own provider](#pointing-it-at-your-own-provider) - [Storage: the one thing to understand](#storage-the-one-thing-to-understand) - [The security model](#the-security-model) -- [Things that are still specific to one provider](#things-that-are-still-specific-to-one-provider) +- [Provider profiles](#provider-profiles) - [Contributing](#contributing) - [License](#license) @@ -52,10 +52,10 @@ As such, I built this authentication library in order to plug my provider into a Using anything else - Astro, SvelteKit, Hono, Express? Use `auth-core` directly and write one [storage adapter](#storage-the-one-thing-to-understand). That's the whole integration. -The first three are versioned together on a shared 2.x line. `auth-machine` has no dependency on the others, so it is versioned independently and is currently 0.x. +The first three are versioned together on a shared 3.x line. `auth-machine` has no dependency on the others, so it is versioned independently and is currently 0.x. -For end-to-end recipes per framework, see **[INTEGRATION.md](INTEGRATION.md)**; for what -changed in 2.0 and how to migrate from 1.x, see **[CHANGELOG.md](CHANGELOG.md)**. This README +For end-to-end recipes per framework, see the **[recipes](https://docs.wilsoon.dev/node-oidc-kit/recipes/nextjs)**; for what +changed in 3.0 and how to migrate, see **[CHANGELOG.md](CHANGELOG.md)**. This README is about the design and about running the project; that one is about wiring an app up. ## Quick start @@ -73,6 +73,7 @@ export const authConfig = { issuer: "https://id.example.com", redirectUri: "https://app.example.com/api/callback", scope: ["openid", "profile", "email", "offline_access"], + rolesClaim: "roles", // where your provider puts roles, if it does }; // One client per request, holding this request's cookies. @@ -95,8 +96,8 @@ client.saveTokens(tokens); ```ts // On any later request -const user = await client.resolveSession(client.getStoredTokens()!); -if (user.role !== "admin") return forbidden(); +const user = await client.resolveSession(client.getStoredTokens() ?? {}); +if (!hasRole(user, "admin")) return forbidden(); // hasRole from @wilsoon/auth-core ``` `handleCallback()` validates `state`, exchanges the code with its PKCE verifier, verifies @@ -114,10 +115,10 @@ Your provider needs to offer: | `/.well-known/openid-configuration` with `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri` | Every other endpoint is discovered from here | `DiscoveryError` at startup | | `issuer` in that document matching your configured `issuer` | Stops tokens being validated against an issuer you didn't choose | `IssuerMismatchError` - set `expectedIssuer` if the difference is deliberate | | Authorization code flow with **PKCE (`S256`)** | The only implemented flow here, there is no implicit flow. | Nothing will work | -| **RS256** ID token signatures | See [algorithms](#2-rs256-only) | `ERR_JOSE_ALG_NOT_ALLOWED` | +| **RS256** ID token signatures | See [RS256 only](#2-rs256-only) | `ERR_JOSE_ALG_NOT_ALLOWED` | | `nonce` added into the ID token | ID token replay protection (also mandatory in the OIDC spec) | `NonceMismatchError` on every login | | `end_session_endpoint` | RP-initiated logout | `getLogoutUrl()` throws `LogoutError`, log out locally instead | -| `introspection_endpoint` (RFC 7662) | Live revocation, and roles for resource servers | `verifyPlatformSession()` and introspection-based `isSessionCurrent()` unavailable; plain `verifyIdToken()` is unaffected | +| `introspection_endpoint` (RFC 7662) | Live access checks, and shared platform sessions | `liveAccess()`, `enforce: "live"` and shared sessions unavailable; plain `verifyIdToken()` is unaffected | Everything below `nonce` is optional. If it doesn't exist, you lose a feature, but the core functionality remains. @@ -125,19 +126,23 @@ Two session models are supported, and which one you're in decides which methods - **Per-application session** (standard OIDC). Each app has its own cookie and its own ID token. Use `verifyIdToken()` / `getSession()`. -- **Platform session** (one cookie across subdomains). Set `apiAudience` and - `clientSecret`, and use `resolveSession()`, which falls back to `verifyPlatformSession()` when the cookie belongs to another app. +- **Platform session** (one cookie across subdomains). Needs a [provider profile](#provider-profiles) that + resolves shared sessions, plus `apiAudience` and `clientSecret`. Use `resolveSession()`, which hands a + sibling app's cookie to the profile, or throws `FOREIGN_SESSION` without one. ## Storage: the one thing to understand The library persists 4 values: -| Key | Lifetime | Purpose | -| --------------------------- | ------------------ | ---------------------------------------------- | -| `STORAGE_KEYS.tokens` | The session | The token response blob | -| `STORAGE_KEYS.state` | One login, minutes | CSRF binding between authorize and callback | -| `STORAGE_KEYS.nonce` | One login, minutes | Binds the ID token to _this_ authorize request | -| `STORAGE_KEYS.codeVerifier` | One login, minutes | The PKCE secret | +| Key (default name) | Lifetime | Purpose | +| -------------------------------- | ------------------ | ---------------------------------------------- | +| `tokens` (`oidc_tokens`) | The session | The token response blob | +| `state` (`oidc_state`) | One login, minutes | CSRF binding between authorize and callback | +| `nonce` (`oidc_nonce`) | One login, minutes | Binds the ID token to _this_ authorize request | +| `codeVerifier` (`oidc_verifier`) | One login, minutes | The PKCE secret | + +`storagePrefix` changes the prefix, a provider profile can set exact names, and +`client.storageKeys` reports the names a client actually uses. The `state`, `nonce`, and `codeVerifier` are written when you build the authorize URL and read after the provider redirects back - a _different request_, often to a different process. Somewhere durable across that redirect has to hold them. That "where" is the only thing that differs between a browser SPA, Next.js, Astro, and an Express app, so it's the only thing the library asks you to provide: @@ -157,7 +162,6 @@ interface AuthStorage { | `MemoryStorage` | `auth-core` | Tests, and single-process flows where authorize and callback are handled by the same instance. Will not survive a restart. | | `UnavailableStorage` | `auth-core` | The server default when you pass nothing. Every method throws `StorageUnavailableError` naming the operation and the fix. It exists because the old default (`{} as AuthStorage`) type-checked as valid storage and then died with `TypeError: setItem is not a function` far from the actual mistake. | | `ServerCookieStorage` | `auth-next` | The Next.js cookie store. Writes `HttpOnly` + `SameSite=Lax`, with a short lifetime for the transient three and a long one for the token blob. Throws a useful error in a Server Component, where Next.js forbids cookie writes. | -| `CookieStorage` | `auth-core` | **Deprecated.** Used to be the default, but now the provider sets the token cookie `HttpOnly` which means no browser-side cookie adapter can ever see it again. Kept so existing calls still compile. Use `hydrateSession()` instead. | ### Writing your own @@ -221,21 +225,21 @@ const client = new AuthClient(config, new AstroServerStorage(cookies, origin)); 7. **A write that fails should throw an error, not silently fail** - Unless the failure to write is deliberate, as in the Next.js middleware, where the _response_ owns cookie writes and the storage is read-only by design. 8. **Don't share one instance across requests** - Storage is per-request state, and the `AuthClient` is what you cache (see `getCachedClient` in `auth-next`, which reuses the JWKS cache across requests). -To put the token blob under a different cookie name, map it inside your adapter - that's what the Next.js middleware's `cookieName` option does. `STORAGE_KEYS` itself is fixed. +To put the token blob under a cookie name that follows neither the prefix nor the profile, map it inside your adapter - that's what the Next.js middleware's `cookieName` option does. ## The security model -**Only five methods** produce a value you may base authorization decisions on: +**Only a handful of methods** produce a value you may base authorization decisions on: | Method | Verified? | Gives you | | ----------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- | -| `verifyIdToken()` | Signature, `iss`, `aud`, `exp`, `nonce`, `evt` | `AuthenticatedUser` - `role`, `authMethods`, `sessionVersion` | -| `verifyAccessToken()` | Signature, `iss`, pinned `aud`, `exp`, scopes | `AccessTokenClaims` - no role, plus `tokenUse` | -| `verifyPlatformSession()` | The above + introspection | `AuthenticatedUser` with **current** role and session version | -| `verifyMachineToken()` | The access token checks, plus `token_use` | `MachineClient` - a `clientId`, and deliberately no user fields | -| `verifyEscalationToken()` | Signature, `iss`, `aud`, `exp`, `evt` | `VerifiedEscalation` - proof one step-up happened | -| `getUser()` / `hydrateSession()` | Transport only | `ProfileUser` - display claims, no `role` field to prevent potential misuse | -| `decodeIdTokenUnsafe()`, `parseIdToken()` | **Nothing** | `UnverifiedUser` - may be attacker-controlled by definition | +| `verifyIdToken()` | Signature, `iss`, `aud`, `exp`, `nonce`, `evt` | `AuthenticatedUser` - `roles`, `permissions`, `authMethods` | +| `verifyAccessToken()` | Signature, `iss`, pinned `aud`, `exp`, scopes | `AccessTokenClaims` - `roles`, `permissions`, `tokenUse` | +| `wilsoon.verifyPlatformSession()` | The above + introspection | `AuthenticatedUser` with **current** permissions (WilsoonID profile) | +| `verifyMachineToken()` | The access token checks, plus machine detection | `MachineClient` - a `clientId`, and deliberately no user fields | +| `wilsoon.verifyEscalationToken()` | Signature, `iss`, `aud`, `exp`, `evt` | `VerifiedEscalation` - proof one step-up happened (WilsoonID profile) | +| `getUser()`, a profile's restore | Transport only | `ProfileUser` - display claims, no `roles` to prevent potential misuse | +| `decodeIdTokenUnsafe()` | **Nothing** | `UnverifiedUser` - may be attacker-controlled by definition | `isTokenNearExpiry()` is a refresh _hint_ read from an unverified payload, not a gate. Unparseable or no `exp` field means expired. @@ -252,8 +256,8 @@ Three flows past authorization-code-plus-PKCE, documented in full at [docs.wilso const { url } = await client.createStepUpRequest(res.headers.get("www-authenticate")); await client.verifyIdToken(idToken, { requiredAcr, maxAuthAgeSeconds: 300 }); -// Step-up through a back channel, for a client with no redirect URI (this provider only). -await client.reauthorize(["passkey", "fido"], true, { idTokenHint, openUrl }); +// Step-up through a back channel, for a client with no redirect URI (WilsoonID profile only). +await client.wilsoon.reauthorize(["passkey", "fido"], true, { idTokenHint, openUrl }); // Device grant (RFC 8628): sign in a CLI with no redirect URI. const { user } = await client.authorizeDevice({ onUserCode }); @@ -265,48 +269,53 @@ await machine.fetch("https://api.example.com/reports"); Escalation and the device grant share one polling loop, because the provider answers both with RFC 8628's vocabulary. In both, **the server owns the deadline**: the loop polls until the provider reports `expired_token` rather than timing out on its own clock, since a client-side timer that fires first turns a server-authoritative answer into a guess. -Of the four, only escalation is provider-specific. Step-up is OIDC Core plus RFC 9470, the device grant is RFC 8628 read from discovery, and `client_credentials` is RFC 6749 - all three work against any conforming provider. - -## Things that are still specific to one provider - -Being honest about what a fork would have to change. Six, and only the first two are likely to matter much. - -### 1. Method escalation is an extension, not a standard +Of the four, only escalation is provider-specific, and it lives in the WilsoonID profile. Step-up is OIDC Core plus RFC 9470, the device grant is RFC 8628 read from discovery, and `client_credentials` is RFC 6749 - all three work against any conforming provider. -`reauthorize()` and the `/api/escalate` endpoints behind it exist only on this provider. There is no OIDC or RFC equivalent and nothing to discover, so a fork pointing elsewhere finds no endpoint to call. +## Provider profiles -Nothing else depends on it. [Step-up](https://docs.wilsoon.dev/node-oidc-kit/flows/step-up) covers the same ground portably with `acr_values` and `max_age`, and is what a fork should use. +Since 3.0 the core is standards-only: nothing in it assumes one provider's claims, cookie +names or endpoints. Pointing it at another provider is configuration, plus one constant. -### 2. Roles are a closed set +### 1. Roles and permissions are wherever your provider puts them ```ts -// packages/auth-core/src/types.ts -export type UserRole = "admin" | "user"; -export const USER_ROLES: readonly UserRole[] = ["admin", "user"]; +new AuthClient({ ...config, rolesClaim: "resource_access.my-app.roles", permissionsClaim: "permissions" }); ``` -Building `id.wilsoon.dev` meant that I only needed `admin` (myself) and `user`.
-This means that any `role` claim outside of that set will throw `ClaimValidationError` (to prevent potential privilege escalation issues), but it does mean a provider issuing `editor` or `owner` must widen both declarations together. **This is the one change most forks will need**. +A dot path or a function of the claims. Any value is accepted - there is no fixed set of +roles - and with no selector configured both lists are empty, so every check fails closed. +`hasRole()` is any-of, `hasPermission()` is all-of. -(This is not yet configurable at runtime. Making it so - `roles?: readonly string[]` on -`AuthConfig` - could be a good first contribution if you would like to help, see [Contributing](#contributing).) - -### 3. RS256 only +### 2. RS256 only `ALLOWED_ALGORITHMS` in `jwt.ts` is `['RS256']`.
An allowlist is mandatory - without one a verifier can be talked into `alg: "none"` or HMAC confusion - but the contents are a choice. A provider signing with ES256 or EdDSA needs that array widened. I suggest to keep it an allowlist and never derive it from the token header. -### 4. Storage key names +### 3. Everything that isn't a standard goes in a profile -`STORAGE_KEYS` uses `wilsoon_id_tokens`, `wilsoon_auth_state`, and so on. Cosmetic, and remappable in your adapter without touching the core library. +Some of what a real provider does is in no spec: an escalation API, shared platform +sessions, a cookie-based session restore, legacy storage names. Those live in a **provider +profile**, which core calls through a fixed set of hooks (`mapUser`, `isMachineToken`, +`restoreSession`, `resolveSharedSession`, `liveAccess`, storage names) and whose own +functions appear under `client.`: -### 5. One hardcoded logout path +```ts +import { createAuthClient } from "@wilsoon/auth-core"; +import { wilsoon } from "@wilsoon/auth-provider-wilsoon"; -`AuthProvider`'s `logout()` falls back to `${issuer}/api/logout` when the session was hydrated and there's no `id_token` to use as a hint. Everything else goes through discovery. Against another provider, you'll have to handle that case in your own code. +const client = createAuthClient({ ...config, profile: wilsoon() }); +await client.wilsoon.reauthorize(["passkey"], true, { idTokenHint, openUrl }); +``` -### 6. `hydrateSession()` assumes a cookie-friendly userinfo endpoint +[`@wilsoon/auth-provider-wilsoon`](https://www.npmjs.com/package/@wilsoon/auth-provider-wilsoon) +is the profile for `id.wilsoon.dev`, and a complete example: escalation, shared platform +sessions, `session_version` revocation and permission registration, all built on those +hooks. See [Pointing it at your own provider](https://docs.wilsoon.dev/node-oidc-kit/core-concepts/your-own-provider) +to write your own with `defineProfile()`. -It calls userinfo with `credentials: 'include'` and no `Authorization` header, so the provider must accept a cookie-authenticated request and send CORS credentials headers for your origin. Providers that only accept bearer tokens won't support it - use a server-side session read instead. +Profiles for other providers live in +[wilsoon-auth-providers](https://github.com/Wilsoon7721/wilsoon-auth-providers), one package +per provider. Look there before writing your own, and send new ones there rather than here. ## Contributing @@ -317,12 +326,12 @@ Contributions are welcome, including forks that take this somewhere I wouldn't. ```bash pnpm install pnpm build -pnpm test # vitest - 145 tests across the three packages +pnpm test # vitest, across all four packages pnpm lint pnpm typecheck ``` -Node 18+ and pnpm 9. The test suite runs against a fake in-process IdP (`packages/auth-core/test/fake-idp.ts`) that mints real RS256-signed tokens, so verification paths are tested for real rather than mocked - including the failure cases (wrong `aud`, wrong `iss`, `alg: none`, expired, replayed nonce). Add to it rather than mocking `jose`. +Node 18+ and pnpm 11. The test suite runs against a fake in-process IdP (`packages/auth-core/test/fake-idp.ts`) that mints real RS256-signed tokens, so verification paths are tested for real rather than mocked - including the failure cases (wrong `aud`, wrong `iss`, `alg: none`, expired, replayed nonce). Add to it rather than mocking `jose`. ### Documentation @@ -340,10 +349,12 @@ Both work with no setup. **Publishing does not**: `docs push` needs a `DOCS_SYNC ``` packages/ auth-core/ index.ts (AuthClient) · jwt.ts (verification) · storage.ts · types.ts · errors.ts - amr.ts · utils.ts (PKCE, crypto) + profile.ts (provider profiles) · claims.ts (roles, permissions) · amr.ts + challenge.ts (step-up) · device.ts · escalation.ts · utils.ts (PKCE, crypto) auth-react/ AuthProvider, useAuth auth-next/ middleware · server (getSession/requireSession) · ServerCookieStorage resolve.ts (shared routing) · client-cache.ts (JWKS reuse) + auth-machine/ client_credentials tokens, standalone ``` ### House Rules @@ -354,18 +365,17 @@ These are what the existing code follows; matching them makes review quick. introspection endpoint, an unparseable token - the answer should be a hard **no**. - **Anything unverified is named `*Unsafe`,** and its return type must not carry a field that looks authoritative. - **Comment the why, not the what.** The codebase is light on JSDoc that restates a method name, and heavy on comments explaining a non-obvious decision: why `new.target` in the error base class, why the refresh is single-flight, why the JWKS cache self-invalidates once. If a future reader would ask "why is it like this?", you could answer it. Lint enforces this at the surface a reader meets first - every exported class and function needs a doc comment - and deliberately not on individual methods, so it never demands a comment that would only restate a name. -- **Don't remove public API.** Deprecate it: a `@deprecated` tag explaining the replacement, - a one-time runtime warning via `warnOnce`, and the old behaviour intact. Real apps depend - on these packages. -- **New provider-specific behaviour needs a config option,** not a hardcoded value - and an - entry in [the list above](#things-that-are-still-specific-to-one-provider) if it can't be. +- **Don't remove public API outside a major.** Deprecate it: a `@deprecated` tag explaining + the replacement, a one-time runtime warning via `warnOnce`, and the old behaviour intact + until the next major. Real apps depend on these packages. +- **New provider-specific behaviour belongs in a [provider profile](#provider-profiles),** not + in core. ### Good first issues -- Make `UserRole` configurable (`roles?: readonly string[]` on `AuthConfig`), preserving the fail-closed narrowing. - Make `ALLOWED_ALGORITHMS` configurable, still as an allowlist. - Storage adapters for SvelteKit, Remix, Hono, Express - ideally as their own packages, with a link from here. -- Broaden `hydrateSession()` so a bearer-only provider can use it. +- Provider profiles for other providers (Auth0, Keycloak, Entra ID), in [wilsoon-auth-providers](https://github.com/Wilsoon7721/wilsoon-auth-providers). Open an issue before a large change so we can discuss a little first. For a suspected security vulnerability, follow [SECURITY.md](SECURITY.md) instead of opening a public issue - it explains what's in scope and how to reach me privately. diff --git a/docs/core-concepts/security-model.md b/docs/core-concepts/security-model.md index ad4d45b..9839015 100644 --- a/docs/core-concepts/security-model.md +++ b/docs/core-concepts/security-model.md @@ -4,22 +4,22 @@ identifier: security-model order: 2 --- -One rule governs the whole library: **only five methods produce a value you may authorize on.** +One rule governs the whole library: **only a handful of methods produce a value you may authorize on.** | Method | Verified? | Gives you | | ----------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------- | -| `verifyIdToken()` | Signature, `iss`, `aud`, `exp`, `nonce`, `evt` | `AuthenticatedUser` - `role`, `authMethods`, `sessionVersion` | -| `verifyAccessToken()` | Signature, `iss`, pinned `aud`, `exp`, scopes | `AccessTokenClaims` - no role, plus `tokenUse` | -| `verifyPlatformSession()` | The above, plus live introspection | `AuthenticatedUser` with **current** role and session version | -| `verifyMachineToken()` | The access token checks, plus `token_use` | `MachineClient` - a `clientId`, and deliberately no user fields | -| `verifyEscalationToken()` | Signature, `iss`, `aud`, `exp`, `evt` | `VerifiedEscalation` - proof one step-up happened | -| `getUser()` / `hydrateSession()` | Transport only | `ProfileUser` - display claims, deliberately no `role` field to misuse | -| `decodeIdTokenUnsafe()`, `parseIdToken()` | **Nothing** | `UnverifiedUser` - attacker-controlled by definition | +| `verifyIdToken()` | Signature, `iss`, `aud`, `exp`, `nonce`, `evt` | `AuthenticatedUser` - `roles`, `permissions`, `authMethods` | +| `verifyAccessToken()` | Signature, `iss`, pinned `aud`, `exp`, scopes | `AccessTokenClaims` - `roles`, `permissions`, `tokenUse` | +| `wilsoon.verifyPlatformSession()` | The above, plus live introspection | `AuthenticatedUser` with **current** permissions (WilsoonID profile) | +| `verifyMachineToken()` | The access token checks, plus machine detection | `MachineClient` - a `clientId`, and deliberately no user fields | +| `wilsoon.verifyEscalationToken()` | Signature, `iss`, `aud`, `exp`, `evt` | `VerifiedEscalation` - proof one step-up happened (WilsoonID profile) | +| `getUser()`, a profile's restore | Transport only | `ProfileUser` - display claims, deliberately no `roles` to misuse | +| `decodeIdTokenUnsafe()` | **Nothing** | `UnverifiedUser` - attacker-controlled by definition | `isTokenNearExpiry()` is a refresh _hint_ read from an unverified payload, not a gate. It fails closed: unparseable, or no `exp` claim, means "expired." {% callout type="danger" title="Anyone can mint a token with role: admin" %} -`decodeIdTokenUnsafe()` and the deprecated `parseIdToken()` read a JWT's payload without checking its signature. A JWT is just base64 - anyone can construct one with any claims and an empty or garbage signature. Reading `role` off an unverified decode is a key issue that v2.x closed. +`decodeIdTokenUnsafe()` reads a JWT's payload without checking its signature. A JWT is just base64 - anyone can construct one with any claims and an empty or garbage signature. Reading `roles` or `permissions` off an unverified decode is exactly the bug the verified methods exist to prevent. {% /callout %} ## The request is not the guarantee @@ -31,7 +31,7 @@ Correct signature, correct issuer, correct audience, not expired. Nothing to cat | You asked for | The claim that answers | If you skip it | | --------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------- | | `acr_values` / `max_age` on the request | `acr`, `auth_time` | A provider that ignored you produces a step-up that appears to have succeeded | -| A user, at a user-facing endpoint | `token_use`, or `sub` vs `client_id` | A machine's `client_id` is read as a person | +| A user, at a user-facing endpoint | `sub` vs `client_id`, or the profile's marker | A machine's `client_id` is read as a person | | Proof a user is signed in | `evt` | A proof that one action was authorised passes as a login | Each of these is checked for you, but only where the library can know what you meant. @@ -57,19 +57,19 @@ Each check below exists because without it one would verify cleanly as another. | Kind | Marked by | Refused where | | ---------------- | ------------------------ | ------------------------------------------ | -| ID token | no `evt`, no `token_use` | - | +| ID token | no `evt`, not a machine token | - | | Escalation proof | `evt: "escalation"` | `verifyIdToken()` refuses any `evt` at all | -| Machine token | `token_use: "client"` | every user path, unless opted into | +| Machine token | `sub === client_id`, or a profile marker | every user path, unless opted into | -A **machine token**'s `sub` is a `client_id`, not a person, so `verifyAccessToken()` refuses one by default and `verifyMachineToken()` returns a type with no `id`, `role` or `authMethods` on it to misread. -Two signals identify one: the reference provider's `token_use: "client"`, and an RFC 9068 `sub` equal to `client_id`, which holds on any provider following that profile. Set `detectMachineToken` on `AuthConfig` for a provider that marks them some other way. `verifyPlatformSession()`, `resolveSession()` and `isSessionCurrent()` refuse outright, with no opt-in - there is no user to resolve a session for. See [Machine tokens](/flows/machine-tokens). +A **machine token**'s `sub` is a `client_id`, not a person, so `verifyAccessToken()` refuses one by default and `verifyMachineToken()` returns a type with no `id`, `roles` or `authMethods` on it to misread. +Without a profile, core recognises one by RFC 9068's `sub` equal to `client_id`, which holds on any provider following that profile. A provider profile can add its own marker (WilsoonID's is `token_use: "client"`), and `detectMachineToken` on `AuthConfig` overrides both. `resolveSession()` and a profile's session checks refuse outright, with no opt-in - there is no user to resolve a session for. See [Machine tokens](/flows/machine-tokens). -An **escalation token** asserts that one step-up happened, not that a user is signed in. The check runs both ways: `verifyEscalationToken()` requires `evt: "escalation"`, and `verifyIdToken()` refuses any token carrying an `evt`. See [Method escalation](/flows/escalation). +An **escalation token** asserts that one step-up happened, not that a user is signed in. The check runs both ways: the WilsoonID profile's `verifyEscalationToken()` requires `evt: "escalation"`, and `verifyIdToken()` refuses any token carrying an `evt`. See [Method escalation](/flows/escalation). ## The type system enforces it, not just the docs -`ProfileUser` - what `getUser()` and `hydrateSession()` return - has no `role` field at -all. Authorizing on a userinfo response is a compile error, not a runtime `undefined` you +`ProfileUser` - what `getUser()` and a profile's cookie restore return - has no `roles` or +`permissions` at all. Authorizing on a userinfo response is a compile error, not a runtime `undefined` you might not notice. `@wilsoon/auth-react`'s `SessionUser` is a discriminated union on `verified`: @@ -86,13 +86,13 @@ user?.name; user?.email; // Only after a verified callback exchange: -if (user?.verified && user.role === "admin") { +if (user?.verified && hasRole(user, "admin")) { return ; } ``` -TypeScript won't let you read `role` without narrowing on `verified` first, so a hydrated -session can't silently produce `undefined` where a role was expected. +TypeScript won't let you read `roles` without narrowing on `verified` first, so a session +restored from a cookie can't silently produce an empty list where roles were expected. {% callout type="warning" title="Client-side checks are for rendering" %} A browser can be told anything. Whatever `AuthProvider` / `useAuth()` report is for @@ -101,15 +101,17 @@ deciding what to _show_, never what to _allow_. Gate real access on the server - `verifyAccessToken()` directly in your API. {% /callout %} -## Roles are validated, not asserted +## Roles are data, not a type -A `role` claim is narrowed against a closed set (`'admin' | 'user'` by default) rather than -cast. An unrecognised value throws `ClaimValidationError` instead of being silently mapped -onto a known role - the failure mode a privilege-escalation bug usually takes. An absent -`role` falls back to the least-privileged `'user'`. +`roles` and `permissions` are plain string arrays, read from verified claims through +`rolesClaim` / `permissionsClaim` or a provider profile. There is no closed set: an unknown +role lands in `roles` and matches only a `hasRole()` check that names it. Nothing is guessed +either - with no selector configured both lists are empty, so a check fails closed rather than +passing on a claim nobody mapped. See [Authorization](/recipes/authorization). -A provider that issues other roles needs that set widened at the source - see -[Pointing it at your own provider](/core-concepts/your-own-provider). +The deprecated `role` field, set only by the WilsoonID profile for 2.x apps, keeps its old +`'admin' | 'user'` narrowing: any other value leaves it unset rather than mapping onto a known +role. ## Every unverified method is named `*Unsafe` diff --git a/docs/core-concepts/session-models.md b/docs/core-concepts/session-models.md index 7d41e45..edc9a8c 100644 --- a/docs/core-concepts/session-models.md +++ b/docs/core-concepts/session-models.md @@ -30,69 +30,81 @@ fails on `aud`. The **access token** is different: it's minted with an audience shared across every application on the platform, so it verifies the same way everywhere. +Resolving that access token is not part of any standard, so it takes a provider profile +that knows how. The WilsoonID profile does it with introspection. + {% callout type="tip" title="resolveSession() routes it for you" %} ```ts const user = await client.resolveSession(tokens); ``` -Verifies the ID token when it's addressed to this client; otherwise falls back to the -access token plus introspection via `verifyPlatformSession()`. The routing decode is +Verifies the ID token when it's addressed to this client; otherwise hands the access token +to the profile's shared-session resolver (WilsoonID's `verifyPlatformSession()`: verify, +then introspect). Without such a profile it throws `FOREIGN_SESSION`. The routing decode is unverified - it's used only to pick a branch, and both branches verify properly, so a forged `aud` gains nothing. {% /callout %} -`verifyPlatformSession()` needs `apiAudience` and a `clientSecret` (introspection is for -confidential clients only - never call it from a browser). Set -`platformSessionCacheSeconds` to reuse a resolved session briefly instead of introspecting +Shared sessions need the profile, `apiAudience` and a `clientSecret` (introspection is for +confidential clients only - never call it from a browser). The WilsoonID profile's +`platformSessionCacheSeconds` reuses a resolved session briefly instead of introspecting on every request; a revocation then takes up to that long to be noticed, and never longer than the token's own expiry. ```ts -const client = new AuthClient({ +import { createAuthClient } from "@wilsoon/auth-core"; +import { wilsoon } from "@wilsoon/auth-provider-wilsoon"; + +const client = createAuthClient({ ...config, apiAudience: "https://api.example.com", clientSecret: process.env.OIDC_CLIENT_SECRET, - platformSessionCacheSeconds: 10, + profile: wilsoon({ platformSessionCacheSeconds: 10 }), }); ``` `@wilsoon/auth-next`'s `getSession()` picks this path automatically when the cookie's ID -token belongs to a sibling service - you don't call `verifyPlatformSession()` yourself in -the common case. +token belongs to a sibling service - you don't call `client.wilsoon.verifyPlatformSession()` +yourself in the common case. ## No session at all A `client_credentials` caller has no user, so none of this applies to it. Every method on this page refuses a machine token rather than inventing a session for it: `resolveSession()` -and `verifyPlatformSession()` throw `MachineTokenNotAllowedError`, and so does -`isSessionCurrent()`, because there is no user record for a revocation to act on. +and the WilsoonID profile's `verifyPlatformSession()` throw `MachineTokenNotAllowedError`, and +so does its `isSessionCurrent()`, because there is no user record for a revocation to act on. Verify those callers with `verifyMachineToken()` instead. ## Session revocation A JWT stays valid until it expires, so "sign out everywhere" doesn't take effect on its -own - the token is still cryptographically valid for as long as it says it is. Two routes -make revocation enforceable: +own - the token is still cryptographically valid for as long as it says it is. The portable +fix is to ask the provider, with a confidential client: ```ts -// 1. Your own backend already knows the current value. -const client = new AuthClient({ - ...config, - resolveSessionVersion: (userId) => myApi.getSessionVersion(userId), -}); - -if (!(await client.isSessionCurrent(user))) return reauthenticate(); +const access = await client.liveAccess(tokens.access_token); +if (!access.active) return reauthenticate(); ``` +`liveAccess()` uses the profile's hook when there is one, and RFC 7662 introspection +otherwise. In Next.js, `enforce: "live"` on the middleware does this on every request. + +{% callout type="note" title="On WilsoonID" %} +The provider also bumps a per-user `session_version` on "sign out everywhere". The profile +checks it without a confidential client when your backend knows the current value: + ```ts -// 2. Or, with a confidential client, let the provider answer via introspection. -if (!(await client.isSessionCurrent(user, { token: idToken }))) return reauthenticate(); +const client = createAuthClient({ + ...config, + profile: wilsoon({ resolveSessionVersion: (userId) => myApi.getSessionVersion(userId) }), +}); + +if (!(await client.wilsoon.isSessionCurrent(user))) return reauthenticate(); ``` -{% callout type="warning" title="Fails closed" %} -Without either route configured, `isSessionCurrent()` throws +Without a resolver it introspects `{ token }` instead, and with neither it throws `SessionCheckUnavailableError` rather than assuming the session is still valid. {% /callout %} diff --git a/docs/core-concepts/storage.md b/docs/core-concepts/storage.md index c386125..207f540 100644 --- a/docs/core-concepts/storage.md +++ b/docs/core-concepts/storage.md @@ -6,12 +6,16 @@ order: 1 The library persists exactly four values, and it will not guess where to put them. -| Key | Lifetime | Purpose | -| --------------------------- | ------------------ | ---------------------------------------------- | -| `STORAGE_KEYS.tokens` | The session | The token response blob | -| `STORAGE_KEYS.state` | One login, minutes | CSRF binding between authorize and callback | -| `STORAGE_KEYS.nonce` | One login, minutes | Binds the ID token to _this_ authorize request | -| `STORAGE_KEYS.codeVerifier` | One login, minutes | The PKCE secret | +| Key (default name) | Lifetime | Purpose | +| --------------------------------- | ------------------ | ---------------------------------------------- | +| `tokens` (`oidc_tokens`) | The session | The token response blob | +| `state` (`oidc_state`) | One login, minutes | CSRF binding between authorize and callback | +| `nonce` (`oidc_nonce`) | One login, minutes | Binds the ID token to _this_ authorize request | +| `codeVerifier` (`oidc_verifier`) | One login, minutes | The PKCE secret | + +`storagePrefix` changes the `oidc_` prefix, and a provider profile can set exact names (the +WilsoonID profile keeps its 2.x `wilsoon_id_tokens` and `wilsoon_auth_*`). `client.storageKeys` +reports the names a client actually uses. The bottom three are the interesting ones. They're written when you build the authorize URL and read after the provider redirects back - a _different request_, often to a @@ -35,7 +39,6 @@ interface AuthStorage { | `MemoryStorage` | `@wilsoon/auth-core` | Tests, and single-process flows where authorize and callback are handled by the same instance. Does not survive a restart or a second replica. | | `UnavailableStorage` | `@wilsoon/auth-core` | The server default when you pass nothing. Every method throws `StorageUnavailableError` naming the operation and the fix, instead of type-checking as valid storage and then failing later with `TypeError: setItem is not a function`. | | `ServerCookieStorage` | `@wilsoon/auth-next` | The Next.js cookie store. Writes `HttpOnly` + `SameSite=Lax`, short-lived for the transient three, long-lived for the token blob. Throws a useful error in a Server Component, where Next.js forbids cookie writes. | -| `CookieStorage` | `@wilsoon/auth-core` | **Deprecated, all no-ops.** Written when the token cookie was readable from JS. The provider now sets it `HttpOnly` - correct, and also why no browser-side cookie adapter can ever see it again. Kept so existing calls compile; use `hydrateSession()` instead. | ## Writing your own @@ -82,6 +85,9 @@ export class AstroServerStorage implements AuthStorage { } ``` +A fuller version, which keeps the transient three host-only and short-lived, is in +[Other server frameworks](/recipes/other-frameworks). + Then hand it to the client, and everything else works unchanged: ```ts @@ -119,7 +125,7 @@ Most of these are things that have actually broken: reuses the JWKS cache across requests). {% aside title="Remapping the token cookie name" %} -`STORAGE_KEYS` is fixed at the library level. To put the token blob under a different cookie -name, map it inside your adapter - that's what the Next.js middleware's `cookieName` -option does - rather than expecting the client to emit a different key. +Names come from `storagePrefix` or the profile, per client. To put the token blob under a +cookie name that follows neither, map it inside your adapter - that's what the Next.js +middleware's `cookieName` option does - rather than expecting the client to emit a different key. {% /aside %} diff --git a/docs/core-concepts/your-own-provider.md b/docs/core-concepts/your-own-provider.md index cd25268..20afb02 100644 --- a/docs/core-concepts/your-own-provider.md +++ b/docs/core-concepts/your-own-provider.md @@ -4,41 +4,26 @@ identifier: your-own-provider order: 4 --- -Discovery means no endpoint URLs are hardcoded, and `amr` policies compare plain strings - -see [Installation](/getting-started/installation) for the endpoints your provider needs to -offer. Past that, being honest about what's left: six things are still specific to one -reference provider, in order of how likely each is to matter to you. +Since 3.0 the core is standards-only. Discovery means no endpoint URLs are hardcoded, `amr` +policies compare plain strings, and nothing in core assumes one provider's claims or cookie +names - see [Installation](/getting-started/installation) for the endpoints your provider +needs to offer. What's left is configuration, plus one constant. -## 1. Method escalation is an extension, not a standard - -`reauthorize()` and the `/api/escalate` endpoints behind it exist only on the reference -provider. There is no OIDC or RFC equivalent, and nothing in discovery to look for, so -pointing it elsewhere finds no endpoint to call. - -Nothing else in the library depends on it. [Step-up](/flows/step-up) covers the same ground with -`acr_values` and `max_age`, works against any conforming provider, and is what a fork should -use instead. - -## 2. Roles are a closed set +## 1. Tell it where roles and permissions live ```ts -// @wilsoon/auth-core, types.ts -export type UserRole = "admin" | "user"; -export const USER_ROLES: readonly UserRole[] = ["admin", "user"]; +const client = new AuthClient({ + ...config, + rolesClaim: "resource_access.my-app.roles", // a dot path, or (claims) => string[] + permissionsClaim: "permissions", +}); ``` -A `role` claim outside that set throws `ClaimValidationError` rather than being asserted -into the union. That's deliberate - silently mapping an unknown role onto a known one is -how privilege escalations ship - but the closed set itself is a compile-time constant. A -provider issuing `editor` or `owner` needs both declarations widened together. +Without these, `roles` and `permissions` are empty and every check on them fails closed. Any +value your provider issues is accepted; there is no fixed set. See +[Authorization](/recipes/authorization) for the common providers. -{% callout type="note" %} -This is the one change most forks will need, and it's two lines. It isn't yet -configurable at runtime through `AuthConfig` - see the library's own contributing guide if -you'd like to change that. -{% /callout %} - -## 3. RS256 only +## 2. RS256 only ```ts // @wilsoon/auth-core, jwt.ts @@ -51,26 +36,51 @@ that verifies it. The _contents_ of the list are a choice, though: a provider si ES256 or EdDSA needs this widened. Keep it an allowlist; never derive it from the token's own header. -## 4. Storage key names +## 3. Machine tokens -`STORAGE_KEYS` uses names like `wilsoon_id_tokens` and `wilsoon_auth_state`. Purely -cosmetic, and remappable inside your own storage adapter without touching the library - see -[Storage](/core-concepts/storage). +Core recognises a `client_credentials` token by RFC 9068's `sub` equal to `client_id`. A +provider that marks them some other way needs `detectMachineToken` on `AuthConfig`, or a +profile's `isMachineToken`. Returning `false` for a real machine token lets a `client_id` +reach code written for users. See [Machine tokens](/flows/machine-tokens). -## 5. One hardcoded logout fallback +## 4. Anything that isn't a standard: a provider profile -`AuthProvider`'s `logout()` in `@wilsoon/auth-react` falls back to `${issuer}/api/logout` -when the session was hydrated from a cookie and there's no `id_token` available to use as -the `id_token_hint` that `getLogoutUrl()` needs. Every other logout path goes through OIDC -discovery. Against a different provider, handle that one fallback case in your own code. +Some of what a real provider does is in no spec: escalation APIs, shared platform sessions, +a cookie-based session restore, legacy storage names. Those go in a **provider profile**, +which core calls through a fixed set of hooks and exposes under the profile's name. -## 6. `hydrateSession()` assumes a cookie-friendly userinfo endpoint +```ts +import { createAuthClient, defineProfile } from "@wilsoon/auth-core"; + +const acme = defineProfile({ + name: "acme", + storagePrefix: "acme_", + mapUser: (claims) => ({ roles: (claims.groups as string[]) ?? [] }), + isMachineToken: (claims) => claims.gty === "client-credentials", + extend: (ctx) => ({ + // Your provider's own endpoints, with this client's credentials and discovery cache. + revokeAllSessions: (userId: string) => ctx.clientFetch(`${ctx.config.issuer}/api/sessions/${userId}`, { method: "DELETE" }), + }), +}); + +const client = createAuthClient({ ...config, profile: acme }); +await client.acme.revokeAllSessions("user-1"); +``` -It calls the userinfo endpoint with `credentials: 'include'` and no `Authorization` -header, so the browser attaches the session cookie itself. That requires a provider that -accepts a cookie-authenticated userinfo request and sends CORS credentials headers for -your origin. A provider that only accepts bearer tokens won't support it - read the -session server-side instead. +| Hook | What it does | +| ---------------------- | ------------------------------------------------------------------------------- | +| `mapUser` | Adds fields to a verified user. Identity fields cannot be overridden | +| `isMachineToken` | Recognises the provider's machine tokens | +| `storagePrefix`, `storageKeys` | Storage names | +| `apiAudience` | Default access token audience | +| `restoreSession` | A non-standard session restore, such as reading a provider cookie | +| `resolveSharedSession` | Resolves a session holding only another client's access token | +| `liveAccess` | Live access for a token; core falls back to plain introspection | +| `extend` | Functions exposed under `client.` | + +The WilsoonID profile, `@wilsoon/auth-provider-wilsoon`, is a complete example: escalation, +shared platform sessions, `session_version` revocation and permission registration, all +built on these hooks. ## That's it! diff --git a/docs/docs.lock.json b/docs/docs.lock.json index 68fba98..2dbdb95 100644 --- a/docs/docs.lock.json +++ b/docs/docs.lock.json @@ -2,39 +2,39 @@ "project": "node-oidc-kit", "sections": { "core-concepts/security-model": { - "hash": "e3a12a24c5f43e0457545dc77a8cdf1bbbc1e4cd042ee8d47bc832b2403fc76c", - "published": "e3a12a24c5f43e0457545dc77a8cdf1bbbc1e4cd042ee8d47bc832b2403fc76c", - "pushedAt": "2026-08-31T15:46:11.364Z" + "hash": "c9e07f210d9ef3457ad5b2414b775e2a12c17ed1fe8e4fd9911b6aca67385543", + "published": "c9e07f210d9ef3457ad5b2414b775e2a12c17ed1fe8e4fd9911b6aca67385543", + "pushedAt": "2026-10-05T18:29:24.485Z" }, "core-concepts/session-models": { - "hash": "5e5a6119491a5a522816a385eca9668aab208a65d400cd02af4c790744dfd9c7", - "published": "5e5a6119491a5a522816a385eca9668aab208a65d400cd02af4c790744dfd9c7", - "pushedAt": "2026-08-31T08:25:48.543Z" + "hash": "951c6166c017d4bf329ca37136292e2324bf080b41a0a21761e898d2b4ada3fb", + "published": "951c6166c017d4bf329ca37136292e2324bf080b41a0a21761e898d2b4ada3fb", + "pushedAt": "2026-10-05T18:29:28.339Z" }, "core-concepts/storage": { - "hash": "ab0a838d6f8b1020487d119795df255ca2a94b687071fad16c5f3ef33decedda", - "published": "ab0a838d6f8b1020487d119795df255ca2a94b687071fad16c5f3ef33decedda", - "pushedAt": "2026-08-31T15:46:11.687Z" + "hash": "f2606f4ec033bc8240bb80877bd1ef228f48c0ea12bdd16fdf9dc9d5b0a3b584", + "published": "f2606f4ec033bc8240bb80877bd1ef228f48c0ea12bdd16fdf9dc9d5b0a3b584", + "pushedAt": "2026-10-05T18:29:29.081Z" }, "core-concepts/your-own-provider": { - "hash": "a7df246a1845711daecc25891750202f6e24e8e2470652dc3b3a1ed72fc0bfc7", - "published": "a7df246a1845711daecc25891750202f6e24e8e2470652dc3b3a1ed72fc0bfc7", - "pushedAt": "2026-08-31T15:46:11.955Z" + "hash": "422de16d11d3ad2f9af6968e715863fa521101341b5bb9eedb45fedc67256edf", + "published": "422de16d11d3ad2f9af6968e715863fa521101341b5bb9eedb45fedc67256edf", + "pushedAt": "2026-10-05T18:29:29.308Z" }, "getting-started/installation": { - "hash": "0cb46f9ff6c6da2108817f2ed72e0f918d1981a8bd25d15d927705fba4835fe0", - "published": "0cb46f9ff6c6da2108817f2ed72e0f918d1981a8bd25d15d927705fba4835fe0", - "pushedAt": "2026-08-31T15:46:13.568Z" + "hash": "9a8873ef5d2abfe7a71224c08daed657a0959f75ba951b5368cea6b29c891ab5", + "published": "9a8873ef5d2abfe7a71224c08daed657a0959f75ba951b5368cea6b29c891ab5", + "pushedAt": "2026-10-05T18:29:29.717Z" }, "getting-started/introduction": { - "hash": "07cc3f459fb3add848e25401f9b9ea05add506398ac24677458a2cdb9e001aee", - "published": "07cc3f459fb3add848e25401f9b9ea05add506398ac24677458a2cdb9e001aee", - "pushedAt": "2026-08-31T15:46:13.843Z" + "hash": "d42e240331bae8a7f9b36cc6235f7313cbceb958772e679346df10e079efe4d0", + "published": "d42e240331bae8a7f9b36cc6235f7313cbceb958772e679346df10e079efe4d0", + "pushedAt": "2026-10-05T18:29:29.802Z" }, "getting-started/quick-start": { - "hash": "d1805ab13f1dc199e01cd94c5d1eb278a32dfab42531635373237b999bc3db91", - "published": "d1805ab13f1dc199e01cd94c5d1eb278a32dfab42531635373237b999bc3db91", - "pushedAt": "2026-08-31T15:46:14.251Z" + "hash": "725f386d521f064b151d4173d0463aa13e3fd4e713a55afb048f801925c73fcc", + "published": "725f386d521f064b151d4173d0463aa13e3fd4e713a55afb048f801925c73fcc", + "pushedAt": "2026-10-05T18:29:29.894Z" }, "flows/device-grant": { "hash": "36cd1bc897458a35eac8b8b87b656658473558676c274188a7eea5f62a7e2843", @@ -42,19 +42,59 @@ "pushedAt": "2026-08-31T15:46:12.224Z" }, "flows/escalation": { - "hash": "0c6a5c288eb3e1fb342ae6593cca922a5f6d9414df6c7c396668b81e3082c6e3", - "published": "0c6a5c288eb3e1fb342ae6593cca922a5f6d9414df6c7c396668b81e3082c6e3", - "pushedAt": "2026-08-31T15:46:12.506Z" + "hash": "ba7f0d874bf25a273b821ffabfd68c65a1afb2bdedfc242f3fc257e7e6c82a50", + "published": "ba7f0d874bf25a273b821ffabfd68c65a1afb2bdedfc242f3fc257e7e6c82a50", + "pushedAt": "2026-10-05T18:29:29.420Z" }, "flows/machine-tokens": { - "hash": "931c30cfa10e0026b494942591d8f67778969ceeb042ad896b58ce935023d7df", - "published": "931c30cfa10e0026b494942591d8f67778969ceeb042ad896b58ce935023d7df", - "pushedAt": "2026-08-31T15:46:13.082Z" + "hash": "06d49a3ece40ae9c6affe4b5d13ec1a391c05bbd96c134f2dae99fca0e53834d", + "published": "06d49a3ece40ae9c6affe4b5d13ec1a391c05bbd96c134f2dae99fca0e53834d", + "pushedAt": "2026-10-05T18:29:29.518Z" }, "flows/step-up": { - "hash": "71c7ddf765307f70ef7a7cdbaee77a42575ddc21f66e1e33dd90dd1a160c0e56", - "published": "71c7ddf765307f70ef7a7cdbaee77a42575ddc21f66e1e33dd90dd1a160c0e56", - "pushedAt": "2026-08-31T15:46:13.327Z" + "hash": "9e727dab3f5d87d1c5fb18885a9c097911e3b273602b234a45627e332b6804d0", + "published": "9e727dab3f5d87d1c5fb18885a9c097911e3b273602b234a45627e332b6804d0", + "pushedAt": "2026-10-05T18:29:29.630Z" + }, + "recipes/authorization": { + "hash": "2b00df0d7298fab19fd506af637d8d10df792d62b642aab9a9c85845883b6e89", + "published": "2b00df0d7298fab19fd506af637d8d10df792d62b642aab9a9c85845883b6e89", + "pushedAt": "2026-10-05T18:29:29.978Z" + }, + "recipes/checklist": { + "hash": "566ac90e0042e5867c0ba012bced0b937ecbd38372d77940b80ac86d641be577", + "published": "566ac90e0042e5867c0ba012bced0b937ecbd38372d77940b80ac86d641be577", + "pushedAt": "2026-10-05T18:29:30.052Z" + }, + "recipes/logout": { + "hash": "c5bc579cbfc4e0e62de895a9b9d9a459d2dd3dab727413ec95d2f20660c121c2", + "published": "c5bc579cbfc4e0e62de895a9b9d9a459d2dd3dab727413ec95d2f20660c121c2", + "pushedAt": "2026-10-05T18:29:30.125Z" + }, + "recipes/nextjs": { + "hash": "8df990993575f02f9a795ba88bbbcc2aa403e480ce17c65725c04648cbfacc58", + "published": "8df990993575f02f9a795ba88bbbcc2aa403e480ce17c65725c04648cbfacc58", + "pushedAt": "2026-10-05T18:29:33.118Z" + }, + "recipes/other-frameworks": { + "hash": "408555ce3ddf84b853d767a3f664be82cd735696152d6789e394cb794f1850d6", + "published": "408555ce3ddf84b853d767a3f664be82cd735696152d6789e394cb794f1850d6", + "pushedAt": "2026-10-05T18:29:34.365Z" + }, + "recipes/react-spa": { + "hash": "4f362ce519b3ea05951dd5d4bed0f2500c8e542ac70d7bc264a9f064b1fd6d46", + "published": "4f362ce519b3ea05951dd5d4bed0f2500c8e542ac70d7bc264a9f064b1fd6d46", + "pushedAt": "2026-10-05T18:29:34.440Z" + }, + "recipes/resource-server": { + "hash": "fda53073c7c5b5f85e5b9a6f92b941c59c38c22429115f6ae249b0e685ebfe59", + "published": "fda53073c7c5b5f85e5b9a6f92b941c59c38c22429115f6ae249b0e685ebfe59", + "pushedAt": "2026-10-05T18:29:34.510Z" + }, + "recipes/troubleshooting": { + "hash": "b885068af8d4f84cbbbd293c5196d5ca3fc42311105ed3e51c010278aa6247b4", + "published": "b885068af8d4f84cbbbd293c5196d5ca3fc42311105ed3e51c010278aa6247b4", + "pushedAt": "2026-10-05T18:29:34.589Z" } } } diff --git a/docs/flows/escalation.md b/docs/flows/escalation.md index ef24684..d66442d 100644 --- a/docs/flows/escalation.md +++ b/docs/flows/escalation.md @@ -8,8 +8,9 @@ Step-up through a back channel: the client asks the provider to require a strong a URL in front of the user, and polls for the verdict. {% callout type="warning" title="A provider extension, not a standard" %} -Escalation is specific to the reference provider. It is not part of OIDC or any RFC, it is not -discoverable, and pointing this at Okta, Auth0 or Keycloak will find no endpoint to talk to. +Escalation is specific to WilsoonID, and lives in its provider profile rather than in core. It +is not part of OIDC or any RFC, it is not discoverable, and Okta, Auth0 or Keycloak have no +endpoint for it. For step-up that works against any conforming provider, use [step-up authentication](/flows/step-up) - `acr_values` and `max_age` on an ordinary @@ -19,7 +20,12 @@ qualifies. {% /callout %} ```ts -await client.reauthorize(["passkey", "fido"], true, { +import { createAuthClient } from "@wilsoon/auth-core"; +import { wilsoon } from "@wilsoon/auth-provider-wilsoon"; + +const client = createAuthClient({ ...config, profile: wilsoon() }); + +await client.wilsoon.reauthorize(["passkey", "fido"], true, { idTokenHint: tokens.id_token, openUrl: (url) => console.log(`Confirm at: ${url}`), }); @@ -52,7 +58,7 @@ when you need to own the loop: | `pollEscalation(request)` | Back channel | One poll: `pending`, or throws terminally | ```ts -const request = await client.createEscalation({ +const request = await client.wilsoon.createEscalation({ use: ["passkey"], force: true, idTokenHint: tokens.id_token, @@ -89,7 +95,7 @@ else about the request. polls once _before_ calling `openUrl`, so that case shows the user nothing at all: ```ts -const result = await client.reauthorize(["passkey"], false, { +const result = await client.wilsoon.reauthorize(["passkey"], false, { idTokenHint: tokens.id_token, openUrl, // never called if the session already qualifies }); @@ -124,7 +130,7 @@ of stopping on its own clock. If a local timer and the server disagree - a user available to bound a hung process, but it is off by default on purpose. ```ts -await client.reauthorize(["passkey"], true, { +await client.wilsoon.reauthorize(["passkey"], true, { idTokenHint, openUrl, onPending: ({ attempt, interval }) => console.log(`waiting… (${attempt})`), @@ -154,7 +160,7 @@ It exists so the outcome can be handed to a resource server that trusts the prov but not you. A boolean returned by your own code proves nothing to anybody else. ```ts -const proof = await client.verifyEscalationToken(result.escalationToken!); +const proof = await client.wilsoon.verifyEscalationToken(result.escalationToken!); // proof.satisfiedBy === "passkey" ``` @@ -170,6 +176,9 @@ that a user is signed in. ## Configuration -Escalation is not an OIDC endpoint, so discovery says nothing about it. The library assumes -`/api/escalate`; override with `escalationEndpoint` if your provider mounts it +Escalation is not an OIDC endpoint, so discovery says nothing about it. The profile assumes +`/api/escalate`; pass `wilsoon({ escalationEndpoint })` if the provider mounts it elsewhere. The poll endpoint is always `/poll`. + +The 2.x calls on the client itself (`client.reauthorize()` and the rest) still work when the +profile is configured. They warn once and forward to `client.wilsoon`, and are removed in 4.0. diff --git a/docs/flows/machine-tokens.md b/docs/flows/machine-tokens.md index 4b1d213..ca0bd6b 100644 --- a/docs/flows/machine-tokens.md +++ b/docs/flows/machine-tokens.md @@ -38,9 +38,9 @@ machine.clientId; // who called machine.tokenUse; // "client" ``` -`MachineClient` deliberately has no `id`, no `role` and no `authMethods`. There is no user, so there is nothing to read - a caller cannot accidentally pull a person out of it, because the type has no person-shaped fields. `verifyMachineToken()` refuses user tokens in turn, so the check runs in both directions. +`MachineClient` deliberately has no `id`, no `roles` and no `authMethods`. There is no user, so there is nothing to read - a caller cannot accidentally pull a person out of it, because the type has no person-shaped fields. `verifyMachineToken()` refuses user tokens in turn, so the check runs in both directions. -Session paths refuse machine tokens outright, with no opt-in at all: `verifyPlatformSession()`, `resolveSession()` and `isSessionCurrent()` all throw. There is no user behind a `client_id` to resolve a session for, and nothing to revoke against. +Session paths refuse machine tokens outright, with no opt-in at all: `resolveSession()`, and the WilsoonID profile's `verifyPlatformSession()` and `isSessionCurrent()`, all throw. There is no user behind a `client_id` to resolve a session for, and nothing to revoke against. `isMachineToken(claims)` is exported from both packages for a server that only needs the one check. Pass it **verified** claims - an unverified decode tells you what the bearer chose to claim, which is a different question. diff --git a/docs/flows/step-up.md b/docs/flows/step-up.md index 7e88d04..af93081 100644 --- a/docs/flows/step-up.md +++ b/docs/flows/step-up.md @@ -131,7 +131,7 @@ not happen is the `acr` claim you did not read. | Honours `max_age`, and returns `auth_time` | `maxAuthAgeSeconds` rejects the token | | Advertises `acr_values_supported` | Nothing breaks; you have to know the values yourself | -The reference provider does all three, and refuses an unsatisfiable request with +WilsoonID does all three, and refuses an unsatisfiable request with `unmet_authentication_requirements` rather than issuing a code that would fail your check anyway. @@ -139,12 +139,12 @@ anyway. [Method escalation](/flows/escalation) solves the same problem through a back channel, and is better in two specific cases - but it is a provider extension rather than a standard, so it -only works against the reference provider. +only works against WilsoonID, through its provider profile. | | Step-up (this page) | [Escalation](/flows/escalation) | | --- | --- | --- | | Basis | OIDC Core + RFC 9470 | Provider extension | -| Works against | Any conforming provider | The reference provider only | +| Works against | Any conforming provider | WilsoonID only (`profile: wilsoon()`) | | Mechanism | Redirect through `/authorize` | Back channel, then poll | | Suits a CLI | No - needs a redirect URI | Yes | | "Already satisfied" without prompting | No | Yes | diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 581149a..1c95734 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -26,7 +26,7 @@ npm install @wilsoon/auth-machine {% callout type="note" title="auth-machine is 0.x, and stands alone" %} It has no dependency on `@wilsoon/auth-core`, so it is versioned independently of the other -three rather than sharing their 2.x line. `0.x` reflects that its API may still move before +three rather than sharing their 3.x line. `0.x` reflects that its API may still move before it has seen production use. See [Machine tokens](/flows/machine-tokens). {% /callout %} @@ -45,7 +45,7 @@ hot path. | `nonce` echoed into the ID token | ID token replay protection, mandatory in the OIDC spec | `NonceMismatchError` on every login | {% callout type="tip" %} -Everything below `nonce` - `end_session_endpoint` for logout, `introspection_endpoint` for live revocation - is optional and degrades cleanly. You lose one feature, not the whole login. See [Pointing it at your own provider](/core-concepts/your-own-provider) for the full picture, including the handful of things that are still specific to one reference provider. +Everything below `nonce` - `end_session_endpoint` for logout, `introspection_endpoint` for live revocation - is optional and degrades cleanly. You lose one feature, not the whole login. See [Pointing it at your own provider](/core-concepts/your-own-provider) for the full picture, including how a provider profile handles what is specific to one provider. {% /callout %} ## Optional endpoints, and what they unlock @@ -56,7 +56,7 @@ Step-up needs nothing beyond the core four. The other two flows each need one mo | ----------------------------------- | ---------------------------------------------------- | --------------------------------------- | | [Step-up](/flows/step-up) | Nothing; it is OIDC Core plus RFC 9470 | Works against any conforming provider | | [Device grant](/flows/device-grant) | `device_authorization_endpoint` in discovery | `DeviceFlowError` naming the gap | -| [Escalation](/flows/escalation) | `/api/escalate`, or `escalationEndpoint` set | Provider extension; nothing to discover | +| [Escalation](/flows/escalation) | The WilsoonID profile (`profile: wilsoon()`) | `PROFILE_REQUIRED`; nothing to discover | ## Requirements diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md index b4c2f65..72339d9 100644 --- a/docs/getting-started/introduction.md +++ b/docs/getting-started/introduction.md @@ -30,14 +30,14 @@ This grew out of running my own OIDC provider for several first-party apps. The | `@wilsoon/auth-next` | `getSession()`, `requireSession()`, `createAuthMiddleware()`, `ServerCookieStorage`. | core, react, Next ≥14 | | `@wilsoon/auth-machine` | `client_credentials` tokens for server-to-server calls, with caching and single-flight. **0.x**, standalone. | nothing | -The first three are versioned together on a shared 2.x line. `auth-machine` has no dependency on the others and is versioned independently - see [Machine tokens](/flows/machine-tokens). +The first three are versioned together on a shared 3.x line. `auth-machine` has no dependency on the others and is versioned independently - see [Machine tokens](/flows/machine-tokens). Using anything else - Astro, SvelteKit, Hono, Express? Use `@wilsoon/auth-core` directly and write one storage adapter. See [Storage](/core-concepts/storage) - that's the entire integration. ## One rule, everywhere -Only five methods produce a value you may authorize on: `verifyIdToken()`, `verifyAccessToken()`, `verifyPlatformSession()`, `verifyMachineToken()` and `verifyEscalationToken()`. Every method whose name ends in `Unsafe`, plus the userinfo-backed `getUser()` / `hydrateSession()`, returns something you can render but never gate access with. [Security model](/core-concepts/security-model) covers why, and how the type system enforces it. +Only three core methods produce a value you may authorize on: `verifyIdToken()`, `verifyAccessToken()` and `verifyMachineToken()`, plus `handleCallback()` and `resolveSession()`, which wrap them. A provider profile can add its own, such as WilsoonID's `verifyPlatformSession()` and `verifyEscalationToken()`. Every method whose name ends in `Unsafe`, plus the userinfo-backed `getUser()`, returns something you can render but never gate access with. [Security model](/core-concepts/security-model) covers why, and how the type system enforces it. {% aside title="Not a provider" %} This is the _client_ half of OIDC. It doesn't run an authorization server, issue tokens, or store users - it talks to one you (or someone else) already runs. @@ -48,3 +48,4 @@ This is the _client_ half of OIDC. It doesn't run an authorization server, issue - [Installation](/getting-started/installation) - requirements and package choice - [Quick start](/getting-started/quick-start) - a working login flow in a few lines - [Flows](/flows/step-up) - step-up, the device grant, and machine-to-machine tokens +- [Recipes](/recipes/nextjs) - end-to-end wiring for Next.js, React, other frameworks and APIs diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index e16419e..58a6cd1 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -15,6 +15,7 @@ export const authConfig = { issuer: "https://id.example.com", redirectUri: "https://app.example.com/api/callback", scope: ["openid", "profile", "email", "offline_access"], + rolesClaim: "roles", // where your provider puts roles; see Authorization }; // One client per request, holding this request's cookies. @@ -41,19 +42,19 @@ client.saveTokens(tokens); ```ts // On any later request -const user = await client.resolveSession(client.getStoredTokens()); -if (user.role !== "admin") return forbidden(); +const user = await client.resolveSession(client.getStoredTokens() ?? {}); +if (!hasRole(user, "admin")) return forbidden(); // hasRole from @wilsoon/auth-core ``` {% callout type="warning" title="Only three methods are safe to authorize on" %} -`verifyIdToken()`, `verifyAccessToken()` and `verifyPlatformSession()` - everything else, including `handleCallback()`'s `user` and any method whose name ends in `Unsafe`, either wraps one of those three or returns something you may render but must never gate access with. See [Security model](/core-concepts/security-model). +`verifyIdToken()`, `verifyAccessToken()` and `verifyMachineToken()`, plus whatever verifiers a provider profile adds - everything else, including `handleCallback()`'s `user` and any method whose name ends in `Unsafe`, either wraps one of those three or returns something you may render but must never gate access with. See [Security model](/core-concepts/security-model). {% /callout %} ## Using a framework package `@wilsoon/auth-react` wraps this in `` / `useAuth()` for browser session state, and `@wilsoon/auth-next` wraps it in `getSession()` / `requireSession()` / `createAuthMiddleware()` for the Next.js App Router. Both call the same `AuthClient` underneath - nothing above changes, only who calls it. -For per-framework, copy-pasteable recipes (Next.js, a React SPA, Astro on Cloudflare Workers, a plain resource server), see the [integration guide](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/INTEGRATION.md) on GitHub. +For per-framework, copy-pasteable recipes (Next.js, a React SPA, Astro on Cloudflare Workers, a plain resource server), see [Recipes](/recipes/nextjs). ## Next diff --git a/docs/recipes/_chapter.md b/docs/recipes/_chapter.md new file mode 100644 index 0000000..fa77912 --- /dev/null +++ b/docs/recipes/_chapter.md @@ -0,0 +1,4 @@ +--- +title: Recipes +order: 4 +--- diff --git a/docs/recipes/authorization.md b/docs/recipes/authorization.md new file mode 100644 index 0000000..f787271 --- /dev/null +++ b/docs/recipes/authorization.md @@ -0,0 +1,84 @@ +--- +title: Authorization +identifier: authorization +order: 5 +--- + +Deciding what a verified user may do: roles, permissions and how they signed in. + +## Roles and permissions + +Providers put these in different places, so tell the client where to look: + +| Provider | Config | +| --------- | ----------------------------------------------------------- | +| Auth0 | `permissionsClaim: "permissions"` | +| Entra ID | `rolesClaim: "roles"` | +| Keycloak | `rolesClaim: "resource_access..roles"` | +| WilsoonID | `profile: wilsoon()`, which fills `permissions` and `roles` | + +A selector is a dot path or a function of the claims. Once set, verified users and access +token claims carry `roles` and `permissions` as plain string arrays. Any value lands there: +there is no fixed set of roles. + +```ts +import { hasPermission, hasRole, requirePermission } from "@wilsoon/auth-core"; + +const user = await client.verifyIdToken(idToken); + +if (!hasRole(user, ["admin", "owner"])) return forbidden(); // any of +if (!hasPermission(user, ["billing.read", "billing.write"])) return forbidden(); // all of +requirePermission(user, "billing.write"); // throws ClaimValidationError +``` + +Many providers carry permissions only in the access token. `handleCallback()` and +`resolveSession()` merge them in from the session's own access token when it verifies, names +this client as `client_id` and has the same subject. + +{% callout type="tip" title="On WilsoonID" %} +Permissions are `.` nodes your app registers. Define them once and get typed checks: + +```ts +import { definePermissionSet } from "@wilsoon/auth-provider-wilsoon"; + +export const permissions = definePermissionSet("games_portal", { + "leaderboard.edit": "Edit the leaderboard", + "rooms.create": "Create rooms", +}); + +await client.wilsoon.definePermissions(permissions); // at boot or in a deploy step; idempotent +permissions.require(user, "leaderboard.edit"); // a typo here fails to compile +``` +{% /callout %} + +## How the user signed in + +`authMethods` holds the verified `amr` claim. + +```ts +import { AMR, assertAmr, satisfiesAmr } from "@wilsoon/auth-core"; + +if (!satisfiesAmr(user, [AMR.FIDO])) return stepUp(); +assertAmr(user, [AMR.MFA]); // throws ClaimValidationError +``` + +**`mfa` does not imply a hardware factor.** WilsoonID, for one, emits it for passkeys and for a +social login that cleared a TOTP challenge: + +| Login | `amr` on WilsoonID | +| --------------------------- | ------------------------ | +| Federated / social provider | `["ext", "social"]` | +| Passkey | `["mfa", "fido", "hw"]` | +| TOTP challenge completed | adds `"otp"` and `"mfa"` | + +Require `AMR.FIDO` or `AMR.HARDWARE` when you mean phishing-resistant. + +To require a recent login, check `auth_time` on the way back: + +```ts +await client.verifyIdToken(idToken, { maxAuthAgeSeconds: 300 }); +``` + +To ask for a stronger or fresher login rather than just refusing a weak one, use +[Step-up authentication](/flows/step-up), which works against any provider. On WilsoonID, +[Escalation](/flows/escalation) proves one extra factor without a new login. diff --git a/docs/recipes/checklist.md b/docs/recipes/checklist.md new file mode 100644 index 0000000..5ac1349 --- /dev/null +++ b/docs/recipes/checklist.md @@ -0,0 +1,36 @@ +--- +title: Checklist before you ship +identifier: checklist +order: 8 +--- + +## Your application + +- [ ] `clientSecret` is only ever read on the server. +- [ ] Every authorization decision goes through `verifyIdToken()`, `getSession()`, + `requireSession()`, `resolveSession()` or `verifyAccessToken()`. +- [ ] `rolesClaim` / `permissionsClaim` or a profile is configured, if you authorize on roles or + permissions. +- [ ] `redirectUri` and any `post_logout_redirect_uri` are registered with the provider. +- [ ] Transient login cookies are `HttpOnly`, `Secure`, `SameSite=Lax`, and host-only. +- [ ] `offline_access` is requested only if you actually refresh, and refreshed tokens are + persisted. The library does it when it owns storage; providers that rotate refresh tokens + revoke a replayed one. +- [ ] Privileged actions check `satisfiesAmr(...)` and, where it matters, the live answer + (`enforce: "live"` or `liveAccess()`). +- [ ] If you share a session cookie across subdomains: a profile that resolves shared sessions, + `apiAudience` and `clientSecret` are set, and you never authorize on an ID token whose + `aud` is not yours. +- [ ] Logout clears your own cookies as well as redirecting to the provider. + +## The identity provider + +For whoever operates it. + +- [ ] The issuer is the deployment's public URL. Tokens and the discovery document must agree, + or every client rejects every token. +- [ ] The JWKS publishes the key that signs tokens, and each `kid` is stable. +- [ ] After a key rotation, the previous `kid` stays published until the longest session has + expired. +- [ ] Introspection is reachable and only accepts confidential clients. +- [ ] For silent sign-in, the authorization endpoint may be framed by your first-party origins. diff --git a/docs/recipes/logout.md b/docs/recipes/logout.md new file mode 100644 index 0000000..697b228 --- /dev/null +++ b/docs/recipes/logout.md @@ -0,0 +1,31 @@ +--- +title: Logout +identifier: logout +order: 6 +--- + +Send the user to the provider's `end_session_endpoint` so it can end its own session, and clear +yours. + +```ts +const url = await client.getLogoutUrl(tokens.id_token, "https://app.example.com/goodbye"); +client.clearStorage(); // your session and transient cookies +return redirect(url); +``` + +Without an ID token, `getLogoutUrl()` sends `client_id` instead (RP-Initiated Logout 1.0). The +provider then knows which application asked, but not which session, and may ask the user to +confirm. + +In React, `logout()` does all of this: + +```tsx + +``` + +Two things to know: + +- The `post_logout_redirect_uri` must be registered with the provider, or it will refuse to + send the user back there. WilsoonID accepts any URI sharing an origin with one of your + registered redirect URIs. +- Clear your app's cookies yourself. The provider can only clear the cookies it set. diff --git a/docs/recipes/nextjs.md b/docs/recipes/nextjs.md new file mode 100644 index 0000000..ab100a0 --- /dev/null +++ b/docs/recipes/nextjs.md @@ -0,0 +1,159 @@ +--- +title: Next.js App Router +identifier: nextjs +order: 1 +--- + +The full wiring for an App Router app: a shared config, login and callback route handlers, +middleware that gates and refreshes, and the server helpers that authorize. + +```bash +npm install @wilsoon/auth-core @wilsoon/auth-next +``` + +## Config + +One object, shared by every file below. + +```ts +// lib/auth-config.ts +export const authConfig = { + clientId: process.env.OIDC_CLIENT_ID!, + clientSecret: process.env.OIDC_CLIENT_SECRET, // server-side only + issuer: "https://id.example.com", + redirectUri: `${process.env.APP_URL}/api/auth/callback`, + scope: ["openid", "profile", "email", "offline_access"], // offline_access only if you refresh + + // Where your provider puts roles and permissions, if it does. See Authorization. + rolesClaim: "roles", + permissionsClaim: "permissions", +}; +``` + +`redirectUri` must exactly match one of the redirect URIs registered with the provider. + +{% callout type="tip" title="On WilsoonID" %} +Pass the provider's profile instead of the claim selectors. It maps WilsoonID's claims, keeps +the 2.x cookie names and adds `client.wilsoon`: + +```ts +import { wilsoon, WILSOON_ISSUER } from "@wilsoon/auth-provider-wilsoon"; + +export const authConfig = { clientId, clientSecret, issuer: WILSOON_ISSUER, redirectUri, profile: wilsoon() }; +``` +{% /callout %} + +## Login and callback + +```ts +// app/api/auth/login/route.ts +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { AuthClient } from "@wilsoon/auth-core"; +import { ServerCookieStorage } from "@wilsoon/auth-next"; +import { authConfig } from "@/lib/auth-config"; + +export async function GET() { + const client = new AuthClient(authConfig, new ServerCookieStorage(await cookies())); + + // Persists state, nonce and the PKCE verifier as HttpOnly cookies (10 min). + // Next.js applies those cookie writes to whatever response this handler returns. + const { url } = await client.createAuthorizeUrl(); + + return NextResponse.redirect(url); +} +``` + +```ts +// app/api/auth/callback/route.ts +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { AuthClient, AuthError } from "@wilsoon/auth-core"; +import { ServerCookieStorage } from "@wilsoon/auth-next"; +import { authConfig } from "@/lib/auth-config"; + +export async function GET(request: Request) { + const client = new AuthClient(authConfig, new ServerCookieStorage(await cookies())); + + try { + // Validates state, exchanges the code with its PKCE verifier, verifies the ID token + // (signature, iss, aud, exp, nonce), clears the transient cookies and stores the tokens. + await client.handleCallback(request.url, { persistTokens: true }); + return NextResponse.redirect(new URL("/dashboard", request.url)); + } catch (error) { + const code = error instanceof AuthError ? error.code : "UNKNOWN"; + return NextResponse.redirect(new URL(`/auth?error=${code}`, request.url)); + } +} +``` + +The session cookie is named after the client's `storageKeys.tokens`: `oidc_tokens` by +default, or whatever `storagePrefix` or the profile sets. + +## Middleware + +```ts +// middleware.ts +import { createAuthMiddleware } from "@wilsoon/auth-next"; +import { authConfig } from "./lib/auth-config"; + +export const middleware = createAuthMiddleware({ + ...authConfig, + loginPath: "/api/auth/login", // default "/auth" + unauthorizedPath: "/unauthorized", + // roles: ["admin"], // any of + // permissions: ["billing.read"], // all of + // amr: ["fido"], + // enforce: "live", // ask the provider on every request +}); + +export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] }; +``` + +Per request it reads the session cookie, refreshes an access token that is close to expiry +(on navigations only, one refresh per page load), verifies the session, and checks the policy. +A session that cannot be verified is cleared and redirected rather than looped. + +`enforce: "live"` asks the provider on every request whether the session still stands, and +checks `permissions` against what the user holds right now. It needs a `clientSecret`. If the +provider cannot be reached it answers 503 and keeps the cookie, so an outage signs nobody out. + +## Server Components and Route Handlers + +```tsx +import { redirect } from "next/navigation"; +import { hasPermission } from "@wilsoon/auth-core"; +import { getSession, requireSession } from "@wilsoon/auth-next"; +import { authConfig } from "@/lib/auth-config"; + +export default async function Page() { + const { user } = await getSession(authConfig); // null unless verified + if (!user) redirect("/api/auth/login"); + + return ( +

+ Hello {user.name} + {hasPermission(user, "billing.read") && Billing} +

+ ); +} +``` + +```ts +// Guard-clause style: throws AuthError with code NO_SESSION or FORBIDDEN. +const user = await requireSession(authConfig, { permissions: ["billing.write"], amr: ["fido"] }); +``` + +Verifying clients are cached per config, so the JWKS is fetched once per runtime instance, not +once per render. Define `authConfig` (and any profile in it) once at module level so the cache +can find it. + +## Client Components + +```tsx +"use client"; +import { AuthProvider, useAuth } from "@wilsoon/auth-next"; // re-exported from auth-react +``` + +See [React SPA](/recipes/react-spa) for the provider API. Client state is for rendering; the +server helpers above are what gate access. diff --git a/docs/recipes/other-frameworks.md b/docs/recipes/other-frameworks.md new file mode 100644 index 0000000..8f2a9fa --- /dev/null +++ b/docs/recipes/other-frameworks.md @@ -0,0 +1,276 @@ +--- +title: Other server frameworks +identifier: other-frameworks +order: 3 +--- + +Astro, Express, Hono, SvelteKit or anything else with request cookies. Implement +[`AuthStorage`](/core-concepts/storage) over your framework's cookies once, and the core +client does the rest. + +## The pattern + +```ts +import type { AuthStorage } from "@wilsoon/auth-core"; + +// `cookies` here is whatever your framework gives you. +export const cookieStorage = (cookies: any, tokensKey: string): AuthStorage => ({ + getItem: (key) => cookies.get(key)?.value ?? null, + setItem: (key, value) => + cookies.set(key, value, { + httpOnly: true, + secure: true, + sameSite: "lax", // must survive the redirect back from the provider + path: "/", + maxAge: key === tokensKey ? 60 * 60 * 24 * 30 : 600, // tokens long-lived, login state 10 min + }), + removeItem: (key) => cookies.delete(key, { path: "/" }), +}); +``` + +```ts +import { AuthClient, storageKeysFor } from "@wilsoon/auth-core"; + +const keys = storageKeysFor(); // the names the client uses: oidc_tokens, oidc_state, ... + +// GET /login +const client = new AuthClient(authConfig, cookieStorage(cookies, keys.tokens)); +const { url } = await client.createAuthorizeUrl(); +return redirect(url); + +// GET /callback +const client = new AuthClient(authConfig, cookieStorage(cookies, keys.tokens)); +const { user } = await client.handleCallback(request.url, { persistTokens: true }); + +// Any later request +const user = await client.resolveSession(client.getStoredTokens() ?? {}); +``` + +`storageKeysFor()` takes the same prefix as `storagePrefix`. With a profile that sets its own +names, read them from `client.storageKeys` or the profile's export (WilsoonID's is +`WILSOON_STORAGE_KEYS`). + +If you would rather keep the transient values yourself: + +```ts +const { url, state, nonce, codeVerifier } = await client.createAuthorizeUrl({ persist: false }); +// ...store all three... +const { user } = await client.handleCallback(request.url, { expected: { state, nonce, codeVerifier } }); +``` + +All three matter: without `state` there is no CSRF protection on the callback, without +`codeVerifier` the exchange cannot complete, and without `nonce` the ID token is not bound to +your request. `handleCallback()` refuses to run if it has neither storage nor `expected`, and +`createAuthorizeUrl()` warns once when it has no storage to persist to. Treat that warning as a +bug in your wiring. + +## Worked example: Astro on Cloudflare Workers + +Five files, written against a WilsoonID-style shared session on `.example.com`. Drop the +`sharedDomain` and the profile for a per-application session. + +### `src/lib/auth/config.ts` + +`process.env` does not exist on Workers. Secrets are runtime bindings, so thread them in from +`Astro.locals.runtime?.env` and fall back to `import.meta.env` for `astro dev`. Don't reach for +`require('cloudflare:workers')`; `require` is undefined in an ESM bundle. + +```ts +import { wilsoon } from "@wilsoon/auth-provider-wilsoon"; + +export type EnvSource = Record | undefined; + +const read = (env: EnvSource, key: string): string | undefined => env?.[key] ?? (import.meta.env as unknown as Record)[key]; + +// Module level, so the client cache and the profile are created once. +const profile = wilsoon({ platformSessionCacheSeconds: 10 }); + +export function authConfig(origin: string, env?: EnvSource) { + const clientId = read(env, "OIDC_CLIENT_ID"); + if (!clientId) throw new Error("Missing OIDC_CLIENT_ID"); + + return { + clientId, + // Safe here: Astro API routes, middleware and .astro frontmatter are all server-side. + // Required for introspection, which is how a shared session resolves. + clientSecret: read(env, "OIDC_CLIENT_SECRET"), + issuer: read(env, "OIDC_ISSUER") ?? "https://id.example.com", + redirectUri: `${origin}/api/callback`, + scope: ["openid", "profile", "email", "offline_access"], + profile, + }; +} +``` + +### `src/lib/auth/AstroCookieStorage.ts` + +The two kinds of value need different scopes. `state`, `nonce` and the PKCE verifier belong to +**one login attempt on one host**. Scoping them to the shared domain means two services +mid-login overwrite each other, and the first callback back fails with `STATE_MISMATCH`. + +```ts +import type { AuthStorage } from "@wilsoon/auth-core"; +import type { AstroCookies } from "astro"; + +export class AstroCookieStorage implements AuthStorage { + private readonly isLocal: boolean; + + constructor( + private cookies: AstroCookies, + origin: string, + private tokensKey: string, + private sharedDomain: string | null = ".example.com", + ) { + const hostname = new URL(origin).hostname; + this.isLocal = hostname === "localhost" || hostname === "127.0.0.1"; + } + + private scope(key: string) { + const long = key === this.tokensKey; + const shared = long && !this.isLocal && this.sharedDomain; + return { long, domain: shared ? this.sharedDomain : undefined }; + } + + getItem(key: string): string | null { + return this.cookies.get(key)?.value ?? null; + } + + setItem(key: string, value: string): void { + const { long, domain } = this.scope(key); + this.cookies.set(key, value, { + path: "/", + httpOnly: true, + sameSite: "lax", // must survive the redirect back from the provider + secure: !this.isLocal, + maxAge: long ? 60 * 60 * 24 * 365 : 600, + ...(domain ? { domain } : {}), + }); + } + + removeItem(key: string): void { + const { domain } = this.scope(key); + // A domain-scoped cookie is only cleared when the same domain is named. + this.cookies.delete(key, { path: "/", ...(domain ? { domain } : {}) }); + } +} +``` + +### `src/lib/auth/utils.ts` + +```ts +import { AuthClient, type AuthenticatedUser } from "@wilsoon/auth-core"; +import { WILSOON_STORAGE_KEYS } from "@wilsoon/auth-provider-wilsoon"; +import type { AstroCookies } from "astro"; +import { AstroCookieStorage } from "./AstroCookieStorage"; +import { authConfig, type EnvSource } from "./config"; + +export function getAuthClient(cookies: AstroCookies, origin: string, env?: EnvSource): AuthClient { + return new AuthClient(authConfig(origin, env), new AstroCookieStorage(cookies, origin, WILSOON_STORAGE_KEYS.tokens)); +} + +/** The verified user for this request, or null. Fails closed. */ +export async function getAuthenticatedUser(cookies: AstroCookies, origin: string, env?: EnvSource): Promise { + const client = getAuthClient(cookies, origin, env); + + const tokens = client.getStoredTokens(); + if (!tokens) return null; + + let current = tokens; + if (client.isTokenNearExpiry(tokens.access_token)) { + if (!tokens.refresh_token) { + client.clearStorage(); + return null; + } + try { + // Refresh tokens rotate; the library stores the rotated response itself. + current = await client.refreshAccessToken(tokens.refresh_token); + } catch { + client.clearStorage(); + return null; + } + } + + try { + // This app's own ID token when the cookie is ours; the shared session otherwise. + return await client.resolveSession(current); + } catch { + return null; + } +} +``` + +`resolveSession()` is the framework-agnostic version of what `getSession()` does in Next.js. + +### `src/pages/auth.ts` and `src/pages/api/callback.ts` + +```ts +// src/pages/auth.ts - login +import type { APIRoute } from "astro"; +import { getAuthClient } from "../lib/auth/utils"; + +const RETURN_TO_COOKIE = "auth_return_to"; + +export const GET: APIRoute = async ({ url, cookies, redirect, locals }) => { + const next = url.searchParams.get("next") ?? "/"; + + // Same-origin paths only, so `?next=` cannot become an open redirect. + if (!next.startsWith("/") || next.startsWith("//")) return new Response("Invalid 'next' parameter", { status: 400 }); + + const client = getAuthClient(cookies, url.origin, (locals as any).runtime?.env); + const { url: authorizeUrl } = await client.createAuthorizeUrl(); + + cookies.set(RETURN_TO_COOKIE, next, { path: "/", httpOnly: true, sameSite: "lax", secure: url.protocol === "https:", maxAge: 600 }); + return redirect(authorizeUrl); +}; +``` + +```ts +// src/pages/api/callback.ts +import type { APIRoute } from "astro"; +import { AuthError } from "@wilsoon/auth-core"; +import { getAuthClient } from "../../lib/auth/utils"; + +const RETURN_TO_COOKIE = "auth_return_to"; + +export const GET: APIRoute = async ({ url, cookies, redirect, locals }) => { + const client = getAuthClient(cookies, url.origin, (locals as any).runtime?.env); + const returnTo = cookies.get(RETURN_TO_COOKIE)?.value || "/"; + cookies.delete(RETURN_TO_COOKIE, { path: "/" }); + + try { + await client.handleCallback(url.href, { persistTokens: true }); + return redirect(returnTo); + } catch (error) { + const code = error instanceof AuthError ? error.code : "AUTH_FAILED"; + const errorUrl = new URL(returnTo, url.origin); + errorUrl.searchParams.set("auth_error", code.toLowerCase()); + return redirect(errorUrl.pathname + errorUrl.search); + } +}; +``` + +### `src/middleware.ts` + +```ts +import { defineMiddleware } from "astro:middleware"; +import { AMR, hasPermission, satisfiesAmr } from "@wilsoon/auth-core"; +import { getAuthenticatedUser } from "./lib/auth/utils"; + +export const onRequest = defineMiddleware(async (context, next) => { + if (!context.url.pathname.startsWith("/admin")) return next(); + + const isApi = context.url.pathname.startsWith("/api/"); + const deny = (status: number, error: string) => new Response(JSON.stringify({ error }), { status }); + const user = await getAuthenticatedUser(context.cookies, context.url.origin, (context.locals as any).runtime?.env); + + if (!user) return isApi ? deny(401, "Unauthorized") : context.redirect(`/auth?next=${encodeURIComponent(context.url.pathname)}`); + if (!hasPermission(user, "my_app.admin")) return deny(403, "Forbidden"); + + // `mfa` alone is not phishing-resistant: WilsoonID emits it for TOTP too. + // To ask for a passkey instead of refusing, see Step-up authentication. + if (!satisfiesAmr(user, [AMR.FIDO])) return deny(403, "A passkey sign-in is required"); + + context.locals.user = user; + return next(); +}); +``` diff --git a/docs/recipes/react-spa.md b/docs/recipes/react-spa.md new file mode 100644 index 0000000..5686ebc --- /dev/null +++ b/docs/recipes/react-spa.md @@ -0,0 +1,81 @@ +--- +title: React SPA +identifier: react-spa +order: 2 +--- + +A browser-only app with no server of its own. + +```bash +npm install @wilsoon/auth-react +``` + +```tsx +import { AuthProvider } from "@wilsoon/auth-react"; + + + +; +``` + +```tsx +import { hasRole } from "@wilsoon/auth-core"; + +const { user, isLoading, error, login, logout } = useAuth(); + +if (isLoading) return ; +if (!user) return ; + +return ( + <> + {/* Display claims are always available: */} + {user.name} + + {/* Authorization claims exist only on a verified session: */} + {user.verified && hasRole(user, "admin") && } + +); +``` + +## Restoring the session on page load + +`restore` decides what happens when the page loads without a callback in the URL. + +| `restore` | What it does | +| -------------------- | -------------------------------------------------------------------------------------------------- | +| `"silent"` (default) | `silentAuthorize()`: a `prompt=none` request in a hidden iframe, then the profile's restore if any | +| `"profile"` | Only the profile's own restore, such as WilsoonID's cookie | +| `"none"` | No restore. The user is signed out until they click sign in | + +The silent path needs two things: the `redirectUri` page must allow being framed by its own +origin, and the provider must allow its authorization endpoint to be framed. When either is +missing the attempt finds nothing and falls through, rather than hanging. + +`user` is a discriminated union: + +| How the session started | `verified` | Has `roles` / `permissions` / `authMethods` | +| ------------------------------------------ | ---------- | ------------------------------------------- | +| The OIDC callback (`?code=...`) | `true` | yes, the ID token was verified | +| A silent restore | `true` | yes, the ID token was verified | +| A profile restore (e.g. a provider cookie) | `false` | no, only display claims | + +TypeScript will not let you read authorization claims without narrowing on `verified`. + +The provider handles the callback itself: it keeps `state`/`nonce`/verifier in +`sessionStorage`, calls `handleCallback()`, strips the query string and surfaces failures on +`error` (`STATE_MISMATCH`, `NONCE_MISMATCH`, `TOKEN_VERIFICATION_FAILED`, +`AUTHORIZATION_RESPONSE_ERROR`). + +{% callout type="warning" title="Client-side checks are for rendering" %} +Anything the browser decides, a user can change. Gate data on your API with +[a resource server](/recipes/resource-server), and use `user.verified` only to decide what to +show. +{% /callout %} + +With a `profile` prop, its extensions appear on `useAuth()` under its name, for example +`useAuth().wilsoon.reauthorize(["passkey"])`. diff --git a/docs/recipes/resource-server.md b/docs/recipes/resource-server.md new file mode 100644 index 0000000..5c41bd6 --- /dev/null +++ b/docs/recipes/resource-server.md @@ -0,0 +1,65 @@ +--- +title: API / resource server +identifier: resource-server +order: 4 +--- + +An API that receives bearer tokens and decides what each caller may do. + +Access tokens are usually minted with an audience shared by every API on the platform, so +verifying only the signature would let any client's token in. Pin the audience: + +```ts +import { AuthClient, ClaimValidationError, TokenVerificationError } from "@wilsoon/auth-core"; + +// A verify-only client needs no redirectUri: it never starts a login. +const client = new AuthClient({ + clientId: process.env.OIDC_CLIENT_ID!, + issuer: "https://id.example.com", + clientSecret: process.env.OIDC_CLIENT_SECRET, // only for liveAccess() / introspection + apiAudience: "https://api.example.com", + permissionsClaim: "permissions", +}); + +export async function authenticate(request: Request) { + const bearer = request.headers.get("authorization")?.replace(/^Bearer /i, ""); + if (!bearer) return unauthorized(); + + try { + const claims = await client.verifyAccessToken(bearer, { requiredScopes: ["openid"] }); + return { userId: claims.subject, scopes: claims.scopes, permissions: claims.permissions }; + } catch (error) { + if (error instanceof TokenVerificationError || error instanceof ClaimValidationError) return unauthorized(); + throw error; + } +} +``` + +`claims.roles` and `claims.permissions` are read through `rolesClaim` / `permissionsClaim` or +the profile, so they are empty until one of those is configured. + +## What the user holds right now + +A token's claims are what was true when it was minted. To check the current answer, ask the +provider (confidential clients only): + +```ts +const access = await client.liveAccess(bearer); +if (!access.active) return unauthorized(); +if (access.permissions && !access.permissions.includes("billing.write")) return forbidden(); +``` + +`liveAccess()` uses the profile's hook when there is one, and plain RFC 7662 introspection +otherwise. An absent `permissions` means the provider did not say, not that nothing is held. +This costs a network call, so put it on privileged actions rather than every request. + +## Callers with no user + +A `client_credentials` token verifies like a user token, but its `sub` is a `client_id`, so +`verifyAccessToken()` refuses it unless you opt in. See +[Machine tokens](/flows/machine-tokens). + +## Asking for a stronger login + +Answer `401` with an RFC 9470 challenge and let the client act on it. See +[Step-up authentication](/flows/step-up). diff --git a/docs/recipes/troubleshooting.md b/docs/recipes/troubleshooting.md new file mode 100644 index 0000000..791a5a9 --- /dev/null +++ b/docs/recipes/troubleshooting.md @@ -0,0 +1,41 @@ +--- +title: Troubleshooting by error code +identifier: troubleshooting +order: 7 +--- + +Every `AuthError` carries a `code`. What each one means, and what to change. + +| Error / code | What happened | Fix | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `INVALID_CONFIG` | A required config value is missing, such as `redirectUri` when starting a login | Set it; a verify-only client needs no `redirectUri` | +| `ISSUER_MISMATCH` | Discovery, or the callback's `iss`, names a different issuer than you configured | Check the configured issuer, or set `expectedIssuer` when the difference is deliberate | +| `DISCOVERY_FAILED` | `/.well-known/openid-configuration` unreachable or incomplete | Check the issuer URL and network egress | +| `TOKEN_VERIFICATION_FAILED`, message mentions the signature | Forged token, or the provider rotated keys | Retry once (the library refetches the JWKS itself); if it persists, the provider's published keys are wrong | +| `TOKEN_VERIFICATION_FAILED`, message mentions `aud` | ID token minted for another `client_id`, or an ID token passed to `verifyAccessToken` | Verify ID tokens with the client they were issued to; pass the access token to API verification | +| `TOKEN_VERIFICATION_FAILED`, message mentions `iss` | Token from a different provider deployment | Align the configured issuer with the provider's | +| `TOKEN_VERIFICATION_FAILED` - "Token has expired" | Clock skew or a genuinely old token | Check server clocks; raise `clockToleranceSeconds` only for real skew | +| `TOKEN_VERIFICATION_FAILED` - "no longer active" | The shared session was revoked, or superseded by a newer one | Send the user back through login | +| `STATE_MISMATCH` | `state` missing or different, often because the transient cookie was lost | Make the cookie `SameSite=Lax` (not `Strict`) with the right `path`, and don't start several logins in parallel | +| `NONCE_MISMATCH` | The ID token was not bound to this authorization request | Use the same storage for authorize and callback, and don't replay a callback URL | +| `INVALID_CALLBACK` | No `code`, or the PKCE verifier is gone | The login attempt expired (10 min); start again from your login route | +| `AUTHORIZATION_RESPONSE_ERROR` | The provider returned `?error=` (e.g. `access_denied`) | Expected when a user declines consent; send them back to your login page | +| `STORAGE_UNAVAILABLE` | A storage-backed call with no usable storage, or a cookie write in a Server Component | Pass a storage implementation; move cookie writes into a Route Handler, Server Action or middleware | +| `SILENT_AUTH_UNAVAILABLE` | `silentAuthorize()` was called with no browser document | Server-rendered apps keep tokens server-side and refresh them instead | +| `FOREIGN_SESSION` | The session cookie holds another application's tokens, and this client cannot resolve them | Configure a profile that resolves shared sessions, `apiAudience` and a `clientSecret`, or give this app its own cookie | +| `PROFILE_REQUIRED` | A provider-specific method (escalation, `verifyPlatformSession`, ...) was called with no profile | Pass the provider's profile, e.g. `profile: wilsoon()` | +| `SESSION_CHECK_UNAVAILABLE` | `isSessionCurrent()` had no way to resolve the live session version | Configure `resolveSessionVersion` on the profile, or pass `{ token }` with a `clientSecret` set | +| `INTROSPECTION_UNAVAILABLE` | No `clientSecret`, or the provider advertises no introspection endpoint | Introspect from your server with a confidential client | +| `CLAIM_INVALID` - "audience this resource server accepts" | A shared session but no `apiAudience` configured | Set `apiAudience`, or expect only your own ID token | +| `CLAIM_INVALID` - "does not satisfy" | The token's `acr` is weaker than `requiredAcr` demanded | The provider ignored or could not meet `acr_values`. This is the check working | +| `CLAIM_INVALID` - "Missing required permission(s)" | `requirePermission()` found a permission missing | Expected for a user without it; if every user fails, check `permissionsClaim` or the profile | +| `MACHINE_TOKEN_NOT_ALLOWED` | A `client_credentials` token reached a path that expects a user | Pass `allowMachineTokens: true` and branch on `tokenUse`, or use `verifyMachineToken()` | +| `NOT_A_MACHINE_TOKEN` | A user token was passed to `verifyMachineToken()` | Use `verifyAccessToken()` for user tokens | +| `INVALID_CHALLENGE` | `createStepUpRequest()` got a header it could not parse, or one naming no `acr_values`/`max_age` | Check `isStepUpChallenge()` first; an ordinary 401 is answered with a plain login | +| `ACCESS_DENIED` | The user refused a step-up, an escalation or a device authorization | Expected; tell them what was declined rather than retrying | +| `EXPIRED_TOKEN` | The escalation or device code passed its deadline | Start the flow again. Only the server decides this; don't add a client-side timeout | +| `ESCALATION_FAILED` | Unknown method (`invalid_use`), unusable `id_token_hint`, or an unknown subject | Check `use` against what the provider supports, and pass an `idTokenHint` you were issued | +| `DEVICE_FLOW_FAILED` | Not registered for the grant, device code already redeemed, or no endpoint advertised | Enable the grant on the client; device codes are single use | +| `CRYPTO_UNAVAILABLE` | The runtime has no Web Crypto | Node.js 18+, a modern browser, or an edge runtime | +| Every user is signed out once after upgrading to 3.0 | Storage names changed from `wilsoon_*` to `oidc_*` | Pass the provider's profile, which keeps the old names, or set `storagePrefix` | +| `roles` / `permissions` always empty | No `rolesClaim` / `permissionsClaim` and no profile | Tell the client where your provider puts them. See [Authorization](/recipes/authorization) | diff --git a/packages/auth-core/README.md b/packages/auth-core/README.md index 5ac49b3..7f14e89 100644 --- a/packages/auth-core/README.md +++ b/packages/auth-core/README.md @@ -21,7 +21,7 @@ Siblings: **`@wilsoon/auth-core`** · [`@wilsoon/auth-react`](https://www.npmjs. npm install @wilsoon/auth-core ``` -Wiring up a whole application? Start with the [integration guide](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/INTEGRATION.md) - end-to-end recipes +Wiring up a whole application? Start with the [recipes](https://docs.wilsoon.dev/node-oidc-kit/recipes/nextjs) - end-to-end wiring for Next.js, React SPAs, other server frameworks and APIs, plus a troubleshooting table. Using a framework with no package here (Astro, SvelteKit, Hono, Express)? Use this package @@ -33,13 +33,14 @@ the entire integration. `verifyIdToken()` is the only method that returns `role`, `authMethods` and `sessionVersion`, and it returns them **only after** verifying the token: ```typescript -import { AMR, AuthClient, MemoryStorage, satisfiesAmr } from "@wilsoon/auth-core"; +import { AMR, AuthClient, hasRole, MemoryStorage, satisfiesAmr } from "@wilsoon/auth-core"; const client = new AuthClient( { clientId: process.env.OIDC_CLIENT_ID!, issuer: "https://id.example.com", redirectUri: "https://app.example.com/callback", + rolesClaim: "roles", // where your provider puts roles, if it does }, new MemoryStorage(), ); @@ -47,7 +48,7 @@ const client = new AuthClient( // Signature (JWKS/RS256) + iss + aud + exp are all checked here. const user = await client.verifyIdToken(idToken); -if (user.role !== "admin") return deny(); +if (!hasRole(user, "admin")) return deny(); if (!satisfiesAmr(user, [AMR.FIDO])) return stepUp(); return allow(); ``` @@ -61,18 +62,22 @@ that cookie holds the tokens of whichever service most recently completed a code the ID token inside it is addressed to that service alone - the access token is the one addressed to the whole platform. -`verifyPlatformSession()` resolves a session from it: verify the access token, then take -`role`/`amr`/`session_version` from introspection, which is live rather than as-minted. +How to resolve a session from that access token is up to the provider, so since 3.0 it is a +[provider profile](https://docs.wilsoon.dev/node-oidc-kit/core-concepts/your-own-provider)'s job. +`resolveSession()` hands a sibling service's session to the profile, and throws +`FOREIGN_SESSION` when there is none. The WilsoonID profile verifies the access token and takes +the current permissions from introspection, which is live rather than as-minted: ```typescript -const user = await client.verifyPlatformSession(tokens.access_token); -user.source; // 'access_token' +import { createAuthClient } from "@wilsoon/auth-core"; +import { wilsoon } from "@wilsoon/auth-provider-wilsoon"; + +const client = createAuthClient({ ...config, profile: wilsoon({ platformSessionCacheSeconds: 30 }) }); +const user = await client.resolveSession(tokens); ``` -Needs `apiAudience` and a `clientSecret`. Set `platformSessionCacheSeconds` to reuse a resolved -session briefly instead of introspecting on every request. `@wilsoon/auth-next`'s `getSession()` -picks this path automatically when the cookie's ID token belongs to a sibling service - see the -[integration guide](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/INTEGRATION.md#two-session-models--pick-yours-first). +Needs `apiAudience` and a `clientSecret`. `@wilsoon/auth-next`'s `getSession()` picks this path +automatically - see the [session models](https://docs.wilsoon.dev/node-oidc-kit/core-concepts/session-models). ## The login flow @@ -115,18 +120,17 @@ const claims = await client.verifyAccessToken(bearerToken, { ## Which method returns what -| Method | Verified? | Returns | Safe to authorize on | -| ------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------ | -| `verifyIdToken(idToken)` | signature, `iss`, `aud`, `exp`, `nonce` | `AuthenticatedUser` - includes `role`, `authMethods`, `sessionVersion` | **Yes** | -| `verifyPlatformSession(accessToken)` | signature, `iss`, `aud`, `exp` + live introspection | `AuthenticatedUser` with live `role` | **Yes** | -| `verifyAccessToken(token, { audience })` | signature, `iss`, `aud`, `exp`, scopes | `AccessTokenClaims` | **Yes** (subject + scopes) | -| `handleCallback(url)` | everything `verifyIdToken` checks, plus `state` | `{ tokens, user }` | **Yes** | -| `getUser(accessToken)` / `hydrateSession()` | the provider validates the token | `ProfileUser` - `id`, `email`, `name`, `picture` only | No - carries no authorization claims | -| `decodeIdTokenUnsafe(idToken)` | **nothing** | `UnverifiedUser` | **No** | -| `parseIdToken(idToken)` _(deprecated)_ | **nothing** | `UnverifiedUser` | **No** | -| `isTokenNearExpiry(token)` | **nothing** | `boolean` refresh hint | No | +| Method | Verified? | Returns | Safe to authorize on | +| ---------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | ------------------------------------ | +| `verifyIdToken(idToken)` | signature, `iss`, `aud`, `exp`, `nonce` | `AuthenticatedUser` - `roles`, `permissions`, `authMethods` | **Yes** | +| `verifyAccessToken(token, { audience })` | signature, `iss`, `aud`, `exp`, scopes | `AccessTokenClaims` - `roles`, `permissions`, scopes | **Yes** | +| `liveAccess(accessToken)` | the provider, live (profile hook or RFC 7662) | `LiveAccess` - `active`, plus current grants where available | **Yes** | +| `handleCallback(url)` | everything `verifyIdToken` checks, plus `state` | `{ tokens, user }` | **Yes** | +| `getUser(accessToken)` | the provider validates the token | `ProfileUser` - `id`, `email`, `name`, `picture` only | No - carries no authorization claims | +| `decodeIdTokenUnsafe(idToken)` | **nothing** | `UnverifiedUser` | **No** | +| `isTokenNearExpiry(token)` | **nothing** | `boolean` refresh hint | No | -`ProfileUser` has no `role` field at all, so authorizing on a userinfo result is a compile error rather than a silent `undefined`. +`ProfileUser` has no `roles` field at all, so authorizing on a userinfo result is a compile error rather than a silent empty list. ## Authentication methods (`amr`) @@ -151,30 +155,17 @@ assertAmr(user, [AMR.FIDO]); // throws ClaimValidationError ## Session revocation A JWT stays valid until it expires, so "sign out everywhere" does not take effect on its own. -The reference provider bumps a per-user session version on revocation, and the ID token carries -the version it was minted with. Comparing the two is what makes revocation enforceable - supply -a resolver so the library can read the live value: - -```typescript -const client = new AuthClient({ - ...config, - resolveSessionVersion: (userId) => myApi.getSessionVersion(userId), -}); - -if (!(await client.isSessionCurrent(user))) return reauthenticate(); -``` - -With a confidential client you can skip the resolver and let the provider answer, via RFC 7662 -introspection: +`liveAccess()` asks the provider whether a token is still good - through the profile's hook when +there is one, RFC 7662 introspection otherwise: ```typescript -if (!(await client.isSessionCurrent(user, { token: idToken }))) return reauthenticate(); - -// Or directly - also the way an API learns the live `role`, which access tokens do not carry: -const info = await client.introspectToken(accessToken); +const access = await client.liveAccess(tokens.access_token); +if (!access.active) return reauthenticate(); ``` -Without either route, `isSessionCurrent()` throws `SessionCheckUnavailableError` - it never assumes the session is current. +In Next.js, `enforce: "live"` on the middleware does this on every request. The WilsoonID +profile adds a `session_version` check, `client.wilsoon.isSessionCurrent(user)` - see the +[session models](https://docs.wilsoon.dev/node-oidc-kit/core-concepts/session-models). ## Token refresh @@ -197,45 +188,59 @@ The provider rotates refresh tokens and revokes the whole family if one is repla All errors extend `AuthError` and carry a stable `code`. `error instanceof StateMismatchError` now works (the base class used to overwrite subclass prototypes, making every specific check false). +3.0 adds the codes `PROFILE_REQUIRED`, `FOREIGN_SESSION`, `SILENT_AUTH_UNAVAILABLE` and `INVALID_CONFIG`. + `TokenVerificationError`, `ClaimValidationError`, `NonceMismatchError`, `StateMismatchError`, `IssuerMismatchError`, `AuthorizationResponseError`, `StorageUnavailableError`, `SessionCheckUnavailableError`, `CryptoUnavailableError`, `DiscoveryError`, `TokenExchangeError`, `TokenRefreshError`, `UserInfoError`, `LogoutError`, `NoTokenError`. +## Migrating to 3.0 + +The core no longer assumes one provider. For WilsoonID, install `@wilsoon/auth-provider-wilsoon` +and pass `profile: wilsoon()`; escalation, platform sessions and `isSessionCurrent()` move to +`client.wilsoon.*`. `role` gives way to `roles`/`permissions` arrays. Step-by-step notes are in the +[changelog](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/CHANGELOG.md). + ## Migrating from 1.x -Nothing was removed; the unsafe paths still work and now warn. The changes that need attention +2.0 removed nothing; the unsafe paths kept working and warned until 3.0 removed them. The changes that need attention (full notes in the [changelog](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/CHANGELOG.md)): -1. **Authorize on `verifyIdToken()`.** `parseIdToken()` never checked a signature, so `role`/`authMethods` from it are attacker-controlled. It still decodes (for display), is `@deprecated`, warns once per process, and its return type is now `UnverifiedUser` with every security field optional. +1. **Authorize on `verifyIdToken()`.** `parseIdToken()` never checked a signature, so `role`/`authMethods` from it are attacker-controlled. It was removed in 3.0; use `decodeIdTokenUnsafe()` when you only need display claims. ```diff - const user = client.parseIdToken(tokens.id_token); - if (user.role === 'admin') { /* ... */ } + const user = await client.verifyIdToken(tokens.id_token); - + if (user.role === 'admin') { /* ... */ } + + if (hasRole(user, 'admin')) { /* ... */ } // 3.0; 2.x read user.role ``` -2. **`getUser()` / `hydrateSession()` return `ProfileUser`.** Reading `.role`, `.authMethods` or `.sessionVersion` off them is now a compile error. At runtime those fields were always `undefined`/`[]`, because the userinfo endpoint never returned them - any check built on them was already broken, and inverted checks (`if (user.role === 'user') restrict()`) were failing open. +2. **`getUser()` returns `ProfileUser`.** Reading `.role`, `.authMethods` or `.sessionVersion` off them is now a compile error. At runtime those fields were always `undefined`/`[]`, because the userinfo endpoint never returned them - any check built on them was already broken, and inverted checks (`if (user.role === 'user') restrict()`) were failing open. -3. **`isTokenExpired()` no longer reports a token with no `exp` as valid.** It is renamed to `isTokenNearExpiry()`; the old name still works and warns. +3. **`isTokenExpired()` no longer reports a token with no `exp` as valid.** It is renamed to `isTokenNearExpiry()`; the old name was removed in 3.0. 4. **`createAuthorizeUrl()` persists `state`, `nonce` and the verifier** when storage is available, and returns `nonce` alongside `state` and `codeVerifier`. Pass `{ persist: false }` to keep managing them yourself. 5. **`AuthClient` needs storage for storage-backed calls.** Previously the server-side default was `{} as AuthStorage`, which type-checked and then threw `TypeError: this.storage.setItem is not a function`. Pass `MemoryStorage`, a cookie store, or nothing if you never touch storage. -6. **`User` is deprecated** in favour of `AuthenticatedUser` (verified), `ProfileUser` (userinfo) and `UnverifiedUser` (decoded). It remains exported as an alias of `AuthenticatedUser`. +6. **`User` is deprecated** in favour of `AuthenticatedUser` (verified), `ProfileUser` (userinfo) and `UnverifiedUser` (decoded). The alias was removed in 3.0. -7. **`role` is validated, not asserted.** An unrecognised value throws `ClaimValidationError`; an absent one falls back to the least-privileged `'user'`. +7. **`role` is validated, not asserted.** In 2.x an unrecognised value threw `ClaimValidationError`. 3.0 replaces it with open `roles`/`permissions` lists - see [Roles and permissions](#roles-and-permissions). ## Claim shapes -The token endpoint spreads identity claims flat onto the ID token (`role`, `amr`, `session_version`), while some documentation nests them under `oidc_fields`. Both shapes are read, with the nested one preferred when present. `sub` is always treated as the authoritative subject identifier. +`decodeIdTokenUnsafe()` reads identity claims both flat and nested under `oidc_fields`, with the nested one preferred when present. `sub` is always treated as the authoritative subject identifier. + +## Roles and permissions -## Roles +`roles` and `permissions` are plain string lists, filled from wherever your provider puts them: + +```typescript +new AuthClient({ ...config, rolesClaim: "resource_access.my-app.roles", permissionsClaim: "permissions" }); +``` -`UserRole` is `'admin' | 'user'`, and a `role` claim outside that set throws -`ClaimValidationError` rather than being asserted into the union - silently mapping an unknown -role onto a known one is how privilege escalations ship. A provider that issues other roles -needs `UserRole` and `USER_ROLES` in `types.ts` widened together. See -[the provider-specific list](https://github.com/Wilsoon7721/node-oidc-kit#things-that-are-still-specific-to-one-provider). +Each selector is a dot path or a function of the claims. With none configured both lists are +empty, so every check fails closed. `hasRole()` passes on any of the given roles, +`hasPermission()` only on all of them, and `requireRole()` / `requirePermission()` throw instead. +A provider profile can fill both lists itself. ## Environments Supported diff --git a/packages/auth-core/package.json b/packages/auth-core/package.json index 110c2e5..bf584dd 100644 --- a/packages/auth-core/package.json +++ b/packages/auth-core/package.json @@ -1,6 +1,6 @@ { "name": "@wilsoon/auth-core", - "version": "2.3.0", + "version": "3.0.0", "description": "Framework-agnostic OpenID Connect relying party: discovery, PKCE, and token verification for browsers, Node and edge runtimes", "keywords": [ "oidc", diff --git a/packages/auth-core/src/claims.ts b/packages/auth-core/src/claims.ts new file mode 100644 index 0000000..384fd60 --- /dev/null +++ b/packages/auth-core/src/claims.ts @@ -0,0 +1,69 @@ +import { ClaimValidationError } from "./errors"; + +/** + * A dot path into a token's claims, e.g. `"permissions"`, `"realm_access.roles"` or `"resource_access.my-app.roles"`. + * Each segment is one property name, so a path cannot address a key that itself contains a dot. + */ +export type ClaimPath = string; + +/** Where to read a list of strings from: a {@link ClaimPath}, or a function over the verified claims. */ +export type ClaimSelector = ClaimPath | ((claims: Readonly>) => readonly string[] | null | undefined); + +/** Anything carrying roles, such as an `AuthenticatedUser` or `AccessTokenClaims`. */ +export interface RoleCarrier { + roles?: readonly string[]; +} + +/** Anything carrying permissions, such as an `AuthenticatedUser` or `AccessTokenClaims`. */ +export interface PermissionCarrier { + permissions?: readonly string[]; +} + +/** Reads the value at a dot path, or `undefined` when any segment is missing. */ +export function readClaimPath(claims: Readonly> | null | undefined, path: ClaimPath): unknown { + let current: unknown = claims; + for (const segment of path.split(".")) { + if (typeof current !== "object" || current === null || Array.isArray(current)) return undefined; + current = (current as Record)[segment]; + } + return current; +} + +/** + * Reads a list of strings through a {@link ClaimSelector}. + * An array keeps its string entries, a string is split on whitespace (the `scope` convention), and anything else is an empty list. + */ +export function readClaimList(claims: Readonly>, selector: ClaimSelector): string[] { + const value = typeof selector === "function" ? selector(claims) : readClaimPath(claims, selector); + if (typeof value === "string") return value.split(/\s+/).filter(Boolean); + if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); + return []; +} + +const listOf = (value: T | readonly T[]): readonly T[] => (typeof value === "string" ? [value] : value); + +/** Whether the subject holds **any** of the given roles. */ +export function hasRole(subject: RoleCarrier | null | undefined, role: string | readonly string[]): boolean { + const held = subject?.roles ?? []; + return listOf(role).some((entry) => held.includes(entry)); +} + +/** Whether the subject holds **every** one of the given permissions. */ +export function hasPermission

(subject: PermissionCarrier | null | undefined, permission: P | readonly P[]): boolean { + const held = subject?.permissions ?? []; + const required = listOf(permission); + return required.length > 0 && required.every((entry) => held.includes(entry)); +} + +/** {@link hasRole} that throws instead of returning `false`. @throws {ClaimValidationError} If no listed role is held. */ +export function requireRole(subject: RoleCarrier | null | undefined, role: string | readonly string[]): void { + if (!hasRole(subject, role)) throw new ClaimValidationError(`One of the roles [${listOf(role).join(", ")}] is required.`); +} + +/** {@link hasPermission} that throws instead of returning `false`. @throws {ClaimValidationError} If any listed permission is missing. */ +export function requirePermission

(subject: PermissionCarrier | null | undefined, permission: P | readonly P[]): void { + if (hasPermission(subject, permission)) return; + const held = subject?.permissions ?? []; + const missing = listOf(permission).filter((entry) => !held.includes(entry)); + throw new ClaimValidationError(`Missing required permission(s): ${missing.join(", ") || "(none requested)"}.`); +} diff --git a/packages/auth-core/src/index.ts b/packages/auth-core/src/index.ts index aaca653..7932604 100644 --- a/packages/auth-core/src/index.ts +++ b/packages/auth-core/src/index.ts @@ -1,21 +1,18 @@ import { AuthenticationChallenge, readAuthenticationChallenge } from "./challenge"; +import { readClaimList } from "./claims"; import { AuthorizeDeviceOptions, DEVICE_GRANT_TYPE, DeviceAuthorization, DeviceAuthorizationOptions, DeviceGrantResult } from "./device"; -import { AuthError, AuthorizationDeniedError, AuthorizationExpiredError, AuthorizationResponseError, ClaimValidationError, DeviceFlowError, DiscoveryError, EscalationError, IssuerMismatchError, LogoutError, MachineTokenNotAllowedError, NonceMismatchError, NotAMachineTokenError, NoTokenError, SessionCheckUnavailableError, StateMismatchError, StorageUnavailableError, TokenExchangeError, TokenRefreshError, TokenVerificationError, UserInfoError } from "./errors"; +import { AuthError, AuthorizationDeniedError, AuthorizationExpiredError, AuthorizationResponseError, ClaimValidationError, DeviceFlowError, DiscoveryError, IssuerMismatchError, LogoutError, MachineTokenNotAllowedError, NonceMismatchError, NotAMachineTokenError, NoTokenError, StateMismatchError, StorageUnavailableError, TokenExchangeError, TokenRefreshError, TokenVerificationError, UserInfoError } from "./errors"; import { CreateEscalationOptions, ESCALATION_TOKEN_TYPE, EscalationRequest, EscalationResult, ReauthorizeOptions, VerifiedEscalation } from "./escalation"; import { decodeTokenPayloadUnsafe, RemoteKeySet, verifyCompactJwt } from "./jwt"; -import { tokenUseOf } from "./machine"; +import { LiveAccess, ProfileContext, ProfileExtensions, ProviderProfile } from "./profile"; import { ACCESS_DENIED, AUTHORIZATION_PENDING, EXPIRED_TOKEN, PollStep, pollUntilResolved, SLOW_DOWN } from "./polling"; -import { AuthStorage, BrowserStorage, isUsableStorage, UnavailableStorage } from "./storage"; -import { AccessTokenClaims, AuthConfig, AuthenticatedUser, AuthorizeRequest, AuthorizeUrlOptions, CallbackResult, DiscoveryDocument, HandleCallbackOptions, IntrospectionResponse, MachineClient, PlatformSessionOptions, ProfileUser, TokenResponse, TokenUse, UnverifiedUser, USER_ROLES, UserRole, VerifyAccessTokenOptions, VerifyIdTokenOptions } from "./types"; +import { AuthStorage, BrowserStorage, DEFAULT_STORAGE_PREFIX, isUsableStorage, StorageKeys, storageKeysFor, UnavailableStorage } from "./storage"; +import { AccessTokenClaims, AuthConfig, AuthenticatedUser, AuthorizeRequest, AuthorizeUrlOptions, CallbackResult, DiscoveryDocument, HandleCallbackOptions, IntrospectionResponse, MachineClient, PlatformSessionOptions, ProfileUser, SilentAuthorizeOptions, TokenResponse, TokenUse, UnverifiedUser, VerifyAccessTokenOptions, VerifyIdTokenOptions } from "./types"; import { generateNonce, generatePKCE, generateState, normalizeIssuer, timingSafeEqual } from "./utils"; /** - * The names the library reads and writes through {@link AuthStorage}. - * - * Exported because a storage adapter usually needs to tell them apart - the token blob is long-lived, the other three are single-use and expire in minutes. - * See `ServerCookieStorage` in `@wilsoon/auth-next` for that split in practice. - * - * These are fixed at the library level. To put the token blob under a different cookie name, map it inside your adapter. + * The storage names 2.x used, which the `wilsoon()` profile keeps so nobody is signed out by the upgrade. + * @deprecated Since 3.0.0 - names now come from `storagePrefix` or the profile. Read `client.storageKeys` for the names a client actually uses. */ export const STORAGE_KEYS = { /** The persisted token response. */ @@ -28,20 +25,29 @@ export const STORAGE_KEYS = { codeVerifier: "wilsoon_auth_verifier", } as const; -const TOKEN_KEY = STORAGE_KEYS.tokens; - const DEFAULT_SCOPES = ["openid", "profile", "email"]; const DEFAULT_CLOCK_TOLERANCE_SECONDS = 60; +const DEFAULT_SILENT_TIMEOUT_MS = 10_000; /** Authorization request parameters the library controls and `extraParams` may not override. */ const RESERVED_AUTHORIZE_PARAMS = new Set(["client_id", "redirect_uri", "response_type", "scope", "state", "nonce", "code_challenge", "code_challenge_method"]); -/** Upper bound on cached platform sessions, so a long-lived client cannot grow unbounded. */ -const SESSION_CACHE_MAX_ENTRIES = 500; +/** + * The `error` values a `prompt=none` request returns when the user would have to interact. + * Each means "not signed in silently", not a failure. + */ +export const SILENT_AUTH_ERRORS: readonly string[] = ["login_required", "consent_required", "interaction_required", "account_selection_required"]; + +/** Whether an error from `handleCallback()` after a `prompt=none` request just means "not signed in". */ +export function isSilentAuthError(error: unknown): boolean { + return error instanceof AuthorizationResponseError && SILENT_AUTH_ERRORS.includes(error.error); +} + +/** The fields a profile's `mapUser` may not override, because they identify the token rather than describe the user. */ +const PROTECTED_USER_FIELDS = ["id", "issuer", "audience", "expiresAt", "issuedAt", "claims", "source", "nonce"] as const; /** - * Fallback seconds between polls, used only when the provider names no interval of its own. - * Matches RFC 8628's recommended default, which the escalation flow reuses. + * Fallback seconds between polls, used only when the provider doesn't provide an interval */ const DEFAULT_POLL_INTERVAL_SECONDS = 5; @@ -49,7 +55,14 @@ const warned = new Set(); const warnOnce = (key: string, message: string) => { if (warned.has(key)) return; warned.add(key); - if (typeof console !== "undefined") console.warn(`[WilsoonID] ${message}`); + if (typeof console !== "undefined") console.warn(`[node-oidc-kit] ${message}`); +}; + +const union = (a: readonly string[], b: readonly string[]): string[] => Array.from(new Set([...a, ...b])); + +const encodeBasic = (id: string, secret: string): string => { + const raw = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`; + return typeof btoa === "function" ? btoa(raw) : Buffer.from(raw).toString("base64"); }; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); @@ -60,7 +73,7 @@ const asStringArray = (value: unknown): string[] => (Array.isArray(value) ? valu const asNumber = (value: unknown): number | undefined => { if (typeof value === "number" && Number.isFinite(value)) return value; - // The provider reads session_version out of Postgres, which can surface as a string. + // Some providers serialise numeric claims as strings. if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value); return undefined; }; @@ -71,25 +84,28 @@ const nowSeconds = () => Math.floor(Date.now() / 1000); * An OIDC relying party: discovery, authorization requests, code exchange, token verification and profile lookup for one registered client. * * Framework-agnostic: it depends on `fetch`, Web Crypto and an {@link AuthStorage} adapter and nothing else - so the same client works in a browser, in Node, and on an edge runtime. + * One rule governs everything below: authorization decisions must come from {@link AuthClient.verifyIdToken}, {@link AuthClient.verifyAccessToken} or {@link AuthClient.resolveSession}. + * Those are the only methods that check a signature against the provider's published keys. Every `*Unsafe` method may return attacker-controlled data. * - * One rule governs everything below: authorization decisions must come from - * {@link AuthClient.verifyIdToken}, {@link AuthClient.verifyAccessToken} or - * {@link AuthClient.verifyPlatformSession}. Those are the only methods that check a - * signature against the provider's published keys. Every `*Unsafe` method returns - * attacker-controlled data by design. + * Provider-specific behaviour comes from {@link AuthConfig.profile}. Use {@link createAuthClient} to get its extensions typed under `client.`. */ export class AuthClient { private discoveryCache: DiscoveryDocument | null = null; private storage: AuthStorage; private keySet: RemoteKeySet; private refreshInFlight = new Map>(); - private sessionCache = new Map(); + private profile?: ProviderProfile; + private context?: ProfileContext; + private extensions: Record = {}; + + /** The names this client stores its tokens and transient values under, from `storagePrefix` or the profile. */ + public readonly storageKeys: Readonly; /** * @param storage Defaults to `localStorage` in the browser. On the server there is no * safe default, so storage-backed calls throw a {@link StorageUnavailableError} naming * the operation instead of failing later with a `TypeError` - pass an adapter. - * @throws {AuthError} If required configuration is missing or the issuer is not a URL. + * @throws {AuthError} If required configuration is missing, the issuer is not a URL, or the profile's name collides with a client member. */ constructor( private config: AuthConfig, @@ -112,6 +128,84 @@ export class AuthClient { this.storage = storage || (typeof window !== "undefined" ? new BrowserStorage() : new UnavailableStorage()); this.keySet = new RemoteKeySet(config.jwks); + + this.profile = config.profile; + const prefix = config.storagePrefix ?? this.profile?.storagePrefix ?? DEFAULT_STORAGE_PREFIX; + // An explicit `storagePrefix` is the app's own choice, so it wins over the profile's exact legacy names. + this.storageKeys = Object.freeze({ ...storageKeysFor(prefix), ...(config.storagePrefix === undefined ? this.profile?.storageKeys : {}) }); + + if (this.profile) this.attachProfile(this.profile); + } + + /** The configured profile's name, or `undefined` when the client runs standards-only. */ + public get profileName(): string | undefined { + return this.profile?.name; + } + + private attachProfile(profile: ProviderProfile): void { + const name = asString(profile.name); + if (!name) throw new AuthError("A provider profile needs a non-empty `name`.", "INVALID_CONFIG"); + if (name in this) throw new AuthError(`The profile name "${name}" collides with an AuthClient member. Choose another name for its namespace.`, "INVALID_CONFIG"); + + const extensions = profile.extend?.(this.profileContext()) ?? {}; + this.extensions = extensions as Record; + Object.defineProperty(this, name, { value: Object.freeze({ ...extensions }), enumerable: false, configurable: false, writable: false }); + } + + /** The context handed to profile hooks, built once per client. */ + private profileContext(): ProfileContext { + if (this.context) return this.context; + + this.context = Object.freeze({ + config: this.config, + client: this, + storage: this.storage, + storageKeys: this.storageKeys, + getEndpoints: () => this.getEndpoints(), + clientFetch: (url: string, init: RequestInit = {}) => this.clientFetch(url, init), + verifyJwt: async (token: string, options: { audience?: string | string[]; typ?: string } = {}) => { + if (!asString(token)) throw new NoTokenError("No token was provided."); + const { jwks_uri } = await this.getEndpoints(); + return (await verifyCompactJwt(this.keySet, token, { + jwksUri: jwks_uri, + issuer: await this.issuerCandidates(), + audience: options.audience ?? this.config.clientId, + clockTolerance: this.clockTolerance(), + typ: options.typ, + })) as Record; + }, + introspect: (token: string) => this.introspectToken(token), + toUser: (claims: Record, details: { source: "id_token" | "access_token"; audience: string }) => this.toAuthenticatedUser(claims, details), + classifyTokenUse: (claims: Record) => this.classifyTokenUse(claims), + warnOnce, + }); + return this.context; + } + + /** Fetch with HTTP Basic client authentication, which keeps the secret out of the body. */ + private clientFetch(url: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + const secret = asString(this.config.clientSecret); + if (secret && !headers.has("Authorization")) headers.set("Authorization", `Basic ${encodeBasic(this.config.clientId, secret)}`); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + return fetch(url, { ...init, headers }); + } + + /** The access token audience: the config's, else the profile's default. */ + private apiAudience(): string | undefined { + return asString(this.config.apiAudience) ?? asString(this.profile?.apiAudience); + } + + /** + * Looks up a profile extension for a 2.x method that moved out of core. + * @throws {AuthError} With code `PROFILE_REQUIRED` when no configured profile provides it. + */ + private extension unknown>(method: string): F { + const fn = this.extensions[method]; + if (typeof fn !== "function") throw new AuthError(`\`${method}()\` is provider-specific and moved out of @wilsoon/auth-core in 3.0.0. ` + "Configure a provider profile that provides it, such as the WilsoonID profile.", "PROFILE_REQUIRED"); + + warnOnce(`forwarder:${method}`, `client.${method}() is deprecated and will be removed in 4.0. Call client.${this.profile?.name}.${method}() on a client made with createAuthClient().`); + return fn as F; } /** Whether this client has a usable storage implementation. */ @@ -121,17 +215,17 @@ export class AuthClient { /** @throws {StorageUnavailableError} If no storage implementation is available. */ public saveTokens(tokens: TokenResponse): void { - this.storage.setItem(TOKEN_KEY, JSON.stringify(tokens)); + this.storage.setItem(this.storageKeys.tokens, JSON.stringify(tokens)); } /** * Reads the persisted token response, or `null` if there is none or it is unusable. * * Never throws on a malformed value: a corrupt or truncated cookie should log the user out, not crash the request. - * Also tolerates a URI-encoded blob, which is how a provider that writes the cookie itself typically stores it. + * Also works with a URI-encoded blob which is how a provider that writes the cookie itself typically stores it. */ public getStoredTokens(): TokenResponse | null { - const raw = this.readStorage(TOKEN_KEY); + const raw = this.readStorage(this.storageKeys.tokens); if (!raw) return null; const parse = (value: string): TokenResponse | null => { @@ -154,14 +248,13 @@ export class AuthClient { } /** - * Removes the tokens and the transient `state`/`nonce`/verifier entries. No-ops when no storage is available. - * + * Removes the tokens and the transient `state`/`nonce`/verifier entries. * Local state only - this does not end the session at the identity provider. Use {@link AuthClient.getLogoutUrl} for that. */ public clearStorage(): void { if (!this.hasStorage()) return; - this.storage.removeItem(TOKEN_KEY); + this.storage.removeItem(this.storageKeys.tokens); this.clearTransientState(); } @@ -222,27 +315,13 @@ export class AuthClient { private clearTransientState(): void { if (!this.hasStorage()) return; - for (const key of [STORAGE_KEYS.state, STORAGE_KEYS.nonce, STORAGE_KEYS.codeVerifier]) { + for (const key of [this.storageKeys.state, this.storageKeys.nonce, this.storageKeys.codeVerifier]) { try { this.storage.removeItem(key); } catch {} } } - /** - * Narrows a raw `role` claim to {@link UserRole} instead of asserting it. - * Absent means least-privileged (`'user'`). Unrecognised values throw. - * - * @throws {ClaimValidationError} If the provider emitted a role outside {@link USER_ROLES}. - */ - private toRole(value: unknown): UserRole { - const raw = asString(value); - if (!raw) return "user"; - if ((USER_ROLES as readonly string[]).includes(raw)) return raw as UserRole; - - throw new ClaimValidationError(`The identity provider issued an unrecognised role "${raw}". Expected one of: ${USER_ROLES.join(", ")}. ` + "Refusing to map it onto a known role - update @wilsoon/auth-core rather than guessing at its privileges."); - } - /** @throws {ClaimValidationError} If the userinfo response carries no subject identifier. */ private toProfileUser(raw: unknown): ProfileUser { if (!isRecord(raw)) throw new ClaimValidationError("The userinfo endpoint returned an unexpected payload."); @@ -260,40 +339,79 @@ export class AuthClient { } /** - * Maps claims that have already passed signature, issuer, audience and expiry checks onto - * {@link AuthenticatedUser}. + * Maps claims that have already passed signature, issuer, audience and expiry checks onto {@link AuthenticatedUser}. + * Standard claims first, then the profile's `mapUser`, then the configured `rolesClaim` / `permissionsClaim`, which win. * - * @throws {ClaimValidationError} If a required claim is missing or the role is unrecognised. + * @throws {ClaimValidationError} If `sub` or `exp` is missing. */ - private toAuthenticatedUser(claims: Record): AuthenticatedUser { - const fields = isRecord(claims.oidc_fields) ? claims.oidc_fields : claims; + private toAuthenticatedUser(claims: Record, details: { source: "id_token" | "access_token"; audience: string } = { source: "id_token", audience: this.config.clientId }): AuthenticatedUser { + const label = details.source === "id_token" ? "ID token" : "access token"; const id = asString(claims.sub); - if (!id) throw new ClaimValidationError("The ID token is missing the `sub` claim."); + if (!id) throw new ClaimValidationError(`The ${label} is missing the \`sub\` claim.`); const expiresAt = asNumber(claims.exp); - if (expiresAt === undefined) throw new ClaimValidationError("The ID token is missing the `exp` claim."); + if (expiresAt === undefined) throw new ClaimValidationError(`The ${label} is missing the \`exp\` claim.`); - return { + const frozen = Object.freeze({ ...claims }); + const base: AuthenticatedUser = { id, - email: asString(fields.email) ?? asString(claims.email), + email: asString(claims.email), emailVerified: typeof claims.email_verified === "boolean" ? claims.email_verified : undefined, - name: asString(fields.name) ?? asString(claims.name), - picture: asString(fields.picture) ?? asString(claims.picture), - role: this.toRole(fields.role ?? claims.role), - authMethods: asStringArray(claims.amr ?? fields.amr), - sessionVersion: asNumber(fields.session_version ?? claims.session_version), + name: asString(claims.name), + picture: asString(claims.picture), + roles: [], + permissions: [], + authMethods: asStringArray(claims.amr), + sid: asString(claims.sid), issuer: asString(claims.iss) ?? "", - audience: this.config.clientId, - source: "id_token", + audience: details.audience, + source: details.source, issuedAt: asNumber(claims.iat), expiresAt, authTime: asNumber(claims.auth_time), nonce: asString(claims.nonce), - claims: Object.freeze({ ...claims }), + claims: frozen, + }; + + // Dropping `undefined` keeps a profile from blanking a standard claim it did not mean to touch. + const extras = Object.fromEntries(Object.entries(this.profile?.mapUser?.(frozen) ?? {}).filter(([key, value]) => value !== undefined && !(PROTECTED_USER_FIELDS as readonly string[]).includes(key))); + + const user = { ...base, ...extras } as AuthenticatedUser; + user.roles = this.config.rolesClaim ? readClaimList(frozen, this.config.rolesClaim) : asStringArray(user.roles); + user.permissions = this.config.permissionsClaim ? readClaimList(frozen, this.config.permissionsClaim) : asStringArray(user.permissions); + return user; + } + + /** Roles and permissions for verified access token claims, read the same way as for a user. */ + private accessTokenGrants(claims: Readonly>): { roles: string[]; permissions: string[] } { + const mapped = this.profile?.mapUser?.(claims) ?? {}; + return { + roles: this.config.rolesClaim ? readClaimList(claims, this.config.rolesClaim) : asStringArray(mapped.roles), + permissions: this.config.permissionsClaim ? readClaimList(claims, this.config.permissionsClaim) : asStringArray(mapped.permissions), }; } + /** + * Adds the roles and permissions a session's own access token carries, since providers often put them only there. + * Only a token that verifies, names this client as `client_id` and has the same subject counts. Anything else leaves the user unchanged. + */ + private async withAccessTokenGrants(user: AuthenticatedUser, accessToken: string | undefined): Promise { + if (!asString(accessToken) || !this.apiAudience()) return user; + + let claims: AccessTokenClaims; + try { + claims = await this.verifyAccessToken(accessToken as string); + } catch (error) { + warnOnce("access-token-grants", `The session's access token did not verify against \`apiAudience\`, so its roles and permissions were not read (${error instanceof Error ? error.message : String(error)}).`); + return user; + } + + if (claims.subject !== user.id || claims.clientId !== this.config.clientId) return user; + + return { ...user, roles: union(user.roles, claims.roles), permissions: union(user.permissions, claims.permissions), sid: user.sid ?? claims.sid }; + } + /** Maps decoded-but-unverified claims onto the legacy user shape. */ private toUnverifiedUser(claims: Record): UnverifiedUser { const fields = isRecord(claims.oidc_fields) ? claims.oidc_fields : claims; @@ -359,9 +477,9 @@ export class AuthClient { if (persist) { if (!this.hasStorage()) throw new StorageUnavailableError("Persisting the authorization request"); - this.storage.setItem(STORAGE_KEYS.state, state); - this.storage.setItem(STORAGE_KEYS.nonce, nonce); - this.storage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier); + this.storage.setItem(this.storageKeys.state, state); + this.storage.setItem(this.storageKeys.nonce, nonce); + this.storage.setItem(this.storageKeys.codeVerifier, codeVerifier); } else if (options.persist === undefined) { warnOnce("authorize-not-persisted", "createAuthorizeUrl() did not persist `state`, `nonce` or the PKCE verifier because no storage is configured. " + "Store all three yourself and pass them to handleCallback({ expected }), or construct the client with a storage implementation."); } @@ -385,6 +503,8 @@ export class AuthClient { const clearTransient = options.clearTransient !== false; + await this.checkResponseIssuer(params, clearTransient); + const providerError = params.get("error"); if (providerError) { if (clearTransient) this.clearTransientState(); @@ -397,9 +517,9 @@ export class AuthClient { if (!options.expected && !this.hasStorage()) throw new StorageUnavailableError("Reading the pending authorization request"); - const expectedState = options.expected?.state ?? this.readStorage(STORAGE_KEYS.state); - const expectedNonce = options.expected?.nonce ?? this.readStorage(STORAGE_KEYS.nonce); - const codeVerifier = options.expected?.codeVerifier ?? this.readStorage(STORAGE_KEYS.codeVerifier); + const expectedState = options.expected?.state ?? this.readStorage(this.storageKeys.state); + const expectedNonce = options.expected?.nonce ?? this.readStorage(this.storageKeys.nonce); + const codeVerifier = options.expected?.codeVerifier ?? this.readStorage(this.storageKeys.codeVerifier); // Throws when absent or mismatched: the CSRF check cannot be skipped on this path. this.validateState(returnedState || "", expectedState || ""); @@ -418,7 +538,7 @@ export class AuthClient { let user: AuthenticatedUser | null = null; if (tokens.id_token) { if (!expectedNonce) warnOnce("callback-without-nonce", "Verifying an ID token without a nonce: the token is not bound to this authorization request. " + "Persist the nonce from createAuthorizeUrl() and pass it via handleCallback({ expected: { nonce } })."); - user = await this.verifyIdToken(tokens.id_token, { nonce: expectedNonce || undefined }); + user = await this.withAccessTokenGrants(await this.verifyIdToken(tokens.id_token, { nonce: expectedNonce || undefined }), tokens.access_token); } if (options.persistTokens) this.saveTokens(tokens); @@ -429,6 +549,97 @@ export class AuthClient { } } + /** + * RFC 9207: an `iss` on the authorization response must be this provider's, which stops a mix-up between providers. + * Required when discovery advertises `authorization_response_iss_parameter_supported`. + */ + private async checkResponseIssuer(params: URLSearchParams, clearTransient: boolean): Promise { + const returned = params.get("iss"); + const discovery = await this.getEndpoints(); + const required = (discovery as unknown as Record).authorization_response_iss_parameter_supported === true; + if (returned === null && !required) return; + + if (returned === null || !(await this.issuerCandidates()).includes(returned)) { + if (clearTransient) this.clearTransientState(); + throw new IssuerMismatchError(this.config.expectedIssuer ?? discovery.issuer, returned ?? "(none)"); + } + } + + /** + * Signs the user in without showing them anything, using an OIDC `prompt=none` request. + * + * In `"iframe"` mode (the default) the request runs in a hidden iframe and resolves with the verified result, or `null` when the user is not signed in at the provider or would have to interact. + * The `redirectUri` must be on this page's origin, and the provider must allow its authorization endpoint to be framed. + * + * @returns The callback result, or `null` when there is no silent session. + * @throws {AuthorizationResponseError} For a provider error other than the {@link SILENT_AUTH_ERRORS}, such as `access_denied`. + * @throws {AuthError} If there is no browser document to run in. + */ + public async silentAuthorize(options: SilentAuthorizeOptions = {}): Promise { + const { mode = "iframe", timeoutMs = DEFAULT_SILENT_TIMEOUT_MS, ...authorize } = options; + + if (typeof window === "undefined" || typeof document === "undefined") throw new AuthError("silentAuthorize() needs a browser. Server-rendered apps keep tokens server-side and refresh them instead.", "SILENT_AUTH_UNAVAILABLE"); + + if (mode === "redirect") { + const { url } = await this.createAuthorizeUrl({ ...authorize, prompt: "none" }); + window.location.assign(url); + return null; + } + + // Not persisted: the values stay in this closure, so a silent attempt cannot clobber an interactive login in progress. + const request = await this.createAuthorizeUrl({ ...authorize, prompt: "none", persist: false }); + const callbackUrl = await this.runInHiddenFrame(request.url, timeoutMs); + if (!callbackUrl) return null; + + try { + return await this.handleCallback(callbackUrl, { expected: { state: request.state, nonce: request.nonce, codeVerifier: request.codeVerifier }, clearTransient: false }); + } catch (error) { + if (isSilentAuthError(error)) return null; + throw error; + } + } + + /** + * Loads `url` in a hidden iframe and returns the URL it lands on once it is back on this origin with a `code` or `error`. + * Resolves `null` on timeout, or as soon as the frame lands somewhere unreadable, which is what a provider page or a framing refusal looks like. + */ + private runInHiddenFrame(url: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const frame = document.createElement("iframe"); + frame.setAttribute("aria-hidden", "true"); + frame.setAttribute("tabindex", "-1"); + frame.title = "Silent sign-in"; + frame.style.cssText = "position:absolute;width:0;height:0;border:0;visibility:hidden"; + + let settled = false; + const finish = (result: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + frame.remove(); + resolve(result); + }; + const timer = setTimeout(() => finish(null), timeoutMs); + + frame.addEventListener("load", () => { + let href: string; + try { + href = frame.contentWindow?.location.href ?? ""; + } catch { + // Cross-origin: the provider rendered a page (it wants interaction) or refused to be framed. + return finish(null); + } + if (!href || href === "about:blank") return; + + const landed = new URL(href); + if (landed.searchParams.has("code") || landed.searchParams.has("error")) finish(href); + }); + + frame.src = url; + document.body.appendChild(frame); + }); + } + /** * Exchanges an authorization code for tokens. * @@ -494,26 +705,24 @@ export class AuthClient { } /** - * Restores browser session state when the tokens live in an HttpOnly cookie. - * - * Calls the userinfo endpoint with `credentials: 'include'` and no `Authorization` header: the browser attaches the session cookie itself, and the provider answers from it. - * This is the only way a browser can learn who it is signed in as when it cannot read the token. - * - * The result carries display claims only and is not sufficient to authorize on - that requires a provider whose userinfo endpoint accepts a cookie-authenticated request and sends CORS credentials headers for your origin. + * Restores a browser session through the profile's non-standard `restoreSession` hook, such as a provider cookie. * + * @deprecated Since 3.0.0 - use {@link AuthClient.silentAuthorize}, which is standard OIDC and returns a verified user. Without a profile that restores sessions this resolves `null`. * @returns The profile, or `null` if there is no usable session. Never throws. */ public async hydrateSession(): Promise { - try { - const { userinfo_endpoint } = await this.getEndpoints(); - const response = await fetch(userinfo_endpoint, { - credentials: "include", - }); - - if (!response.ok) return null; + return this.restoreSession(); + } - const raw = await response.json(); - return this.toProfileUser(raw); + /** The profile's `restoreSession`, or `null` without one. Never throws. @internal */ + public async restoreSession(): Promise { + const restore = this.profile?.restoreSession; + if (!restore) { + warnOnce("restore-without-profile", "hydrateSession() has nothing to restore from: cookie-based restore is provider-specific since 3.0.0. Use silentAuthorize(), or configure a profile."); + return null; + } + try { + return await restore.call(this.profile, this.profileContext()); } catch { return null; } @@ -523,16 +732,17 @@ export class AuthClient { * Builds an RP-initiated logout URL from the provider's advertised `end_session_endpoint`. * Redirect the user agent to it to end the session at the provider, then clear your own storage. * - * @param idToken Sent as `id_token_hint`, so the provider knows which session to end. + * @param idToken Sent as `id_token_hint`, so the provider knows which session to end. Without one, `client_id` is sent and the provider may ask the user to confirm. * @param postLogoutRedirectUri Must be registered with the provider, or it will be ignored. * @throws {LogoutError} If the provider advertises no `end_session_endpoint`. */ - public async getLogoutUrl(idToken: string, postLogoutRedirectUri: string): Promise { + public async getLogoutUrl(idToken: string | null | undefined, postLogoutRedirectUri: string): Promise { const { end_session_endpoint } = await this.getEndpoints(); if (!end_session_endpoint) throw new LogoutError(); const url = new URL(end_session_endpoint); - url.searchParams.set("id_token_hint", idToken); + if (asString(idToken)) url.searchParams.set("id_token_hint", idToken as string); + else url.searchParams.set("client_id", this.config.clientId); url.searchParams.set("post_logout_redirect_uri", postLogoutRedirectUri); return url.toString(); @@ -582,7 +792,7 @@ export class AuthClient { const tokens = await this.readTokenResponse(response, new TokenRefreshError("The token endpoint returned no access token.")); - const persist = options.persist ?? (this.hasStorage() && this.readStorage(TOKEN_KEY) !== null); + const persist = options.persist ?? (this.hasStorage() && this.readStorage(this.storageKeys.tokens) !== null); if (persist) this.saveTokens(tokens); return tokens; @@ -599,8 +809,7 @@ export class AuthClient { * say) rather than an authenticated session, and the two must not be interchangeable. * * @throws {NonceMismatchError} If the token is not bound to the supplied nonce. - * @throws {ClaimValidationError} If a required claim is missing, the role is unrecognised, - * or the token is an event token rather than an ID token. + * @throws {ClaimValidationError} If a required claim is missing, or the token is an event token rather than an ID token. * @throws {DiscoveryError} If the OIDC discovery process fails. */ public async verifyIdToken(idToken: string, options: VerifyIdTokenOptions = {}): Promise { @@ -617,7 +826,7 @@ export class AuthClient { // An ID token never carries `evt`; the provider stamps it on purpose-built tokens like the escalation proof, which are otherwise identical - same issuer, same audience, same algorithm. // Without this, a step-up proof would verify here as a login, which is a much stronger claim than the one it actually makes. const eventType = asString(claims.evt); - if (eventType) throw new ClaimValidationError(`This is a "${eventType}" token, not an ID token. It asserts a single event rather than an ` + "authenticated session, so it cannot stand in for a login. Verify it with the method that matches " + `its type - \`verifyEscalationToken\` for evt: "${ESCALATION_TOKEN_TYPE}".`); + if (eventType) throw new ClaimValidationError(`This is a "${eventType}" token, not an ID token. It asserts a single event rather than an ` + "authenticated session, so it cannot stand in for a login. Verify it with the method that matches " + `its type - the profile's \`verifyEscalationToken\` for evt: "${ESCALATION_TOKEN_TYPE}".`); if (options.nonce !== undefined) { if (!timingSafeEqual(asString(claims.nonce) || "", options.nonce)) throw new NonceMismatchError(); @@ -659,7 +868,7 @@ export class AuthClient { public async verifyAccessToken(accessToken: string, options: VerifyAccessTokenOptions = {}): Promise { if (!asString(accessToken)) throw new NoTokenError("No access token was provided."); - const audience = options.audience ?? this.config.apiAudience; + const audience = options.audience ?? this.apiAudience(); if (!audience || (Array.isArray(audience) && audience.length === 0)) throw new ClaimValidationError("verifyAccessToken requires the audience this resource server accepts. " + "Pass `{ audience }` or set `apiAudience` on AuthConfig - the provider issues access tokens with an " + "audience shared across applications, so it cannot be defaulted safely."); const { jwks_uri } = await this.getEndpoints(); @@ -684,18 +893,23 @@ export class AuthClient { if (missing.length > 0) throw new ClaimValidationError(`The access token is missing required scope(s): ${missing.join(", ")}.`); const audienceClaim = typeof claims.aud === "string" ? [claims.aud] : asStringArray(claims.aud); + const frozen = Object.freeze({ ...claims }); + const grants = tokenUse === "client" ? { roles: [], permissions: [] } : this.accessTokenGrants(frozen); return { subject, tokenUse, clientId: asString(claims.client_id), scopes, + roles: grants.roles, + permissions: grants.permissions, + sid: asString(claims.sid), issuer: asString(claims.iss) || "", audience: audienceClaim, issuedAt: asNumber(claims.iat), expiresAt, jwtId: asString(claims.jti), - claims: Object.freeze({ ...claims }), + claims: frozen, }; } @@ -756,22 +970,6 @@ export class AuthClient { return decodeTokenPayloadUnsafe(token); } - /** - * Decodes the ID token locally to get user data without a network call. - * - * @deprecated Since v2.0.0 - this method never verified the token's signature, issuer, audience or expiry, and it was the only way to reach `role`, `amr` and `session_version`. - * Any check built on its result can be bypassed by generating an unsigned JWT. Use {@link AuthClient.verifyIdToken} for authorization, or {@link AuthClient.decodeIdTokenUnsafe} when you knowingly want display-only claims. - * - * @param idToken The ID token obtained from a previous token exchange. - * @returns The unverified claims in the library's user shape. - * @throws {NoTokenError} If the ID token is invalid or cannot be parsed. - */ - // eslint-disable-next-line @typescript-eslint/naming-convention -- frozen 1.x name - public parseIdToken(idToken: string): UnverifiedUser { - warnOnce("parseIdToken", "parseIdToken() does not verify the ID token - its signature, issuer, audience and expiry are all unchecked, " + "so `role`, `authMethods` and `sessionVersion` from it are attacker-controlled. " + "Use `await client.verifyIdToken(idToken)` for anything that gates access."); - return this.decodeIdTokenUnsafe(idToken); - } - /** * Timing-safe CSRF check on the callback's `state`. An empty value on either side fails. * @@ -802,30 +1000,17 @@ export class AuthClient { } } - /** - * Checks if a JWT token is expired or about to expire. - * - * @deprecated Since v2.0.0 - renamed to {@link AuthClient.isTokenNearExpiry} because the ame read like a security check while parsing an unverified payload. - * Behaviour is otherwise identical, except that a token with no `exp` claim is now reported as expired instead of valid forever. - * - * @param token The JWT string. - * @param offsetSeconds Buffer time (default 60s) to refresh before actual expiry. - * @returns True when the token should be treated as expired. - */ - public isTokenExpired(token: string, offsetSeconds = 60): boolean { - warnOnce("isTokenExpired", "isTokenExpired() is deprecated; use isTokenNearExpiry(). It is a refresh hint, not a security check - real expiry enforcement happens in verifyIdToken()/verifyAccessToken()."); - return this.isTokenNearExpiry(token, offsetSeconds); - } - /** * Resolves the verified user behind a token response, whichever way the deployment is wired. * + * An ID token issued to this client is verified directly, with the session's access token adding its roles and permissions. + * A session holding only another client's tokens goes to the profile's `resolveSharedSession`, such as WilsoonID's shared platform cookie. + * * @param tokens The token response read from your session store. - * @param options Platform-session cache behaviour, when that branch is taken. + * @param options Shared-session cache behaviour, when that branch is taken. * @returns The verified user, safe to authorize on. * @throws {NoTokenError} If there is nothing usable to verify. - * @throws {AuthError} If the cookie belongs to a sibling service but this client is not - * configured for platform sessions. + * @throws {AuthError} If the session belongs to another client and this one cannot resolve it. * @throws {MachineTokenNotAllowedError} If the session holds a `client_credentials` token. * @throws {TokenVerificationError} If verification fails. */ @@ -833,14 +1018,16 @@ export class AuthClient { const idToken = asString(tokens?.id_token); const accessToken = asString(tokens?.access_token); - if (idToken && this.isAddressedToThisClient(idToken)) return this.verifyIdToken(idToken); + if (idToken && this.isAddressedToThisClient(idToken)) return this.withAccessTokenGrants(await this.verifyIdToken(idToken), accessToken); if (accessToken && this.looksLikeMachineToken(accessToken)) throw new MachineTokenNotAllowedError("This session holds a client_credentials token, which represents a client rather than a user, so no " + "session can be resolved from it. Verify machine callers with `verifyMachineToken`."); if (accessToken || idToken) { - if (accessToken && asString(this.config.apiAudience)) return this.verifyPlatformSession(accessToken, options); + const shared = this.profile?.resolveSharedSession; + // Shared sessions are resolved through introspection, which needs a pinned audience and a confidential client. + if (accessToken && shared && this.apiAudience() && asString(this.config.clientSecret)) return shared.call(this.profile, this.profileContext(), accessToken, options); - throw new AuthError("This session was established by another application, and this client is not configured for shared " + "platform sessions. Set `apiAudience` (and `clientSecret`) to resolve them, or give this application " + "its own session cookie.", "FOREIGN_SESSION"); + throw new AuthError("This session was established by another application, and this client cannot resolve shared sessions. " + "That needs a profile that supports them (such as `wilsoon()`), `apiAudience` and a `clientSecret`; " + "otherwise give this application its own session cookie.", "FOREIGN_SESSION"); } throw new NoTokenError("The session holds no tokens to verify."); @@ -861,13 +1048,15 @@ export class AuthClient { } /** - * Decides whether verified claims describe a machine caller, honouring {@link AuthConfig.detectMachineToken}. - * A configured predicate replaces the built-in signals entirely, so a provider that marks machine tokens its own way is not fighting a default that disagrees. + * Decides whether verified claims describe a machine caller. + * {@link AuthConfig.detectMachineToken} wins, then the profile's `isMachineToken`, then RFC 9068's `sub === client_id`. */ private classifyTokenUse(claims: Record): TokenUse { - const detect = this.config.detectMachineToken; + const detect = this.config.detectMachineToken ?? this.profile?.isMachineToken?.bind(this.profile); if (detect) return detect(claims) ? "client" : "user"; - return tokenUseOf(claims); + + const subject = asString(claims.sub); + return subject !== undefined && subject === asString(claims.client_id) ? "client" : "user"; } /** Whether a token *claims* this client's audience. A routing hint, never a trust decision. */ @@ -882,86 +1071,18 @@ export class AuthClient { } /** - * Resolves a verified session from a **platform access token** - the token that rides in the shared, domain-wide session cookie and is addressed to the platform API audience rather than to any one application. - * Requires `apiAudience` and a `clientSecret` (introspection is for confidential clients). + * Resolves a verified session from a platform access token issued to a sibling client. * - * @param accessToken The access token from the shared session. - * @param options Cache behaviour for this call. - * @returns The verified user, safe to authorize on. - * @throws {TokenVerificationError} If the token fails verification, or the session is no - * longer active (revoked, or superseded by a newer one). - * @throws {ClaimValidationError} If no audience is pinned, or a claim is unusable. - * @throws {MachineTokenNotAllowedError} If the token came from the `client_credentials` grant. - * @throws {AuthError} If introspection is unavailable to this client. + * @deprecated Since 3.0.0 - call `client.wilsoon.verifyPlatformSession()` from the `wilsoon()` profile. Removed in 4.0. + * @throws {AuthError} With code `PROFILE_REQUIRED` when no profile provides it. */ public async verifyPlatformSession(accessToken: string, options: PlatformSessionOptions = {}): Promise { - if (!asString(accessToken)) throw new NoTokenError("No access token was provided."); - - const cacheSeconds = options.cacheSeconds ?? this.config.platformSessionCacheSeconds ?? 0; - if (!options.force && cacheSeconds > 0) { - const cached = this.readCachedSession(accessToken); - if (cached) return cached; - } - - const claims = await this.verifyAccessToken(accessToken, { allowMachineTokens: true }); - if (claims.tokenUse === "client") throw new MachineTokenNotAllowedError(`A platform session cannot be resolved from a client_credentials token: it was issued to the client ` + `"${claims.subject}" with no user behind it, so there is no identity to return. Machine callers should ` + "be authorized with `verifyMachineToken` instead of being given a session.", claims.subject); - - const introspection = await this.introspectToken(accessToken); - - if (!introspection.active) throw new TokenVerificationError("The platform session is no longer active: it was revoked, or superseded by a newer session."); - if (introspection.sub && introspection.sub !== claims.subject) throw new ClaimValidationError("The introspection response describes a different subject than the token."); - - const user: AuthenticatedUser = { - id: claims.subject, - email: asString(introspection.email), - emailVerified: typeof introspection.email_verified === "boolean" ? introspection.email_verified : undefined, - name: asString(introspection.name), - picture: asString(introspection.picture), - role: this.toRole(introspection.role), - authMethods: asStringArray(introspection.amr), - sessionVersion: asNumber(introspection.session_version), - issuer: claims.issuer, - audience: claims.audience[0] ?? "", - source: "access_token", - issuedAt: claims.issuedAt, - expiresAt: claims.expiresAt, - authTime: asNumber(introspection.auth_time), - claims: claims.claims, - }; - - if (cacheSeconds > 0) this.cacheSession(accessToken, user, cacheSeconds); - return user; - } - - private readCachedSession(token: string): AuthenticatedUser | undefined { - const entry = this.sessionCache.get(token); - if (!entry) return undefined; - - if (entry.expiresAt <= Date.now()) { - this.sessionCache.delete(token); - return undefined; - } - return entry.user; - } - - private cacheSession(token: string, user: AuthenticatedUser, cacheSeconds: number): void { - // Never cache past the token's own expiry - const expiresAt = Math.min(Date.now() + cacheSeconds * 1000, user.expiresAt * 1000); - if (expiresAt <= Date.now()) return; - - if (this.sessionCache.size >= SESSION_CACHE_MAX_ENTRIES) { - const oldest = this.sessionCache.keys().next(); - if (!oldest.done) this.sessionCache.delete(oldest.value); - } - this.sessionCache.set(token, { user, expiresAt }); + return this.extension<(token: string, options: PlatformSessionOptions) => Promise>("verifyPlatformSession")(accessToken, options); } /** * Calls the provider's RFC 7662 introspection endpoint. * - * Answers whether the token has been revoked, and what the user's `role` and `session_version` are **right now**. - * It is also how a resource server that only ever sees an access token learns the role, since access tokens do not carry one. - * * Requires a confidential client - the provider rejects introspection without a client secret, so never call this from a browser. * * @param token An access token or ID token this client legitimately holds. @@ -995,44 +1116,31 @@ export class AuthClient { } /** - * Checks a verified identity's `session_version` against the user's current version, so that a "sign out everywhere" performed at the identity provider can be honoured before the token's own expiry. + * Asks the provider whether a token may still be used, and with which permissions, right now. + * Uses the profile's `liveAccess` when it has one, otherwise plain introspection, which answers `active` and whatever `permissions` the provider returns. * - * Resolves the live version in one of two ways: - * 1. {@link AuthConfig.resolveSessionVersion}, if configured - useful when your backend already knows the value, or when the client is public; - * 2. otherwise, introspection - pass the token you verified as `options.token`. This needs a confidential client. - * - * It will throw an error rather than assuming the session is still current. - * - * @param options The token to introspect, when relying on introspection. - * @returns True when the token's session version is the current one. - * @throws {SessionCheckUnavailableError} If neither route is available, the token asserts no version, or the live version cannot be determined. - * @throws {MachineTokenNotAllowedError} If `options.token` is a machine token, which has no user and so nothing to revoke against. + * @throws {AuthError} If introspection is unavailable to this client. */ - public async isSessionCurrent(user: Pick, options: { token?: string } = {}): Promise { - if (!user || !asString(user.id)) throw new SessionCheckUnavailableError("A verified user with an `id` is required to check the session version."); - if (typeof user.sessionVersion !== "number") throw new SessionCheckUnavailableError("This token asserts no `session_version`, so revocation cannot be checked."); - - const resolver = this.config.resolveSessionVersion; - if (resolver) { - const current = asNumber(await resolver(user.id)); - if (current === undefined) throw new SessionCheckUnavailableError("`resolveSessionVersion` did not return the current session version."); - - return user.sessionVersion === current; - } - - if (options.token) { - if (this.looksLikeMachineToken(options.token)) throw new MachineTokenNotAllowedError("A client_credentials token has no user and therefore no `session_version` to check. Machine tokens " + "cannot be revoked at all - the grant issues no refresh token - so a short lifetime is the only " + "control. Do not gate machine callers on this check."); + public async liveAccess(token: string): Promise { + const hook = this.profile?.liveAccess; + if (hook) return hook.call(this.profile, this.profileContext(), token); - const introspection = await this.introspectToken(options.token); - if (!introspection.active) return false; - - const current = asNumber(introspection.session_version); - if (current === undefined) throw new SessionCheckUnavailableError("The introspection response carried no `session_version`."); - - return user.sessionVersion === current; - } + const result = await this.introspectToken(token); + return { + active: result.active, + ...(Array.isArray(result.permissions) ? { permissions: asStringArray(result.permissions) } : {}), + ...(asString(result.sid) ? { sid: asString(result.sid) } : {}), + }; + } - throw new SessionCheckUnavailableError(); + /** + * Checks a verified identity's WilsoonID `session_version` against the live one. + * + * @deprecated Since 3.0.0 - call `client.wilsoon.isSessionCurrent()` from the `wilsoon()` profile. Removed in 4.0. + * @throws {AuthError} With code `PROFILE_REQUIRED` when no profile provides it. + */ + public async isSessionCurrent(user: Pick, options: { token?: string } = {}): Promise { + return this.extension<(user: Pick, options: { token?: string }) => Promise>("isSessionCurrent")(user, options); } /** @@ -1209,223 +1317,43 @@ export class AuthClient { } /** - * Where the escalation API lives. - * Assumed rather than discovered - escalation is not an OIDC endpoint, so the discovery document says nothing about it. {@link AuthConfig.escalationEndpoint} overrides. - */ - private escalationEndpoints(): { create: string; poll: string } { - const base = (asString(this.config.escalationEndpoint) || `${normalizeIssuer(this.config.issuer)}/api/escalate`).replace(/\/+$/, ""); - return { create: base, poll: `${base}/poll` }; - } - - /** - * Client authentication for the escalation endpoints, which follow the token endpoint's rule: a client with a registered secret must present it, a public client need not. - */ - private escalationClientAuth(): Record { - const secret = asString(this.config.clientSecret); - return { client_id: this.config.clientId, ...(secret ? { client_secret: secret } : {}) }; - } - - /** - * Creates a method escalation request: asks the provider to require a stronger authentication method from a user who is already signed in. - * This is the back channel. It returns a URL to put in front of the user and a poll token to collect the outcome with - see {@link AuthClient.reauthorize} for the whole exchange as one call, which is what most callers want. - * - * The subject must be named, and how you may name it depends on what kind of client you are: {@link CreateEscalationOptions.idTokenHint} always works and is always preferred, while {@link CreateEscalationOptions.subject} is accepted only from a confidential client. - * Either way the provider only lets a client demand step-up from a user it has already been issued a token for. + * Creates a WilsoonID method escalation request. * - * @param options The methods to require, whether to force a fresh assertion, and the subject. - * @returns The created request, including the deadline the server chose. - * @throws {EscalationError} If the provider refuses the request - an unknown method (`invalid_use`), an unusable `id_token_hint`, or an unknown subject. + * @deprecated Since 3.0.0 - call `client.wilsoon.createEscalation()` from the `wilsoon()` profile. Removed in 4.0. + * @throws {AuthError} With code `PROFILE_REQUIRED` when no profile provides it. */ public async createEscalation(options: CreateEscalationOptions): Promise { - const use = Array.isArray(options.use) ? options.use.join(" ") : asString(options.use); - if (!use) throw new EscalationError("`use` is required: an escalation must say which methods would satisfy it."); - - const idTokenHint = asString(options.idTokenHint); - const subject = asString(options.subject); - if (!idTokenHint && !subject) throw new EscalationError("An escalation must name its subject. Pass `idTokenHint` (preferred, and the only option for a public " + "client) or `subject` from a confidential client."); - - const response = await fetch(this.escalationEndpoints().create, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: new URLSearchParams({ - ...this.escalationClientAuth(), - use, - force: options.force ? "true" : "false", - ...(idTokenHint ? { id_token_hint: idTokenHint } : {}), - ...(!idTokenHint && subject ? { sub: subject } : {}), - ...(options.expiresInSeconds !== undefined ? { expires_in: String(options.expiresInSeconds) } : {}), - }), - }); - - const body = await response.json().catch(() => null); - if (!response.ok || !isRecord(body)) { - const error = isRecord(body) ? asString(body.error) : undefined; - const description = isRecord(body) ? asString(body.error_description) : undefined; - throw new EscalationError(description || `The escalation request was refused (status ${response.status}).`, error); - } - - const escalationId = asString(body.escalation_id); - const escalationUrl = asString(body.escalation_url); - const pollToken = asString(body.poll_token); - if (!escalationId || !escalationUrl || !pollToken) throw new EscalationError("The provider returned an escalation without an id, URL or poll token."); - - return { - escalationId, - escalationUrl, - strategies: asStringArray(body.strategies), - force: body.force === true, - pollToken, - expiresIn: asNumber(body.expires_in) ?? 0, - expiresAt: asNumber(body.expires_at) ?? 0, - interval: asNumber(body.interval) ?? DEFAULT_POLL_INTERVAL_SECONDS, - }; + return this.extension<(options: CreateEscalationOptions) => Promise>("createEscalation")(options); } /** - * Polls an escalation once. + * Polls a WilsoonID escalation once. * - * Returns `pending` while the provider is still waiting, and throws for every terminal outcome - the user refused, or the deadline passed. - * Most callers want {@link AuthClient.reauthorize}, which handles the whole flow to an answer. Use this when you need to control the loop (such as an event loop that cannot block). - * - * @returns `done` with the result, or `pending` with the interval to wait. - * @throws {AuthorizationDeniedError} If the user refused. - * @throws {AuthorizationExpiredError} If the provider says the request expired. - * @throws {EscalationError} If the request is unknown, or the poll token does not match. + * @deprecated Since 3.0.0 - call `client.wilsoon.pollEscalation()` from the `wilsoon()` profile. Removed in 4.0. + * @throws {AuthError} With code `PROFILE_REQUIRED` when no profile provides it. */ public async pollEscalation(request: Pick): Promise> { - const response = await fetch(this.escalationEndpoints().poll, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: new URLSearchParams({ - ...this.escalationClientAuth(), - escalation_id: request.escalationId, - poll_token: request.pollToken, - }), - }); - - const body = await response.json().catch(() => null); - if (!isRecord(body)) throw new EscalationError(`The escalation poll returned an unreadable response (status ${response.status}).`); - - if (!response.ok) { - const error = asString(body.error) || "invalid_grant"; - const description = asString(body.error_description) || "The escalation poll was refused."; - - // The provider raises its own interval on `slow_down`, so the number in the body is authoritative - adopting it is the whole point of the signal. - if (error === AUTHORIZATION_PENDING || error === SLOW_DOWN) { - return { - state: "pending", - interval: asNumber(body.interval) ?? request.interval, - slowDown: error === SLOW_DOWN, - expiresAt: asNumber(body.expires_at), - }; - } - - if (error === EXPIRED_TOKEN) throw new AuthorizationExpiredError(description, asNumber(body.expires_at)); - if (error === ACCESS_DENIED) throw new AuthorizationDeniedError(description, asString(body.reason)); - - throw new EscalationError(description, error); - } - - return { - state: "done", - value: { - escalationId: asString(body.escalation_id) || request.escalationId, - alreadySatisfied: body.already_satisfied === true, - satisfiedBy: asString(body.satisfied_by), - authMethods: asStringArray(body.amr), - acr: asString(body.acr), - authTime: asNumber(body.auth_time), - escalationToken: asString(body.escalation_token), - }, - }; + return this.extension<(request: Pick) => Promise>>("pollEscalation")(request); } /** - * Requires a stronger authentication method from the signed-in user, and waits for the - * answer. + * Requires a stronger authentication method through WilsoonID escalation, and waits for the answer. * - * The whole exchange in one call: create the request, hand the URL to `openUrl`, poll until the provider gives a verdict, and verify the token it returns. - * - * ```ts - * const result = await client.reauthorize(['passkey', 'fido'], true, { - * idTokenHint: tokens.id_token, - * openUrl: (url) => console.log(`Confirm at: ${url}`), - * }); - * if (!result.alreadySatisfied) console.log(`Verified by ${result.satisfiedBy}.`); - * ``` - * - * With `force: false` a session that already qualifies completes on the first poll, and `openUrl` is never called. - * - * The deadline belongs to the server. This keeps polling until the provider says the request expired, rather than deciding for itself that too long has passed. Pass `maxWaitSeconds` only to bound a hung process, and `signal` to cancel. - * - * @param use The methods that would satisfy the requirement. - * @param force Require a fresh assertion even if the session already qualifies. - * @param options The subject, how to show the URL, and polling behaviour. - * @returns The completed escalation. - * @throws {AuthorizationDeniedError} If the user refused. - * @throws {AuthorizationExpiredError} If the request expired before it was completed. - * @throws {EscalationError} If the provider refuses the request, or returns a token that fails verification. + * @deprecated Since 3.0.0 - call `client.wilsoon.reauthorize()` from the `wilsoon()` profile, or use the portable {@link AuthClient.createStepUpRequest}. Removed in 4.0. + * @throws {AuthError} With code `PROFILE_REQUIRED` when no profile provides it. */ public async reauthorize(use: string | string[], force = false, options: Omit = {}): Promise { - const request = await this.createEscalation({ ...options, use, force }); - const first = await this.pollEscalation(request); - - let result: EscalationResult; - if (first.state === "done") { - result = first.value; - } else { - await options.openUrl?.(request.escalationUrl, request); - result = await pollUntilResolved(() => this.pollEscalation(request), first.interval, options); - } - - if (options.verifyToken !== false && result.escalationToken) await this.verifyEscalationToken(result.escalationToken); - - return result; + return this.extension<(use: string | string[], force: boolean, options: Omit) => Promise>("reauthorize")(use, force, options); } /** - * Verifies an escalation token against the provider's JWKS. - * - * Checks the signature, issuer, audience and expiry as any token check would, and one thing besides: that `evt` is `escalation`. - * That claim is the only difference between this and an ID token - same issuer, same audience, same algorithm - so without the check, either would pass where the other belongs, and they authorize very different things. + * Verifies a WilsoonID escalation token. * - * @param escalationToken The token from {@link EscalationResult.escalationToken}. - * @returns The verified outcome, safe to act on and to forward to a resource server. - * @throws {EscalationError} If the token is not an escalation token. - * @throws {TokenVerificationError} If the signature, issuer, audience or expiry fails. + * @deprecated Since 3.0.0 - call `client.wilsoon.verifyEscalationToken()` from the `wilsoon()` profile. Removed in 4.0. + * @throws {AuthError} With code `PROFILE_REQUIRED` when no profile provides it. */ public async verifyEscalationToken(escalationToken: string): Promise { - if (!asString(escalationToken)) throw new NoTokenError("No escalation token was provided."); - - const { jwks_uri } = await this.getEndpoints(); - const claims = (await verifyCompactJwt(this.keySet, escalationToken, { - jwksUri: jwks_uri, - issuer: await this.issuerCandidates(), - audience: this.config.clientId, - clockTolerance: this.clockTolerance(), - })) as Record; - - const eventType = asString(claims.evt); - if (eventType !== ESCALATION_TOKEN_TYPE) throw new EscalationError(`Expected an escalation token (evt: "${ESCALATION_TOKEN_TYPE}") but this one carries evt: ` + `"${eventType || "none"}". An ID token is not proof of a step-up, and refusing it here is what keeps ` + "the two from being interchangeable."); - - const subject = asString(claims.sub); - if (!subject) throw new ClaimValidationError("The escalation token is missing the `sub` claim."); - - const expiresAt = asNumber(claims.exp); - if (expiresAt === undefined) throw new ClaimValidationError("The escalation token is missing the `exp` claim."); - - return { - subject, - satisfiedBy: asString(claims.satisfied_by), - alreadySatisfied: claims.already_satisfied === true, - authMethods: asStringArray(claims.amr), - acr: asString(claims.acr), - authTime: asNumber(claims.auth_time), - issuer: asString(claims.iss) || "", - audience: this.config.clientId, - expiresAt, - claims: Object.freeze({ ...claims }), - }; + return this.extension<(token: string) => Promise>("verifyEscalationToken")(escalationToken); } /** @@ -1465,15 +1393,29 @@ export class AuthClient { } } +/** + * Creates an {@link AuthClient} with the profile's extensions typed under its name, e.g. `client.wilsoon.reauthorize()`. + * `new AuthClient(config)` attaches the same extensions at runtime, just without the types. + * + * ```ts + * const client = createAuthClient({ issuer, clientId, profile: wilsoon() }); + * await client.wilsoon.reauthorize(["passkey"]); + * ``` + */ +export function createAuthClient | undefined = undefined>(config: AuthConfig & { profile?: P }, storage?: AuthStorage): AuthClient & ProfileExtensions

{ + return new AuthClient(config, storage) as AuthClient & ProfileExtensions

; +} + export * from "./amr"; export * from "./challenge"; +export * from "./claims"; export * from "./device"; export * from "./errors"; export * from "./escalation"; export { ALLOWED_ALGORITHMS, decodeTokenHeaderUnsafe, decodeTokenPayloadUnsafe, splitJwt } from "./jwt"; export * from "./machine"; export * from "./polling"; +export * from "./profile"; export * from "./storage"; -export * from "./storage/CookieStorage"; export * from "./types"; export * from "./utils"; diff --git a/packages/auth-core/src/jwt.ts b/packages/auth-core/src/jwt.ts index 91f358a..7ec3eaf 100644 --- a/packages/auth-core/src/jwt.ts +++ b/packages/auth-core/src/jwt.ts @@ -183,6 +183,8 @@ export interface VerifyJwtParams { audience: string | string[]; /** Leeway in seconds for time-based claims. */ clockTolerance: number; + /** The `typ` header to require, when set. */ + typ?: string; } /** @@ -204,6 +206,7 @@ export async function verifyCompactJwt(keySet: RemoteKeySet, token: string, para algorithms: [...ALLOWED_ALGORITHMS], clockTolerance: params.clockTolerance, requiredClaims: ["exp"], + ...(params.typ ? { typ: params.typ } : {}), }; try { diff --git a/packages/auth-core/src/profile.ts b/packages/auth-core/src/profile.ts new file mode 100644 index 0000000..c317403 --- /dev/null +++ b/packages/auth-core/src/profile.ts @@ -0,0 +1,88 @@ +import type { AuthClient } from "./index"; +import type { AuthStorage, StorageKeys } from "./storage"; +import type { AuthConfig, AuthenticatedUser, DiscoveryDocument, IntrospectionResponse, PlatformSessionOptions, ProfileUser, TokenUse } from "./types"; + +/** What a profile's {@link ProviderProfile.mapUser} may add to a verified user, on top of the standard claims core maps itself. */ +export type ProfileUserFields = Partial> & Record; + +/** The live answer to "may this token still be used, and with what", from {@link ProviderProfile.liveAccess} or plain introspection. */ +export interface LiveAccess { + /** Whether the provider still honours the token. */ + active: boolean; + /** What the user holds right now for this client, when the provider says. Absent means the provider did not answer, not that nothing is held. */ + permissions?: string[]; + /** The provider session the token belongs to, when known. */ + sid?: string; +} + +/** Options for {@link ProfileContext.verifyJwt}. */ +export interface ProfileVerifyJwtOptions { + /** The audience to require (default: this client's ID). */ + audience?: string | string[]; + /** The `typ` header to require, e.g. `"logout+jwt"`. Unchecked when omitted. */ + typ?: string; +} + +/** + * What core hands a profile so its extensions can talk to the provider as this client. + * Everything here goes through the same discovery cache, key set and credentials as the client's own standard methods. + */ +export interface ProfileContext { + /** The client's configuration. */ + readonly config: Readonly; + /** The core client, for calling standard methods. */ + readonly client: AuthClient; + /** The client's storage adapter. */ + readonly storage: AuthStorage; + /** The names the client stores its tokens and transient values under. */ + readonly storageKeys: Readonly; + /** The provider's discovery document, cached for the life of the client. */ + getEndpoints(): Promise; + /** Fetch with this client's credentials as HTTP Basic auth. A public client sends no `Authorization` header. */ + clientFetch(url: string, init?: RequestInit): Promise; + /** Verifies a JWT's signature, issuer, audience and expiry against the provider's JWKS, returning its claims. */ + verifyJwt(token: string, options?: ProfileVerifyJwtOptions): Promise>; + /** RFC 7662 introspection with this client's credentials. */ + introspect(token: string): Promise; + /** Maps verified claims onto an {@link AuthenticatedUser} exactly as `verifyIdToken` does, profile `mapUser` included. */ + toUser(claims: Record, details: { source: "id_token" | "access_token"; audience: string }): AuthenticatedUser; + /** Whether verified claims describe a machine caller, honouring `detectMachineToken` and the profile's `isMachineToken`. */ + classifyTokenUse(claims: Record): TokenUse; + /** Logs a warning once per key, under the library's prefix. */ + warnOnce(key: string, message: string): void; +} + +/** + * Adapts the standards-only core to one identity provider: claim mapping, storage names, and extra functions that are in no spec. + * The functions returned by {@link ProviderProfile.extend} are exposed under the profile's {@link ProviderProfile.name} on a client made with `createAuthClient`. + */ +export interface ProviderProfile { + /** The namespace the extensions are exposed under, e.g. `"wilsoon"` gives `client.wilsoon.reauthorize()`. */ + readonly name: Name; + /** Prefix for the storage names, used when the config sets no `storagePrefix`. */ + readonly storagePrefix?: string; + /** Exact storage names, for a provider whose legacy names do not follow ``. Wins over any prefix. */ + readonly storageKeys?: Partial; + /** Default access token audience, used when the config sets no `apiAudience`. */ + readonly apiAudience?: string; + /** Adds provider-specific fields to a verified user. Identity fields (`id`, `issuer`, `audience`, `expiresAt`, `claims`, `source`) cannot be overridden. */ + mapUser?(claims: Readonly>): ProfileUserFields; + /** Recognises the provider's machine tokens. A config `detectMachineToken` wins over this. */ + isMachineToken?(claims: Readonly>): boolean; + /** A non-standard session restore, such as reading a provider cookie. Never throws; `null` means no session. */ + restoreSession?(ctx: ProfileContext): Promise; + /** Resolves a session that holds only an access token issued to another client, such as a shared platform cookie. */ + resolveSharedSession?(ctx: ProfileContext, accessToken: string, options: PlatformSessionOptions): Promise; + /** Live access for a token. Without it, core falls back to plain introspection, which answers `active` only. */ + liveAccess?(ctx: ProfileContext, token: string): Promise; + /** The injected functions. Called once per client. */ + extend?(ctx: ProfileContext): Ext; +} + +/** The namespaced extensions a profile adds to a client: `{ [name]: ReturnType }`, or nothing for an untyped or absent profile. */ +export type ProfileExtensions

= P extends ProviderProfile ? (string extends Name ? unknown : { readonly [K in Name]: Ext }) : unknown; + +/** Identity helper that keeps a profile's `name` as a string literal, so `createAuthClient` can type `client.`. */ +export function defineProfile(profile: ProviderProfile): ProviderProfile { + return profile; +} diff --git a/packages/auth-core/src/storage.ts b/packages/auth-core/src/storage.ts index 00db582..d469a79 100644 --- a/packages/auth-core/src/storage.ts +++ b/packages/auth-core/src/storage.ts @@ -3,26 +3,16 @@ import { StorageUnavailableError } from "./errors"; /** * The library's only persistence dependency: three synchronous string operations. * - * Everything the library persists - the token response, plus the single-use `state`, `nonce` - * and PKCE verifier - is read and written through this interface under the - * `STORAGE_KEYS` names. Supporting a new framework therefore means writing one adapter, - * not touching `AuthClient`. Implementations ship for the browser - * ({@link BrowserStorage}), for tests and single-process flows ({@link MemoryStorage}), - * and for the Next.js cookie store (`ServerCookieStorage` in `@wilsoon/auth-next`). - * + * Everything the library persists - the token response, plus the single-use `state`, `nonce` and PKCE verifier - is read and written through this interface under the `STORAGE_KEYS` names. * The contract an implementation must honour: - * * - **Synchronous.** `AuthClient` calls these inline, so an async store (Redis, a * database) has to be loaded into memory first - see {@link MemoryStorage}. - * - **Round-trips values unchanged.** Token blobs are JSON; a store that encodes on write - * must decode on read. + * - **Round-trips values unchanged.** Token blobs are JSON; a store that encodes on write must decode on read. * - **A missing key returns `null`,** never `undefined`, and never throws. - * - **A write that cannot happen should throw** rather than silently no-op, unless the - * no-op is deliberate (as in the Next.js middleware, where the response owns writes). + * - **A write that cannot happen should throw** rather than silently no-op, unless the no-op is deliberate (as in the Next.js middleware, where the response owns writes). * - * A cookie-backed adapter should also set `HttpOnly`, `Secure` and `SameSite=Lax`. `Lax` - * is specifically what lets the `state`/`nonce`/verifier cookies survive the redirect back - * from the identity provider; `Strict` drops them and every login fails on state mismatch. + * A cookie-backed adapter should also set `HttpOnly`, `Secure` and `SameSite=Lax`. + * `Lax` lets the `state`/`nonce`/verifier cookies survive the redirect back from the identity provider, while `Strict` drops them and every login fails on state mismatch. */ export interface AuthStorage { /** Returns the stored value, or `null` when the key is absent. */ @@ -38,13 +28,10 @@ export interface AuthStorage { /** * Web Storage API adapter, defaulting to `localStorage`. * - * Pass `window.sessionStorage` for the transient login values - `state`, `nonce` and the - * PKCE verifier are single-use and scoped to one login attempt in one tab, so - * `sessionStorage` expires them for free when the tab closes. This is what - * `@wilsoon/auth-react`'s `AuthProvider` does. + * Pass `window.sessionStorage` for the transient login values - `state`, `nonce` and the PKCE verifier are single-use and scoped to one login attempt in one tab. + * `sessionStorage` expires them for free when the tab closes. This is what `@wilsoon/auth-react`'s `AuthProvider` does. * - * Anything kept here is readable by any script on the page, so it is not a safe home for - * tokens on a site that renders untrusted content. + * Anything kept here is readable by any script on the page, so it is not a safe home for tokens on a site that renders untrusted content. */ export class BrowserStorage implements AuthStorage { constructor(private storage: Storage = window.localStorage) {} @@ -65,11 +52,8 @@ export class BrowserStorage implements AuthStorage { /** * An in-process storage implementation. * - * Useful for tests and for single-request server flows where the authorize call and the - * callback are handled by the same client instance. It does **not** survive a process - * restart and is not shared between instances or server processes, so it cannot carry - * `state`/PKCE across a redirect in a multi-instance deployment - use a cookie-backed - * store for that. + * Useful for tests and for single-request server flows where the authorize call and the callback are handled by the same client instance. + * Doesn't survive restarts. */ export class MemoryStorage implements AuthStorage { private store = new Map(); @@ -89,12 +73,8 @@ export class MemoryStorage implements AuthStorage { } /** - * The storage used when no implementation was supplied and none can be inferred (i.e. on - * the server with no `window`). - * + * The storage used when no implementation was supplied and none can be inferred (e.g. on the server with no `window`). * Every operation throws {@link StorageUnavailableError} naming the operation and the fix. - * The previous behaviour - `{} as AuthStorage` - type-checked as valid storage and then - * failed with `TypeError: this.storage.setItem is not a function` far from the mistake. * * @internal */ @@ -116,3 +96,26 @@ export class UnavailableStorage implements AuthStorage { export function isUsableStorage(storage: AuthStorage | undefined | null): boolean { return !!storage && !(storage instanceof UnavailableStorage) && typeof storage.getItem === "function" && typeof storage.setItem === "function" && typeof storage.removeItem === "function"; } + +/** The names the library reads and writes through {@link AuthStorage}. */ +export interface StorageKeys { + /** The persisted token response. Long-lived. */ + tokens: string; + /** The pending authorization request's CSRF state. Single-use. */ + state: string; + /** The pending authorization request's ID token nonce. Single-use. */ + nonce: string; + /** The pending authorization request's PKCE code verifier. Single-use. */ + codeVerifier: string; +} + +/** The storage prefix to use without any config or profile overriding. */ +export const DEFAULT_STORAGE_PREFIX = "oidc_"; + +/** Builds the four storage names from a prefix: `tokens`, `state`, `nonce` and `verifier`. */ +export function storageKeysFor(prefix: string = DEFAULT_STORAGE_PREFIX): StorageKeys { + return { tokens: `${prefix}tokens`, state: `${prefix}state`, nonce: `${prefix}nonce`, codeVerifier: `${prefix}verifier` }; +} + +/** The storage names a client uses by default (`oidc_tokens`, `oidc_state`, `oidc_nonce`, `oidc_verifier`). */ +export const DEFAULT_STORAGE_KEYS: Readonly = Object.freeze(storageKeysFor()); diff --git a/packages/auth-core/src/storage/CookieStorage.ts b/packages/auth-core/src/storage/CookieStorage.ts deleted file mode 100644 index 9b0b123..0000000 --- a/packages/auth-core/src/storage/CookieStorage.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { AuthStorage } from '../storage'; - -/** - * @deprecated Since v1.2.0. Every method is a no-op; the class is kept only so existing - * `new CookieStorage()` calls keep compiling. - * - * It was written when the token cookie was readable from `document.cookie`. The provider - * now sets that cookie `HttpOnly`, which is the correct thing for it to do and also means - * no browser-side cookie adapter can ever see it again. - * - * **Instead:** call `AuthClient.hydrateSession()` on page load - the browser attaches the - * HttpOnly cookie itself on a `credentials: 'include'` request, so the provider can answer - * from it. Use {@link BrowserStorage} when you genuinely want JS-visible client storage, - * and a server-side cookie adapter (`ServerCookieStorage`, or your own - see - * {@link AuthStorage}) when you want to own the cookie yourself. - */ -export class CookieStorage implements AuthStorage { - private static _deprecationWarned = false; - - constructor(private domain?: string) { - if (!CookieStorage._deprecationWarned) { - console.warn( - '[WilsoonID] CookieStorage is deprecated. ' + - 'The auth cookie is now HttpOnly and managed server-side. ' + - 'Use AuthClient.hydrateSession() to load session state on page load.' - ); - CookieStorage._deprecationWarned = true; - } - } - - /** @deprecated Always `null`: an HttpOnly cookie is not visible to `document.cookie`. */ - getItem(_key: string): string | null { - return null; - } - - /** @deprecated No-op: the provider sets the token cookie at its token endpoint. */ - setItem(_key: string, _value: string): void { } - - /** @deprecated No-op: the provider clears the token cookie at its logout endpoint. */ - removeItem(_key: string): void { } -} \ No newline at end of file diff --git a/packages/auth-core/src/types.ts b/packages/auth-core/src/types.ts index 1268b82..cd7243d 100644 --- a/packages/auth-core/src/types.ts +++ b/packages/auth-core/src/types.ts @@ -1,3 +1,6 @@ +import type { ClaimSelector } from "./claims"; +import type { ProviderProfile } from "./profile"; + /** * Everything the library needs to talk to one identity provider as one registered client. * @@ -35,16 +38,14 @@ export interface AuthConfig { * Cookie domain, for adapters and middleware that write cookies. A leading-dot domain * (`.example.com`) shares the session across subdomains. * - * It must match whatever domain the cookie was set with, or clearing it silently fails - * and the user is stuck in a redirect loop. + * @deprecated Since 3.0.0 - core writes no cookies. Set `cookieDomain` on the `@wilsoon/auth-next` middleware options or `ServerCookieStorage` instead. */ cookieDomain?: string; /** * The client secret, for confidential clients. Server-side only - never ship this to a * browser. * - * Required for {@link AuthClient.introspectToken} and therefore for - * {@link AuthClient.verifyPlatformSession}. + * Required for {@link AuthClient.introspectToken}, and for anything a profile builds on it. */ clientSecret?: string; /** @@ -68,13 +69,9 @@ export interface AuthConfig { /** Leeway, in seconds, applied to `exp`/`nbf` checks during verification (default 60). */ clockToleranceSeconds?: number; /** - * How long a resolved platform session may be reused before it is re-checked with the - * provider, in seconds (default 0 - every call re-checks). + * How long a resolved platform session may be reused before it is re-checked with the provider, in seconds (default 0). * - * {@link AuthClient.verifyPlatformSession} makes one introspection request per call, which - * on a shared-cookie platform means one per request. A small window (5–15s) removes that - * cost from hot paths; the trade-off is that a revocation takes up to that long to be - * noticed. Never longer than the token's own expiry. + * @deprecated Since 3.0.0 - pass `platformSessionCacheSeconds` to the WilsoonID profile, which still reads this as a fallback. */ platformSessionCacheSeconds?: number; /** Tuning for the cached remote JSON Web Key Set. */ @@ -82,32 +79,39 @@ export interface AuthConfig { /** * Decides whether verified access token claims describe a machine (`client_credentials`) caller rather than a user. * - * Overrides the built-in check, which recognises `token_use: "client"` and an RFC 9068 `sub` equal to `client_id`. + * Overrides the profile's `isMachineToken` and the built-in check, which recognises an RFC 9068 `sub` equal to `client_id`. * Set this for a provider that marks machine tokens some other way - returning `false` for a token that really is one lets a `client_id` reach code written for users. */ detectMachineToken?: (claims: Readonly>) => boolean; /** * Base URL of the provider's method-escalation API (default `/api/escalate`). * - * Escalation is not part of OIDC discovery, so unlike every other endpoint this one - * cannot be looked up and has to be assumed. The default matches the reference - * provider; set this if yours mounts it elsewhere. The poll endpoint is always - * `/poll`. + * @deprecated Since 3.0.0 - pass `escalationEndpoint` to the WilsoonID profile, which still reads this as a fallback. */ escalationEndpoint?: string; /** - * Resolves the user's current `session_version` from your backend, enabling - * {@link AuthClient.isSessionCurrent}. - * - * The reference provider bumps a per-user session version when the user revokes all - * sessions, but does not expose that value to relying parties outside introspection. - * Supply a resolver - an authenticated call to your own API, a shared cache, a DB read - * - to make revocation enforceable without a confidential client. + * Resolves the user's current `session_version` from your backend. * - * Return `null`/`undefined` when the version cannot be determined; the check then fails - * closed rather than assuming the session is still good. + * @deprecated Since 3.0.0 - pass `resolveSessionVersion` to the WilsoonID profile, which still reads this as a fallback. */ resolveSessionVersion?: (userId: string) => Promise | number | null | undefined; + /** + * Where verified tokens carry the user's roles, e.g. `"roles"` (Entra ID) or `"resource_access.my-app.roles"` (Keycloak). + * Unset means {@link AuthenticatedUser.roles} is empty unless the profile fills it. + */ + rolesClaim?: ClaimSelector; + /** + * Where verified tokens carry the user's permissions, e.g. `"permissions"` (Auth0, WilsoonID). + * Unset means {@link AuthenticatedUser.permissions} is empty unless the profile fills it. + */ + permissionsClaim?: ClaimSelector; + /** + * Prefix for the names the client stores its tokens and transient values under (default `"oidc_"`). + * Changing it on a live app signs every user out, because their stored tokens sit under the old names. + */ + storagePrefix?: string; + /** The provider profile: claim mapping, storage names and provider-specific functions. See `createAuthClient`. */ + profile?: ProviderProfile; } /** Tuning options for the remote JWKS used to verify token signatures. */ @@ -175,19 +179,13 @@ export interface TokenResponse { } /** - * The roles this library models. + * The two legacy WilsoonID roles. * - * Deliberately a closed set: a `role` claim arriving from the network is narrowed against - * {@link USER_ROLES} rather than asserted, so a value the library does not model throws instead - * of being quietly treated as a known one. - * - * This is the library's one hardcoded assumption about the provider's vocabulary. A provider - * that issues other roles needs this union and {@link USER_ROLES} widened together - see - * "Roles" in the repository README. Everything else, `amr` included, compares plain strings. + * @deprecated Since 3.0.0 - core no longer models a provider's role vocabulary. Read {@link AuthenticatedUser.roles} or {@link AuthenticatedUser.permissions} instead. */ export type UserRole = "admin" | "user"; -/** The runtime counterpart of {@link UserRole}, used to validate the claim at the trust boundary. */ +/** @deprecated Since 3.0.0 - see {@link UserRole}. */ export const USER_ROLES: readonly UserRole[] = ["admin", "user"]; /** @@ -218,21 +216,27 @@ export interface ProfileUser { * This is the only type in the library that is safe to authorize on. */ export interface AuthenticatedUser extends ProfileUser { + /** Roles read through {@link AuthConfig.rolesClaim} or the profile. Empty unless one of them is configured. */ + roles: string[]; + /** + * Permissions read through {@link AuthConfig.permissionsClaim} or the profile. Empty unless one of them is configured. + * When the session's access token verifies and was issued to this client, its permissions are merged in, since many providers carry them only there. + */ + permissions: string[]; /** - * The user's role. + * The legacy WilsoonID role, set only by the `wilsoon()` profile. * - * Validated against {@link USER_ROLES} at the trust boundary: an unrecognised value - * throws rather than being asserted into the union, and an absent value falls back to - * the least-privileged role (`'user'`). + * @deprecated Since 3.0.0 - use {@link AuthenticatedUser.roles} or {@link AuthenticatedUser.permissions}. */ - role: UserRole; + role?: UserRole; /** Authentication methods used for this session (the `amr` claim). Empty if absent. */ authMethods: string[]; + /** The provider session this identity belongs to (the `sid` claim), when present. */ + sid?: string; /** - * The session version asserted by the token, used for global revocation. + * The WilsoonID session version, set only by the `wilsoon()` profile. * - * Optional because the provider omits it in edge cases (e.g. a deleted user record). - * {@link AuthClient.isSessionCurrent} fails closed when it is missing. + * @deprecated Since 3.0.0 - provider-specific; use the profile's `isSessionCurrent`. */ sessionVersion?: number; /** The verified `iss` claim. */ @@ -259,7 +263,7 @@ export interface AuthenticatedUser extends ProfileUser { /** * The shape returned by the unverified decode helpers. * - * @deprecated Only for display and debugging. Authorize on {@link AuthenticatedUser}. + * Only for display and debugging - never authorize on it. Authorize on {@link AuthenticatedUser}. */ export interface UnverifiedUser { /** The `id`/`sub` claim as it appeared in the token, if any. */ @@ -278,14 +282,6 @@ export interface UnverifiedUser { sessionVersion?: number; } -/** - * @deprecated Ambiguous: it claimed verified authorization fields for values that the - * userinfo endpoint never returns. Use {@link AuthenticatedUser} for verified identities, - * {@link ProfileUser} for userinfo profiles, or {@link UnverifiedUser} for decoded-only - * claims. - */ -export type User = AuthenticatedUser; - /** * Who an access token represents. * @@ -340,13 +336,19 @@ export interface AccessTokenClaims { subject: string; /** * Who the token represents: a user who authenticated, or a client acting as itself through the `client_credentials` grant. - * Derived from the provider's `token_use` claim, defaulting to `'user'` when absent - the provider stamps `token_use: "client"` only on machine tokens, so a token without it predates the grant or came from a user flow. + * Decided by {@link AuthConfig.detectMachineToken}, then the profile's `isMachineToken`, then RFC 9068's `sub === client_id`; `'user'` otherwise. */ tokenUse: TokenUse; /** The client the token was issued to, when the provider includes it. */ clientId?: string; /** Granted scopes, split from the `scope` claim. */ scopes: string[]; + /** Roles read through {@link AuthConfig.rolesClaim} or the profile. */ + roles: string[]; + /** Permissions read through {@link AuthConfig.permissionsClaim} or the profile. */ + permissions: string[]; + /** The provider session the token belongs to (the `sid` claim), when present. */ + sid?: string; /** The verified `iss` claim. */ issuer: string; /** The verified `aud` claim(s). */ @@ -363,7 +365,7 @@ export interface AccessTokenClaims { /** * An RFC 7662 token introspection response. - * Beyond the RFC's own fields, the reference provider adds `role`, `amr` and - the reason to call it at all - the **live** `session_version`, so a "sign out everywhere" is honoured before the token's own expiry rather than after it. + * Fields past the RFC's own (`role`, `session_version`, `permissions`, `sid`) are provider extensions and may be absent. */ export interface IntrospectionResponse { /** Whether the provider considers the token usable right now. */ @@ -398,6 +400,10 @@ export interface IntrospectionResponse { token_session_version?: number; /** `"client"` when the token came from the `client_credentials` grant; absent otherwise. */ token_use?: string; + /** What the user holds right now for the calling client, when the provider answers live. */ + permissions?: string[]; + /** The provider session the token belongs to. */ + sid?: string; /** Any additional fields the provider returns. */ [claim: string]: unknown; } @@ -405,15 +411,13 @@ export interface IntrospectionResponse { /** Options for {@link AuthClient.verifyIdToken}. */ export interface VerifyIdTokenOptions { /** - * The nonce that was sent on the authorization request. When provided it must match - * the token's `nonce` claim, which is what binds the token to your request. + * The nonce that was sent on the authorization request. When provided it must match the token's `nonce` claim, which is what binds the token to your request. */ nonce?: string; /** Reject the token if `auth_time` is older than this many seconds (step-up checks). */ maxAuthAgeSeconds?: number; /** * Require the token's `acr` claim to be one of these values. - * * Requesting `acr_values` is a demand the provider is free to ignore, and one that ignores it returns a perfectly valid token describing a weaker authentication. Checking here is what turns the request into a guarantee. */ requiredAcr?: string | string[]; @@ -431,7 +435,7 @@ export interface VerifyAccessTokenOptions { /** * Accept a `client_credentials` machine token (default `false`). * A machine token is refused with {@link MachineTokenNotAllowedError}. This fails closed on purpose: a machine token's `sub` is a `client_id`, and an endpoint written for users would otherwise treat it as one. - * Turn it on only where the caller genuinely handles both, and branch on {@link AccessTokenClaims.tokenUse} when it does. + * Turn it on only where the caller genuinely handles both and separate it based on {@link AccessTokenClaims.tokenUse} when it does. */ allowMachineTokens?: boolean; } @@ -455,7 +459,7 @@ export interface AuthorizeUrlOptions { persist?: boolean; /** Overrides the configured scopes for this request. */ scope?: string[]; - /** OIDC `prompt` value. The provider accepts `none`, `reauthenticate` and `consent`. */ + /** OIDC `prompt` value: `none`, `login`, `consent` or `select_account`. Prefer `silentAuthorize()` over `none`. */ prompt?: string; /** OIDC `acr_values`, used to request a stronger authentication context. */ acrValues?: string | string[]; @@ -508,3 +512,14 @@ export interface CallbackResult { /** The validated `state` value. */ state: string; } + +/** Options for {@link AuthClient.silentAuthorize}. */ +export interface SilentAuthorizeOptions extends Omit { + /** + * `"iframe"` (default) runs the request in a hidden iframe and resolves with the result; `"redirect"` navigates the page itself. + * After a redirect, finish with `handleCallback()` and treat {@link isSilentAuthError} as "not signed in". + */ + mode?: "iframe" | "redirect"; + /** How long the iframe may take before the attempt counts as "not signed in", in ms (default 10000). */ + timeoutMs?: number; +} diff --git a/packages/auth-core/test/escalation.test.ts b/packages/auth-core/test/escalation.test.ts deleted file mode 100644 index d754024..0000000 --- a/packages/auth-core/test/escalation.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { - AuthClient, - AuthorizationDeniedError, - AuthorizationExpiredError, - EscalationError, - MemoryStorage, - pollUntilResolved, - TokenVerificationError, - type PollClock, -} from '../src/index'; -import { API_AUDIENCE, CLIENT_ID, OTHER_CLIENT_ID, startFakeIdp, type FakeIdp } from './fake-idp'; - -let idp: FakeIdp; - -const client = (overrides: Record = {}) => new AuthClient({ - clientId: CLIENT_ID, - issuer: idp.issuer, - apiAudience: API_AUDIENCE, - ...overrides, -}, new MemoryStorage()); - -/** Polls resolve instantly, so a test that scripts three rounds still runs in milliseconds. */ -const instantClock: PollClock = { now: () => Date.now(), sleep: () => Promise.resolve() }; - -const pending = (extra: Record = {}) => ({ - status: 400, - body: { error: 'authorization_pending', error_description: 'Not yet.', interval: 1, ...extra }, -}); - -beforeAll(async () => { - idp = await startFakeIdp(); -}); - -afterAll(async () => { - idp.setEscalation({}); - await idp.close(); -}); - -describe('createEscalation', () => { - it('sends the subject as id_token_hint and the methods as a space-delimited `use`', async () => { - idp.setEscalation({}); - const before = idp.escalationRequests.length; - - const request = await client().createEscalation({ - use: ['passkey', 'fido'], - force: true, - idTokenHint: await idp.mintIdToken(), - }); - - const sent = idp.escalationRequests[before]; - expect(sent.get('use')).toBe('passkey fido'); - expect(sent.get('force')).toBe('true'); - expect(sent.get('id_token_hint')).toBeTruthy(); - expect(sent.get('sub')).toBeNull(); - expect(request.escalationId).toBe('esc-1'); - expect(request.pollToken).toBe('poll-secret'); - }); - - it('sends `sub` only when there is no id_token_hint to prefer', async () => { - idp.setEscalation({}); - const before = idp.escalationRequests.length; - - await client({ clientSecret: 'shh' }).createEscalation({ use: 'passkey', subject: 'user-1' }); - - const sent = idp.escalationRequests[before]; - expect(sent.get('sub')).toBe('user-1'); - expect(sent.get('client_secret')).toBe('shh'); - }); - - it('omits the client secret for a public client', async () => { - idp.setEscalation({}); - const before = idp.escalationRequests.length; - - await client().createEscalation({ use: 'passkey', idTokenHint: await idp.mintIdToken() }); - - expect(idp.escalationRequests[before].get('client_secret')).toBeNull(); - }); - - it('refuses to send a request that names no subject', async () => { - await expect(client().createEscalation({ use: 'passkey' })).rejects.toThrow(EscalationError); - }); - - it('refuses to send a request that names no method', async () => { - await expect(client().createEscalation({ use: '', subject: 'user-1' })).rejects.toThrow(EscalationError); - }); - - it("surfaces the provider's error code, so invalid_use is distinguishable", async () => { - idp.setEscalation({ create: { status: 400, body: { error: 'invalid_use', error_description: 'Unknown method "telepathy".' } } }); - - await client().createEscalation({ use: 'telepathy', subject: 'user-1' }).catch((error: EscalationError) => { - expect(error.error).toBe('invalid_use'); - expect(error.message).toContain('telepathy'); - }); - expect.assertions(2); - }); -}); - -describe('pollEscalation', () => { - it('reports pending without throwing, and adopts the interval the provider named', async () => { - idp.setEscalation({ polls: [pending({ interval: 7 })] }); - - const step = await client().pollEscalation({ escalationId: 'esc-1', pollToken: 'poll-secret', interval: 1 }); - - expect(step.state).toBe('pending'); - if (step.state === 'pending') { - expect(step.interval).toBe(7); - expect(step.slowDown).toBe(false); - } - }); - - it('flags slow_down as pending rather than an error, and takes the raised interval', async () => { - idp.setEscalation({ polls: [{ status: 400, body: { error: 'slow_down', interval: 10 } }] }); - - const step = await client().pollEscalation({ escalationId: 'esc-1', pollToken: 'poll-secret', interval: 1 }); - - expect(step.state).toBe('pending'); - if (step.state === 'pending') { - expect(step.slowDown).toBe(true); - expect(step.interval).toBe(10); - } - }); - - it('throws a denial as its own error, carrying the reason', async () => { - idp.setEscalation({ polls: [{ status: 400, body: { error: 'access_denied', error_description: 'The user refused.', reason: 'user_cancelled' } }] }); - - await client().pollEscalation({ escalationId: 'esc-1', pollToken: 'poll-secret', interval: 1 }) - .catch((error: AuthorizationDeniedError) => { - expect(error).toBeInstanceOf(AuthorizationDeniedError); - expect(error.reason).toBe('user_cancelled'); - }); - expect.assertions(2); - }); - - it('throws an expiry as its own error, distinct from a denial', async () => { - idp.setEscalation({ polls: [{ status: 400, body: { error: 'expired_token', error_description: 'Too late.', expires_at: 1700000000 } }] }); - - await client().pollEscalation({ escalationId: 'esc-1', pollToken: 'poll-secret', interval: 1 }) - .catch((error: AuthorizationExpiredError) => { - expect(error).toBeInstanceOf(AuthorizationExpiredError); - expect(error.expiresAt).toBe(1700000000); - }); - expect.assertions(2); - }); - - it('sends the poll token and the escalation id together', async () => { - idp.setEscalation({}); - const before = idp.escalationPolls.length; - - await client().pollEscalation({ escalationId: 'esc-1', pollToken: 'poll-secret', interval: 1 }); - - const sent = idp.escalationPolls[before]; - expect(sent.get('escalation_id')).toBe('esc-1'); - expect(sent.get('poll_token')).toBe('poll-secret'); - }); -}); - -describe('reauthorize', () => { - it('completes without ever showing the user a URL when the session already qualifies', async () => { - idp.setEscalation({ - polls: [{ - body: { - status: 'completed', - escalation_id: 'esc-1', - already_satisfied: true, - satisfied_by: 'passkey', - acr: 'urn:wilsoon:acr:passkey', - escalation_token: await idp.mintEscalationToken({ already_satisfied: true }), - }, - }], - }); - const openUrl = vi.fn(); - - const result = await client().reauthorize(['passkey'], false, { subject: 'user-1', openUrl }); - - expect(result.alreadySatisfied).toBe(true); - expect(result.satisfiedBy).toBe('passkey'); - expect(openUrl).not.toHaveBeenCalled(); - }); - - it('shows the URL once, then polls to completion', async () => { - idp.setEscalation({ - polls: [ - pending(), - pending(), - { - body: { - status: 'completed', - escalation_id: 'esc-1', - already_satisfied: false, - satisfied_by: 'passkey', - amr: ['mfa', 'fido', 'hw'], - acr: 'urn:wilsoon:acr:passkey', - escalation_token: await idp.mintEscalationToken(), - }, - }, - ], - }); - const openUrl = vi.fn(); - const onPending = vi.fn(); - - const result = await client().reauthorize(['passkey'], true, { - subject: 'user-1', - openUrl, - onPending, - }); - - expect(openUrl).toHaveBeenCalledTimes(1); - expect(openUrl.mock.calls[0][0]).toContain('/2fa/escalate'); - expect(result.alreadySatisfied).toBe(false); - expect(result.authMethods).toEqual(['mfa', 'fido', 'hw']); - expect(onPending).toHaveBeenCalled(); - }, 20000); - - it('propagates a denial rather than treating it as "keep waiting"', async () => { - idp.setEscalation({ polls: [{ status: 400, body: { error: 'access_denied', error_description: 'No.' } }] }); - - await expect(client().reauthorize('passkey', true, { subject: 'user-1' })) - .rejects.toThrow(AuthorizationDeniedError); - }); - - it('rejects a completion whose escalation token does not verify', async () => { - idp.setEscalation({ - polls: [{ - body: { - status: 'completed', - escalation_id: 'esc-1', - already_satisfied: true, - // Minted for a different application: a step-up proof addressed elsewhere. - escalation_token: await idp.mintEscalationToken({}, { audience: OTHER_CLIENT_ID }), - }, - }], - }); - - await expect(client().reauthorize('passkey', false, { subject: 'user-1' })) - .rejects.toThrow(TokenVerificationError); - }); - - it('can be told not to verify, for a caller that forwards the token untouched', async () => { - idp.setEscalation({ - polls: [{ - body: { - status: 'completed', - escalation_id: 'esc-1', - already_satisfied: true, - escalation_token: await idp.mintEscalationToken({}, { audience: OTHER_CLIENT_ID }), - }, - }], - }); - - const result = await client().reauthorize('passkey', false, { subject: 'user-1', verifyToken: false }); - expect(result.escalationToken).toBeTruthy(); - }); -}); - -describe('verifyEscalationToken', () => { - it('accepts a genuine escalation token and maps the outcome', async () => { - const verified = await client().verifyEscalationToken(await idp.mintEscalationToken()); - - expect(verified.subject).toBe('user-1'); - expect(verified.satisfiedBy).toBe('passkey'); - expect(verified.acr).toBe('urn:wilsoon:acr:passkey'); - expect(verified.authMethods).toEqual(['mfa', 'fido', 'hw']); - expect(verified.alreadySatisfied).toBe(false); - }); - - it('refuses an ID token, which is otherwise identically shaped', async () => { - await expect(client().verifyEscalationToken(await idp.mintIdToken())) - .rejects.toThrow(EscalationError); - }); - - it('refuses a token minted for another application', async () => { - const elsewhere = await idp.mintEscalationToken({}, { audience: OTHER_CLIENT_ID }); - - await expect(client().verifyEscalationToken(elsewhere)).rejects.toThrow(TokenVerificationError); - }); - - it('refuses an expired token', async () => { - const stale = await idp.mintEscalationToken({}, { expiresInSeconds: -60 }); - - await expect(client().verifyEscalationToken(stale)).rejects.toThrow(TokenVerificationError); - }); - - it('and verifyIdToken refuses an escalation token, so the check runs both ways', async () => { - // The reverse direction. Both tokens are RS256, from this issuer, audienced to this - // client - `evt` is the only thing separating "signed in" from "just proved one - // thing", so a step-up proof must not verify as a login. - await expect(client().verifyIdToken(await idp.mintEscalationToken())) - .rejects.toThrow(/not an ID token/); - }); -}); - -describe('pollUntilResolved', () => { - it('honours the provider interval and never lowers it', async () => { - const waits: number[] = []; - const clock: PollClock = { now: () => 0, sleep: (ms) => { waits.push(ms); return Promise.resolve(); } }; - let attempts = 0; - - const value = await pollUntilResolved( - () => { - attempts += 1; - if (attempts === 1) return Promise.resolve({ state: 'pending', interval: 5, slowDown: false }); - if (attempts === 2) return Promise.resolve({ state: 'pending', interval: 2, slowDown: false }); - return Promise.resolve({ state: 'done', value: 'ok' }); - }, - 1, - {}, - clock - ); - - expect(value).toBe('ok'); - // 5s was requested, then 2s - the loop keeps 5, because backing off is one-way. - expect(waits).toEqual([5000, 5000]); - }); - - it('stops immediately when the signal is already aborted', async () => { - const controller = new AbortController(); - controller.abort(new Error('cancelled')); - - await expect(pollUntilResolved( - () => Promise.resolve({ state: 'pending', interval: 1, slowDown: false }), - 1, - { signal: controller.signal }, - instantClock - )).rejects.toThrow('cancelled'); - }); - - it('gives up on maxWaitSeconds rather than polling a hung request forever', async () => { - let now = 0; - const clock: PollClock = { now: () => now, sleep: (ms) => { now += ms; return Promise.resolve(); } }; - - await expect(pollUntilResolved( - () => Promise.resolve({ state: 'pending', interval: 5, slowDown: false }), - 5, - { maxWaitSeconds: 12 }, - clock - )).rejects.toThrow(/Gave up waiting/); - }); - - it('lets a throwing onPending cancel the loop', async () => { - await expect(pollUntilResolved( - () => Promise.resolve({ state: 'pending', interval: 1, slowDown: false }), - 1, - { onPending: () => { throw new Error('user cancelled'); } }, - instantClock - )).rejects.toThrow('user cancelled'); - }); -}); diff --git a/packages/auth-core/test/flow.test.ts b/packages/auth-core/test/flow.test.ts index 7e7aed0..a38a573 100644 --- a/packages/auth-core/test/flow.test.ts +++ b/packages/auth-core/test/flow.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { AMR, assertAmr, AuthClient, AuthError, AuthorizationResponseError, ClaimValidationError, MemoryStorage, NonceMismatchError, NoTokenError, satisfiesAmr, SessionCheckUnavailableError, StateMismatchError, STORAGE_KEYS, StorageUnavailableError, TokenRefreshError, TokenVerificationError, UserInfoError, generateCodeChallenge, type AuthConfig } from "../src/index"; +import { AMR, assertAmr, AuthClient, AuthError, AuthorizationResponseError, ClaimValidationError, MemoryStorage, NonceMismatchError, NoTokenError, satisfiesAmr, StateMismatchError, DEFAULT_STORAGE_KEYS, StorageUnavailableError, TokenRefreshError, TokenVerificationError, UserInfoError, generateCodeChallenge, type AuthConfig } from "../src/index"; import { API_AUDIENCE, CLIENT_ID, OTHER_CLIENT_ID, attackerKeys, startFakeIdp, unsignedToken, type FakeIdp } from "./fake-idp"; let idp: FakeIdp; @@ -51,9 +51,9 @@ describe("createAuthorizeUrl", () => { const { client, storage } = withStorage(); const request = await client.createAuthorizeUrl(); - expect(storage.getItem(STORAGE_KEYS.state)).toBe(request.state); - expect(storage.getItem(STORAGE_KEYS.nonce)).toBe(request.nonce); - expect(storage.getItem(STORAGE_KEYS.codeVerifier)).toBe(request.codeVerifier); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.state)).toBe(request.state); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.nonce)).toBe(request.nonce); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.codeVerifier)).toBe(request.codeVerifier); }); it("passes through prompt, acr_values and login_hint", async () => { @@ -126,7 +126,8 @@ describe("handleCallback", () => { const result = await client.handleCallback(`http://localhost:3000/callback?code=the-code&state=${request.state}`); - expect(result.user?.role).toBe("admin"); + expect(result.user?.id).toBe("user-1"); + expect(result.user?.roles).toEqual([]); expect(result.user?.authMethods).toContain(AMR.FIDO); expect(result.state).toBe(request.state); expect(result.tokens.access_token).toBeTruthy(); @@ -137,9 +138,9 @@ describe("handleCallback", () => { expect(sent.get("code_verifier")).toBe(request.codeVerifier); // Single-use values are cleared so the callback cannot be replayed. - expect(storage.getItem(STORAGE_KEYS.state)).toBeNull(); - expect(storage.getItem(STORAGE_KEYS.nonce)).toBeNull(); - expect(storage.getItem(STORAGE_KEYS.codeVerifier)).toBeNull(); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.state)).toBeNull(); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.nonce)).toBeNull(); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.codeVerifier)).toBeNull(); }); it("rejects a mismatched state without touching the token endpoint", async () => { @@ -170,7 +171,7 @@ describe("handleCallback", () => { const { client, storage, request } = await startFlow(); await expect(client.handleCallback(`http://localhost:3000/callback?error=access_denied&error_description=User+said+no&state=${request.state}`)).rejects.toThrow(AuthorizationResponseError); - expect(storage.getItem(STORAGE_KEYS.state)).toBeNull(); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.state)).toBeNull(); }); it("requires an authorization code", async () => { @@ -225,7 +226,7 @@ describe("handleCallback", () => { await client.handleCallback(`http://localhost:3000/callback?code=c&state=${request.state}`, { persistTokens: true }); expect(client.getStoredTokens()?.refresh_token).toBe("rotated-refresh-token"); - expect(storage.getItem(STORAGE_KEYS.tokens)).toBeTruthy(); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.tokens)).toBeTruthy(); }); it("accepts URLSearchParams as well as a URL", async () => { @@ -272,7 +273,7 @@ describe("refreshAccessToken", () => { await client.refreshAccessToken("rt-1"); - expect(storage.getItem(STORAGE_KEYS.tokens)).toBeNull(); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.tokens)).toBeNull(); }); it("reports refresh failures and requires a token", async () => { @@ -298,13 +299,9 @@ describe("refreshAccessToken", () => { describe("expiry hints", () => { it("treats a token with no exp claim as expired rather than valid forever", async () => { const { client } = withStorage(); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const eternal = unsignedToken({ sub: "user-1", role: "admin" }); expect(client.isTokenNearExpiry(eternal)).toBe(true); - expect(client.isTokenExpired(eternal)).toBe(true); - - warn.mockRestore(); }); it("treats an unparseable token as expired", () => { @@ -342,12 +339,12 @@ describe("storage handling", () => { expect(client.getStoredTokens()).toEqual({ access_token: "a", refresh_token: "r" }); client.clearStorage(); - expect(storage.getItem(STORAGE_KEYS.tokens)).toBeNull(); + expect(storage.getItem(DEFAULT_STORAGE_KEYS.tokens)).toBeNull(); }); it("reads the URI-encoded cookie form the identity provider writes", () => { const { client, storage } = withStorage(); - storage.setItem(STORAGE_KEYS.tokens, encodeURIComponent(JSON.stringify({ access_token: "a", id_token: "b" }))); + storage.setItem(DEFAULT_STORAGE_KEYS.tokens, encodeURIComponent(JSON.stringify({ access_token: "a", id_token: "b" }))); expect(client.getStoredTokens()).toEqual({ access_token: "a", id_token: "b" }); }); @@ -355,10 +352,10 @@ describe("storage handling", () => { it("returns null for junk in storage rather than throwing", () => { const { client, storage } = withStorage(); - storage.setItem(STORAGE_KEYS.tokens, "not json"); + storage.setItem(DEFAULT_STORAGE_KEYS.tokens, "not json"); expect(client.getStoredTokens()).toBeNull(); - storage.setItem(STORAGE_KEYS.tokens, JSON.stringify({ nothing: true })); + storage.setItem(DEFAULT_STORAGE_KEYS.tokens, JSON.stringify({ nothing: true })); expect(client.getStoredTokens()).toBeNull(); }); }); @@ -396,39 +393,6 @@ describe("profile fetching", () => { }); }); -describe("session_version enforcement", () => { - const verified = async (client: AuthClient, sessionVersion?: number) => client.verifyIdToken(await idp.mintIdToken({ session_version: sessionVersion })); - - it("fails closed when no resolver is configured", async () => { - const { client } = withStorage(); - const user = await verified(client, 3); - - await expect(client.isSessionCurrent(user)).rejects.toThrow(SessionCheckUnavailableError); - }); - - it("compares the token version against the live version", async () => { - const current = { value: 3 as number | null }; - const { client } = withStorage({ resolveSessionVersion: () => current.value }); - const user = await verified(client, 3); - - await expect(client.isSessionCurrent(user)).resolves.toBe(true); - - current.value = 4; // the user revoked all sessions - await expect(client.isSessionCurrent(user)).resolves.toBe(false); - - current.value = null; - await expect(client.isSessionCurrent(user)).rejects.toThrow(SessionCheckUnavailableError); - }); - - it("fails closed when the token asserts no version", async () => { - const { client } = withStorage({ resolveSessionVersion: () => 3 }); - const user = await verified(client, undefined); - - expect(user.sessionVersion).toBeUndefined(); - await expect(client.isSessionCurrent(user)).rejects.toThrow(SessionCheckUnavailableError); - }); -}); - describe("introspection", () => { const confidential = () => { const storage = new MemoryStorage(); @@ -474,173 +438,6 @@ describe("introspection", () => { idp.setIntrospection({ active: true, sub: "user-1", role: "admin", session_version: 3 }); }); - - it("enforces session_version through introspection when no resolver is configured", async () => { - const client = confidential(); - const token = await idp.mintIdToken({ session_version: 3 }); - const user = await client.verifyIdToken(token); - - idp.setIntrospection({ active: true, sub: "user-1", session_version: 3 }); - await expect(client.isSessionCurrent(user, { token })).resolves.toBe(true); - - idp.setIntrospection({ active: true, sub: "user-1", session_version: 4 }); - await expect(client.isSessionCurrent(user, { token })).resolves.toBe(false); - - // The provider itself refuses a token from a superseded session. - idp.setIntrospection({ active: false }); - await expect(client.isSessionCurrent(user, { token })).resolves.toBe(false); - - idp.setIntrospection({ active: true, sub: "user-1", session_version: 3 }); - }); - - it("prefers a configured resolver over introspection", async () => { - const client = new AuthClient(config({ clientSecret: "shh", resolveSessionVersion: () => 9 }), new MemoryStorage()); - const token = await idp.mintIdToken({ session_version: 3 }); - const user = await client.verifyIdToken(token); - const before = idp.introspectionRequests.length; - - await expect(client.isSessionCurrent(user, { token })).resolves.toBe(false); - expect(idp.introspectionRequests.length).toBe(before); - }); - - it("fails closed when introspection cannot answer", async () => { - const client = confidential(); - const token = await idp.mintIdToken({ session_version: 3 }); - const user = await client.verifyIdToken(token); - - idp.setIntrospection({ active: true, sub: "user-1" }); - await expect(client.isSessionCurrent(user, { token })).rejects.toThrow(SessionCheckUnavailableError); - - idp.setIntrospection({ active: true, sub: "user-1", role: "admin", session_version: 3 }); - }); -}); - -describe("platform sessions (shared cookie across services)", () => { - /** A sibling first-party service: different client_id, same platform access token. */ - const sibling = (overrides: Partial = {}) => new AuthClient(config({ clientId: "dash-service", clientSecret: "shh", ...overrides }), new MemoryStorage()); - - const platformIntrospection = (extra: Record = {}) => - idp.setIntrospection({ - active: true, - sub: "user-1", - role: "admin", - amr: ["mfa", "fido", "hw"], - session_version: 3, - name: "Ada Lovelace", - email: "ada@example.com", - email_verified: true, - auth_time: Math.floor(Date.now() / 1000), - ...extra, - }); - - it("resolves a verified user from an access token minted for another service", async () => { - platformIntrospection(); - // The token was issued to `go-service`; `dash-service` receives it via the shared cookie. - const accessToken = await idp.mintAccessToken({ client_id: "go-service" }); - - const user = await sibling().verifyPlatformSession(accessToken); - - expect(user.id).toBe("user-1"); - expect(user.role).toBe("admin"); - expect(user.authMethods).toEqual(["mfa", "fido", "hw"]); - expect(user.sessionVersion).toBe(3); - expect(user.name).toBe("Ada Lovelace"); - expect(user.email).toBe("ada@example.com"); - expect(user.source).toBe("access_token"); - expect(user.audience).toBe(API_AUDIENCE); - }); - - it("marks an ID-token identity with its own source", async () => { - const user = await sibling({ clientId: CLIENT_ID }).verifyIdToken(await idp.mintIdToken()); - - expect(user.source).toBe("id_token"); - }); - - it("refuses a session the provider reports as inactive", async () => { - idp.setIntrospection({ active: false }); - - await expect(sibling().verifyPlatformSession(await idp.mintAccessToken())).rejects.toThrow(/no longer active/); - }); - - it("still rejects a token that fails cryptographic verification", async () => { - platformIntrospection(); - const { privateKey } = await attackerKeys(); - - await expect(sibling().verifyPlatformSession(await idp.mintAccessToken({}, { key: privateKey }))).rejects.toThrow(TokenVerificationError); - await expect(sibling().verifyPlatformSession(await idp.mintAccessToken({}, { expiresInSeconds: -60 }))).rejects.toThrow(TokenVerificationError); - }); - - it("rejects an audience that is not the platform API", async () => { - platformIntrospection(); - const other = await idp.mintAccessToken({}, { audience: "https://someone-elses-api.example.com" }); - - await expect(sibling().verifyPlatformSession(other)).rejects.toThrow(TokenVerificationError); - }); - - it("refuses when introspection disagrees about the subject", async () => { - platformIntrospection({ sub: "somebody-else" }); - - await expect(sibling().verifyPlatformSession(await idp.mintAccessToken())).rejects.toThrow(/different subject/); - }); - - it("requires a confidential client and a pinned audience", async () => { - platformIntrospection(); - const token = await idp.mintAccessToken(); - - await expect(sibling({ clientSecret: undefined }).verifyPlatformSession(token)).rejects.toThrow(/clientSecret/); - await expect(sibling({ apiAudience: undefined }).verifyPlatformSession(token)).rejects.toThrow(ClaimValidationError); - }); - - it("validates the live role rather than asserting it", async () => { - platformIntrospection({ role: "superuser" }); - - await expect(sibling().verifyPlatformSession(await idp.mintAccessToken())).rejects.toThrow(ClaimValidationError); - }); - - it("caches within the configured window and re-checks after it", async () => { - platformIntrospection(); - const client = sibling({ platformSessionCacheSeconds: 60 }); - const token = await idp.mintAccessToken(); - const before = idp.introspectionRequests.length; - - await client.verifyPlatformSession(token); - await client.verifyPlatformSession(token); - await client.verifyPlatformSession(token); - expect(idp.introspectionRequests.length - before).toBe(1); - - // A revocation is picked up once the window passes... - idp.setIntrospection({ active: false }); - await expect(client.verifyPlatformSession(token, { cacheSeconds: 0 })).rejects.toThrow(/no longer active/); - - // ...and `force` bypasses the cache immediately. - platformIntrospection(); - await expect(client.verifyPlatformSession(token, { force: true })).resolves.toBeDefined(); - }); - - it("does not cache when no window is configured", async () => { - platformIntrospection(); - const client = sibling(); - const token = await idp.mintAccessToken(); - const before = idp.introspectionRequests.length; - - await client.verifyPlatformSession(token); - await client.verifyPlatformSession(token); - - expect(idp.introspectionRequests.length - before).toBe(2); - }); - - it("never caches past the token expiry", async () => { - platformIntrospection(); - const client = sibling({ platformSessionCacheSeconds: 3600 }); - const token = await idp.mintAccessToken({}, { expiresInSeconds: 65 }); - const before = idp.introspectionRequests.length; - - await client.verifyPlatformSession(token); - await client.verifyPlatformSession(token); - - // 65s of validity, so the 3600s window is clamped and the entry is still live here. - expect(idp.introspectionRequests.length - before).toBe(1); - }); }); describe("resolveSession (framework-agnostic routing)", () => { @@ -662,31 +459,16 @@ describe("resolveSession (framework-agnostic routing)", () => { expect(idp.introspectionRequests.length).toBe(before); }); - it("falls back to the platform session for a sibling service cookie", async () => { - const user = await dash().resolveSession({ - id_token: await idp.mintIdToken({}, { audience: "go-service" }), - access_token: await idp.mintAccessToken({ client_id: "go-service" }), - }); - - expect(user.source).toBe("access_token"); - expect(user.role).toBe("admin"); - }); - - it("uses the access token when there is no ID token at all", async () => { - const user = await dash().resolveSession({ access_token: await idp.mintAccessToken() }); - - expect(user.source).toBe("access_token"); - }); - - it("explains a foreign session when platform mode is not configured", async () => { - const notConfigured = dash({ apiAudience: undefined, clientSecret: undefined }); + it("explains a foreign session when no profile can resolve shared sessions", async () => { + // Standards-only core has no shared-session mechanism, so even a confidential, pinned client refuses. + const notConfigured = dash(); await expect( notConfigured.resolveSession({ id_token: await idp.mintIdToken({}, { audience: "go-service" }), access_token: await idp.mintAccessToken(), }), - ).rejects.toThrow(/established by another application/); + ).rejects.toMatchObject({ code: "FOREIGN_SESSION" }); }); it("requires something to verify", async () => { diff --git a/packages/auth-core/test/profile.test.ts b/packages/auth-core/test/profile.test.ts new file mode 100644 index 0000000..5677529 --- /dev/null +++ b/packages/auth-core/test/profile.test.ts @@ -0,0 +1,228 @@ +import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { + AuthClient, + ClaimValidationError, + createAuthClient, + DEFAULT_STORAGE_KEYS, + defineProfile, + hasPermission, + hasRole, + IssuerMismatchError, + isSilentAuthError, + AuthorizationResponseError, + MachineTokenNotAllowedError, + MemoryStorage, + readClaimList, + readClaimPath, + requirePermission, + requireRole, + type AuthConfig, + type ProfileContext, +} from '../src/index'; +import { API_AUDIENCE, CLIENT_ID, startFakeIdp, type FakeIdp } from './fake-idp'; + +let idp: FakeIdp; + +const config = (overrides: Partial = {}): AuthConfig => ({ + clientId: CLIENT_ID, + issuer: idp.issuer, + redirectUri: 'http://localhost:3000/callback', + apiAudience: API_AUDIENCE, + ...overrides, +}); + +beforeAll(async () => { + idp = await startFakeIdp(); +}); + +afterAll(async () => { + await idp.close(); +}); + +describe('claim paths and guards', () => { + const claims = { + permissions: ['read:reports', 'write:reports'], + roles: 'admin auditor', + realm_access: { roles: ['offline_access'] }, + resource_access: { 'my-app': { roles: ['editor'] } }, + }; + + it('reads dot paths, arrays and space-delimited strings', () => { + expect(readClaimPath(claims, 'realm_access.roles')).toEqual(['offline_access']); + expect(readClaimPath(claims, 'nope.deeper')).toBeUndefined(); + expect(readClaimList(claims, 'resource_access.my-app.roles')).toEqual(['editor']); + expect(readClaimList(claims, 'roles')).toEqual(['admin', 'auditor']); + expect(readClaimList(claims, 'realm_access')).toEqual([]); + expect(readClaimList(claims, (c) => Object.keys(c))).toEqual(Object.keys(claims)); + }); + + it('treats roles as any-of and permissions as all-of', () => { + const user = { roles: ['editor'], permissions: ['a', 'b'] }; + + expect(hasRole(user, ['admin', 'editor'])).toBe(true); + expect(hasRole(user, 'admin')).toBe(false); + expect(hasPermission(user, ['a', 'b'])).toBe(true); + expect(hasPermission(user, ['a', 'c'])).toBe(false); + // Asking for nothing grants nothing. + expect(hasPermission(user, [])).toBe(false); + expect(hasPermission(null, 'a')).toBe(false); + + expect(() => requireRole(user, 'admin')).toThrow(ClaimValidationError); + expect(() => requirePermission(user, ['a', 'c'])).toThrow(/Missing required permission\(s\): c/); + }); + + it('maps Auth0-style permissions and Keycloak-style roles without any profile', async () => { + const client = new AuthClient(config({ permissionsClaim: 'permissions', rolesClaim: 'resource_access.test-client.roles' }), new MemoryStorage()); + const user = await client.verifyIdToken(await idp.mintIdToken({ permissions: ['read:reports'], resource_access: { 'test-client': { roles: ['editor'] } } })); + + expect(user.permissions).toEqual(['read:reports']); + expect(user.roles).toEqual(['editor']); + + const access = await client.verifyAccessToken(await idp.mintAccessToken({ permissions: ['read:reports'] })); + expect(access.permissions).toEqual(['read:reports']); + }); + + it('merges permissions from the session access token issued to this client', async () => { + const client = new AuthClient(config({ permissionsClaim: 'permissions' }), new MemoryStorage()); + const user = await client.resolveSession({ + id_token: await idp.mintIdToken(), + access_token: await idp.mintAccessToken({ permissions: ['read:reports'] }), + }); + + expect(user.permissions).toEqual(['read:reports']); + }); +}); + +describe('storage names', () => { + it('defaults to neutral oidc_ names', () => { + expect(new AuthClient(config(), new MemoryStorage()).storageKeys).toEqual(DEFAULT_STORAGE_KEYS); + expect(DEFAULT_STORAGE_KEYS).toEqual({ tokens: 'oidc_tokens', state: 'oidc_state', nonce: 'oidc_nonce', codeVerifier: 'oidc_verifier' }); + }); + + it('honours storagePrefix', async () => { + const storage = new MemoryStorage(); + const client = new AuthClient(config({ storagePrefix: 'app_' }), storage); + const request = await client.createAuthorizeUrl(); + + expect(storage.getItem('app_state')).toBe(request.state); + client.saveTokens({ access_token: 'a' }); + expect(storage.getItem('app_tokens')).toBeTruthy(); + }); +}); + +describe('provider profiles', () => { + const acme = (spy = vi.fn()) => defineProfile({ + name: 'acme', + storagePrefix: 'acme_', + mapUser: (claims) => ({ permissions: ['acme.seen'], tier: claims.tier, id: 'hijacked', issuer: 'https://evil.example' }), + isMachineToken: (claims) => claims.kind === 'robot', + extend: (ctx: ProfileContext) => { + spy(ctx); + return { hello: () => `hi from ${ctx.config.clientId}`, endpoints: () => ctx.getEndpoints() }; + }, + }); + + it('namespaces the extensions and types them from the profile', async () => { + const spy = vi.fn(); + const client = createAuthClient({ ...config(), profile: acme(spy) }, new MemoryStorage()); + + expectTypeOf(client.acme.hello).toEqualTypeOf<() => string>(); + expect(client.acme.hello()).toBe(`hi from ${CLIENT_ID}`); + expect((await client.acme.endpoints()).issuer).toBe(idp.issuer); + expect(spy).toHaveBeenCalledTimes(1); + expect(client.storageKeys.tokens).toBe('acme_tokens'); + }); + + it('lets mapUser add fields but never override identity', async () => { + const client = new AuthClient(config({ profile: acme() }), new MemoryStorage()); + const user = await client.verifyIdToken(await idp.mintIdToken({ tier: 'gold' })); + + expect(user.id).toBe('user-1'); + expect(user.issuer).toBe(idp.issuer); + expect(user.permissions).toEqual(['acme.seen']); + expect((user as unknown as { tier: string }).tier).toBe('gold'); + }); + + it('lets a configured claim path win over the profile', async () => { + const client = new AuthClient(config({ profile: acme(), permissionsClaim: 'perms' }), new MemoryStorage()); + const user = await client.verifyIdToken(await idp.mintIdToken({ perms: ['x'] })); + expect(user.permissions).toEqual(['x']); + }); + + it('uses the profile machine detector, and lets detectMachineToken override it', async () => { + const robot = await idp.mintAccessToken({ kind: 'robot' }); + + await expect(new AuthClient(config({ profile: acme() }), new MemoryStorage()).verifyAccessToken(robot)).rejects.toThrow(MachineTokenNotAllowedError); + await expect(new AuthClient(config({ profile: acme(), detectMachineToken: () => false }), new MemoryStorage()).verifyAccessToken(robot)).resolves.toBeDefined(); + }); + + it('refuses a profile name that would shadow a client member', () => { + expect(() => new AuthClient(config({ profile: defineProfile({ name: 'verifyIdToken' }) }), new MemoryStorage())).toThrow(/collides/); + expect(() => new AuthClient(config({ profile: defineProfile({ name: '' }) }), new MemoryStorage())).toThrow(/non-empty/); + }); + + it('explains that a moved 2.x method needs a profile', async () => { + const client = new AuthClient(config(), new MemoryStorage()); + + await expect(client.reauthorize(['passkey'])).rejects.toMatchObject({ code: 'PROFILE_REQUIRED' }); + await expect(client.verifyPlatformSession('x')).rejects.toMatchObject({ code: 'PROFILE_REQUIRED' }); + await expect(client.isSessionCurrent({ id: 'u', sessionVersion: 1 })).rejects.toMatchObject({ code: 'PROFILE_REQUIRED' }); + }); + + it('resolves hydrateSession to null without a profile that restores sessions', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => { }); + await expect(new AuthClient(config(), new MemoryStorage()).hydrateSession()).resolves.toBeNull(); + warn.mockRestore(); + }); + + it('falls back to plain introspection for liveAccess', async () => { + idp.setIntrospection({ active: true, sub: 'user-1', permissions: ['a'], sid: 's1' }); + const client = new AuthClient(config({ clientSecret: 'shh' }), new MemoryStorage()); + + await expect(client.liveAccess(await idp.mintAccessToken())).resolves.toEqual({ active: true, permissions: ['a'], sid: 's1' }); + + idp.setIntrospection({ active: false }); + await expect(client.liveAccess(await idp.mintAccessToken())).resolves.toEqual({ active: false }); + idp.setIntrospection({ active: true, sub: 'user-1', role: 'admin', session_version: 3 }); + }); +}); + +describe('authorization response issuer (RFC 9207)', () => { + const start = async () => { + const client = new AuthClient(config(), new MemoryStorage()); + const request = await client.createAuthorizeUrl(); + idp.setTokenHandler(async () => ({ body: { access_token: await idp.mintAccessToken(), id_token: await idp.mintIdToken({ nonce: request.nonce }) } })); + return { client, request }; + }; + + afterAll(() => idp.setTokenHandler(null)); + + it('accepts the configured issuer', async () => { + const { client, request } = await start(); + const result = await client.handleCallback(`http://localhost:3000/callback?code=c&state=${request.state}&iss=${encodeURIComponent(idp.issuer)}`); + expect(result.user?.id).toBe('user-1'); + }); + + it('refuses a response from another issuer before exchanging the code', async () => { + const { client, request } = await start(); + const before = idp.tokenHits; + + await expect(client.handleCallback(`http://localhost:3000/callback?code=c&state=${request.state}&iss=${encodeURIComponent('https://evil.example')}`)).rejects.toThrow(IssuerMismatchError); + expect(idp.tokenHits).toBe(before); + }); +}); + +describe('isSilentAuthError', () => { + it('reads the prompt=none outcomes as "not signed in", and nothing else', () => { + expect(isSilentAuthError(new AuthorizationResponseError('login_required'))).toBe(true); + expect(isSilentAuthError(new AuthorizationResponseError('account_selection_required'))).toBe(true); + expect(isSilentAuthError(new AuthorizationResponseError('access_denied'))).toBe(false); + expect(isSilentAuthError(new Error('login_required'))).toBe(false); + }); +}); + +describe('silentAuthorize', () => { + it('needs a browser', async () => { + await expect(new AuthClient(config(), new MemoryStorage()).silentAuthorize()).rejects.toMatchObject({ code: 'SILENT_AUTH_UNAVAILABLE' }); + }); +}); diff --git a/packages/auth-core/test/verify.test.ts b/packages/auth-core/test/verify.test.ts index b404886..73cfd00 100644 --- a/packages/auth-core/test/verify.test.ts +++ b/packages/auth-core/test/verify.test.ts @@ -1,5 +1,5 @@ import { SignJWT } from 'jose'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthClient, ClaimValidationError, @@ -36,9 +36,13 @@ describe('verifyIdToken', () => { const user = await client().verifyIdToken(await idp.mintIdToken()); expect(user.id).toBe('user-1'); - expect(user.role).toBe('admin'); expect(user.authMethods).toEqual(['mfa', 'fido', 'hw']); - expect(user.sessionVersion).toBe(3); + // Standards-only: provider vocabulary stays in `claims` until a profile or claim path maps it. + expect(user.roles).toEqual([]); + expect(user.permissions).toEqual([]); + expect(user.role).toBeUndefined(); + expect(user.sessionVersion).toBeUndefined(); + expect(user.claims.role).toBe('admin'); expect(user.audience).toBe(CLIENT_ID); expect(user.issuer).toBe(idp.issuer); expect(user.email).toBe('ada@example.com'); @@ -125,25 +129,6 @@ describe('verifyIdToken', () => { await expect(client().verifyIdToken(await idp.mintIdToken(), { maxAuthAgeSeconds: 300 })).resolves.toBeDefined(); }); - it('validates role at the boundary instead of asserting it', async () => { - await expect(client().verifyIdToken(await idp.mintIdToken({ role: 'superuser' }))).rejects.toThrow(ClaimValidationError); - - const noRole = await client().verifyIdToken(await idp.mintIdToken({ role: undefined })); - expect(noRole.role).toBe('user'); - }); - - it('reads security claims from a nested oidc_fields payload as well as a flat one', async () => { - const nested = await idp.mintIdToken({ - role: undefined, - session_version: undefined, - oidc_fields: { role: 'user', session_version: 7, id: 'user-1' }, - }); - - const user = await client().verifyIdToken(nested); - expect(user.role).toBe('user'); - expect(user.sessionVersion).toBe(7); - }); - it('caches the key set across verifications', async () => { const shared = client(); const before = idp.jwksHits; @@ -241,16 +226,13 @@ describe('discovery', () => { }); describe('unverified decode paths', () => { - it('parseIdToken still decodes, but warns and never claims verification', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => { }); + it('decodeIdTokenUnsafe decodes a forged token without verifying it', () => { const forged = unsignedToken({ sub: 'attacker', role: 'admin', amr: ['mfa'], session_version: 99 }); - const decoded = client().parseIdToken(forged); + const decoded = client().decodeIdTokenUnsafe(forged); expect(decoded.role).toBe('admin'); expect(decoded.authMethods).toEqual(['mfa']); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('does not verify')); - warn.mockRestore(); }); it('decodeIdTokenUnsafe throws on a malformed token', () => { @@ -347,23 +329,11 @@ describe('machine tokens (client_credentials)', () => { await expect(client().verifyMachineToken(elsewhere)).rejects.toThrow(TokenVerificationError); }); - it('refuses to resolve a platform session from a machine token', async () => { - await expect(confidential().verifyPlatformSession(await mintMachineToken())) - .rejects.toThrow(MachineTokenNotAllowedError); - }); - it('refuses to resolve a session from a cookie holding a machine token', async () => { await expect(confidential().resolveSession({ access_token: await mintMachineToken() })) .rejects.toThrow(MachineTokenNotAllowedError); }); - it('refuses a session-version check on a machine token rather than failing as undeterminable', async () => { - await expect(confidential().isSessionCurrent( - { id: MACHINE_CLIENT, sessionVersion: 1 }, - { token: await mintMachineToken() } - )).rejects.toThrow(MachineTokenNotAllowedError); - }); - it('catches a machine token from a provider that emits no token_use', async () => { // RFC 9068 §5: a client_credentials `sub` SHOULD be the client id. Relying on `token_use` // alone failed open here - every machine token read as a user, which is the exact diff --git a/packages/auth-core/tsconfig.test.json b/packages/auth-core/tsconfig.test.json index b3fa782..768ad93 100644 --- a/packages/auth-core/tsconfig.test.json +++ b/packages/auth-core/tsconfig.test.json @@ -3,5 +3,12 @@ "compilerOptions": { "noEmit": true }, - "include": ["src/**/*", "test/**/*"] + "include": [ + "src/**/*", + "test/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] } diff --git a/packages/auth-next/README.md b/packages/auth-next/README.md index 4c02c3f..0afca59 100644 --- a/packages/auth-next/README.md +++ b/packages/auth-next/README.md @@ -8,7 +8,7 @@ Siblings: [`@wilsoon/auth-core`](https://www.npmjs.com/package/@wilsoon/auth-cor ## Features -- **Verified sessions:** `getSession()` verifies the ID token's signature, issuer, audience and expiry against the provider's JWKS on every request, so `user.role` and `user.authMethods` can gate access. +- **Verified sessions:** `getSession()` verifies the ID token's signature, issuer, audience and expiry against the provider's JWKS on every request, so `user.roles`, `user.permissions` and `user.authMethods` can gate access. - **Edge Middleware:** Protect your routes, enforce role/`amr` policies, and refresh tokens at the Edge. - **Server Components:** Access session data securely using `next/headers` without client-side waterfalls. - **Client Components:** Re-exports `@wilsoon/auth-react` so you can use it client-side with `"use client"`. @@ -19,8 +19,8 @@ Siblings: [`@wilsoon/auth-core`](https://www.npmjs.com/package/@wilsoon/auth-cor npm install @wilsoon/auth-next @wilsoon/auth-core @wilsoon/auth-react ``` -Full end-to-end recipes, migration notes and a troubleshooting table live in the -[integration guide](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/INTEGRATION.md). +Full end-to-end recipes and a troubleshooting table live in the +[recipes](https://docs.wilsoon.dev/node-oidc-kit/recipes/nextjs). Migration notes are in the [changelog](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/CHANGELOG.md). ## Basic Usage @@ -77,13 +77,13 @@ export default async function DashboardPage() { return (

Welcome to your Dashboard, {user.email}!

-

Role: {user.role}

+

Roles: {user.roles.join(", ")}

); } ``` -`user` is `null` unless the ID token verified, so `user.role` is a verified claim rather than a decoded one. When a cookie was present but unusable, the reason is in `error`. +`user` is `null` unless the ID token verified, so `user.roles` is a verified claim rather than a decoded one. When a cookie was present but unusable, the reason is in `error`. For a guard clause, `requireSession()` throws instead: diff --git a/packages/auth-next/package.json b/packages/auth-next/package.json index 814b214..fd66189 100644 --- a/packages/auth-next/package.json +++ b/packages/auth-next/package.json @@ -1,6 +1,6 @@ { "name": "@wilsoon/auth-next", - "version": "2.3.0", + "version": "3.0.0", "description": "OpenID Connect for the Next.js App Router: verified sessions, middleware and hardened cookie storage", "keywords": [ "oidc", diff --git a/packages/auth-next/src/client-cache.ts b/packages/auth-next/src/client-cache.ts index 7027068..83cdfd9 100644 --- a/packages/auth-next/src/client-cache.ts +++ b/packages/auth-next/src/client-cache.ts @@ -15,21 +15,27 @@ const fingerprint = (value: string): string => { return (hash >>> 0).toString(36); }; +/** Functions and profiles cannot be serialised, so each distinct object gets a stable id instead. */ +const identities = new WeakMap(); +let nextIdentity = 1; +const identityOf = (value: unknown): string | number => { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return typeof value === 'string' ? value : ''; + let id = identities.get(value); + if (id === undefined) { + id = nextIdentity++; + identities.set(value, id); + } + return `#${id}`; +}; + /** * Returns a client for this configuration, reused across requests. * - * Verification fetches the provider's JWKS and caches the keys *inside* the client, so a fresh - * client per request would refetch the key set on every render. Platform-session results are - * cached in there too. - * - * The key covers every field that changes behaviour, **including a fingerprint of the client - * secret**: keying on a subset means two configurations that differ only in an uncovered field - * silently share a client, and the second caller gets one configured for someone else. - * `resolveSessionVersion` is excluded because a function cannot be fingerprinted; it affects - * only `isSessionCurrent`, never verification. + * Verification fetches the provider's JWKS and caches the keys *inside* the client, so a fresh client per request would refetch the key set on every render. + * The key covers every field that changes behaviour, including a fingerprint of the client secret and the identity of the profile and claim selectors; `resolveSessionVersion` is excluded because it never affects verification. * * @param config The application's authentication configuration. - * @returns A client for that exact configuration. + * @returns A client for that exact configuration, with the profile's extensions attached. */ export function getCachedClient(config: AuthConfig): AuthClient { const key = fingerprint(JSON.stringify([ @@ -43,6 +49,11 @@ export function getCachedClient(config: AuthConfig): AuthClient { config.clockToleranceSeconds ?? '', config.platformSessionCacheSeconds ?? '', config.jwks ?? '', + config.storagePrefix ?? '', + identityOf(config.profile), + identityOf(config.rolesClaim), + identityOf(config.permissionsClaim), + identityOf(config.detectMachineToken), ])); const existing = clients.get(key); diff --git a/packages/auth-next/src/middleware.ts b/packages/auth-next/src/middleware.ts index 64994aa..2d864d0 100644 --- a/packages/auth-next/src/middleware.ts +++ b/packages/auth-next/src/middleware.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { AuthClient, AuthConfig, AuthStorage, satisfiesAmr, STORAGE_KEYS, TokenResponse, UserRole } from "@wilsoon/auth-core"; +import { AuthClient, AuthConfig, AuthError, AuthStorage, hasPermission, hasRole, LiveAccess, satisfiesAmr, TokenResponse } from "@wilsoon/auth-core"; import { getCachedClient } from "./client-cache"; import { isAddressedTo, resolveSessionUser } from "./resolve"; @@ -15,12 +15,24 @@ export interface AuthMiddlewareOptions extends AuthConfig { * request through (default true). */ verify?: boolean; - /** Restrict access to these roles. Requires verification. */ - roles?: UserRole[]; + /** Let through only users holding **any** of these roles. Requires verification. */ + roles?: string[]; + /** Let through only users holding **every** one of these permissions, e.g. `["games_portal.access"]`. Requires verification. */ + permissions?: string[]; + /** + * `"live"` asks the provider on every request whether the session still stands, signing the user out when it does not and guarding `permissions` on what the user holds now (default `"token"`). + * Without it, a revoked permission lands at the next refresh. Needs a `clientSecret`, since it uses introspection. + */ + enforce?: "token" | "live"; + /** + * Domain of the session cookie. A leading-dot domain (`.example.com`) shares the session across subdomains. + * It must match the domain the cookie was set with, or signing out silently fails and the user loops. + */ + cookieDomain?: string; /** Require these authentication methods (`amr`). Requires verification. */ amr?: string[]; /** - * Name of the cookie holding the token response (default `STORAGE_KEYS.tokens`). + * Name of the cookie holding the token response (default: the client's `storageKeys.tokens`, `oidc_tokens` unless a prefix or profile says otherwise). * * Set this when the provider writes the session cookie under a name of its own. Only * the token blob is remapped; the transient `state`/`nonce`/verifier cookies keep their @@ -53,12 +65,13 @@ const REFRESH_FLOOR_SECONDS = 30; class RequestCookieStorage implements AuthStorage { constructor( private request: NextRequest, + private tokenKey: string, private tokenCookieName: string, ) {} getItem(key: string): string | null { // The token blob may live under a custom cookie name; the transient keys do not. - const name = key === STORAGE_KEYS.tokens ? this.tokenCookieName : key; + const name = key === this.tokenKey ? this.tokenCookieName : key; return this.request.cookies.get(name)?.value ?? null; } @@ -75,7 +88,7 @@ class RequestCookieStorage implements AuthStorage { * that are about to expire. * * Per request, in order: read the session cookie, refresh if the access token is near expiry (writing the rotated tokens back onto the response) - * then verify, and check the `roles`/`amr` policy if one was given. A session that cannot be verified redirects to `loginPath` and expires the cookie, so a request never proceeds on one. + * then verify, with `enforce: "live"` ask the provider whether the session still stands, and check the `roles`/`permissions`/`amr` policy if one was given. A session that cannot be verified redirects to `loginPath` and expires the cookie, so a request never proceeds on one. * * Refreshes are attempted `refreshThresholdSeconds` before expiry and, by default, only on navigations - one per page load rather than one per parallel fetch, all of them presenting the same single-use refresh token. * A refresh that fails while the access token is still valid is ignored. @@ -83,21 +96,26 @@ class RequestCookieStorage implements AuthStorage { * Scope it with a `config.matcher` as usual - it runs on every matched request, and verification is not free even with the JWKS cached. * * ```ts - * export default createAuthMiddleware({ ...authConfig, roles: ['admin'] }); + * export default createAuthMiddleware({ ...authConfig, permissions: ['games_portal.admin'], enforce: 'live' }); * export const config = { matcher: ['/admin/:path*'] }; * ``` */ export function createAuthMiddleware(options: AuthMiddlewareOptions) { const loginPath = options.loginPath ?? "/auth"; const unauthorizedPath = options.unauthorizedPath ?? loginPath; - const cookieName = options.cookieName ?? STORAGE_KEYS.tokens; const shouldVerify = options.verify !== false; const refreshThreshold = options.refreshThresholdSeconds ?? 300; const refreshOn = options.refreshOn ?? "navigation"; + const live = options.enforce === "live"; + + if (live && !shouldVerify) throw new AuthError('`enforce: "live"` needs verification; it cannot be combined with `verify: false`.', "INVALID_CONFIG"); + if (live && !options.clientSecret) throw new AuthError('`enforce: "live"` asks the provider through introspection, which needs a `clientSecret`.', "INVALID_CONFIG"); // Cached per configuration: the JWKS is then fetched once per runtime instance, not once // per request. const sharedClient = getCachedClient(options); + const tokenKey = sharedClient.storageKeys.tokens; + const cookieName = options.cookieName ?? tokenKey; return async (request: NextRequest) => { const redirectTo = (pathname: string) => { @@ -108,6 +126,20 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions) { return url; }; + // The provider could not be asked. Failing closed without clearing the cookie keeps an outage from signing everyone out. + const unavailable = () => new NextResponse("Could not confirm access with the identity provider.", { status: 503, headers: { "Retry-After": "5" } }); + + /** `enforce: "live"`: the provider's answer, or a response to return when there is none to go on. */ + const askLive = async (): Promise => { + let access: LiveAccess; + try { + access = await sharedClient.liveAccess(current.access_token); + } catch { + return unavailable(); + } + return access.active ? access : signOut(loginPath); + }; + const signOut = (pathname: string) => { const response = NextResponse.redirect(redirectTo(pathname)); @@ -126,7 +158,7 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions) { // Parsing goes through the library, which tolerates the URI-encoded form the identity // provider writes and returns null instead of throwing on a malformed cookie. - const reader = new AuthClient(options, new RequestCookieStorage(request, cookieName)); + const reader = new AuthClient(options, new RequestCookieStorage(request, tokenKey, cookieName)); const tokens = reader.getStoredTokens(); if (!tokens) { @@ -170,15 +202,26 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions) { } if (shouldVerify) { - const needsClaims = !!(options.roles || options.amr); + const needsClaims = !!(options.roles || options.permissions || options.amr); + let access: LiveAccess | null = null; try { if (needsClaims) { - // Resolves through the ID token when it is ours, or the shared platform access token + introspection when it belongs to a sibling service. + // Resolves through the ID token when it is ours, or through the profile when the session belongs to a sibling service. const { user, error } = await resolveSessionUser(sharedClient, current); if (!user) throw error ?? new Error("no session"); - if (options.roles && !options.roles.includes(user.role)) { + if (live) { + const answer = await askLive(); + if (answer instanceof NextResponse) return answer; + access = answer; + } + + if (options.roles && !hasRole(user, options.roles)) { + return NextResponse.redirect(redirectTo(unauthorizedPath)); + } + // The live answer replaces the token's snapshot when the provider gave one. + if (options.permissions && !hasPermission({ permissions: access?.permissions ?? user.permissions }, options.permissions)) { return NextResponse.redirect(redirectTo(unauthorizedPath)); } if (options.amr && !satisfiesAmr(user, options.amr)) { @@ -195,6 +238,11 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions) { } catch { return signOut(loginPath); } + + if (live && !needsClaims) { + const answer = await askLive(); + if (answer instanceof NextResponse) return answer; + } } return response ?? NextResponse.next(); diff --git a/packages/auth-next/src/server.ts b/packages/auth-next/src/server.ts index 4ac5879..78618e6 100644 --- a/packages/auth-next/src/server.ts +++ b/packages/auth-next/src/server.ts @@ -5,6 +5,8 @@ import { AuthConfig, AuthenticatedUser, AuthError, + hasPermission, + hasRole, TokenResponse, } from '@wilsoon/auth-core'; import { getCachedClient } from './client-cache'; @@ -17,7 +19,7 @@ export interface Session { * The verified user, or `null` when there is no valid session. * * Populated only after the ID token's signature, issuer, audience and expiry have been - * checked against the provider's JWKS, so `role` and `authMethods` on it can be used to + * checked against the provider's JWKS, so `roles`, `permissions` and `authMethods` on it can be used to * authorize. */ user: AuthenticatedUser | null; @@ -37,12 +39,12 @@ export interface GetSessionOptions { * Retrieves the current session from cookies in a Server Component or Route Handler. * * The ID token in the cookie is **verified** against the identity provider's published keys - * before its claims are returned, so the `role` and `authMethods` on the result are safe to + * before its claims are returned, so the `roles`, `permissions` and `authMethods` on the result are safe to * gate access with: * * ```ts * const { user } = await getSession(authConfig); - * if (!user || user.role !== 'admin') return notFound(); + * if (!user || !hasPermission(user, 'games_portal.admin')) return notFound(); * ``` * * Fails closed: any verification failure yields `user: null`, with the reason in `error` @@ -74,19 +76,23 @@ export async function getSession(config: AuthConfig, options: GetSessionOptions * underlying verification failure) so an error boundary can tell "sign in" apart from * "you may not see this". * - * @param policy Roles and authentication methods the session must satisfy. + * @param policy Roles (any of), permissions (all of) and authentication methods the session must satisfy. * @throws {AuthError} If there is no valid session, or it does not satisfy the policy. */ export async function requireSession( config: AuthConfig, - policy: { roles?: AuthenticatedUser['role'][]; amr?: string[] } = {} + policy: { roles?: string[]; permissions?: string[]; amr?: string[] } = {} ): Promise { const { user, error } = await getSession(config); if (!user) throw error ?? new AuthError('No authenticated session.', 'NO_SESSION'); - if (policy.roles && !policy.roles.includes(user.role)) { - throw new AuthError(`Role "${user.role}" is not permitted here.`, 'FORBIDDEN'); + if (policy.roles && !hasRole(user, policy.roles)) { + throw new AuthError(`One of the roles [${policy.roles.join(', ')}] is required here.`, 'FORBIDDEN'); + } + + if (policy.permissions && !hasPermission(user, policy.permissions)) { + throw new AuthError(`The permission(s) [${policy.permissions.join(', ')}] are required here.`, 'FORBIDDEN'); } if (policy.amr) assertAmr(user, policy.amr); diff --git a/packages/auth-next/src/storage/ServerCookieStorage.ts b/packages/auth-next/src/storage/ServerCookieStorage.ts index afb28fe..3efe99c 100644 --- a/packages/auth-next/src/storage/ServerCookieStorage.ts +++ b/packages/auth-next/src/storage/ServerCookieStorage.ts @@ -1,4 +1,4 @@ -import { AuthStorage, STORAGE_KEYS, StorageUnavailableError } from '@wilsoon/auth-core'; +import { AuthStorage, StorageKeys, StorageUnavailableError } from '@wilsoon/auth-core'; /** Cookie attributes used when this storage writes. */ export interface ServerCookieStorageOptions { @@ -14,9 +14,12 @@ export interface ServerCookieStorageOptions { transientMaxAgeSeconds?: number; /** Lifetime of the token cookie (default 1 year, matching the identity provider). */ tokenMaxAgeSeconds?: number; + /** The client's storage names (`client.storageKeys`), for a token key that does not end in `tokens`. */ + storageKeys?: Partial; } -const TRANSIENT_KEYS: string[] = [STORAGE_KEYS.state, STORAGE_KEYS.nonce, STORAGE_KEYS.codeVerifier]; +/** The token blob is the only long-lived value: `oidc_tokens`, `wilsoon_id_tokens`, `tokens`. */ +const isTokenKey = (key: string, keys?: Partial): boolean => (keys?.tokens ? key === keys.tokens : key.endsWith('tokens')); /** * An {@link AuthStorage} backed by the Next.js cookie store. @@ -40,7 +43,7 @@ export class ServerCookieStorage implements AuthStorage { /** @throws {StorageUnavailableError} If this context cannot set cookies. */ setItem(key: string, value: string): void { - const isTransient = TRANSIENT_KEYS.includes(key); + const isTransient = !isTokenKey(key, this.options.storageKeys); try { this.cookieStore.set({ diff --git a/packages/auth-next/test/middleware.test.ts b/packages/auth-next/test/middleware.test.ts index d9a6fcc..abd1752 100644 --- a/packages/auth-next/test/middleware.test.ts +++ b/packages/auth-next/test/middleware.test.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { STORAGE_KEYS, type AuthConfig } from '@wilsoon/auth-core'; +import { DEFAULT_STORAGE_KEYS as STORAGE_KEYS, type AuthConfig } from '@wilsoon/auth-core'; +import { sharedSessions } from './shared-profile'; import { createAuthMiddleware, type AuthMiddlewareOptions } from '../src/middleware'; import { API_AUDIENCE, CLIENT_ID, startFakeIdp, unsignedToken, type FakeIdp } from '../../auth-core/test/fake-idp'; @@ -10,6 +11,7 @@ const config = (): AuthConfig => ({ clientId: CLIENT_ID, issuer: idp.issuer, redirectUri: 'http://localhost:3000/callback', + rolesClaim: 'role', }); const middlewareFor = (overrides: Partial = {}) => @@ -284,6 +286,7 @@ describe('createAuthMiddleware', () => { clientId: 'dash-service', apiAudience: API_AUDIENCE, clientSecret: 'shh', + profile: sharedSessions, roles: ['admin'], unauthorizedPath: '/unauthorized', }); @@ -314,6 +317,68 @@ describe('createAuthMiddleware', () => { expect(locationOf(response)?.pathname).toBe('/auth'); }); + it('enforces a permission policy from the session access token', async () => { + const middleware = middlewareFor({ apiAudience: API_AUDIENCE, permissionsClaim: 'permissions', permissions: ['games_portal.access'], unauthorizedPath: '/unauthorized' }); + + const allowed = await middleware(requestWith({ access_token: await idp.mintAccessToken({ permissions: ['games_portal.access'] }), id_token: await idp.mintIdToken() })); + expect(allowed.headers.get('location')).toBeNull(); + + const denied = await middleware(requestWith({ access_token: await idp.mintAccessToken({ permissions: [] }), id_token: await idp.mintIdToken() })); + expect(locationOf(denied)?.pathname).toBe('/unauthorized'); + }); + + describe('enforce: "live"', () => { + const live = (overrides: Partial = {}) => middlewareFor({ apiAudience: API_AUDIENCE, clientSecret: 'shh', permissionsClaim: 'permissions', enforce: 'live', unauthorizedPath: '/unauthorized', ...overrides }); + const session = async (permissions: string[] = ['games_portal.access']) => requestWith({ access_token: await idp.mintAccessToken({ permissions }), id_token: await idp.mintIdToken() }); + + afterEach(() => idp.setIntrospection({ active: true, sub: 'user-1', role: 'admin', session_version: 3 })); + + it('lets a session through while the provider still honours it', async () => { + idp.setIntrospection({ active: true, sub: 'user-1', permissions: ['games_portal.access'] }); + const before = idp.introspectionRequests.length; + + const response = await live()(await session()); + + expect(response.headers.get('location')).toBeNull(); + expect(idp.introspectionRequests.length - before).toBe(1); + }); + + it('signs the user out at once when the provider says the session is gone', async () => { + idp.setIntrospection({ active: false }); + + const response = await live({ permissions: ['games_portal.access'] })(await session()); + + expect(locationOf(response)?.pathname).toBe('/auth'); + expect(response.cookies.get(STORAGE_KEYS.tokens)?.value).toBe(''); + }); + + it('guards on the live permissions, not the token snapshot', async () => { + // The token still says `admin`; the provider says it was taken away a moment ago. + idp.setIntrospection({ active: true, sub: 'user-1', permissions: ['games_portal.access'] }); + const revoked = await live({ permissions: ['games_portal.admin'] })(await session(['games_portal.access', 'games_portal.admin'])); + expect(locationOf(revoked)?.pathname).toBe('/unauthorized'); + + // And the reverse: granted since the token was issued. + idp.setIntrospection({ active: true, sub: 'user-1', permissions: ['games_portal.access', 'games_portal.admin'] }); + const granted = await live({ permissions: ['games_portal.admin'] })(await session(['games_portal.access'])); + expect(granted.headers.get('location')).toBeNull(); + }); + + it('answers 503 without clearing the cookie when the provider cannot be asked', async () => { + idp.setIntrospection({ error: 'server_error' }, { status: 500 }); + + const response = await live({ permissions: ['games_portal.access'] })(await session()); + + expect(response.status).toBe(503); + expect(response.cookies.get(STORAGE_KEYS.tokens)).toBeUndefined(); + }); + + it('refuses a configuration that cannot work', () => { + expect(() => middlewareFor({ enforce: 'live' })).toThrow(/clientSecret/); + expect(() => middlewareFor({ enforce: 'live', clientSecret: 'shh', verify: false })).toThrow(/verify: false/); + }); + }); + it('with verify:false, routes on cookie presence only', async () => { const middleware = middlewareFor({ verify: false }); diff --git a/packages/auth-next/test/server.test.ts b/packages/auth-next/test/server.test.ts index adcbd3b..91c624d 100644 --- a/packages/auth-next/test/server.test.ts +++ b/packages/auth-next/test/server.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { AuthError, STORAGE_KEYS, type AuthConfig } from '@wilsoon/auth-core'; +import { AuthError, DEFAULT_STORAGE_KEYS as STORAGE_KEYS, type AuthConfig } from '@wilsoon/auth-core'; +import { sharedSessions } from './shared-profile'; import { API_AUDIENCE, CLIENT_ID, OTHER_CLIENT_ID, startFakeIdp, unsignedToken, type FakeIdp } from '../../auth-core/test/fake-idp'; /** A stand-in for the Next.js cookie store, shared with the mocked `next/headers`. */ @@ -30,6 +31,7 @@ const config = (): AuthConfig => ({ clientId: CLIENT_ID, issuer: idp.issuer, redirectUri: 'http://localhost:3000/callback', + rolesClaim: 'role', }); const setSessionCookie = (value: unknown, encode = false) => { @@ -58,7 +60,7 @@ describe('getSession', () => { expect(error).toBeUndefined(); expect(tokens?.id_token).toBeTruthy(); - expect(user).toMatchObject({ id: 'user-1', role: 'admin', authMethods: ['mfa', 'fido', 'hw'], sessionVersion: 3 }); + expect(user).toMatchObject({ id: 'user-1', roles: ['admin'], authMethods: ['mfa', 'fido', 'hw'] }); }); it('reads the URI-encoded cookie the identity provider actually writes', async () => { @@ -66,7 +68,7 @@ describe('getSession', () => { const { user } = await getSession(config()); - expect(user?.role).toBe('admin'); + expect(user?.roles).toEqual(['admin']); }); it('returns no session when there is no cookie', async () => { @@ -153,6 +155,8 @@ describe('getSession on a shared platform cookie', () => { issuer: idp.issuer, redirectUri: 'https://dash.wilsoon.dev/callback', apiAudience: API_AUDIENCE, + rolesClaim: 'role', + profile: sharedSessions, }); const platformIntrospection = () => idp.setIntrospection({ @@ -175,7 +179,7 @@ describe('getSession on a shared platform cookie', () => { const { user, error } = await getSession(dashConfig()); expect(error).toBeUndefined(); - expect(user).toMatchObject({ id: 'user-1', role: 'admin', source: 'access_token', name: 'Ada Lovelace' }); + expect(user).toMatchObject({ id: 'user-1', roles: ['admin'], source: 'access_token', name: 'Ada Lovelace' }); }); it('prefers its own ID token and skips introspection entirely', async () => { @@ -212,8 +216,12 @@ describe('getSession on a shared platform cookie', () => { expect(error?.code).toBe('FOREIGN_SESSION'); expect(error?.message).toMatch(/another application/); + // Shared sessions go through introspection, so without a secret core does not even try. const { error: secretless } = await getSession({ ...dashConfig(), clientSecret: undefined }); - expect(secretless?.code).toBe('INTROSPECTION_UNAVAILABLE'); + expect(secretless?.code).toBe('FOREIGN_SESSION'); + + const { error: profileless } = await getSession({ ...dashConfig(), profile: undefined }); + expect(profileless?.code).toBe('FOREIGN_SESSION'); }); }); @@ -233,7 +241,15 @@ describe('requireSession', () => { it('throws when the role is not permitted', async () => { setSessionCookie({ access_token: 'a', id_token: await idp.mintIdToken({ role: 'user' }) }); - await expect(requireSession(config(), { roles: ['admin'] })).rejects.toThrow(/not permitted/); + await expect(requireSession(config(), { roles: ['admin'] })).rejects.toThrow(/roles \[admin\] is required/); + }); + + it('requires every listed permission, read from the session access token', async () => { + const configured = { ...config(), apiAudience: API_AUDIENCE, permissionsClaim: 'permissions' }; + setSessionCookie({ access_token: await idp.mintAccessToken({ permissions: ['games_portal.access'] }), id_token: await idp.mintIdToken() }); + + await expect(requireSession(configured, { permissions: ['games_portal.access'] })).resolves.toMatchObject({ permissions: ['games_portal.access'] }); + await expect(requireSession(configured, { permissions: ['games_portal.access', 'games_portal.admin'] })).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); it('throws when the authentication methods are insufficient', async () => { diff --git a/packages/auth-next/test/shared-profile.ts b/packages/auth-next/test/shared-profile.ts new file mode 100644 index 0000000..c09096f --- /dev/null +++ b/packages/auth-next/test/shared-profile.ts @@ -0,0 +1,17 @@ +import { defineProfile, TokenVerificationError } from '@wilsoon/auth-core'; + +/** + * A minimal profile that resolves shared platform sessions the way WilsoonID's does: verify the sibling's access token, then take the live claims from introspection. + * The real one lives in a separate package that these tests do not depend on. + */ +export const sharedSessions = defineProfile({ + name: 'platform', + resolveSharedSession: async (ctx, accessToken) => { + const claims = await ctx.client.verifyAccessToken(accessToken); + const live = await ctx.introspect(accessToken); + if (!live.active) throw new TokenVerificationError('The platform session is no longer active.'); + + const { active: _active, ...fields } = live; + return ctx.toUser({ ...claims.claims, ...fields }, { source: 'access_token', audience: claims.audience[0] ?? '' }); + }, +}); diff --git a/packages/auth-react/README.md b/packages/auth-react/README.md index 15c72ba..4913cd6 100644 --- a/packages/auth-react/README.md +++ b/packages/auth-react/README.md @@ -18,8 +18,8 @@ Siblings: [`@wilsoon/auth-core`](https://www.npmjs.com/package/@wilsoon/auth-cor npm install @wilsoon/auth-react @wilsoon/auth-core ``` -Full end-to-end recipes, migration notes and a troubleshooting table live in the -[integration guide](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/INTEGRATION.md). +Full end-to-end recipes and a troubleshooting table live in the +[recipes](https://docs.wilsoon.dev/node-oidc-kit/recipes/nextjs). Migration notes are in the [changelog](https://github.com/Wilsoon7721/node-oidc-kit/blob/main/CHANGELOG.md). ## Basic Usage @@ -73,17 +73,17 @@ user?.name; user?.email; // Only after a verified callback exchange: -if (user?.verified && user.role === "admin") { +if (user?.verified && hasRole(user, "admin")) { // hasRole from @wilsoon/auth-core return ; } ``` -| How the session was established | `verified` | Has `role` / `authMethods` / `sessionVersion` | -| --------------------------------------------------------- | ---------- | ----------------------------------------------- | -| OIDC callback (`?code=...`) - ID token verified | `true` | Yes | -| Page load, hydrated from the HttpOnly cookie via userinfo | `false` | No - the userinfo endpoint does not return them | +| How the session was established | `verified` | Has `roles` / `permissions` / `authMethods` | +| ------------------------------------------------------- | ---------- | ----------------------------------------------- | +| OIDC callback (`?code=...`) - ID token verified | `true` | Yes | +| Page load, restored by a provider profile (via userinfo) | `false` | No - the userinfo endpoint does not return them | -TypeScript will not let you read `role` without narrowing on `verified` first, so a hydrated session cannot silently produce `undefined` where a role was expected. +TypeScript will not let you read `roles` without narrowing on `verified` first, so a hydrated session cannot silently produce `undefined` where a role was expected. **Client-side checks are for rendering.** A browser can be told anything. Gate real access on the server with `@wilsoon/auth-next`'s `getSession()` / `requireSession()`, or with `verifyIdToken()` / `verifyAccessToken()` in your API. diff --git a/packages/auth-react/package.json b/packages/auth-react/package.json index 13ae339..f74ab2a 100644 --- a/packages/auth-react/package.json +++ b/packages/auth-react/package.json @@ -1,6 +1,6 @@ { "name": "@wilsoon/auth-react", - "version": "2.3.0", + "version": "3.0.0", "description": "React context and hooks for OpenID Connect browser sessions", "keywords": [ "oidc", diff --git a/packages/auth-react/src/index.tsx b/packages/auth-react/src/index.tsx index cf21816..6e04821 100644 --- a/packages/auth-react/src/index.tsx +++ b/packages/auth-react/src/index.tsx @@ -1,39 +1,45 @@ "use client"; import React, { createContext, useContext, useEffect, useState, useMemo, useRef } from "react"; -import { AuthClient, BrowserStorage, TokenResponse } from "@wilsoon/auth-core"; -import { AuthState, LoginOptions, SessionUser } from "./types"; +import { AuthClient, AuthConfig, BrowserStorage, ProfileExtensions, ProviderProfile, TokenResponse } from "@wilsoon/auth-core"; +import { AuthState, LoginOptions, SessionRestore, SessionUser } from "./types"; const AuthContext = createContext(null); -/** - * Provider component that handles authentication state for the application. - * It manages the login redirect, the callback exchange, session hydration via HttpOnly - * cookies, and logout. - * - * On the OIDC callback the provider calls `AuthClient.handleCallback()`, which validates - * `state`, exchanges the code with its PKCE verifier and **verifies the returned ID token** - * against the identity provider's published keys. The resulting user is marked - * `verified: true`, and only then does it carry `role`, `authMethods` and `sessionVersion`. - * - * On a normal page load the provider hydrates from the HttpOnly session cookie by calling - * the userinfo endpoint with `credentials: 'include'`. That endpoint returns profile claims - * only, so the hydrated user is marked `verified: false` and carries no authorization - * fields. Check `user.verified` before reading them. - * - * No refresh here. The session cookie is `HttpOnly`, so this provider cannot see the refresh token. - * Instead, SPAs should react reactively (on 401s) and Next.js can utilise a middleware instead. - */ -export const AuthProvider: React.FC<{ +/** Props for {@link AuthProvider}. */ +export interface AuthProviderProps { clientId: string; issuer: string; redirectUri: string; scope?: string[]; + /** @deprecated Since 3.0.0 - the provider writes no cookies; this prop is ignored. */ cookieDomain?: string; + /** The provider profile, e.g. `wilsoon()`. Its extensions are exposed on `useAuth()` under its name. */ + profile?: ProviderProfile; + /** Where roles live in the ID token, as on `AuthConfig`. */ + rolesClaim?: AuthConfig["rolesClaim"]; + /** Where permissions live in the ID token, as on `AuthConfig`. */ + permissionsClaim?: AuthConfig["permissionsClaim"]; + /** Storage name prefix, as on `AuthConfig`. */ + storagePrefix?: string; + /** How a page load restores the session (default `"silent"`). */ + restore?: SessionRestore; + /** How long the silent attempt may take, in ms (default 10000). */ + silentTimeoutMs?: number; children: React.ReactNode; -}> = ({ clientId, issuer, redirectUri, scope, cookieDomain, children }) => { +} + +/** + * Provider component that handles authentication state for the application: the login redirect, the callback exchange, session restore and logout. + * + * On the OIDC callback, and after a silent restore, the ID token has been verified, so the user is marked `verified: true`. + * A profile's non-standard restore (such as a provider cookie) yields display claims only, marked `verified: false`. + * + * No refresh here: a silent restore on the next page load renews the session. Next.js apps should keep tokens server-side and use the middleware instead. + */ +export const AuthProvider: React.FC = ({ clientId, issuer, redirectUri, scope, profile, rolesClaim, permissionsClaim, storagePrefix, restore = "silent", silentTimeoutMs, children }) => { const scopeKey = scope ? scope.join(" ") : ""; - const config = useMemo(() => ({ clientId, issuer, redirectUri, cookieDomain, scope: scopeKey ? scopeKey.split(" ") : undefined }), [clientId, issuer, redirectUri, cookieDomain, scopeKey]); + const config = useMemo(() => ({ clientId, issuer, redirectUri, scope: scopeKey ? scopeKey.split(" ") : undefined, profile, rolesClaim, permissionsClaim, storagePrefix }), [clientId, issuer, redirectUri, scopeKey, profile, rolesClaim, permissionsClaim, storagePrefix]); // sessionStorage, not localStorage: `state`, `nonce` and the PKCE verifier are single-use // values scoped to one login attempt in one tab. @@ -50,6 +56,12 @@ export const AuthProvider: React.FC<{ if (processingRef.current) return; processingRef.current = true; + // Inside the silent sign-in iframe this page is only a landing spot: the parent reads the URL and finishes the exchange. + if (window.parent !== window) { + setIsLoading(false); + return; + } + const params = new URLSearchParams(window.location.search); const isCallback = params.has("code") || params.has("error"); @@ -63,9 +75,16 @@ export const AuthProvider: React.FC<{ if (verified) setUser({ verified: true, ...verified }); window.history.replaceState({}, document.title, window.location.pathname); - } else { - const profile = await client.hydrateSession(); - if (profile) setUser({ verified: false, ...profile }); + } else if (restore !== "none") { + const silent = restore === "silent" ? await client.silentAuthorize({ timeoutMs: silentTimeoutMs }) : null; + + if (silent) { + setTokens(silent.tokens); + if (silent.user) setUser({ verified: true, ...silent.user }); + } else if (profile?.restoreSession) { + const restored = await client.restoreSession(); + if (restored) setUser({ verified: false, ...restored }); + } } } catch (err: any) { setError(err); @@ -77,7 +96,7 @@ export const AuthProvider: React.FC<{ }; init(); - }, [client]); + }, [client, restore, silentTimeoutMs, profile]); const login = async (options?: LoginOptions | React.MouseEvent) => { // `login` is commonly passed straight to `onClick`, so an event argument is discarded @@ -95,25 +114,11 @@ export const AuthProvider: React.FC<{ try { setIsLoading(true); - if (tokens?.id_token) { - // The id_token_hint tells the provider which session to end, so it can clear the - // HttpOnly cookie it set. - const logoutUrl = await client.getLogoutUrl(tokens.id_token, returnTo); - setUser(null); - setTokens(null); - window.location.href = logoutUrl; - } else { - // A hydrated session has no id_token to hint with, and discovery cannot help: - // `end_session_endpoint` is what getLogoutUrl() needs the hint *for*. So fall back - // to the reference provider's logout path. - // - // This is the one provider-specific URL in the library. Against a different provider, - // handle logout in your own code rather than relying on this branch. - const logoutUrl = `${config.issuer.replace(/\/+$/, "")}/api/logout?post_logout_redirect_uri=${encodeURIComponent(returnTo)}`; - setUser(null); - setTokens(null); - window.location.href = logoutUrl; - } + // With an ID token the provider knows which session to end; without one it is sent `client_id` and may ask the user to confirm. + const logoutUrl = await client.getLogoutUrl(tokens?.id_token, returnTo); + setUser(null); + setTokens(null); + window.location.href = logoutUrl; } catch (err) { console.error("Logout failed:", err); setUser(null); @@ -122,20 +127,29 @@ export const AuthProvider: React.FC<{ } }; - const value = { user, tokens, isAuthenticated: !!user, isLoading, error, login, logout }; + const value = useMemo(() => { + const state: AuthState = { user, tokens, isAuthenticated: !!user, isLoading, error, login, logout, client }; + // Namespaced, as on the client: `useAuth().wilsoon.reauthorize()`. + const name = client.profileName; + if (name) (state as unknown as Record)[name] = (client as unknown as Record)[name]; + return state; + }, [user, tokens, isLoading, error, client]); + return {children}; }; /** * Reads the session state and actions from the nearest {@link AuthProvider}. + * Pass the profile's type to get its extensions typed: `useAuth>().wilsoon`. + * * @throws If called outside an {@link AuthProvider}. */ -export const useAuth = () => { +export const useAuth =

| undefined = undefined>(): AuthState & ProfileExtensions

=> { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth must be used within an AuthProvider"); } - return context; + return context as AuthState & ProfileExtensions

; }; export * from "./types"; diff --git a/packages/auth-react/src/types.ts b/packages/auth-react/src/types.ts index f66b2e8..52eea1b 100644 --- a/packages/auth-react/src/types.ts +++ b/packages/auth-react/src/types.ts @@ -1,21 +1,18 @@ import type { MouseEvent } from 'react'; -import { AuthenticatedUser, ProfileUser, TokenResponse } from '@wilsoon/auth-core'; +import { AuthClient, AuthenticatedUser, ProfileUser, TokenResponse } from '@wilsoon/auth-core'; /** * The user in the React session. * - * A discriminated union, because the two ways a browser session comes into being differ in - * what they can prove: + * A discriminated union, because the ways a browser session comes into being differ in what they can prove: * - * - after the OIDC callback the ID token has been verified, so `role`, `authMethods` and - * `sessionVersion` are present and trustworthy; - * - after hydrating from the HttpOnly cookie the app has only the userinfo profile, which - * carries no authorization claims at all. + * - after the OIDC callback or a silent restore the ID token has been verified, so `roles`, `permissions` and `authMethods` are present and trustworthy; + * - after a profile's non-standard restore (such as a provider cookie) the app has only a userinfo profile, which carries no authorization claims at all. * * Narrow on `verified` before reading anything security-relevant: * * ```tsx - * if (user?.verified && user.role === 'admin') showAdminNav(); + * if (user?.verified && user.permissions.includes('games_portal.admin')) showAdminNav(); * ``` * * Client-side checks are for rendering. Every real decision still belongs on the server. @@ -24,6 +21,15 @@ export type SessionUser = | ({ verified: true } & AuthenticatedUser) | ({ verified: false } & ProfileUser); +/** + * How a page load restores the session. + * + * - `"silent"`: a `prompt=none` request in a hidden iframe, falling back to the profile's `restoreSession` when that finds nothing. + * - `"profile"`: only the profile's `restoreSession`, the 2.x behaviour. + * - `"none"`: nothing; the user is signed out until they log in. + */ +export type SessionRestore = 'silent' | 'profile' | 'none'; + export interface AuthState { user: SessionUser | null; tokens: TokenResponse | null; @@ -38,13 +44,15 @@ export interface AuthState { */ login: (options?: LoginOptions | MouseEvent) => Promise; logout: (returnTo?: string) => Promise; + /** The underlying client, for standard methods the hook does not wrap. */ + client: AuthClient; } /** Options for starting a login. */ export interface LoginOptions { /** Overrides the configured scopes. */ scope?: string[]; - /** OIDC `prompt` value. */ + /** OIDC `prompt` value: `login` forces re-authentication. */ prompt?: string; /** OIDC `acr_values`, to request a stronger authentication context. */ acrValues?: string | string[]; diff --git a/packages/auth-react/test/AuthProvider.test.tsx b/packages/auth-react/test/AuthProvider.test.tsx index f6c4515..49eadfb 100644 --- a/packages/auth-react/test/AuthProvider.test.tsx +++ b/packages/auth-react/test/AuthProvider.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { STORAGE_KEYS } from '@wilsoon/auth-core'; -import { AuthProvider, useAuth } from '../src/index'; +import { DEFAULT_STORAGE_KEYS as STORAGE_KEYS, defineProfile, type ProfileContext } from '@wilsoon/auth-core'; +import { AuthProvider, useAuth, type AuthProviderProps } from '../src/index'; import { CLIENT_ID, OTHER_CLIENT_ID, startFakeIdp, unsignedToken, type FakeIdp } from '../../auth-core/test/fake-idp'; let idp: FakeIdp; @@ -18,7 +18,7 @@ const Probe = () => {

{String(isAuthenticated)}
{user ? String(user.verified) : 'none'}
{user?.name ?? 'none'}
-
{user && user.verified ? user.role : 'none'}
+
{user && user.verified ? user.roles.join(',') || 'none' : 'none'}
{user && user.verified ? user.authMethods.join(',') : 'none'}
{error ? (error as { code?: string }).code ?? error.message : 'none'}
@@ -27,9 +27,21 @@ const Probe = () => { ); }; -const renderProvider = () => render( - - +/** The 2.x cookie restore, as a profile: userinfo with credentials, answered from a provider cookie. */ +const cookieProfile = defineProfile({ + name: 'cookie', + restoreSession: async (ctx: ProfileContext) => { + const response = await fetch((await ctx.getEndpoints()).userinfo_endpoint, { credentials: 'include' }); + if (!response.ok) return null; + const body = await response.json(); + return { id: body.sub, name: body.name, email: body.email }; + }, + extend: () => ({ ping: () => 'pong' }), +}); + +const renderProvider = (props: Partial = {}, probe: React.ReactNode = ) => render( + + {probe} ); @@ -71,7 +83,7 @@ afterEach(() => { describe('AuthProvider hydration', () => { it('hydrates an unverified profile from the session cookie', async () => { - renderProvider(); + renderProvider({ profile: cookieProfile, restore: 'profile' }); await settled(); expect(screen.getByTestId('authenticated').textContent).toBe('true'); @@ -84,7 +96,7 @@ describe('AuthProvider hydration', () => { it('stays unauthenticated when there is no session', async () => { idp.setUserinfo({ error: 'invalid_token' }, 401); try { - renderProvider(); + renderProvider({ profile: cookieProfile, restore: 'profile' }); await settled(); expect(screen.getByTestId('authenticated').textContent).toBe('false'); @@ -259,7 +271,120 @@ describe('AuthProvider callback', () => { }); }); +describe('AuthProvider silent restore', () => { + /** + * jsdom does not navigate iframes, so this stands in for the provider: it reads the authorize URL off the frame and decides where the frame lands. + * `"cross-origin"` makes the landing unreadable, which is what a provider page or a framing refusal looks like. + */ + const landSilentFrame = (landing: (authorize: URL) => string) => { + const original = document.body.appendChild.bind(document.body); + const frames: URL[] = []; + + vi.spyOn(document.body, 'appendChild').mockImplementation((node: T): T => { + if (!(node instanceof HTMLIFrameElement)) return original(node); + + const authorize = new URL(node.src); + frames.push(authorize); + const target = landing(authorize); + Object.defineProperty(node, 'contentWindow', { + configurable: true, + get: () => ({ + get location() { + if (target === 'cross-origin') throw new DOMException('Blocked a frame from accessing a cross-origin frame.', 'SecurityError'); + return { href: target }; + }, + }), + }); + original(node); + setTimeout(() => node.dispatchEvent(new Event('load')), 0); + return node; + }); + + return frames; + }; + + it('restores a verified session through prompt=none without touching the login state', async () => { + const frames = landSilentFrame((authorize) => { + idp.setTokenHandler(async () => ({ body: { access_token: await idp.mintAccessToken(), id_token: await idp.mintIdToken({ nonce: authorize.searchParams.get('nonce') }) } })); + return `http://localhost:3000/callback?code=silent-code&state=${authorize.searchParams.get('state')}`; + }); + + renderProvider({ restore: 'silent' }); + await settled(); + + expect(screen.getByTestId('verified').textContent).toBe('true'); + expect(screen.getByTestId('role').textContent).toBe('admin'); + expect(frames[0].searchParams.get('prompt')).toBe('none'); + expect(idp.tokenRequests.at(-1)?.get('code')).toBe('silent-code'); + // The attempt kept its values in memory, so an interactive login in another tab is not clobbered. + expect(window.sessionStorage.getItem(STORAGE_KEYS.state)).toBeNull(); + expect(document.querySelector('iframe')).toBeNull(); + }); + + it('treats login_required as signed out, and falls back to the profile restore', async () => { + landSilentFrame((authorize) => `http://localhost:3000/callback?error=login_required&state=${authorize.searchParams.get('state')}`); + const before = idp.tokenHits; + + renderProvider({ restore: 'silent', profile: cookieProfile }); + await settled(); + + expect(screen.getByTestId('error').textContent).toBe('none'); + expect(screen.getByTestId('verified').textContent).toBe('false'); + expect(screen.getByTestId('name').textContent).toBe('Ada Lovelace'); + expect(idp.tokenHits).toBe(before); + }); + + it('stays signed out without a fallback, and gives up at once on an unreadable frame', async () => { + landSilentFrame(() => 'cross-origin'); + const started = Date.now(); + + renderProvider({ restore: 'silent' }); + await settled(); + + expect(screen.getByTestId('authenticated').textContent).toBe('false'); + expect(screen.getByTestId('error').textContent).toBe('none'); + expect(Date.now() - started).toBeLessThan(5000); + }); + + it('surfaces a real denial rather than reading it as signed out', async () => { + landSilentFrame((authorize) => `http://localhost:3000/callback?error=access_denied&state=${authorize.searchParams.get('state')}`); + + renderProvider({ restore: 'silent' }); + await settled(); + + expect(screen.getByTestId('error').textContent).toBe('AUTHORIZATION_RESPONSE_ERROR'); + }); + + it('does nothing when it is itself the page inside the silent frame', async () => { + const parent = Object.getOwnPropertyDescriptor(window, 'parent'); + Object.defineProperty(window, 'parent', { configurable: true, value: {} }); + try { + setLocation('?code=meant-for-the-parent&state=whatever'); + const before = idp.tokenHits; + + renderProvider({ restore: 'silent' }); + await settled(); + + expect(idp.tokenHits).toBe(before); + expect(screen.getByTestId('error').textContent).toBe('none'); + } finally { + if (parent) Object.defineProperty(window, 'parent', parent); + } + }); +}); + describe('useAuth', () => { + it('exposes the profile extensions under its name, and the client', async () => { + const Extensions = () => { + const auth = useAuth(); + return
{auth.isLoading ? 'loading' : `${auth.cookie.ping()} ${auth.client.profileName}`}
; + }; + + renderProvider({ profile: cookieProfile }, ); + + await waitFor(() => expect(screen.getByTestId('ping').textContent).toBe('pong cookie')); + }); + it('throws outside a provider', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => { });