diff --git a/.changeset/oidc-initial-release.md b/.changeset/oidc-initial-release.md new file mode 100644 index 00000000..f0e2bca3 --- /dev/null +++ b/.changeset/oidc-initial-release.md @@ -0,0 +1,11 @@ +--- +'@exortek/oidc': major +--- + +Initial release of `@exortek/oidc` — OpenID Connect Core 1.0 on top of `@exortek/oauth2`. + +- **Relying party** (`@exortek/oidc/client`) — a discovery-first `createClient` that enforces the `openid` scope and reuses oauth2's verified authorization-code flow: `authorize()` (with OIDC auth-request params), `handleCallback()` returning `{ idToken, claims, userinfo }` with the id_token verified end to end (`iss`/`aud`/`nonce`/`exp`, `azp`, `at_hash`), plus `endSessionUrl()` (RP-Initiated Logout 1.0), `refresh` and `revoke`. +- **OpenID Provider** (`@exortek/oidc/provider`) — add-ons to mount beside an `@exortek/oauth2/server` authorization server: `discoveryHandler` (a full `/.well-known/openid-configuration`), `userinfoHandler` (OIDC Core §5.3 with scope→claims release and a `claims.userinfo` policy), `jwksHandler`, `endSessionHandler` (validates `post_logout_redirect_uri`), `checkSessionHandler` + `sessionState()` (Session Management 1.0), and an `idTokenSigner` reusing oauth2's `createIdTokenSigner`. +- **Framework adapters** — `@exortek/oidc/client/express`, `/client/fastify`, `/provider/express`, `/provider/fastify`, with `express` / `fastify` as optional peers. + +Server-only, built on `node:crypto`; runtime deps are `@exortek/oauth2`, `@exortek/jwt`, `@exortek/jwks`. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4ff68ee4..06e2c624 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -37,6 +37,7 @@ body: - '@exortek/passkey' - '@exortek/paseto' - '@exortek/oauth2' + - '@exortek/oidc' - 'Repo tooling (build, tests, CI, docs site)' - "Other / I don't know" validations: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index fde1fc33..808674a5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -36,6 +36,7 @@ body: - '@exortek/passkey' - '@exortek/paseto' - '@exortek/oauth2' + - '@exortek/oidc' - 'A new package (please describe below)' - 'Repo tooling / docs site' validations: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 76b3b736..593ff2a1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -29,6 +29,7 @@ doesn't apply — a "🟢 N/A" is fine, empty checkbox lists are noise. - [ ] `@exortek/passkey` - [ ] `@exortek/paseto` - [ ] `@exortek/oauth2` +- [ ] `@exortek/oidc` - [ ] Repo tooling (build, CI, docs site, lint/format) - [ ] Docs only (no source code changed) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f69a6ea8..71588e9a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -124,7 +124,7 @@ Status legend: ✅ shipped to npm · 🛠 on disk, pre-release · ⏳ planned. | 16 | `@exortek/opaque` | ✅ | Opaque reference tokens, RFC 7662 introspection + RFC 7009 revocation | | 17 | `@exortek/paseto` | ✅ | PASETO v4 — `v4.local` (XChaCha20 + keyed BLAKE2b) + `v4.public` (Ed25519), no `alg` header | | 18 | `@exortek/oauth2` | ✅ | OAuth 2.1 — RP flow + provider presets + full authorization server (DPoP · PAR · JAR/JARM · device · token-exchange · dynamic registration · opt-in OIDC id_token · FAPI) | -| 19 | `@exortek/oidc` | ⏳ | OpenID Connect on top of `@exortek/oauth2` | +| 19 | `@exortek/oidc` | ✅ | OpenID Connect Core 1.0 on `@exortek/oauth2` — discovery-first RP + OP add-ons (discovery · UserInfo · JWKS · RP-Initiated Logout · Session Management) | | 20 | `@exortek/auth` | ⏳ | Umbrella — re-exports every package above | Not versioned in this table: `@exortek/shared` — internal consolidation @@ -303,7 +303,7 @@ Where each protocol is anchored: | `session` | OWASP ASVS 4.0.3 V3, RFC 6265 (Cookies) | | `security` | OWASP ASVS 4.0.3 V13 / V14, RFC 6749 §10 (OAuth2 threats), RFC 7231 §5 (HTTP) | | `oauth2` | OAuth 2.1 / RFC 9700 (BCP), RFC 6749, RFC 7636 (PKCE), RFC 9207 (iss), RFC 8414 (metadata), RFC 9449 (DPoP), RFC 9126 (PAR), RFC 8707 (resource), RFC 9396 (RAR), RFC 9101 (JAR/JARM), RFC 7523 / 8705 (client auth), RFC 8693 (exchange), RFC 8628 (device), RFC 7591 (dynamic registration), RFC 9068 (JWT profile), OpenID Connect Core (id_token, opt-in OP mode), FAPI 2.0 | -| `oidc` | _(planned)_ OpenID Connect Core 1.0, OpenID Connect Discovery | +| `oidc` | OpenID Connect Core 1.0, Discovery 1.0, RP-Initiated Logout 1.0, Session Management 1.0 (on `@exortek/oauth2`) | | `passkey` | W3C WebAuthn Level 3, FIDO2 CTAP2 | For deeper per-package interface tables (JSDoc typedefs, worked API diff --git a/README.md b/README.md index a7d1eb12..b02c22d7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ packages under one scope. Every package is built on `node:crypto`, ships secure- [![license](https://img.shields.io/github/license/ExorTek/auth?style=flat-square&color=blue)](./LICENSE) [![docs](https://img.shields.io/badge/docs-auth.memet.dev-cb3837?style=flat-square&logo=readthedocs&logoColor=white)](https://auth.memet.dev) -**18 of 20 packages published** · [Documentation](https://auth.memet.dev) · [Guides](https://auth.memet.dev/guides) · [Comparison](https://auth.memet.dev/comparison) +**19 of 20 packages published** · [Documentation](https://auth.memet.dev) · [Guides](https://auth.memet.dev/guides) · [Comparison](https://auth.memet.dev/comparison) ## Why @@ -53,7 +53,7 @@ Some packages pull in **optional peers** only when you use a feature that needs | Peer | Needed for | Packages | |------|------------|----------| | `ioredis` **or** `redis` | multi-process stores | apikey · magic-link · opaque · passkey · paseto · session · jwt · oauth2 · security | -| `express` **or** `fastify` | middleware adapters | apikey · opaque · passkey · ua · security · session · oauth2 | +| `express` **or** `fastify` | middleware adapters | apikey · opaque · passkey · ua · security · session · oauth2 · oidc | | `argon2` / `bcrypt` | those hash algorithms | password | ## Packages @@ -81,7 +81,7 @@ time. Linked names are **published on npm**; the rest are planned. | 16 | [`@exortek/opaque`](https://auth.memet.dev/opaque) | [![v](https://img.shields.io/npm/v/@exortek/opaque?style=flat-square&color=07d600&label=)](https://www.npmjs.com/package/@exortek/opaque) | opaque reference tokens, RFC 7662 introspection + RFC 7009 revocation handlers | | 17 | [`@exortek/paseto`](https://auth.memet.dev/paseto) | [![v](https://img.shields.io/npm/v/@exortek/paseto?style=flat-square&color=07d600&label=)](https://www.npmjs.com/package/@exortek/paseto) | PASETO v4 — `v4.local` (XChaCha20 + BLAKE2b) · `v4.public` (Ed25519), `tokenPair` reuse detection | | 18 | [`@exortek/oauth2`](https://auth.memet.dev/oauth2) | [![v](https://img.shields.io/npm/v/@exortek/oauth2?style=flat-square&color=07d600&label=)](https://www.npmjs.com/package/@exortek/oauth2) | OAuth 2.1 — `createOAuth` RP flow + 18 provider presets, login middleware, full authorization server (DPoP · PAR · PKCE · JAR/JARM · device · token-exchange · FAPI) | -| 19 | `@exortek/oidc` | _planned_ | OpenID Connect on top of `oauth2` | +| 19 | [`@exortek/oidc`](https://auth.memet.dev/oidc) | [![v](https://img.shields.io/npm/v/@exortek/oidc?style=flat-square&color=07d600&label=)](https://www.npmjs.com/package/@exortek/oidc) | OpenID Connect Core 1.0 on top of `oauth2` — discovery-first RP + OpenID Provider add-ons (discovery · UserInfo · JWKS · RP-Initiated Logout · Session Management) | | 20 | `@exortek/auth` | _planned_ | umbrella — re-exports every package above | ## Documentation diff --git a/SECURITY.md b/SECURITY.md index 7e6043b1..8e101a9b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,6 +25,7 @@ lines are not patched unless the project has an explicit LTS commitment (none do | `@exortek/passkey` | `1.x` — current | | `@exortek/paseto` | `1.x` — current | | `@exortek/oauth2` | `1.x` — current | +| `@exortek/oidc` | `1.x` — current | Everything else in the roadmap is **not yet published** — file bug reports through the usual template once a version is out. diff --git a/docs/compliance.md b/docs/compliance.md index 45ebf37e..e203ba9a 100644 --- a/docs/compliance.md +++ b/docs/compliance.md @@ -186,6 +186,17 @@ never transmit plaintext passwords over the network. | RFC 7009 / 7662 | Revocation + introspection, no cross-client oracle | ✅ | Revoke is idempotent; introspection denies cross-client by default (`allowCrossClient` opt-in) | | FAPI 2.0 | PAR + PKCE + DPoP/mTLS + `iss` in one profile | ✅ | `security: { fapi: true }` tightens the defaults together | +## OpenID Connect (`@exortek/oidc`) + +| § | Requirement | Status | How | +|------------------|--------------------------------------------------------------------|:------:|--------------------------------------------------------------------------------------------------| +| OIDC Core §3.1.3.7 | RP `id_token` validation (`iss`/`aud`/`nonce`/`exp`, `azp`, `at_hash`) | ✅ | `createClient` reuses oauth2's verified flow; `openid` scope is non-optional | +| OIDC Core §2 | OP `id_token` issuance — signed JWS with `nonce` / `auth_time` / `at_hash` | ✅ | `provider.idTokenSigner` (oauth2 `createIdTokenSigner`), asymmetric alg only, never `none`/HS* | +| OIDC Core §5.3 / §5.4 | UserInfo endpoint — `sub` always, claims released per granted scope | ✅ | `userinfoHandler` — Bearer resolve, scope→claims map + `claims.userinfo` policy, 401 on bad token | +| OIDC Discovery 1.0 | `/.well-known/openid-configuration` metadata | ✅ | `discoveryHandler` — superset of RFC 8414, advertises only endpoints the OP actually serves | +| RP-Initiated Logout 1.0 | `end_session_endpoint`, validated `post_logout_redirect_uri` | ✅ | `endSessionHandler` exact-matches registered URIs before redirect (open-redirect lever); `endSessionUrl` on the RP | +| Session Management 1.0 | `session_state` + `check_session_iframe` | ✅ | `sessionState()` (§4.2 hash) + `checkSessionHandler` serving the OP iframe | + ## Summary — what we ship today - ✅ **NIST SP 800-63B AAL2** — memorized secret + OOB OTP paths @@ -200,6 +211,10 @@ never transmit plaintext passwords over the network. `@exortek/oauth2` (mandatory PKCE, `iss`, DPoP incl. nonce, PAR, RAR, JAR/JARM, resource indicators, token exchange, device grant, FAPI 2.0; RP flow + authorization server; see table above) +- ✅ **OpenID Connect Core 1.0 + Discovery / RP-Initiated Logout / + Session Management** — `@exortek/oidc` (discovery-first RP with full + `id_token` validation; OpenID Provider add-ons: discovery, UserInfo, + JWKS, logout, session; see table above) - ✅ **ASVS V2.9 cryptographic authenticators** — `@exortek/passkey` (WebAuthn L3 / FIDO2 CTAP2 server verification, all seven attestation formats) diff --git a/packages/oidc/CHANGELOG.md b/packages/oidc/CHANGELOG.md new file mode 100644 index 00000000..b9333f48 --- /dev/null +++ b/packages/oidc/CHANGELOG.md @@ -0,0 +1 @@ +# @exortek/oidc diff --git a/packages/oidc/README.md b/packages/oidc/README.md new file mode 100644 index 00000000..bf0c9f73 --- /dev/null +++ b/packages/oidc/README.md @@ -0,0 +1,143 @@ +# @exortek/oidc + +> OpenID Connect Core 1.0 for Node.js 22+ — the identity layer on top of **[`@exortek/oauth2`](../oauth2)**. Relying party + OpenID Provider, discovery, UserInfo, RP-Initiated Logout, Session Management. Server-only, built on `node:crypto`. + +[![npm](https://img.shields.io/npm/v/@exortek/oidc.svg?color=cb3837)](https://www.npmjs.com/package/@exortek/oidc) +[![tests](https://github.com/ExorTek/auth/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/ExorTek/auth/actions/workflows/ci.yml) +[![node](https://img.shields.io/node/v/@exortek/oidc.svg?color=339933)](https://nodejs.org) +[![install size](https://packagephobia.com/badge?p=@exortek/oidc)](https://packagephobia.com/result?p=@exortek/oidc) +[![types](https://img.shields.io/badge/types-included-3178C6)](./dist/index.d.ts) +[![license](https://img.shields.io/npm/l/@exortek/oidc.svg?color=blue)](https://github.com/ExorTek/auth/blob/master/LICENSE) + +`@exortek/oauth2` already speaks OAuth 2.1 and can issue an `id_token`. +`@exortek/oidc` makes **OpenID Connect** the first-class shape on both +sides of the exchange: a discovery-first relying-party client that enforces +the `openid` scope and validates the `id_token` end to end, and an OpenID +Provider add-on that serves discovery, UserInfo, logout and session +management beside your `@exortek/oauth2/server` authorization server. + +📖 **Docs:** [**auth.memet.dev/oidc**](https://auth.memet.dev/oidc) + +## Why + +- **`@exortek/oauth2`** gives you the OAuth 2.1 machinery — PKCE, `state`, + `nonce`, DPoP, PAR, the authorization server. OIDC is the identity + contract layered on it: *who the user is*, proven by a signed `id_token` + and a UserInfo endpoint. +- Bolting OIDC onto a generic OAuth client by hand is where the subtle bugs + live — a skipped `nonce` check, an unvalidated `aud`, trusting UserInfo + `sub` over the `id_token` `sub`, a `post_logout_redirect_uri` open + redirect. This package refuses to let the caller skip them, and reuses + oauth2's verified flow rather than reimplementing it. + +## Modules + +| Import | Purpose | +|--------|---------| +| `@exortek/oidc` | Barrel — `createClient`, `createProvider`, `ErrorCode`, `OidcError`. | +| `@exortek/oidc/client` | Relying-party (SSO) client. | +| `@exortek/oidc/client/express` · `/client/fastify` | Browser-login route adapters. | +| `@exortek/oidc/provider` | OpenID Provider add-ons (discovery / UserInfo / JWKS / logout / session). | +| `@exortek/oidc/provider/express` · `/provider/fastify` | Mount the provider endpoints. | + +## Install + +```bash +npm install @exortek/oidc +``` + +## Relying party (SSO) + +```js +import { createClient } from '@exortek/oidc/client'; + +const client = createClient({ + issuer: 'https://accounts.google.com', + clientId: '...', + clientSecret: '...', + redirectUri: 'https://myapp.com/callback', + scope: ['openid', 'email', 'profile'], +}); + +// 1. Start — redirect the user to `url`, keep `session` (cookie / store). +const { url, session } = await client.authorize({ prompt: 'login' }); + +// 2. Callback — id_token signature / iss / aud / nonce are verified inside. +const { idToken, claims, userinfo } = await client.handleCallback(req.query, { session }); + +// 3. Logout (RP-Initiated Logout 1.0) +const logoutUrl = await client.endSessionUrl({ + idTokenHint: idToken, + postLogoutRedirectUri: 'https://myapp.com/', + state: 'xyz', +}); +``` + +With Express, the browser flow is two routes: + +```js +import { mountOidcLogin } from '@exortek/oidc/client/express'; + +mountOidcLogin(app, { + client, + onSuccess: ({ res, claims }) => { + req.session.user = claims.sub; + res.redirect('/'); + }, +}); +``` + +## OpenID Provider + +`createProvider` supplies the OIDC endpoints to mount **beside** your +`@exortek/oauth2/server` `createServer` (which already issues the +`id_token` off the `openid` scope): + +```js +import { createProvider } from '@exortek/oidc/provider'; +import { mountOidcProvider } from '@exortek/oidc/provider/express'; + +const provider = createProvider({ + issuer: 'https://auth.myapp.com', + signing: { key: signingPrivateJwk, alg: 'ES256', kid: 'key-1' }, + jwks: [publicJwk], // published at jwks_uri + endpoints: { authorization: '/authorize', token: '/token' }, + claims: { + supported: ['sub', 'email', 'email_verified', 'name', 'picture'], + userinfo: ['email', 'name', 'picture'], + }, + userinfo: { + // turn a Bearer access token into { sub, scope, claims } + resolve: accessToken => introspect(accessToken), + }, + logout: { postLogoutRedirectUris: ['https://myapp.com/'] }, + session: { cookieName: 'op_browser_state' }, +}); + +mountOidcProvider(app, provider); +// → /.well-known/openid-configuration, /.well-known/jwks.json, +// /userinfo, /end_session, /check_session +``` + +Handlers are framework-agnostic (`{ method, url, headers, query } → +{ status, headers, body }`) — mount them by hand, or use the express / +fastify adapters. `provider.idTokenSigner` is the same signer your +`createServer` should use, so both sign with one key. + +## Why not just `@exortek/oauth2`? + +Use `@exortek/oauth2` on its own when you need access-token authorization +and nothing more. Reach for `@exortek/oidc` the moment you need +*authentication* — a verified end-user identity — with the OIDC Core +guarantees (nonce, `aud`, `sub` binding, logout, session) enforced rather +than reassembled per app. + +## Specifications + +- OpenID Connect Core 1.0 · Discovery 1.0 · RP-Initiated Logout 1.0 · + Session Management 1.0 +- Builds on OAuth 2.1 (`@exortek/oauth2`), RFC 7519 (JWT), RFC 7517 (JWK). + +## License + +MIT © [ExorTek](https://github.com/ExorTek) diff --git a/packages/oidc/package.json b/packages/oidc/package.json new file mode 100644 index 00000000..2b2e11c9 --- /dev/null +++ b/packages/oidc/package.json @@ -0,0 +1,115 @@ +{ + "name": "@exortek/oidc", + "version": "0.0.0", + "description": "OpenID Connect Core 1.0 for Node.js 22+ — the identity layer on top of @exortek/oauth2. A relying-party client (id_token + UserInfo, discovery, nonce/state) and an OpenID Provider (discovery metadata, UserInfo endpoint, id_token issuance). Server-only, built on node:crypto.", + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.mjs", + "require": "./dist/client/index.cjs" + }, + "./client/express": { + "types": "./dist/client/express.d.ts", + "import": "./dist/client/express.mjs", + "require": "./dist/client/express.cjs" + }, + "./client/fastify": { + "types": "./dist/client/fastify.d.ts", + "import": "./dist/client/fastify.mjs", + "require": "./dist/client/fastify.cjs" + }, + "./provider": { + "types": "./dist/provider/index.d.ts", + "import": "./dist/provider/index.mjs", + "require": "./dist/provider/index.cjs" + }, + "./provider/express": { + "types": "./dist/provider/express.d.ts", + "import": "./dist/provider/express.mjs", + "require": "./dist/provider/express.cjs" + }, + "./provider/fastify": { + "types": "./dist/provider/fastify.d.ts", + "import": "./dist/provider/fastify.mjs", + "require": "./dist/provider/fastify.cjs" + } + }, + "files": [ + "dist", + "/README.md", + "/CHANGELOG.md", + "/LICENSE" + ], + "scripts": { + "build": "rm -rf dist tsconfig.tsbuildinfo && rollup -c rollup.config.js && tsc -p tsconfig.json && rollup -c ../../rollup.dts.config.mjs", + "build:watch": "rollup -c rollup.config.js --watch", + "build:types": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test 'tests/**/*.test.js'", + "test:coverage": "node --test --experimental-test-coverage 'tests/**/*.test.js'", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "prepack": "cp ../../LICENSE ./LICENSE" + }, + "keywords": [ + "backend", + "security", + "oidc", + "openid", + "openid-connect", + "id-token", + "userinfo", + "discovery", + "oauth2", + "sso", + "single-sign-on", + "authentication", + "identity", + "node-crypto" + ], + "license": "MIT", + "homepage": "https://github.com/ExorTek/auth/tree/master/packages/oidc#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/ExorTek/auth.git", + "directory": "packages/oidc" + }, + "bugs": { + "url": "https://github.com/ExorTek/auth/issues" + }, + "dependencies": { + "@exortek/jwks": "workspace:^", + "@exortek/jwt": "workspace:^", + "@exortek/oauth2": "workspace:^" + }, + "devDependencies": { + "@exortek/jwk": "workspace:^" + }, + "peerDependencies": { + "express": ">=4.0.0", + "fastify": ">=4.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + }, + "fastify": { + "optional": true + } + }, + "engines": { + "node": ">=22.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/oidc/rollup.config.js b/packages/oidc/rollup.config.js new file mode 100644 index 00000000..04b9d467 --- /dev/null +++ b/packages/oidc/rollup.config.js @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { createConfig } from '../../rollup.config.base.js'; + +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); + +export default createConfig(pkg, { + entries: { + index: 'src/index.js', + // The relying-party (SSO) client and the OpenID Provider each get their + // own entry so an app that only logs users in never bundles the provider + // half, and vice-versa. Framework adapters are split per framework so an + // Express app never bundles the Fastify one. + 'client/index': 'src/client/index.js', + 'client/express': 'src/client/express.js', + 'client/fastify': 'src/client/fastify.js', + 'provider/index': 'src/provider/index.js', + 'provider/express': 'src/provider/express.js', + 'provider/fastify': 'src/provider/fastify.js', + }, +}); diff --git a/packages/oidc/src/client/express.js b/packages/oidc/src/client/express.js new file mode 100644 index 00000000..9163eb2b --- /dev/null +++ b/packages/oidc/src/client/express.js @@ -0,0 +1,100 @@ +/** + * Express adapter for the `@exortek/oidc` relying-party login flow. + * + * import { mountOidcLogin } from '@exortek/oidc/client/express'; + * + * mountOidcLogin(app, { + * client, // from createClient(...) + * onSuccess: ({ res, idToken, claims, userinfo }) => { ... res.redirect('/'); }, + * }); + * + * A browser-redirect flow: `start` sends the user to the OP with the flow + * session stashed in a short-lived cookie; `callback` reads it back, runs + * `client.handleCallback`, and hands the result to `onSuccess`. API / SPA / + * mobile clients can call `client.authorize` / `client.handleCallback` + * directly instead. + */ +import { parseCookies, serialiseCookie, serialiseDeleteCookie } from '@exortek/shared/cookie'; +import { isFunction, isObject } from '@exortek/shared/predicates'; + +const DEFAULT_COOKIE = 'oidc_flow'; + +/** + * @typedef {object} OidcLoginConfig + * @property {ReturnType} client + * @property {{ name?: string, path?: string, maxAge?: number, secure?: boolean, sameSite?: string }} [cookie] + * @property {(ctx: object) => unknown} [onSuccess] called with `{ req, res, ...result }`; default 204. + * @property {(ctx: { req: any, res: any, error: unknown }) => unknown} [onError] default `next(error)`. + * @property {(req: any) => object} [authorizeOptions] per-request options passed to `client.authorize`. + */ + +/** + * Build the `{ start, callback }` Express handlers — mount them on your own + * routes. + * + * @param {OidcLoginConfig} config + * @returns {{ start: Function, callback: Function }} + */ +export function oidcLogin(config) { + if (!isObject(config) || !isObject(config.client) || !isFunction(config.client.authorize)) { + throw new TypeError('oidcLogin requires { client } from createClient()'); + } + const client = config.client; + const cookieName = config.cookie?.name ?? DEFAULT_COOKIE; + const cookieOptions = { + httpOnly: true, + sameSite: config.cookie?.sameSite ?? 'lax', + secure: config.cookie?.secure ?? true, + path: config.cookie?.path ?? '/', + maxAge: config.cookie?.maxAge ?? 600, + }; + + return { + async start(req, res, next) { + try { + const options = isFunction(config.authorizeOptions) ? config.authorizeOptions(req) : {}; + const { url, session } = await client.authorize(options); + res.setHeader('Set-Cookie', serialiseCookie(cookieName, session, cookieOptions)); + res.redirect(url); + } catch (err) { + onError(config, req, res, err, next); + } + }, + + async callback(req, res, next) { + try { + const session = parseCookies(req.headers.cookie)[cookieName]; + const result = await client.handleCallback(req.query, { session }); + res.setHeader('Set-Cookie', serialiseDeleteCookie(cookieName, { path: cookieOptions.path })); + if (isFunction(config.onSuccess)) { + return config.onSuccess({ req, res, ...result }); + } + res.status(204).end(); + } catch (err) { + onError(config, req, res, err, next); + } + }, + }; +} + +/** + * Register the login + callback routes on an Express app / router. + * + * @param {any} app + * @param {OidcLoginConfig & { loginPath?: string, callbackPath?: string }} config + */ +export function mountOidcLogin(app, config) { + const { start, callback } = oidcLogin(config); + app.get(config.loginPath ?? '/login', start); + app.get(config.callbackPath ?? '/callback', callback); +} + +function onError(config, req, res, err, next) { + if (isFunction(config.onError)) { + return config.onError({ req, res, error: err }); + } + if (isFunction(next)) { + return next(err); + } + throw err; +} diff --git a/packages/oidc/src/client/fastify.js b/packages/oidc/src/client/fastify.js new file mode 100644 index 00000000..f8b60234 --- /dev/null +++ b/packages/oidc/src/client/fastify.js @@ -0,0 +1,69 @@ +/** + * Fastify adapter for the `@exortek/oidc` relying-party login flow. + * + * import { oidcLoginPlugin } from '@exortek/oidc/client/fastify'; + * + * await app.register(oidcLoginPlugin, { + * client, + * onSuccess: ({ reply, idToken, claims }) => reply.redirect('/'), + * }); + * + * `fastify-plugin` is an OPTIONAL peer — pulled from `@exortek/shared`'s + * bundled `fastifyPlugin` so the routes register at the app's top level. + * API / SPA / mobile clients can call `client.authorize` / + * `client.handleCallback` directly instead. + */ +import { fastifyPlugin } from '@exortek/shared/fastify-plugin'; +import { parseCookies, serialiseCookie, serialiseDeleteCookie } from '@exortek/shared/cookie'; +import { isFunction, isObject } from '@exortek/shared/predicates'; + +const DEFAULT_COOKIE = 'oidc_flow'; + +/** + * @param {any} fastify + * @param {import('./express.js').OidcLoginConfig & { loginPath?: string, callbackPath?: string }} options + */ +async function oidcLoginPluginFn(fastify, options) { + if (!isObject(options) || !isObject(options.client) || !isFunction(options.client.authorize)) { + throw new TypeError('oidcLoginPlugin requires { client } from createClient()'); + } + const client = options.client; + const cookieName = options.cookie?.name ?? DEFAULT_COOKIE; + const cookieOptions = { + httpOnly: true, + sameSite: options.cookie?.sameSite ?? 'lax', + secure: options.cookie?.secure ?? true, + path: options.cookie?.path ?? '/', + maxAge: options.cookie?.maxAge ?? 600, + }; + + fastify.route({ + method: 'GET', + url: options.loginPath ?? '/login', + async handler(request, reply) { + const authorizeOptions = isFunction(options.authorizeOptions) ? options.authorizeOptions(request) : {}; + const { url, session } = await client.authorize(authorizeOptions); + reply.header('set-cookie', serialiseCookie(cookieName, session, cookieOptions)); + reply.redirect(url); + }, + }); + + fastify.route({ + method: 'GET', + url: options.callbackPath ?? '/callback', + async handler(request, reply) { + const session = parseCookies(request.headers.cookie)[cookieName]; + const result = await client.handleCallback(request.query, { session }); + reply.header('set-cookie', serialiseDeleteCookie(cookieName, { path: cookieOptions.path })); + if (isFunction(options.onSuccess)) { + return options.onSuccess({ request, reply, ...result }); + } + reply.status(204).send(); + }, + }); +} + +export const oidcLoginPlugin = fastifyPlugin(oidcLoginPluginFn, { + fastify: '>=4', + name: '@exortek/oidc-login', +}); diff --git a/packages/oidc/src/client/index.js b/packages/oidc/src/client/index.js new file mode 100644 index 00000000..a1dcd3c9 --- /dev/null +++ b/packages/oidc/src/client/index.js @@ -0,0 +1,217 @@ +/** + * `@exortek/oidc/client` — the OpenID Connect **relying party** (SSO). + * + * A thin identity facade over `@exortek/oauth2`. Rather than a provider + * preset, you point the client at an `issuer` and it discovers the + * endpoints (`/.well-known/openid-configuration`), runs the OAuth 2.1 + * authorization-code flow with mandatory PKCE / `state` / `nonce`, and — + * because the request always carries the `openid` scope — verifies the + * returned `id_token` end to end (`iss` / `aud` / `nonce` / `exp`, `azp`, + * `at_hash`, per OIDC Core §3.1.3.7) and layers UserInfo on top. + * + * All of that machinery already lives in `@exortek/oauth2` + * (`defineProvider({ discover: true })` + `createOAuth`); this module is + * the OIDC-shaped surface over it and does not reimplement discovery or + * token verification. + */ +import { createOAuth, defineProvider } from '@exortek/oauth2'; +import { decode } from '@exortek/jwt'; +import { isArray, isNonEmptyString, isObject } from '@exortek/shared/predicates'; + +import { invalidArgument } from '../internal/errors.js'; +import { buildEndSessionUrl } from '../internal/logout.js'; +import { resolveIssuerMetadata } from '../internal/issuer-discovery.js'; + +/** + * Standard OIDC claim projection. A relying party consumes the registered + * claims verbatim; the normalization simply surfaces the common ones with + * camelCase names while `handleCallback` also returns the raw claim set. + * + * @param {Record} raw + * @returns {{ sub: string, email?: string, emailVerified?: boolean, name?: string, picture?: string }} + */ +function mapStandardClaims(raw) { + return { + sub: /** @type {string} */ (raw.sub), + email: /** @type {string | undefined} */ (raw.email), + emailVerified: /** @type {boolean | undefined} */ (raw.email_verified), + name: /** @type {string | undefined} */ (raw.name), + picture: /** @type {string | undefined} */ (raw.picture), + }; +} + +/** Map the camelCase OIDC auth-request options onto their wire names. */ +const AUTH_PARAM_NAMES = { + prompt: 'prompt', + loginHint: 'login_hint', + maxAge: 'max_age', + acrValues: 'acr_values', + uiLocales: 'ui_locales', +}; + +/** + * @typedef {object} OidcClientConfig + * @property {string} issuer The OpenID Provider's issuer identifier. + * @property {string} clientId This relying party's client id. + * @property {string} [clientSecret] Client secret for confidential clients. + * @property {string} redirectUri Registered redirect URI for the callback. + * @property {string[]} [scope] Requested scopes; `openid` is enforced. + * @property {string[]} [idTokenAlgs] Signature alg allowlist for the id_token. + * @property {string|number} [clockTolerance] Leeway for `exp`/`nbf`/`iat`. + * @property {import('@exortek/jwks').RemoteJWKSOptions} [jwksOptions] Forwarded to the id_token JWKS resolver. + * @property {string} [endSessionEndpoint] RP-Initiated Logout endpoint; auto-discovered from OP metadata when omitted. + * @property {typeof fetch} [fetch] Override used only for the logout-endpoint metadata fetch (tests / proxies). + * @property {{ set: Function, get: Function, delete: Function }} [store] Flow-session store keyed by `state`. + */ + +/** + * Create an OpenID Connect relying-party client. + * + * @param {OidcClientConfig} config + */ +export function createClient(config) { + if (!isObject(config)) { + invalidArgument('createClient(config): config must be an object.'); + } + const { issuer, clientId, clientSecret, redirectUri } = config; + + if (!isNonEmptyString(issuer)) { + invalidArgument('createClient(config): `issuer` must be a non-empty string.'); + } + if (!isNonEmptyString(clientId)) { + invalidArgument('createClient(config): `clientId` must be a non-empty string.'); + } + if (!isNonEmptyString(redirectUri)) { + invalidArgument('createClient(config): `redirectUri` must be a non-empty string.'); + } + if (config.scope !== undefined && (!isArray(config.scope) || !config.scope.every(isNonEmptyString))) { + invalidArgument('createClient(config): `scope` must be an array of non-empty strings.'); + } + if ( + config.idTokenAlgs !== undefined && + (!isArray(config.idTokenAlgs) || !config.idTokenAlgs.every(isNonEmptyString)) + ) { + invalidArgument('createClient(config): `idTokenAlgs` must be an array of non-empty strings.'); + } + + // `autoOpenidScope` (default true in defineProvider) prepends `openid`, so a + // caller can never accidentally drop the scope that makes this OIDC. + const providerFactory = defineProvider({ + id: 'oidc', + kind: 'oidc', + discover: true, + issuer, + idTokenAlgs: config.idTokenAlgs, + jwksOptions: config.jwksOptions, + mapUser: mapStandardClaims, + }); + const provider = providerFactory({ + clientId, + clientSecret, + scope: config.scope, + redirectUri, + }); + + const oauth = createOAuth({ + providers: [provider], + store: config.store, + security: config.clockTolerance === undefined ? {} : { clockTolerance: config.clockTolerance }, + }); + + return { + issuer, + clientId, + + /** + * Build the authorization-request URL (PKCE / `state` / `nonce` handled + * by the oauth2 hub) with the OIDC auth-request parameters threaded in. + * + * @param {{ scope?: string[], prompt?: string, loginHint?: string, maxAge?: string|number, acrValues?: string, uiLocales?: string, params?: Record }} [options] + * @returns {Promise<{ url: string, session: string }>} + */ + async authorize(options = {}) { + /** @type {Record} */ + const params = { ...options.params }; + for (const [key, wire] of Object.entries(AUTH_PARAM_NAMES)) { + const value = /** @type {Record} */ (options)[key]; + if (value !== undefined) { + params[wire] = String(value); + } + } + const { url, session } = await oauth.authorize('oidc', { scope: options.scope, params }); + return { url, session }; + }, + + /** + * Complete the flow: validate the callback, exchange the code, verify the + * `id_token`, and fetch UserInfo. Signature / `nonce` / `iss` / `aud` + * are already checked inside the oauth2 hub; `decode` here only reads the + * now-trusted payload (no second network hop). + * + * @param {Record} query the callback query params + * @param {{ session?: string }} [options] + * @returns {Promise<{ idToken: string, claims: Record, userinfo: Record, user: object, tokens: Record, warnings: object[] }>} + */ + async handleCallback(query, options = {}) { + const { tokens, user, warnings } = await oauth.callback('oidc', query, { session: options.session }); + const idToken = /** @type {string} */ (tokens.id_token); + return { + idToken, + claims: decode(idToken).payload, + userinfo: /** @type {Record} */ (user).raw, + user, + tokens, + warnings, + }; + }, + + /** + * Build the RP-Initiated Logout URL (OIDC RP-Initiated Logout 1.0). The + * issuer's `end_session_endpoint` is taken from `config.endSessionEndpoint` + * when set, else resolved from the OP metadata. + * + * @param {{ idTokenHint: string, postLogoutRedirectUri?: string, state?: string, logoutHint?: string, uiLocales?: string }} params + * @returns {Promise} + */ + async endSessionUrl(params = /** @type {any} */ ({})) { + if (!isObject(params) || !isNonEmptyString(params.idTokenHint)) { + invalidArgument('endSessionUrl(params): `idTokenHint` must be a non-empty string.'); + } + let endpoint = config.endSessionEndpoint; + if (!isNonEmptyString(endpoint)) { + const meta = await resolveIssuerMetadata(issuer, { fetchImpl: config.fetch }); + endpoint = /** @type {string} */ (meta.end_session_endpoint); + if (!isNonEmptyString(endpoint)) { + invalidArgument(`endSessionUrl: issuer ${issuer} advertises no end_session_endpoint.`); + } + } + return buildEndSessionUrl(endpoint, { + idTokenHint: params.idTokenHint, + postLogoutRedirectUri: params.postLogoutRedirectUri, + state: params.state, + clientId, + logoutHint: params.logoutHint, + uiLocales: params.uiLocales, + }); + }, + + /** + * Exchange a refresh token for fresh tokens (RFC 6749 §6). + * @param {string} refreshToken + * @returns {Promise>} + */ + refresh(refreshToken) { + return oauth.refresh('oidc', refreshToken); + }, + + /** + * Revoke an access or refresh token (RFC 7009). + * @param {string} token + * @param {string} [tokenTypeHint] + * @returns {Promise>} + */ + revoke(token, tokenTypeHint) { + return oauth.revoke('oidc', token, tokenTypeHint); + }, + }; +} diff --git a/packages/oidc/src/index.js b/packages/oidc/src/index.js new file mode 100644 index 00000000..33f6a895 --- /dev/null +++ b/packages/oidc/src/index.js @@ -0,0 +1,11 @@ +/** + * `@exortek/oidc` — OpenID Connect Core 1.0 for Node.js. + * + * The root entry re-exports the two halves and the shared error surface. + * Import the relying-party client from `@exortek/oidc/client` and the + * OpenID Provider from `@exortek/oidc/provider` when you want only one + * side bundled; this barrel is the convenience re-export. + */ +export { createClient } from './client/index.js'; +export { createProvider } from './provider/index.js'; +export { ErrorCode, OidcError } from './internal/errors.js'; diff --git a/packages/oidc/src/internal/discovery-doc.js b/packages/oidc/src/internal/discovery-doc.js new file mode 100644 index 00000000..5f53302b --- /dev/null +++ b/packages/oidc/src/internal/discovery-doc.js @@ -0,0 +1,67 @@ +/** + * Build the OpenID Provider metadata served at + * `/.well-known/openid-configuration` (OpenID Connect Discovery 1.0 §3, a + * superset of the RFC 8414 authorization-server metadata `@exortek/oauth2` + * already produces). Only capabilities the provider actually offers are + * advertised — an absent endpoint is omitted, never sent empty. + */ +import { isNonEmptyString } from '@exortek/shared/predicates'; + +/** + * @param {object} config resolved provider config + * @param {string} config.issuer + * @param {Record} config.endpoints absolute endpoint URLs (authorization/token/userinfo/jwks/endSession/checkSession/revocation/introspection/registration) + * @param {string[]} config.scopes + * @param {string[]} config.claimsSupported + * @param {string[]} config.idTokenAlgs `id_token_signing_alg_values_supported` + * @param {string[]} [config.authMethods] token_endpoint_auth_methods_supported + * @returns {Record} + */ +export function buildDiscoveryDocument(config) { + const { issuer, endpoints, scopes, claimsSupported, idTokenAlgs, authMethods } = config; + + /** @type {Record} */ + const doc = { + issuer, + authorization_endpoint: endpoints.authorization, + token_endpoint: endpoints.token, + jwks_uri: endpoints.jwks, + // OAuth 2.1 is code-only — no implicit/hybrid response types. + response_types_supported: ['code'], + response_modes_supported: ['query', 'fragment'], + grant_types_supported: ['authorization_code', 'refresh_token'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: idTokenAlgs, + scopes_supported: scopes, + claims_supported: claimsSupported, + // PKCE S256 only (OAuth 2.1) — `plain` is never offered. + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: authMethods ?? ['client_secret_basic', 'client_secret_post'], + // RFC 9207 — the AS returns `iss` on the authorization response. + authorization_response_iss_parameter_supported: true, + }; + + // Optional endpoints — advertised only when the provider serves them. + if (isNonEmptyString(endpoints.userinfo)) { + doc.userinfo_endpoint = endpoints.userinfo; + } + if (isNonEmptyString(endpoints.endSession)) { + // OpenID Connect RP-Initiated Logout 1.0. + doc.end_session_endpoint = endpoints.endSession; + } + if (isNonEmptyString(endpoints.checkSession)) { + // OpenID Connect Session Management 1.0. + doc.check_session_iframe = endpoints.checkSession; + } + if (isNonEmptyString(endpoints.revocation)) { + doc.revocation_endpoint = endpoints.revocation; + } + if (isNonEmptyString(endpoints.introspection)) { + doc.introspection_endpoint = endpoints.introspection; + } + if (isNonEmptyString(endpoints.registration)) { + doc.registration_endpoint = endpoints.registration; + } + + return doc; +} diff --git a/packages/oidc/src/internal/errors.js b/packages/oidc/src/internal/errors.js new file mode 100644 index 00000000..57ed9165 --- /dev/null +++ b/packages/oidc/src/internal/errors.js @@ -0,0 +1,47 @@ +/** + * Stable machine-readable codes for every failure that `@exortek/oidc` + * can raise. Branch on `code`, never on the message. + * + * These are the library's own error codes — distinct from the OpenID + * Connect protocol `error` values an OpenID Provider returns on the wire. + * The client flow delegates to `@exortek/oauth2`, so callback-validation + * failures surface as `OAuth2Error` and are left to propagate. + */ +import { BaseError } from '@exortek/shared/errors'; + +export const ErrorCode = Object.freeze({ + // Configuration / argument guards raised by `createClient` / `createProvider` + // and the provider handlers. + INVALID_ARGUMENT: 'INVALID_ARGUMENT', + + // The client's own OP-metadata fetch failed (used to resolve the + // `end_session_endpoint` for RP-Initiated Logout — the main login flow's + // discovery is delegated to @exortek/oauth2 and surfaces as OAuth2Error). + DISCOVERY_FAILED: 'DISCOVERY_FAILED', +}); + +/** + * Every recoverable failure raised by this package. Carries a stable `code` + * (from {@link ErrorCode}) and, via the {@link OidcError.statuses} map, the + * HTTP `status` a middleware layer maps it to. + * + * @augments BaseError + */ +export class OidcError extends BaseError { + static statuses = { + INVALID_ARGUMENT: 400, + DISCOVERY_FAILED: 502, + }; + + static defaultStatus = 500; +} + +/** + * Guard helper — throw a configuration error with a consistent shape. + * + * @param {string} message + * @returns {never} + */ +export function invalidArgument(message) { + throw new OidcError(ErrorCode.INVALID_ARGUMENT, message); +} diff --git a/packages/oidc/src/internal/http-io.js b/packages/oidc/src/internal/http-io.js new file mode 100644 index 00000000..8c669616 --- /dev/null +++ b/packages/oidc/src/internal/http-io.js @@ -0,0 +1,124 @@ +/** + * Minimal framework-agnostic request/response shapes for the provider + * handlers. `@exortek/oauth2`'s server keeps its own equivalents private, so + * — following the deliberate leaf-package duplication principle — oidc owns a + * small copy sized to its handful of endpoints (discovery / userinfo / jwks / + * end-session / check-session). The `./provider/express` and + * `./provider/fastify` adapters translate native req/res into and out of + * these. + * + * @typedef {object} OidcRequest + * @property {string} method upper-cased HTTP method + * @property {Record} headers lower-cased header names + * @property {Record} query parsed query params + * @property {(name: string) => string | undefined} header + * @property {(name: string) => string | undefined} param query lookup + * + * @typedef {object} OidcResponse + * @property {number} status + * @property {Record} headers + * @property {string} body + */ +import { isObject } from '@exortek/shared/predicates'; + +/** + * Build an {@link OidcRequest} from a raw descriptor `{ method, url, headers, + * query? }`. Query is taken from `query` when present, else parsed from `url`. + * + * @param {{ method?: string, url?: string, headers?: Record, query?: Record }} raw + * @returns {OidcRequest} + */ +export function normalizeRequest(raw = {}) { + const method = typeof raw.method === 'string' ? raw.method.toUpperCase() : 'GET'; + + /** @type {Record} */ + const headers = {}; + if (isObject(raw.headers)) { + for (const [name, value] of Object.entries(raw.headers)) { + headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value); + } + } + + /** @type {Record} */ + const query = {}; + if (isObject(raw.query)) { + for (const [name, value] of Object.entries(raw.query)) { + if (value !== undefined && value !== null) { + query[name] = Array.isArray(value) ? String(value[0]) : String(value); + } + } + } else if (typeof raw.url === 'string') { + const qIndex = raw.url.indexOf('?'); + if (qIndex !== -1) { + for (const [name, value] of new URLSearchParams(raw.url.slice(qIndex + 1))) { + query[name] = value; + } + } + } + + return { + method, + headers, + query, + header(name) { + return headers[String(name).toLowerCase()]; + }, + param(name) { + return query[name]; + }, + }; +} + +/** + * A JSON response. Discovery / jwks are cacheable; userinfo passes an explicit + * `no-store` via `headers`. + * + * @param {number} status + * @param {Record} payload + * @param {Record} [headers] + * @returns {OidcResponse} + */ +export function jsonResponse(status, payload, headers = {}) { + return { + status, + headers: { 'content-type': 'application/json', ...lower(headers) }, + body: JSON.stringify(payload), + }; +} + +/** + * An HTML response (the check-session iframe document). + * + * @param {number} status + * @param {string} html + * @param {Record} [headers] + * @returns {OidcResponse} + */ +export function htmlResponse(status, html, headers = {}) { + return { + status, + headers: { 'content-type': 'text/html; charset=utf-8', ...lower(headers) }, + body: html, + }; +} + +/** + * A 302 redirect to `location`. + * + * @param {string} location + * @param {Record} [headers] + * @returns {OidcResponse} + */ +export function redirectResponse(location, headers = {}) { + return { status: 302, headers: { location, ...lower(headers) }, body: '' }; +} + +/** @param {Record} headers */ +function lower(headers) { + /** @type {Record} */ + const out = {}; + for (const [name, value] of Object.entries(headers)) { + out[name.toLowerCase()] = value; + } + return out; +} diff --git a/packages/oidc/src/internal/issuer-discovery.js b/packages/oidc/src/internal/issuer-discovery.js new file mode 100644 index 00000000..f8be0d9f --- /dev/null +++ b/packages/oidc/src/internal/issuer-discovery.js @@ -0,0 +1,64 @@ +/** + * Minimal OP-metadata fetch for the fields `@exortek/oauth2`'s (private) + * discovery does not surface to the RP — chiefly `end_session_endpoint` and + * `check_session_iframe`. The login flow's discovery still runs inside oauth2; + * this is only reached by `endSessionUrl` when the caller has not supplied an + * explicit endpoint. Results are cached per issuer for the process lifetime. + */ +import { isObject } from '@exortek/shared/predicates'; + +import { ErrorCode, OidcError } from './errors.js'; + +const WELL_KNOWN = '.well-known/openid-configuration'; +const DEFAULT_TIMEOUT_MS = 8_000; + +/** @type {Map>} */ +const cache = new Map(); + +/** + * Resolve (and cache) the OP metadata document for `issuer`. + * + * @param {string} issuer + * @param {{ timeout?: number, fetchImpl?: typeof fetch }} [options] + * @returns {Promise>} + */ +export async function resolveIssuerMetadata(issuer, options = {}) { + const cached = cache.get(issuer); + if (cached) { + return cached; + } + + const url = new URL(WELL_KNOWN, issuer.endsWith('/') ? issuer : `${issuer}/`).toString(); + const doFetch = options.fetchImpl ?? fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeout ?? DEFAULT_TIMEOUT_MS); + + /** @type {Record} */ + let doc; + try { + // A discovery endpoint never legitimately redirects — refuse it (SSRF). + const res = await doFetch(url, { redirect: 'manual', signal: controller.signal }); + if (!res.ok) { + throw new OidcError(ErrorCode.DISCOVERY_FAILED, `OP metadata fetch for ${issuer} returned HTTP ${res.status}`); + } + doc = await res.json(); + } catch (err) { + if (err instanceof OidcError) { + throw err; + } + throw new OidcError(ErrorCode.DISCOVERY_FAILED, `OP metadata fetch for ${issuer} failed`, { cause: err }); + } finally { + clearTimeout(timer); + } + + if (!isObject(doc) || doc.issuer !== issuer) { + throw new OidcError(ErrorCode.DISCOVERY_FAILED, `OP metadata issuer mismatch for ${issuer}`); + } + cache.set(issuer, doc); + return doc; +} + +/** Clear the metadata cache — test-only. */ +export function _clearIssuerMetadataCache() { + cache.clear(); +} diff --git a/packages/oidc/src/internal/logout.js b/packages/oidc/src/internal/logout.js new file mode 100644 index 00000000..5b7b10aa --- /dev/null +++ b/packages/oidc/src/internal/logout.js @@ -0,0 +1,81 @@ +/** + * OpenID Connect **RP-Initiated Logout 1.0** helpers. + * + * The relying party sends the end user to the OP's `end_session_endpoint` + * with an `id_token_hint`; the OP ends its session and (after validating the + * `post_logout_redirect_uri` against the ones registered for the client) + * redirects back. These are the pure builders/validators shared by the + * client's `endSessionUrl` and the provider's `endSessionHandler`. + */ +import { decode } from '@exortek/jwt'; +import { isArray, isNonEmptyString } from '@exortek/shared/predicates'; + +/** + * Build the RP-Initiated Logout URL for the OP's end-session endpoint. + * + * @param {string} endpoint the OP's `end_session_endpoint` + * @param {{ idTokenHint: string, postLogoutRedirectUri?: string, state?: string, clientId?: string, logoutHint?: string, uiLocales?: string }} params + * @returns {string} + */ +export function buildEndSessionUrl(endpoint, params) { + const url = new URL(endpoint); + const q = url.searchParams; + q.set('id_token_hint', params.idTokenHint); + if (isNonEmptyString(params.postLogoutRedirectUri)) { + q.set('post_logout_redirect_uri', params.postLogoutRedirectUri); + } + if (isNonEmptyString(params.state)) { + q.set('state', params.state); + } + if (isNonEmptyString(params.clientId)) { + q.set('client_id', params.clientId); + } + if (isNonEmptyString(params.logoutHint)) { + q.set('logout_hint', params.logoutHint); + } + if (isNonEmptyString(params.uiLocales)) { + q.set('ui_locales', params.uiLocales); + } + return url.toString(); +} + +/** + * Read the (unverified) `sub` / `aud` from an `id_token_hint`. A logout hint + * is frequently expired, so the OP validates issuer/audience rather than the + * full signature+exp; the return lets the handler match the session to clear. + * + * @param {string} idTokenHint + * @param {string} issuer the OP's own issuer — the hint's `iss` must match + * @returns {{ sub?: string, aud?: string | string[] } | null} null when the hint is unusable / not ours + */ +export function readIdTokenHint(idTokenHint, issuer) { + if (!isNonEmptyString(idTokenHint)) { + return null; + } + let payload; + try { + payload = decode(idTokenHint).payload; + } catch { + return null; + } + if (payload.iss !== issuer) { + return null; + } + return { + sub: /** @type {string|undefined} */ (payload.sub), + aud: /** @type {string|string[]|undefined} */ (payload.aud), + }; +} + +/** + * Exact-match a `post_logout_redirect_uri` against the URIs registered for a + * client (OIDC RP-Initiated Logout §2 — the OP MUST verify it). Exact string + * comparison, the same rule redirect_uri validation uses. + * + * @param {string | undefined} uri + * @param {string[]} registered + * @returns {boolean} + */ +export function isRegisteredPostLogoutUri(uri, registered) { + return isNonEmptyString(uri) && isArray(registered) && registered.includes(uri); +} diff --git a/packages/oidc/src/internal/session.js b/packages/oidc/src/internal/session.js new file mode 100644 index 00000000..6dbc3bd8 --- /dev/null +++ b/packages/oidc/src/internal/session.js @@ -0,0 +1,92 @@ +/** + * OpenID Connect **Session Management 1.0** helpers. + * + * The OP tracks login state in a browser-scoped value (the "OP browser + * state", an opaque cookie the OP sets and rotates on login/logout). It + * derives a `session_state` from that value, the client id and the RP's + * origin, and returns it to the RP. The RP renders a hidden iframe pointing + * at the OP's `check_session_iframe`; a `postMessage` handshake lets the RP + * poll whether the OP session changed — all without a network round-trip. + * + * This module owns the two server-side pieces: computing `session_state` + * (§4.2) and rendering the OP iframe document (§4.2, the calculation the + * iframe re-runs in the browser). + */ +import { createHash, randomBytes } from 'node:crypto'; + +import { encode as base64urlEncode } from '@exortek/shared/base64url'; + +/** + * Compute a `session_state` value (OIDC Session Management §4.2): + * `base64url(sha256(client_id + " " + origin + " " + op_browser_state + " " + + * salt)) + "." + salt`. + * + * @param {{ clientId: string, origin: string, opBrowserState: string, salt?: string }} input + * @returns {string} + */ +export function computeSessionState(input) { + const salt = input.salt ?? base64urlEncode(randomBytes(8)); + const material = `${input.clientId} ${input.origin} ${input.opBrowserState} ${salt}`; + const hash = base64urlEncode(createHash('sha256').update(material).digest()); + return `${hash}.${salt}`; +} + +/** + * Render the OP `check_session_iframe` document. The script re-runs the + * §4.2 calculation in the browser on each `postMessage` and replies + * `unchanged` / `changed` / `error`. The OP browser state is read from the + * cookie named `cookieName`. + * + * @param {{ cookieName?: string }} [options] + * @returns {string} a complete HTML document + */ +export function checkSessionIframeHtml(options = {}) { + const cookieName = options.cookieName ?? 'op_browser_state'; + // The cookie name is the only injected value; JSON.stringify keeps it a safe + // string literal inside the script. + const cookieLiteral = JSON.stringify(cookieName); + return ` + +check_session_iframe + + + +`; +} diff --git a/packages/oidc/src/internal/userinfo.js b/packages/oidc/src/internal/userinfo.js new file mode 100644 index 00000000..464b89ea --- /dev/null +++ b/packages/oidc/src/internal/userinfo.js @@ -0,0 +1,81 @@ +/** + * UserInfo claim selection (OpenID Connect Core §5.3 / §5.4). + * + * Which claims a UserInfo response may carry is the intersection of three + * things: the scopes the access token was granted (each standard scope maps + * to a fixed claim set, §5.4), the provider's `claims.userinfo` allow-list + * (when configured), and the claims the backing store actually holds for the + * subject. `sub` is always present (§5.3.2). + */ + +// OIDC Core §5.4 — the claims each standard scope releases. +export const SCOPE_CLAIMS = Object.freeze({ + profile: [ + 'name', + 'family_name', + 'given_name', + 'middle_name', + 'nickname', + 'preferred_username', + 'profile', + 'picture', + 'website', + 'gender', + 'birthdate', + 'zoneinfo', + 'locale', + 'updated_at', + ], + email: ['email', 'email_verified'], + address: ['address'], + phone: ['phone_number', 'phone_number_verified'], +}); + +/** + * Resolve the set of claim names releasable for a granted scope list. + * + * @param {string[]} grantedScopes + * @param {string[]} [allowList] optional `claims.userinfo` policy narrowing + * @returns {Set} + */ +export function releasableClaims(grantedScopes, allowList) { + const names = new Set(); + for (const scope of grantedScopes) { + const claims = SCOPE_CLAIMS[scope]; + if (claims) { + for (const name of claims) { + names.add(name); + } + } + } + if (Array.isArray(allowList)) { + for (const name of [...names]) { + if (!allowList.includes(name)) { + names.delete(name); + } + } + } + return names; +} + +/** + * Build the UserInfo response body: `sub` plus every releasable claim the + * store holds a value for. + * + * @param {string} sub + * @param {Record} storedClaims every claim held for the subject + * @param {string[]} grantedScopes + * @param {string[]} [allowList] optional `claims.userinfo` policy + * @returns {Record} + */ +export function buildUserInfo(sub, storedClaims, grantedScopes, allowList) { + const releasable = releasableClaims(grantedScopes, allowList); + /** @type {Record} */ + const out = { sub }; + for (const name of releasable) { + if (storedClaims && Object.hasOwn(storedClaims, name) && storedClaims[name] !== undefined) { + out[name] = storedClaims[name]; + } + } + return out; +} diff --git a/packages/oidc/src/provider/express.js b/packages/oidc/src/provider/express.js new file mode 100644 index 00000000..f9c009e1 --- /dev/null +++ b/packages/oidc/src/provider/express.js @@ -0,0 +1,71 @@ +/** + * Express adapter for the `@exortek/oidc` OpenID Provider add-ons. + * + * import express from 'express'; + * import { createProvider } from '@exortek/oidc/provider'; + * import { mountOidcProvider } from '@exortek/oidc/provider/express'; + * + * const provider = createProvider({ ... }); + * mountOidcProvider(app, provider); + * + * The provider handlers are framework-agnostic (`{ method, url, headers, + * query } → { status, headers, body }`); this adapter only translates to and + * from Express's native `req`/`res`, and mounts each endpoint at the path the + * discovery document advertises. + */ +import { pathOf, providerRoutes } from './routes.js'; + +/** + * Wrap a single provider handler as an Express `(req, res)` handler. + * + * @param {(raw: object) => (object | Promise)} handler + * @returns {(req: any, res: any) => Promise} + */ +export function expressHandler(handler) { + return async function oidcExpressHandler(req, res) { + const out = await handler({ + method: req.method, + url: req.originalUrl ?? req.url, + headers: req.headers, + query: req.query, + }); + res.status(out.status); + for (const [name, value] of Object.entries(out.headers)) { + res.setHeader(name, value); + } + res.send(out.body); + }; +} + +/** + * Build the Express handlers for every endpoint the provider serves — mount + * them on your own routes. + * + * @param {ReturnType} provider + * @returns {Record} + */ +export function oidcProviderHandlers(provider) { + /** @type {Record} */ + const out = {}; + for (const [name, { path, handler }] of Object.entries(providerRoutes(provider))) { + out[name] = { path, handler: expressHandler(handler) }; + } + return out; +} + +/** + * Register every provider endpoint on an Express app / router — the one-call + * form of {@link oidcProviderHandlers}. Discovery is served from the + * well-known path; the rest at the path portion of their advertised URLs. + * + * @param {any} app + * @param {ReturnType} provider + * @param {{ basePath?: string }} [options] + */ +export function mountOidcProvider(app, provider, options = {}) { + const base = options.basePath ?? ''; + const routes = providerRoutes(provider); + for (const { method, path, handler } of Object.values(routes)) { + app[method.toLowerCase()](`${base}${pathOf(path)}`, expressHandler(handler)); + } +} diff --git a/packages/oidc/src/provider/fastify.js b/packages/oidc/src/provider/fastify.js new file mode 100644 index 00000000..0ef93782 --- /dev/null +++ b/packages/oidc/src/provider/fastify.js @@ -0,0 +1,59 @@ +/** + * Fastify adapter for the `@exortek/oidc` OpenID Provider add-ons. + * + * import Fastify from 'fastify'; + * import { createProvider } from '@exortek/oidc/provider'; + * import { oidcProviderPlugin } from '@exortek/oidc/provider/fastify'; + * + * const app = Fastify(); + * await app.register(oidcProviderPlugin, { provider: createProvider({ ... }) }); + * + * `fastify-plugin` is an OPTIONAL peer — pulled from `@exortek/shared`'s + * bundled `fastifyPlugin` so the routes register at the app's top level + * without a hard dependency on the npm package. + */ +import { fastifyPlugin } from '@exortek/shared/fastify-plugin'; +import { isFunction, isObject } from '@exortek/shared/predicates'; + +import { pathOf, providerRoutes } from './routes.js'; + +/** + * Adapt a framework-agnostic provider handler to a Fastify route handler. + * + * @param {(raw: object) => (object | Promise)} handler + */ +export function adapt(handler) { + return async function oidcFastifyRoute(request, reply) { + const out = await handler({ + method: request.method, + url: request.url, + headers: request.headers, + query: request.query, + }); + reply.status(out.status); + for (const [name, value] of Object.entries(out.headers)) { + reply.header(name, value); + } + reply.send(out.body); + }; +} + +/** + * @param {any} fastify + * @param {{ provider: ReturnType, basePath?: string }} options + */ +async function oidcProviderPluginFn(fastify, options) { + const provider = options?.provider; + if (!isObject(provider) || !isFunction(provider.discoveryHandler)) { + throw new TypeError('oidcProviderPlugin requires { provider } from createProvider()'); + } + const base = options.basePath ?? ''; + for (const { method, path, handler } of Object.values(providerRoutes(provider))) { + fastify.route({ method, url: `${base}${pathOf(path)}`, handler: adapt(handler) }); + } +} + +export const oidcProviderPlugin = fastifyPlugin(oidcProviderPluginFn, { + fastify: '>=4', + name: '@exortek/oidc-provider', +}); diff --git a/packages/oidc/src/provider/index.js b/packages/oidc/src/provider/index.js new file mode 100644 index 00000000..1b67d31a --- /dev/null +++ b/packages/oidc/src/provider/index.js @@ -0,0 +1,308 @@ +/** + * `@exortek/oidc/provider` — the **OpenID Provider** (OP) add-ons. + * + * `@exortek/oauth2`'s authorization server (`@exortek/oauth2/server`) already + * issues an `id_token` off the `openid` scope; what it has no first-class + * answer for is the OIDC identity surface. `createProvider` supplies exactly + * that, to mount **beside** your `createServer`: + * + * - the OpenID discovery document (`/.well-known/openid-configuration`), + * - the UserInfo endpoint (OIDC Core §5.3), + * - the published JWKS, + * - and an `id_token` signer (`createIdTokenSigner`) so the server and this + * provider sign with the same key. + * + * RP-Initiated Logout and Session Management add their handlers in follow-up + * work. + */ +import { createIdTokenSigner } from '@exortek/oauth2/server'; +import { isArray, isNonEmptyString, isObject } from '@exortek/shared/predicates'; + +import { invalidArgument } from '../internal/errors.js'; +import { buildDiscoveryDocument } from '../internal/discovery-doc.js'; +import { buildUserInfo } from '../internal/userinfo.js'; +import { isRegisteredPostLogoutUri, readIdTokenHint } from '../internal/logout.js'; +import { checkSessionIframeHtml, computeSessionState } from '../internal/session.js'; +import { htmlResponse, jsonResponse, normalizeRequest, redirectResponse } from '../internal/http-io.js'; + +const DEFAULT_SCOPES = ['openid', 'profile', 'email']; +const DEFAULT_CLAIMS_SUPPORTED = ['sub', 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce']; + +/** + * @typedef {object} OidcProviderConfig + * @property {string} issuer the OP issuer identifier (https URL). + * @property {{ key: unknown, alg: string, kid?: string, expiresIn?: string|number }} signing id_token signer. + * @property {object[] | { keys: object[] }} [jwks] public JWK Set to publish at `jwks_uri`. + * @property {Record} endpoints endpoint URLs/paths to advertise (authorization + token required). + * @property {{ supported?: string[], id_token?: string[], userinfo?: string[] }} [claims] claim policy. + * @property {string[]} [scopes] advertised scopes (default openid/profile/email). + * @property {string[]} [authMethods] token_endpoint_auth_methods_supported. + * @property {{ resolve: (accessToken: string) => (Promise<{ sub: string, scope?: string|string[], claims?: Record } | null> | { sub: string, scope?: string|string[], claims?: Record } | null) }} [userinfo] access-token resolver for the UserInfo endpoint. + * @property {{ postLogoutRedirectUris?: string[], onLogout?: (ctx: { sub?: string, idTokenHint?: string }) => unknown }} [logout] RP-Initiated Logout policy. + * @property {{ cookieName?: string }} [session] enable Session Management; `cookieName` names the OP browser-state cookie. + */ + +/** + * Create an OpenID Provider add-on. + * + * @param {OidcProviderConfig} config + */ +export function createProvider(config) { + if (!isObject(config)) { + invalidArgument('createProvider(config): config must be an object.'); + } + const { issuer, signing, endpoints } = config; + + if (!isNonEmptyString(issuer)) { + invalidArgument('createProvider(config): `issuer` must be a non-empty string.'); + } + if (!isObject(signing) || signing.key === undefined || signing.key === null || !isNonEmptyString(signing.alg)) { + invalidArgument('createProvider(config): `signing` must be { key, alg }.'); + } + if (!isObject(endpoints) || !isNonEmptyString(endpoints.authorization) || !isNonEmptyString(endpoints.token)) { + invalidArgument('createProvider(config): `endpoints` must include `authorization` and `token`.'); + } + if (config.claims !== undefined && !isObject(config.claims)) { + invalidArgument('createProvider(config): `claims` must be an object when provided.'); + } + + const claimsPolicy = config.claims ?? {}; + const scopes = isArray(config.scopes) ? config.scopes : DEFAULT_SCOPES; + + // Resolve every advertised endpoint to an absolute URL; default the two the + // provider itself serves (userinfo + jwks) to conventional paths. + const resolved = { + authorization: toAbsolute(endpoints.authorization, issuer), + token: toAbsolute(endpoints.token, issuer), + jwks: toAbsolute(endpoints.jwks ?? '/.well-known/jwks.json', issuer), + }; + // UserInfo is advertised only when a resolver is configured — a provider + // that can't back the endpoint must not announce it in discovery. + if (isObject(config.userinfo) || isNonEmptyString(endpoints.userinfo)) { + resolved.userinfo = toAbsolute(endpoints.userinfo ?? '/userinfo', issuer); + } + for (const optional of ['endSession', 'checkSession', 'revocation', 'introspection', 'registration']) { + if (isNonEmptyString(endpoints[optional])) { + resolved[optional] = toAbsolute(endpoints[optional], issuer); + } + } + // Configuring logout auto-advertises the end-session endpoint at its + // conventional path when the caller did not pin one. + if (isObject(config.logout) && !resolved.endSession) { + resolved.endSession = toAbsolute('/end_session', issuer); + } + // Likewise for Session Management's check-session iframe. + if (isObject(config.session) && !resolved.checkSession) { + resolved.checkSession = toAbsolute('/check_session', issuer); + } + + const discoveryDoc = buildDiscoveryDocument({ + issuer, + endpoints: resolved, + scopes, + claimsSupported: isArray(claimsPolicy.supported) ? claimsPolicy.supported : DEFAULT_CLAIMS_SUPPORTED, + idTokenAlgs: [signing.alg], + authMethods: config.authMethods, + }); + + const publicJwks = normalizeJwks(config.jwks); + + const idTokenSigner = createIdTokenSigner({ + signingKey: signing.key, + alg: signing.alg, + kid: signing.kid, + expiresIn: signing.expiresIn, + }); + + return { + issuer, + idTokenSigner, + + /** The assembled discovery document (also served by `discoveryHandler`). */ + metadata() { + return discoveryDoc; + }, + + /** + * Serves `/.well-known/openid-configuration`. Cacheable. + * @returns {(req?: object) => import('../internal/http-io.js').OidcResponse} + */ + discoveryHandler() { + return () => jsonResponse(200, discoveryDoc, { 'cache-control': 'public, max-age=3600' }); + }, + + /** + * Serves the published JWKS at `jwks_uri`. Cacheable. + * @returns {(req?: object) => import('../internal/http-io.js').OidcResponse} + */ + jwksHandler() { + return () => jsonResponse(200, { keys: publicJwks }, { 'cache-control': 'public, max-age=3600' }); + }, + + /** + * Serves the UserInfo endpoint (OIDC Core §5.3). Requires `config.userinfo. + * resolve` to turn a Bearer access token into `{ sub, scope, claims }`. + * @returns {(req: object) => Promise} + */ + userinfoHandler() { + if (!isObject(config.userinfo) || typeof config.userinfo.resolve !== 'function') { + invalidArgument('userinfoHandler(): config.userinfo.resolve must be a function.'); + } + const resolve = config.userinfo.resolve; + return async raw => { + const req = normalizeRequest(raw); + const token = bearerToken(req); + if (!token) { + return unauthorized('invalid_request', 'a Bearer access token is required'); + } + let resolved; + try { + resolved = await resolve(token); + } catch { + resolved = null; + } + if (!isObject(resolved) || !isNonEmptyString(resolved.sub)) { + return unauthorized('invalid_token', 'the access token is invalid or expired'); + } + const grantedScopes = toScopeArray(resolved.scope); + const body = buildUserInfo(resolved.sub, resolved.claims ?? {}, grantedScopes, claimsPolicy.userinfo); + return jsonResponse(200, body, { 'cache-control': 'no-store', pragma: 'no-cache' }); + }; + }, + + /** + * Serves the RP-Initiated Logout endpoint (OIDC RP-Initiated Logout 1.0). + * Validates `post_logout_redirect_uri` against the client's registered + * URIs before redirecting; calls `config.logout.onLogout` (when given) to + * clear the OP session. + * @returns {(req: object) => Promise} + */ + endSessionHandler() { + const logout = isObject(config.logout) ? config.logout : {}; + const registered = isArray(logout.postLogoutRedirectUris) ? logout.postLogoutRedirectUris : []; + const onLogout = typeof logout.onLogout === 'function' ? logout.onLogout : undefined; + return async raw => { + const req = normalizeRequest(raw); + const idTokenHint = req.param('id_token_hint'); + const postLogout = req.param('post_logout_redirect_uri'); + const state = req.param('state'); + const hint = readIdTokenHint(idTokenHint, issuer); + + if (onLogout) { + try { + await onLogout({ sub: hint ? hint.sub : undefined, idTokenHint }); + } catch { + // Session teardown is best-effort — never block the logout redirect. + } + } + + if (isNonEmptyString(postLogout)) { + if (!isRegisteredPostLogoutUri(postLogout, registered)) { + return jsonResponse( + 400, + { error: 'invalid_request', error_description: 'post_logout_redirect_uri is not registered' }, + { 'cache-control': 'no-store' }, + ); + } + const target = new URL(postLogout); + if (isNonEmptyString(state)) { + target.searchParams.set('state', state); + } + return redirectResponse(target.toString(), { 'cache-control': 'no-store' }); + } + + return jsonResponse(200, { logged_out: true }, { 'cache-control': 'no-store' }); + }; + }, + + /** + * Compute a `session_state` (OIDC Session Management §4.2) for an auth + * response, from the client id, the RP's origin and the OP browser-state + * value the OP set in the user's browser. + * + * @param {{ clientId: string, origin: string, opBrowserState: string, salt?: string }} input + * @returns {string} + */ + sessionState(input) { + if ( + !isObject(input) || + !isNonEmptyString(input.clientId) || + !isNonEmptyString(input.origin) || + !isNonEmptyString(input.opBrowserState) + ) { + invalidArgument('sessionState(input): { clientId, origin, opBrowserState } are required.'); + } + return computeSessionState(input); + }, + + /** + * Serves the OP `check_session_iframe` document (OIDC Session Management + * §4.2). Cacheable; reads the OP browser-state cookie named + * `config.session.cookieName` (default `op_browser_state`). + * @returns {(req?: object) => import('../internal/http-io.js').OidcResponse} + */ + checkSessionHandler() { + const cookieName = isObject(config.session) ? config.session.cookieName : undefined; + const html = checkSessionIframeHtml({ cookieName }); + return () => htmlResponse(200, html, { 'cache-control': 'public, max-age=3600' }); + }, + }; +} + +/** + * @param {string} value absolute URL or a path resolved against `issuer` + * @param {string} issuer + * @returns {string} + */ +function toAbsolute(value, issuer) { + if (/^https?:\/\//i.test(value)) { + return value; + } + return new URL(value, issuer.endsWith('/') ? issuer : `${issuer}/`).toString(); +} + +/** @param {object[] | { keys: object[] } | undefined} jwks */ +function normalizeJwks(jwks) { + if (isArray(jwks)) { + return jwks; + } + if (isObject(jwks) && isArray(jwks.keys)) { + return jwks.keys; + } + return []; +} + +/** @param {import('../internal/http-io.js').OidcRequest} req */ +function bearerToken(req) { + const auth = req.header('authorization'); + if (isNonEmptyString(auth) && auth.slice(0, 7).toLowerCase() === 'bearer ') { + return auth.slice(7).trim(); + } + // OIDC Core §5.3.1 also permits the token as an `access_token` form/query param. + const param = req.param('access_token'); + return isNonEmptyString(param) ? param : undefined; +} + +/** @param {string|string[]|undefined} scope */ +function toScopeArray(scope) { + if (isArray(scope)) { + return scope; + } + if (isNonEmptyString(scope)) { + return scope.split(/\s+/).filter(Boolean); + } + return []; +} + +/** + * @param {string} error + * @param {string} description + * @returns {import('../internal/http-io.js').OidcResponse} + */ +function unauthorized(error, description) { + return jsonResponse( + 401, + { error, error_description: description }, + { 'www-authenticate': `Bearer error="${error}", error_description="${description}"`, 'cache-control': 'no-store' }, + ); +} diff --git a/packages/oidc/src/provider/routes.js b/packages/oidc/src/provider/routes.js new file mode 100644 index 00000000..2d3c1a98 --- /dev/null +++ b/packages/oidc/src/provider/routes.js @@ -0,0 +1,55 @@ +/** + * Shared route table for the provider's framework adapters. The endpoints a + * provider actually serves are exactly the ones its discovery document + * advertises, so the table is derived from `provider.metadata()` — mount only + * what is announced, at the path the metadata names. + */ +import { isNonEmptyString } from '@exortek/shared/predicates'; + +/** + * @param {ReturnType} provider + * @returns {Record} + */ +export function providerRoutes(provider) { + const meta = provider.metadata(); + /** @type {Record} */ + const routes = { + discovery: { method: 'GET', path: '/.well-known/openid-configuration', handler: provider.discoveryHandler() }, + jwks: { method: 'GET', path: pathOf(meta.jwks_uri), handler: provider.jwksHandler() }, + }; + if (isNonEmptyString(meta.userinfo_endpoint)) { + routes.userinfo = { method: 'GET', path: pathOf(meta.userinfo_endpoint), handler: provider.userinfoHandler() }; + } + if (isNonEmptyString(meta.end_session_endpoint)) { + routes.endSession = { + method: 'GET', + path: pathOf(meta.end_session_endpoint), + handler: provider.endSessionHandler(), + }; + } + if (isNonEmptyString(meta.check_session_iframe)) { + routes.checkSession = { + method: 'GET', + path: pathOf(meta.check_session_iframe), + handler: provider.checkSessionHandler(), + }; + } + return routes; +} + +/** + * The path portion of an advertised endpoint URL (absolute or already a path). + * + * @param {unknown} url + * @returns {string} + */ +export function pathOf(url) { + if (!isNonEmptyString(url)) { + return '/'; + } + try { + return new URL(url).pathname; + } catch { + return url.startsWith('/') ? url : `/${url}`; + } +} diff --git a/packages/oidc/tests/adapters.test.js b/packages/oidc/tests/adapters.test.js new file mode 100644 index 00000000..6ef29559 --- /dev/null +++ b/packages/oidc/tests/adapters.test.js @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import Fastify from 'fastify'; + +import { createClient, createProvider } from '../src/index.js'; +import { mountOidcProvider } from '../src/provider/express.js'; +import { oidcProviderPlugin } from '../src/provider/fastify.js'; +import { oidcLoginPlugin } from '../src/client/fastify.js'; +import { makeSigner, startStubAS } from './helpers/oidc.js'; + +const ISSUER = 'https://auth.example.com'; +const CLIENT_ID = 'test-client'; + +/** @type {Array<() => Promise>} */ +const cleanups = []; +afterEach(async () => { + while (cleanups.length) { + await cleanups.pop()(); + } +}); + +async function providerFor(extra = {}) { + const signer = await makeSigner(); + return createProvider({ + issuer: ISSUER, + signing: { key: signer.privateJwk, alg: signer.alg, kid: signer.kid }, + jwks: [signer.publicJwk], + endpoints: { authorization: '/authorize', token: '/token' }, + userinfo: { + resolve: t => (t === 'ok' ? { sub: 'u1', scope: 'openid email', claims: { email: 'u@e.com' } } : null), + }, + logout: { postLogoutRedirectUris: ['https://app.example.com/out'] }, + session: {}, + ...extra, + }); +} + +describe('provider fastify plugin', () => { + it('serves discovery, jwks, userinfo, end_session and check_session', async () => { + const app = Fastify(); + cleanups.push(() => app.close()); + await app.register(oidcProviderPlugin, { provider: await providerFor() }); + await app.ready(); + + const disco = await app.inject({ method: 'GET', url: '/.well-known/openid-configuration' }); + assert.equal(disco.statusCode, 200); + assert.equal(JSON.parse(disco.body).issuer, ISSUER); + + const jwks = await app.inject({ method: 'GET', url: '/.well-known/jwks.json' }); + assert.equal(JSON.parse(jwks.body).keys.length, 1); + + const ui = await app.inject({ method: 'GET', url: '/userinfo', headers: { authorization: 'Bearer ok' } }); + assert.equal(ui.statusCode, 200); + assert.equal(JSON.parse(ui.body).email, 'u@e.com'); + + const ui401 = await app.inject({ method: 'GET', url: '/userinfo' }); + assert.equal(ui401.statusCode, 401); + + const cs = await app.inject({ method: 'GET', url: '/check_session' }); + assert.match(cs.headers['content-type'], /text\/html/); + }); +}); + +describe('provider express mount', () => { + it('registers a route per advertised endpoint and the handler responds', async () => { + const routes = []; + const app = { + get: (path, handler) => routes.push({ path, handler }), + }; + mountOidcProvider(app, await providerFor()); + const paths = routes.map(r => r.path); + assert.ok(paths.includes('/.well-known/openid-configuration')); + assert.ok(paths.includes('/.well-known/jwks.json')); + assert.ok(paths.includes('/userinfo')); + assert.ok(paths.includes('/end_session')); + assert.ok(paths.includes('/check_session')); + + // Invoke the discovery handler through a mock res. + const discovery = routes.find(r => r.path === '/.well-known/openid-configuration').handler; + const rec = mockRes(); + await discovery({ method: 'GET', url: '/.well-known/openid-configuration', headers: {}, query: {} }, rec); + assert.equal(rec.statusCode, 200); + assert.equal(JSON.parse(rec.body).issuer, ISSUER); + }); +}); + +describe('client fastify login plugin', () => { + it('runs a browser login round-trip against a stub OP', async () => { + const signer = await makeSigner(); + const holder = { idToken: '' }; + const as = await startStubAS({ + publicJwks: [signer.publicJwk], + token: () => ({ access_token: 'at', token_type: 'Bearer', id_token: holder.idToken }), + userinfo: () => ({ sub: 'user-1', email: 'u@e.com' }), + }); + cleanups.push(as.close); + + const client = createClient({ + issuer: as.issuer, + clientId: CLIENT_ID, + redirectUri: 'https://app.example.com/callback', + jwksOptions: { allowInsecure: true }, + }); + + let captured; + const app = Fastify(); + cleanups.push(() => app.close()); + await app.register(oidcLoginPlugin, { + client, + cookie: { secure: false }, + onSuccess: ({ reply, claims }) => { + captured = claims; + reply.status(200).send('ok'); + }, + }); + await app.ready(); + + const start = await app.inject({ method: 'GET', url: '/login' }); + assert.equal(start.statusCode, 302); + const authUrl = new URL(start.headers.location); + const nonce = authUrl.searchParams.get('nonce'); + const state = authUrl.searchParams.get('state'); + const session = cookieValue(start.headers['set-cookie'], 'oidc_flow'); + + holder.idToken = await signer.mint({ iss: as.issuer, sub: 'user-1', aud: CLIENT_ID, nonce }); + + const cb = await app.inject({ + method: 'GET', + url: `/callback?code=abc&state=${encodeURIComponent(state)}`, + headers: { cookie: `oidc_flow=${session}` }, + }); + assert.equal(cb.statusCode, 200); + assert.equal(captured.sub, 'user-1'); + }); +}); + +function mockRes() { + return { + statusCode: 0, + headers: {}, + body: '', + status(code) { + this.statusCode = code; + return this; + }, + setHeader(name, value) { + this.headers[name] = value; + }, + send(body) { + this.body = body; + return this; + }, + }; +} + +/** @param {string|string[]|undefined} setCookie @param {string} name */ +function cookieValue(setCookie, name) { + const list = Array.isArray(setCookie) ? setCookie : [setCookie]; + for (const raw of list) { + if (typeof raw === 'string' && raw.startsWith(`${name}=`)) { + return raw.slice(name.length + 1).split(';')[0]; + } + } + return undefined; +} diff --git a/packages/oidc/tests/client.test.js b/packages/oidc/tests/client.test.js new file mode 100644 index 00000000..48a43511 --- /dev/null +++ b/packages/oidc/tests/client.test.js @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import { ErrorCode, OidcError, createClient } from '../src/index.js'; +import { makeSigner, startStubAS } from './helpers/oidc.js'; + +const CLIENT_ID = 'test-client'; +const REDIRECT_URI = 'https://app.example.com/callback'; + +/** @type {Array<() => Promise>} */ +const cleanups = []; +afterEach(async () => { + while (cleanups.length) { + await cleanups.pop()(); + } +}); + +/** + * Stand up a stub OP and an oidc client pointed at it. The token endpoint + * serves whatever id_token the test mints into `holder.idToken` before the + * exchange (the sync stub handler can't await, but the id_token is known + * once `authorize` has produced the nonce). + */ +async function rig({ userinfo } = {}) { + const signer = await makeSigner(); + const holder = { idToken: '' }; + const as = await startStubAS({ + publicJwks: [signer.publicJwk], + token: () => ({ + access_token: 'access-token-value', + token_type: 'Bearer', + refresh_token: 'refresh-token-value', + id_token: holder.idToken, + }), + userinfo, + }); + cleanups.push(as.close); + const client = createClient({ + issuer: as.issuer, + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + // The stub AS serves its JWKS over http on loopback. + jwksOptions: { allowInsecure: true }, + }); + return { as, client, signer, holder }; +} + +describe('createClient config guards', () => { + it('rejects a non-object config', () => { + assert.throws(() => createClient(null), { code: ErrorCode.INVALID_ARGUMENT }); + }); + + it('rejects a missing issuer', () => { + assert.throws( + () => createClient({ clientId: 'a', redirectUri: REDIRECT_URI }), + err => { + assert.ok(err instanceof OidcError); + assert.equal(err.code, ErrorCode.INVALID_ARGUMENT); + assert.equal(err.status, 400); + return true; + }, + ); + }); + + it('rejects a non-array scope', () => { + assert.throws( + () => createClient({ issuer: 'https://x', clientId: 'a', redirectUri: REDIRECT_URI, scope: 'openid' }), + { code: ErrorCode.INVALID_ARGUMENT }, + ); + }); +}); + +describe('createClient.authorize', () => { + it('builds an authorization URL carrying openid scope, state and nonce', async () => { + const { client } = await rig(); + const { url, session } = await client.authorize(); + const p = new URL(url).searchParams; + assert.ok(p.get('scope').split(' ').includes('openid')); + assert.ok(p.get('state')); + assert.ok(p.get('nonce')); + assert.ok(p.get('code_challenge')); + assert.equal(typeof session, 'string'); + }); + + it('threads OIDC auth params (prompt, login_hint, max_age) onto the URL', async () => { + const { client } = await rig(); + const { url } = await client.authorize({ prompt: 'login', loginHint: 'a@b.com', maxAge: 3600 }); + const p = new URL(url).searchParams; + assert.equal(p.get('prompt'), 'login'); + assert.equal(p.get('login_hint'), 'a@b.com'); + assert.equal(p.get('max_age'), '3600'); + }); +}); + +describe('createClient.handleCallback', () => { + it('verifies the id_token and returns claims + userinfo', async () => { + const { as, client, signer, holder } = await rig({ + userinfo: () => ({ sub: 'user-123', email: 'user@example.com', name: 'Test User' }), + }); + + const { url, session } = await client.authorize(); + const params = new URL(url).searchParams; + + // Mint the id_token bound to this flow's nonce before the exchange. + holder.idToken = await signer.mint({ iss: as.issuer, sub: 'user-123', aud: CLIENT_ID, nonce: params.get('nonce') }); + + const result = await client.handleCallback({ code: 'auth-code', state: params.get('state') }, { session }); + + assert.equal(result.claims.sub, 'user-123'); + assert.equal(result.claims.aud, CLIENT_ID); + assert.equal(result.userinfo.email, 'user@example.com'); + assert.equal(typeof result.idToken, 'string'); + assert.equal(result.tokens.access_token, 'access-token-value'); + }); + + it('rejects a callback whose id_token nonce does not match', async () => { + const { as, client, signer, holder } = await rig(); + const { url, session } = await client.authorize(); + const params = new URL(url).searchParams; + holder.idToken = await signer.mint({ iss: as.issuer, sub: 'user-123', aud: CLIENT_ID, nonce: 'wrong-nonce' }); + await assert.rejects(client.handleCallback({ code: 'auth-code', state: params.get('state') }, { session })); + }); +}); diff --git a/packages/oidc/tests/helpers/oidc.js b/packages/oidc/tests/helpers/oidc.js new file mode 100644 index 00000000..179933f2 --- /dev/null +++ b/packages/oidc/tests/helpers/oidc.js @@ -0,0 +1,90 @@ +/** + * Hermetic OIDC test rig — a locally-signed id_token plus a stub + * authorization server that serves the matching JWKS, discovery document, + * token, and userinfo endpoints. No network, no live provider. Adapted from + * the `@exortek/oauth2` test helper. + */ +import { createServer } from 'node:http'; + +import { generate } from '@exortek/jwk/generate'; +import { sign } from '@exortek/jwt'; + +/** + * Generate an EC P-256 signing key and return a signer plus its public JWK + * (for the stub JWKS). + * + * @param {{ kid?: string, alg?: string }} [opts] + */ +export async function makeSigner({ kid = 'test-key-1', alg = 'ES256' } = {}) { + const { publicJwk, privateJwk } = await generate('EC', { curve: 'P-256', kid, alg, use: 'sig' }); + + /** + * @param {Record} claims + * @param {import('@exortek/jwt').SignOptions} [opts] + */ + const mint = (claims, opts = {}) => sign(claims, privateJwk, { alg, kid, ...opts }); + + return { publicJwk, privateJwk, mint, kid, alg }; +} + +/** + * Start a stub AS. Pass the public JWK(s) to publish and per-endpoint + * handlers. Returns `{ base, issuer, jwksUri, close }`. + * + * @param {{ + * publicJwks: object[], + * token?: (body: URLSearchParams) => object, + * userinfo?: (auth: string | undefined) => object, + * extraDiscovery?: object, + * }} config + */ +export async function startStubAS(config) { + const { publicJwks, token, userinfo, extraDiscovery } = config; + let base = ''; + const server = createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const json = (status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + if (url.pathname === '/.well-known/jwks.json') { + return json(200, { keys: publicJwks }); + } + if (url.pathname === '/.well-known/openid-configuration') { + return json(200, { + issuer: base, + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/token`, + userinfo_endpoint: `${base}/userinfo`, + jwks_uri: `${base}/.well-known/jwks.json`, + end_session_endpoint: `${base}/end_session`, + ...extraDiscovery, + }); + } + if (url.pathname === '/token' && req.method === 'POST') { + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => { + const body = new URLSearchParams(Buffer.concat(chunks).toString()); + json(200, token ? token(body) : { access_token: 'at', token_type: 'Bearer' }); + }); + return; + } + if (url.pathname === '/userinfo') { + return json(200, userinfo ? userinfo(req.headers.authorization) : {}); + } + json(404, { error: 'not_found' }); + }); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = /** @type {import('node:net').AddressInfo} */ (server.address()); + base = `http://127.0.0.1:${port}`; + + return { + base, + issuer: base, + jwksUri: `${base}/.well-known/jwks.json`, + close: () => new Promise(resolve => server.close(resolve)), + }; +} diff --git a/packages/oidc/tests/logout.test.js b/packages/oidc/tests/logout.test.js new file mode 100644 index 00000000..f9803759 --- /dev/null +++ b/packages/oidc/tests/logout.test.js @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import { ErrorCode, createClient, createProvider } from '../src/index.js'; +import { _clearIssuerMetadataCache } from '../src/internal/issuer-discovery.js'; +import { makeSigner, startStubAS } from './helpers/oidc.js'; + +const ISSUER = 'https://auth.example.com'; +const CLIENT_ID = 'test-client'; +const POST_LOGOUT = 'https://app.example.com/after-logout'; + +/** @type {Array<() => Promise>} */ +const cleanups = []; +afterEach(async () => { + _clearIssuerMetadataCache(); + while (cleanups.length) { + await cleanups.pop()(); + } +}); + +async function makeProvider() { + const signer = await makeSigner(); + const provider = createProvider({ + issuer: ISSUER, + signing: { key: signer.privateJwk, alg: signer.alg, kid: signer.kid }, + jwks: [signer.publicJwk], + endpoints: { authorization: '/authorize', token: '/token' }, + logout: { postLogoutRedirectUris: [POST_LOGOUT] }, + }); + return { provider, signer }; +} + +describe('client.endSessionUrl', () => { + it('discovers end_session_endpoint and builds the logout URL', async () => { + const signer = await makeSigner(); + const as = await startStubAS({ publicJwks: [signer.publicJwk] }); + cleanups.push(as.close); + + const client = createClient({ issuer: as.issuer, clientId: CLIENT_ID, redirectUri: POST_LOGOUT }); + const hint = await signer.mint({ iss: as.issuer, sub: 'u1', aud: CLIENT_ID }); + const url = await client.endSessionUrl({ idTokenHint: hint, postLogoutRedirectUri: POST_LOGOUT, state: 'xyz' }); + + const parsed = new URL(url); + assert.equal(parsed.origin + parsed.pathname, `${as.base}/end_session`); + assert.equal(parsed.searchParams.get('id_token_hint'), hint); + assert.equal(parsed.searchParams.get('post_logout_redirect_uri'), POST_LOGOUT); + assert.equal(parsed.searchParams.get('state'), 'xyz'); + assert.equal(parsed.searchParams.get('client_id'), CLIENT_ID); + }); + + it('prefers an explicit endSessionEndpoint over discovery', async () => { + const client = createClient({ + issuer: ISSUER, + clientId: CLIENT_ID, + redirectUri: POST_LOGOUT, + endSessionEndpoint: 'https://auth.example.com/logout', + }); + const url = await client.endSessionUrl({ idTokenHint: 'hint-token' }); + assert.equal(new URL(url).origin + new URL(url).pathname, 'https://auth.example.com/logout'); + }); + + it('requires an idTokenHint', async () => { + const client = createClient({ issuer: ISSUER, clientId: CLIENT_ID, redirectUri: POST_LOGOUT }); + await assert.rejects(client.endSessionUrl({}), { code: ErrorCode.INVALID_ARGUMENT }); + }); +}); + +describe('provider.endSessionHandler', () => { + it('advertises end_session_endpoint once logout is configured', async () => { + const { provider } = await makeProvider(); + const doc = JSON.parse(provider.discoveryHandler()().body); + assert.equal(doc.end_session_endpoint, `${ISSUER}/end_session`); + }); + + it('redirects to a registered post_logout_redirect_uri with state, and clears the session', async () => { + const signer = await makeSigner(); + const seen = []; + const provider = createProvider({ + issuer: ISSUER, + signing: { key: signer.privateJwk, alg: signer.alg, kid: signer.kid }, + jwks: [signer.publicJwk], + endpoints: { authorization: '/authorize', token: '/token' }, + logout: { postLogoutRedirectUris: [POST_LOGOUT], onLogout: ctx => seen.push(ctx.sub) }, + }); + const hint = await signer.mint({ iss: ISSUER, sub: 'user-9', aud: CLIENT_ID }); + + const res = await provider.endSessionHandler()({ + query: { id_token_hint: hint, post_logout_redirect_uri: POST_LOGOUT, state: 's1' }, + }); + assert.equal(res.status, 302); + const loc = new URL(res.headers.location); + assert.equal(loc.origin + loc.pathname, POST_LOGOUT); + assert.equal(loc.searchParams.get('state'), 's1'); + assert.deepEqual(seen, ['user-9']); + }); + + it('rejects an unregistered post_logout_redirect_uri', async () => { + const { provider } = await makeProvider(); + const res = await provider.endSessionHandler()({ + query: { post_logout_redirect_uri: 'https://evil.example/steal' }, + }); + assert.equal(res.status, 400); + }); + + it('returns a logged_out confirmation when no redirect is requested', async () => { + const { provider } = await makeProvider(); + const res = await provider.endSessionHandler()({ query: {} }); + assert.equal(res.status, 200); + assert.equal(JSON.parse(res.body).logged_out, true); + }); +}); diff --git a/packages/oidc/tests/provider.test.js b/packages/oidc/tests/provider.test.js new file mode 100644 index 00000000..8798c52a --- /dev/null +++ b/packages/oidc/tests/provider.test.js @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { ErrorCode, createProvider } from '../src/index.js'; +import { makeSigner } from './helpers/oidc.js'; + +const ISSUER = 'https://auth.example.com'; + +async function makeProvider(overrides = {}) { + const signer = await makeSigner(); + const provider = createProvider({ + issuer: ISSUER, + signing: { key: signer.privateJwk, alg: signer.alg, kid: signer.kid }, + jwks: [signer.publicJwk], + endpoints: { authorization: '/authorize', token: '/token' }, + claims: { supported: ['sub', 'email', 'email_verified', 'name'] }, + userinfo: { resolve: () => null }, + ...overrides, + }); + return { provider, signer }; +} + +describe('createProvider config guards', () => { + it('rejects a non-object config', () => { + assert.throws(() => createProvider(null), { code: ErrorCode.INVALID_ARGUMENT }); + }); + + it('rejects missing signing', () => { + assert.throws(() => createProvider({ issuer: ISSUER, endpoints: { authorization: '/a', token: '/t' } }), { + code: ErrorCode.INVALID_ARGUMENT, + }); + }); + + it('rejects endpoints without authorization/token', async () => { + const signer = await makeSigner(); + assert.throws( + () => createProvider({ issuer: ISSUER, signing: { key: signer.privateJwk, alg: signer.alg }, endpoints: {} }), + { code: ErrorCode.INVALID_ARGUMENT }, + ); + }); +}); + +describe('discoveryHandler', () => { + it('serves an OIDC discovery document with resolved absolute endpoints', async () => { + const { provider } = await makeProvider(); + const res = provider.discoveryHandler()(); + assert.equal(res.status, 200); + assert.match(res.headers['cache-control'], /max-age/); + const doc = JSON.parse(res.body); + assert.equal(doc.issuer, ISSUER); + assert.equal(doc.authorization_endpoint, `${ISSUER}/authorize`); + assert.equal(doc.token_endpoint, `${ISSUER}/token`); + assert.equal(doc.userinfo_endpoint, `${ISSUER}/userinfo`); + assert.equal(doc.jwks_uri, `${ISSUER}/.well-known/jwks.json`); + assert.deepEqual(doc.response_types_supported, ['code']); + assert.deepEqual(doc.subject_types_supported, ['public']); + assert.deepEqual(doc.code_challenge_methods_supported, ['S256']); + assert.ok(doc.id_token_signing_alg_values_supported.includes('ES256')); + assert.ok(doc.claims_supported.includes('email')); + }); + + it('omits end_session/check_session until they are configured', async () => { + const { provider } = await makeProvider(); + const doc = JSON.parse(provider.discoveryHandler()().body); + assert.equal('end_session_endpoint' in doc, false); + assert.equal('check_session_iframe' in doc, false); + }); +}); + +describe('jwksHandler', () => { + it('publishes the configured public JWK set', async () => { + const { provider, signer } = await makeProvider(); + const res = provider.jwksHandler()(); + assert.equal(res.status, 200); + const body = JSON.parse(res.body); + assert.equal(body.keys.length, 1); + assert.equal(body.keys[0].kid, signer.kid); + assert.equal(body.keys[0].kty, 'EC'); + }); +}); + +describe('idTokenSigner', () => { + it('signs a verifiable id_token', async () => { + const { provider } = await makeProvider(); + const idToken = await provider.idTokenSigner.sign({ + subject: 'user-1', + clientId: 'client-1', + issuer: ISSUER, + nonce: 'n-1', + }); + assert.equal(typeof idToken, 'string'); + assert.equal(idToken.split('.').length, 3); + }); +}); + +describe('userinfoHandler', () => { + const resolve = async token => { + if (token !== 'good-token') { + return null; + } + return { + sub: 'user-1', + scope: 'openid email profile', + claims: { email: 'u@example.com', email_verified: true, name: 'U', phone_number: '+100' }, + }; + }; + + it('returns sub + scope-releasable claims, filtered by the userinfo policy', async () => { + const { provider } = await makeProvider({ + userinfo: { resolve }, + claims: { supported: ['sub', 'email', 'name'], userinfo: ['email', 'email_verified', 'name'] }, + }); + const res = await provider.userinfoHandler()({ headers: { authorization: 'Bearer good-token' } }); + assert.equal(res.status, 200); + assert.equal(res.headers['cache-control'], 'no-store'); + const body = JSON.parse(res.body); + assert.equal(body.sub, 'user-1'); + assert.equal(body.email, 'u@example.com'); + assert.equal(body.email_verified, true); + assert.equal(body.name, 'U'); + // phone_number is not released — `phone` scope was not granted. + assert.equal('phone_number' in body, false); + }); + + it('401s with WWW-Authenticate when no token is present', async () => { + const { provider } = await makeProvider({ userinfo: { resolve } }); + const res = await provider.userinfoHandler()({ headers: {} }); + assert.equal(res.status, 401); + assert.match(res.headers['www-authenticate'], /Bearer/); + }); + + it('401s invalid_token when the token does not resolve', async () => { + const { provider } = await makeProvider({ userinfo: { resolve } }); + const res = await provider.userinfoHandler()({ headers: { authorization: 'Bearer nope' } }); + assert.equal(res.status, 401); + assert.match(res.headers['www-authenticate'], /invalid_token/); + }); + + it('throws INVALID_ARGUMENT if userinfo.resolve is missing', async () => { + const { provider } = await makeProvider({ userinfo: undefined }); + assert.throws(() => provider.userinfoHandler(), { code: ErrorCode.INVALID_ARGUMENT }); + }); +}); diff --git a/packages/oidc/tests/session.test.js b/packages/oidc/tests/session.test.js new file mode 100644 index 00000000..bb109346 --- /dev/null +++ b/packages/oidc/tests/session.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { ErrorCode, createProvider } from '../src/index.js'; +import { computeSessionState } from '../src/internal/session.js'; +import { makeSigner } from './helpers/oidc.js'; + +const ISSUER = 'https://auth.example.com'; + +async function makeProvider(session = {}) { + const signer = await makeSigner(); + return createProvider({ + issuer: ISSUER, + signing: { key: signer.privateJwk, alg: signer.alg, kid: signer.kid }, + jwks: [signer.publicJwk], + endpoints: { authorization: '/authorize', token: '/token' }, + session, + }); +} + +describe('computeSessionState', () => { + it('is deterministic for the same inputs + salt and changes with browser state', () => { + const a = computeSessionState({ clientId: 'c', origin: 'https://app', opBrowserState: 'state-1', salt: 'salty' }); + const b = computeSessionState({ clientId: 'c', origin: 'https://app', opBrowserState: 'state-1', salt: 'salty' }); + const c = computeSessionState({ clientId: 'c', origin: 'https://app', opBrowserState: 'state-2', salt: 'salty' }); + assert.equal(a, b); + assert.notEqual(a, c); + assert.match(a, /^[\w-]+\.salty$/); + }); + + it('generates a random salt when none is given', () => { + const a = computeSessionState({ clientId: 'c', origin: 'https://app', opBrowserState: 's' }); + const b = computeSessionState({ clientId: 'c', origin: 'https://app', opBrowserState: 's' }); + assert.notEqual(a.split('.')[1], b.split('.')[1]); + }); +}); + +describe('provider session management', () => { + it('advertises check_session_iframe once session is configured', async () => { + const provider = await makeProvider(); + const doc = JSON.parse(provider.discoveryHandler()().body); + assert.equal(doc.check_session_iframe, `${ISSUER}/check_session`); + }); + + it('sessionState() validates its input', async () => { + const provider = await makeProvider(); + assert.throws(() => provider.sessionState({ clientId: 'c' }), { code: ErrorCode.INVALID_ARGUMENT }); + const ss = provider.sessionState({ clientId: 'c', origin: 'https://app', opBrowserState: 'x' }); + assert.match(ss, /\./); + }); + + it('serves an HTML check-session iframe embedding the configured cookie name', async () => { + const provider = await makeProvider({ cookieName: 'my_op_state' }); + const res = provider.checkSessionHandler()(); + assert.equal(res.status, 200); + assert.match(res.headers['content-type'], /text\/html/); + assert.match(res.body, /addEventListener\('message'/); + assert.match(res.body, /"my_op_state"/); + }); +}); diff --git a/packages/oidc/tests/smoke.test.js b/packages/oidc/tests/smoke.test.js new file mode 100644 index 00000000..920125bd --- /dev/null +++ b/packages/oidc/tests/smoke.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { ErrorCode, OidcError, createClient, createProvider } from '../src/index.js'; +import { createClient as createClientSub } from '../src/client/index.js'; +import { createProvider as createProviderSub } from '../src/provider/index.js'; + +describe('@exortek/oidc surface', () => { + it('exposes the public surface from the root barrel', () => { + assert.equal(typeof createClient, 'function'); + assert.equal(typeof createProvider, 'function'); + assert.equal(typeof OidcError, 'function'); + assert.equal(ErrorCode.INVALID_ARGUMENT, 'INVALID_ARGUMENT'); + }); + + it('subpath entries resolve to the same factories', () => { + assert.equal(createClientSub, createClient); + assert.equal(createProviderSub, createProvider); + }); +}); diff --git a/packages/oidc/tsconfig.json b/packages/oidc/tsconfig.json new file mode 100644 index 00000000..3a8a08a7 --- /dev/null +++ b/packages/oidc/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.js"], + "exclude": ["tests", "dist", "node_modules"] +} diff --git a/scripts/setup-labels.sh b/scripts/setup-labels.sh index 61e4cdc0..db44ac1c 100644 --- a/scripts/setup-labels.sh +++ b/scripts/setup-labels.sh @@ -44,6 +44,7 @@ LABELS=( "pkg:passkey|9dd6ff|Concerns @exortek/passkey" "pkg:paseto|fed7aa|Concerns @exortek/paseto" "pkg:oauth2|b5e7a0|Concerns @exortek/oauth2" + "pkg:oidc|c0a0f0|Concerns @exortek/oidc" "pkg:tooling|e4e669|Repo tooling — build, CI, docs site, monorepo config" "good-first-issue|7057ff|Small, well-scoped — a nice entry point for new contributors" diff --git a/web/content/_meta.js b/web/content/_meta.js index 4f8d2651..33208cc9 100644 --- a/web/content/_meta.js +++ b/web/content/_meta.js @@ -19,6 +19,7 @@ export default { passkey: '@exortek/passkey', paseto: '@exortek/paseto', oauth2: '@exortek/oauth2', + oidc: '@exortek/oidc', comparison: 'Comparison', compliance: 'Compliance', }; diff --git a/web/content/compliance.mdx b/web/content/compliance.mdx index 6d1e1ab6..ef0e325c 100644 --- a/web/content/compliance.mdx +++ b/web/content/compliance.mdx @@ -158,6 +158,17 @@ never transmit plaintext passwords over the network. | RFC 7523 / 8705 | `private_key_jwt` / `client_secret_jwt` / mTLS client auth | ✅ | Assertion `exp` required + lifetime-bounded, single-use `jti`; certificate-bound `cnf` | | FAPI 2.0 | PAR + PKCE + DPoP/mTLS + `iss` in one profile | ✅ | `security: { fapi: true }` | +## OpenID Connect (`@exortek/oidc`) + +| § | Requirement | Status | How | +|------------------|--------------------------------------------------------------------|:------:|--------------------------------------------------------------------------------------------------| +| OIDC Core §3.1.3.7 | RP `id_token` validation (`iss`/`aud`/`nonce`/`exp`, `azp`, `at_hash`) | ✅ | `createClient` reuses oauth2's verified flow; `openid` scope non-optional | +| OIDC Core §2 | OP `id_token` issuance — signed JWS, `nonce` / `auth_time` / `at_hash` | ✅ | `provider.idTokenSigner`, asymmetric alg only (never `none`/HS\*) | +| OIDC Core §5.3 / §5.4 | UserInfo — `sub` always, claims released per granted scope | ✅ | `userinfoHandler` — Bearer resolve, scope→claims + `claims.userinfo` policy, 401 on bad token | +| OIDC Discovery 1.0 | `/.well-known/openid-configuration` | ✅ | `discoveryHandler` — RFC 8414 superset, advertises only served endpoints | +| RP-Initiated Logout 1.0 | validated `post_logout_redirect_uri` | ✅ | `endSessionHandler` exact-matches registered URIs before redirect; `endSessionUrl` on the RP | +| Session Management 1.0 | `session_state` + `check_session_iframe` | ✅ | `sessionState()` (§4.2) + `checkSessionHandler` | + ## Summary - ✅ **NIST SP 800-63B AAL2** — memorized secret + OOB OTP paths @@ -171,6 +182,8 @@ never transmit plaintext passwords over the network. - ✅ **OAuth 2.1 / RFC 9700 + the modern extension stack** — `@exortek/oauth2` (mandatory PKCE, `iss`, DPoP incl. nonce, PAR, RAR, JAR/JARM, token exchange, device grant, FAPI 2.0) +- ✅ **OpenID Connect Core 1.0 + Discovery / Logout / Session** — + `@exortek/oidc` (discovery-first RP + OpenID Provider add-ons) - ✅ **ASVS V2.9 cryptographic authenticators** — `@exortek/passkey` (WebAuthn L3 / FIDO2 CTAP2 server verification) - ✅ **RFC 7516 JSON Web Encryption (JWE)** — `@exortek/jwe` diff --git a/web/content/index.mdx b/web/content/index.mdx index 715028fd..98e16752 100644 --- a/web/content/index.mdx +++ b/web/content/index.mdx @@ -97,7 +97,7 @@ time. | 16 | [`@exortek/opaque`](/opaque) | [![npm](https://img.shields.io/npm/v/@exortek/opaque.svg?label=&color=12b76a)](https://www.npmjs.com/package/@exortek/opaque) | opaque reference tokens — RFC 7662 introspection + RFC 7009 revocation | | 17 | [`@exortek/paseto`](/paseto) | [![npm](https://img.shields.io/npm/v/@exortek/paseto.svg?label=&color=12b76a)](https://www.npmjs.com/package/@exortek/paseto) | PASETO v4 — `v4.local` (XChaCha20 + BLAKE2b) / `v4.public` (Ed25519), no `alg` header, tokenPair reuse detection | | 18 | [`@exortek/oauth2`](/oauth2) | [![npm](https://img.shields.io/npm/v/@exortek/oauth2.svg?label=&color=12b76a)](https://www.npmjs.com/package/@exortek/oauth2) | OAuth 2.1 — RP flow + 18 provider presets, login middleware, full authorization server (DPoP · PAR · JAR/JARM · device · FAPI) | -| 19 | `@exortek/oidc` | _planned_ | OpenID Connect on top of `oauth2` | +| 19 | [`@exortek/oidc`](/oidc) | [![npm](https://img.shields.io/npm/v/@exortek/oidc.svg?label=&color=12b76a)](https://www.npmjs.com/package/@exortek/oidc) | OpenID Connect Core 1.0 on `oauth2` — discovery-first RP + OP add-ons (discovery · UserInfo · JWKS · RP-Initiated Logout · Session Management) | | 20 | `@exortek/auth` | _planned_ | umbrella — re-exports every package above | ## Install diff --git a/web/content/oidc/_meta.js b/web/content/oidc/_meta.js new file mode 100644 index 00000000..7ae818d9 --- /dev/null +++ b/web/content/oidc/_meta.js @@ -0,0 +1,9 @@ +export default { + index: 'Overview', + client: 'Relying party — createClient', + provider: 'OpenID Provider — createProvider', + logout: 'RP-Initiated Logout', + session: 'Session Management', + middleware: 'Framework adapters', + errors: 'errors — OidcError', +}; diff --git a/web/content/oidc/client.mdx b/web/content/oidc/client.mdx new file mode 100644 index 00000000..b84f0fe3 --- /dev/null +++ b/web/content/oidc/client.mdx @@ -0,0 +1,87 @@ +--- +title: '@exortek/oidc — Relying party' +sidebarTitle: 'Relying party — createClient' +--- + +import { Callout } from 'nextra/components'; + +# Relying party — `createClient` + +`createClient` is a **discovery-first** OpenID Connect relying party. You +point it at an `issuer` (not a provider preset) and it discovers the +endpoints, runs the OAuth 2.1 authorization-code flow with mandatory PKCE / +`state` / `nonce`, and — because every request carries the `openid` scope — +verifies the returned `id_token` end to end. + + + The flow itself is `@exortek/oauth2`'s, reused verbatim: discovery, + `id_token` signature + `iss` / `aud` / `nonce` / `exp` / `azp` / `at_hash` + validation, and the UserInfo `sub`-match are done there, not reimplemented. + + +```js +import { createClient } from '@exortek/oidc/client'; + +const client = createClient({ + issuer: 'https://accounts.google.com', + clientId: '...', + clientSecret: '...', // omit for public clients + redirectUri: 'https://myapp.com/callback', + scope: ['openid', 'email', 'profile'], // openid is enforced either way +}); +``` + +## Config + +| Option | Type | Notes | +|--------|------|-------| +| `issuer` | `string` | OP issuer identifier; endpoints are discovered from it. | +| `clientId` | `string` | Required. | +| `clientSecret` | `string?` | Confidential clients. | +| `redirectUri` | `string` | Registered callback URI. | +| `scope` | `string[]?` | `openid` is always included. | +| `idTokenAlgs` | `string[]?` | Signature-alg allowlist for the `id_token`. | +| `clockTolerance` | `string \| number?` | Leeway for `exp`/`nbf`/`iat`. | +| `store` | object? | Flow-session store keyed by `state` (else the session is carried by the caller). | +| `endSessionEndpoint` | `string?` | Pin the logout endpoint instead of discovering it. | + +## `authorize(options?)` + +Returns `{ url, session }` — redirect the user to `url` and keep `session` +(cookie or store) for the callback. OIDC auth-request parameters are threaded +through: + +```js +const { url, session } = await client.authorize({ + prompt: 'login', // → prompt + loginHint: 'a@b.com', // → login_hint + maxAge: 3600, // → max_age + acrValues: 'urn:...', // → acr_values + scope: ['openid', 'email'], +}); +``` + +## `handleCallback(query, { session })` + +Completes the flow and returns the verified identity: + +```js +const { idToken, claims, userinfo, tokens } = await client.handleCallback(req.query, { session }); +``` + +| Field | What | +|-------|------| +| `idToken` | The raw `id_token` (already verified). | +| `claims` | The verified `id_token` payload. | +| `userinfo` | The merged profile (`id_token` claims + UserInfo, `sub`-matched). | +| `tokens` | The raw token response (`access_token`, `refresh_token`, …). | + +## `refresh` / `revoke` + +```js +const fresh = await client.refresh(refreshToken); // RFC 6749 §6 +await client.revoke(accessToken, 'access_token'); // RFC 7009 +``` + +For logout, see [RP-Initiated Logout](/oidc/logout); for the Express / +Fastify route wrappers, see [Framework adapters](/oidc/middleware). diff --git a/web/content/oidc/errors.mdx b/web/content/oidc/errors.mdx new file mode 100644 index 00000000..26ae5415 --- /dev/null +++ b/web/content/oidc/errors.mdx @@ -0,0 +1,39 @@ +--- +title: '@exortek/oidc — errors' +sidebarTitle: 'errors — OidcError' +--- + +# `errors` — `OidcError` + +`@exortek/oidc` raises `OidcError` for its own configuration and discovery +failures. Branch on the stable `code`, never on the message. + +```js +import { OidcError, ErrorCode } from '@exortek/oidc'; + +try { + await client.endSessionUrl({ idTokenHint }); +} catch (err) { + if (err instanceof OidcError && err.code === ErrorCode.DISCOVERY_FAILED) { + // the OP metadata could not be fetched + } +} +``` + +## Codes + +| `ErrorCode` | Status | Raised when | +|-------------|:------:|-------------| +| `INVALID_ARGUMENT` | 400 | A `createClient` / `createProvider` / handler config guard fails. | +| `DISCOVERY_FAILED` | 502 | The client's own OP-metadata fetch (used to resolve `end_session_endpoint`) fails or the issuer mismatches. | + +`OidcError` extends the shared `BaseError`, so it carries `code`, `status`, +and the standard `cause` chain. + +## Where oauth2 errors surface + +The relying-party flow delegates to `@exortek/oauth2`, so callback-validation +failures — a `state` mismatch, a bad `nonce`, an `id_token` that fails +`iss` / `aud` / signature — surface as **`OAuth2Error`** (from +`@exortek/oauth2`), not `OidcError`. The UserInfo endpoint returns a +`401` with `WWW-Authenticate` rather than throwing. diff --git a/web/content/oidc/index.mdx b/web/content/oidc/index.mdx new file mode 100644 index 00000000..146313d8 --- /dev/null +++ b/web/content/oidc/index.mdx @@ -0,0 +1,99 @@ +--- +title: '@exortek/oidc — Overview' +sidebarTitle: Overview +--- + +import { Callout } from 'nextra/components'; + +# `@exortek/oidc` + +OpenID Connect Core 1.0 for Node.js 22+ — the identity layer on top of +[`@exortek/oauth2`](/oauth2). Where oauth2 speaks OAuth 2.1 and can issue an +`id_token`, `@exortek/oidc` makes **OpenID Connect** the first-class shape on +both sides: a discovery-first **relying-party** client that enforces the +`openid` scope and validates the `id_token` end to end, and an **OpenID +Provider** add-on that serves discovery, UserInfo, logout and session +management beside your `@exortek/oauth2/server` authorization server. + + + OIDC's guarantees live in the parts people skip — a `nonce` that is sent but + never checked, an unvalidated `aud`, trusting UserInfo `sub` over the + `id_token` `sub`, an open `post_logout_redirect_uri`. This package reuses + oauth2's verified flow rather than reimplementing it, and refuses to let the + caller skip the checks that matter. + + +## Install + +```bash +npm install @exortek/oidc +``` + +## Relying party (SSO) + +```js +import { createClient } from '@exortek/oidc/client'; + +const client = createClient({ + issuer: 'https://accounts.google.com', + clientId: '...', + clientSecret: '...', + redirectUri: 'https://myapp.com/callback', + scope: ['openid', 'email', 'profile'], +}); + +const { url, session } = await client.authorize({ prompt: 'login' }); +// → redirect to `url`, keep `session` + +const { idToken, claims, userinfo } = await client.handleCallback(req.query, { session }); + +const logoutUrl = await client.endSessionUrl({ + idTokenHint: idToken, + postLogoutRedirectUri: 'https://myapp.com/', +}); +``` + +`@exortek/oidc/client/express` and `/client/fastify` wrap this as a +two-route browser login (`mountOidcLogin` / `oidcLoginPlugin`). + +## OpenID Provider + +`createProvider` supplies the OIDC endpoints to mount **beside** your +`@exortek/oauth2/server` `createServer`: + +```js +import { createProvider } from '@exortek/oidc/provider'; +import { mountOidcProvider } from '@exortek/oidc/provider/express'; + +const provider = createProvider({ + issuer: 'https://auth.myapp.com', + signing: { key: signingPrivateJwk, alg: 'ES256', kid: 'key-1' }, + jwks: [publicJwk], + endpoints: { authorization: '/authorize', token: '/token' }, + claims: { supported: ['sub', 'email', 'name'], userinfo: ['email', 'name'] }, + userinfo: { resolve: accessToken => introspect(accessToken) }, + logout: { postLogoutRedirectUris: ['https://myapp.com/'] }, + session: { cookieName: 'op_browser_state' }, +}); + +mountOidcProvider(app, provider); +// → /.well-known/openid-configuration · /.well-known/jwks.json · +// /userinfo · /end_session · /check_session +``` + +`provider.idTokenSigner` is the same signer your `createServer` uses, so both +sign with one key. + +## Continue + +- [Relying party — `createClient`](/oidc/client) — login, `id_token`, UserInfo +- [OpenID Provider — `createProvider`](/oidc/provider) — discovery, UserInfo, JWKS +- [RP-Initiated Logout](/oidc/logout) +- [Session Management](/oidc/session) +- [Framework adapters](/oidc/middleware) — Express & Fastify +- [errors — `OidcError`](/oidc/errors) + +## Specifications + +OpenID Connect Core 1.0 · Discovery 1.0 · RP-Initiated Logout 1.0 · Session +Management 1.0, on OAuth 2.1 ([`@exortek/oauth2`](/oauth2)). diff --git a/web/content/oidc/logout.mdx b/web/content/oidc/logout.mdx new file mode 100644 index 00000000..38fb3238 --- /dev/null +++ b/web/content/oidc/logout.mdx @@ -0,0 +1,58 @@ +--- +title: '@exortek/oidc — RP-Initiated Logout' +sidebarTitle: 'RP-Initiated Logout' +--- + +import { Callout } from 'nextra/components'; + +# RP-Initiated Logout + +OpenID Connect **RP-Initiated Logout 1.0** — the relying party sends the end +user to the OP's `end_session_endpoint` with an `id_token_hint`; the OP ends +its session and, after validating the `post_logout_redirect_uri`, redirects +back. + +## Relying party — `client.endSessionUrl` + +```js +const logoutUrl = await client.endSessionUrl({ + idTokenHint: idToken, // required + postLogoutRedirectUri: 'https://myapp.com/', + state: 'xyz', +}); +// redirect the user to logoutUrl +``` + +The `end_session_endpoint` is taken from `endSessionEndpoint` in the client +config when set, otherwise resolved from the OP metadata (cached). + +## OpenID Provider — `endSessionHandler` + +```js +const provider = createProvider({ + // ... + logout: { + postLogoutRedirectUris: ['https://myapp.com/'], // exact-match allow-list + onLogout: async ({ sub }) => sessionStore.destroyForUser(sub), + }, +}); +``` + + + `post_logout_redirect_uri` is **exact-matched** against the registered list + before any redirect — an unregistered URI gets a `400`, never a redirect + (the open-redirect lever). Configuring `logout` auto-advertises + `end_session_endpoint` in discovery. + + +The handler: + +- calls `onLogout({ sub, idTokenHint })` (best-effort — never blocks the + redirect) to clear the OP session, +- redirects to a **registered** `post_logout_redirect_uri`, echoing `state`, +- returns a `{ logged_out: true }` confirmation when no redirect is + requested. + +`sub` is read from the (unverified) `id_token_hint` after checking its `iss` +matches the OP — a logout hint is frequently expired, so the issuer/audience +check stands in for a full signature+`exp` verification. diff --git a/web/content/oidc/middleware.mdx b/web/content/oidc/middleware.mdx new file mode 100644 index 00000000..8dc636d0 --- /dev/null +++ b/web/content/oidc/middleware.mdx @@ -0,0 +1,73 @@ +--- +title: '@exortek/oidc — Framework adapters' +sidebarTitle: 'Framework adapters' +--- + +import { Callout } from 'nextra/components'; + +# Framework adapters + +Both halves ship Express and Fastify adapters. `express` / `fastify` are +**optional peers** — installed only if you use them; the Fastify plugins are +`fastify-plugin`-wrapped so their routes register at the app's top level. + +## Relying party — login routes + +A browser-redirect login in two routes: `start` stashes the flow session in +a short-lived cookie and redirects to the OP; `callback` reads it back, runs +`handleCallback`, and hands the result to `onSuccess`. + +```js +// Express +import { mountOidcLogin } from '@exortek/oidc/client/express'; + +mountOidcLogin(app, { + client, + loginPath: '/login', + callbackPath: '/callback', + onSuccess: ({ res, claims }) => { + req.session.user = claims.sub; + res.redirect('/'); + }, +}); +``` + +```js +// Fastify +import { oidcLoginPlugin } from '@exortek/oidc/client/fastify'; + +await app.register(oidcLoginPlugin, { + client, + onSuccess: ({ reply, claims }) => reply.redirect('/'), +}); +``` + + + API / SPA / mobile clients don't need an adapter — call `client.authorize` + and `client.handleCallback` directly and carry the `session` yourself. + + +## OpenID Provider — endpoint mounts + +`mountOidcProvider` mounts exactly the endpoints discovery advertises, at the +paths it names: + +```js +// Express +import { mountOidcProvider } from '@exortek/oidc/provider/express'; + +mountOidcProvider(app, provider); +// → /.well-known/openid-configuration · /.well-known/jwks.json · +// /userinfo · /end_session · /check_session +``` + +```js +// Fastify +import { oidcProviderPlugin } from '@exortek/oidc/provider/fastify'; + +await app.register(oidcProviderPlugin, { provider }); +``` + +Prefer your own routes? `oidcProviderHandlers(provider)` (Express) returns +`{ [name]: { path, handler } }` so you can mount each endpoint with your own +middleware and paths. diff --git a/web/content/oidc/provider.mdx b/web/content/oidc/provider.mdx new file mode 100644 index 00000000..211dcac8 --- /dev/null +++ b/web/content/oidc/provider.mdx @@ -0,0 +1,84 @@ +--- +title: '@exortek/oidc — OpenID Provider' +sidebarTitle: 'OpenID Provider — createProvider' +--- + +import { Callout } from 'nextra/components'; + +# OpenID Provider — `createProvider` + +Your `@exortek/oauth2/server` `createServer` already issues an `id_token` +off the `openid` scope. `createProvider` supplies the OIDC identity surface +it has no first-class answer for, to mount **beside** it. + + + `createProvider` is an **add-on**, not a replacement authorization server — + it does not re-wrap `createServer`. Mount its handlers next to your oauth2 + server's on the same app. + + +```js +import { createProvider } from '@exortek/oidc/provider'; + +const provider = createProvider({ + issuer: 'https://auth.myapp.com', + signing: { key: signingPrivateJwk, alg: 'ES256', kid: 'key-1' }, + jwks: [publicJwk], // published at jwks_uri + endpoints: { authorization: '/authorize', token: '/token' }, + claims: { + supported: ['sub', 'email', 'email_verified', 'name', 'picture'], + userinfo: ['email', 'name', 'picture'], // release policy + }, + userinfo: { resolve: accessToken => introspect(accessToken) }, + logout: { postLogoutRedirectUris: ['https://myapp.com/'] }, + session: { cookieName: 'op_browser_state' }, +}); +``` + +## Config + +| Option | Type | Notes | +|--------|------|-------| +| `issuer` | `string` | OP issuer identifier (https). | +| `signing` | `{ key, alg, kid?, expiresIn? }` | Private signing key for the `id_token`. Reuses oauth2's `createIdTokenSigner`. | +| `jwks` | `JWK[] \| { keys }` | Public key set published at `jwks_uri`. | +| `endpoints` | object | Advertised endpoint URLs/paths; `authorization` + `token` required. | +| `claims` | `{ supported?, id_token?, userinfo? }` | Claim policy. | +| `scopes` | `string[]?` | Advertised scopes (default `openid`/`profile`/`email`). | +| `userinfo` | `{ resolve }` | `accessToken → { sub, scope, claims }`. Enables the UserInfo endpoint. | +| `logout` | `{ postLogoutRedirectUris?, onLogout? }` | See [RP-Initiated Logout](/oidc/logout). | +| `session` | `{ cookieName? }` | See [Session Management](/oidc/session). | + +## Handlers + +Each returns a framework-agnostic handler (`{ method, url, headers, query } +→ { status, headers, body }`). Mount them by hand, or use the +[adapters](/oidc/middleware). + +| Handler | Serves | +|---------|--------| +| `discoveryHandler()` | `/.well-known/openid-configuration` — the OIDC metadata (superset of RFC 8414), advertising only the endpoints the OP actually serves. | +| `userinfoHandler()` | UserInfo (OIDC Core §5.3) — resolves the Bearer token, releases claims per granted scope (§5.4) and the `claims.userinfo` policy; 401 + `WWW-Authenticate` on a bad token. | +| `jwksHandler()` | The published JWK Set at `jwks_uri`. | +| `endSessionHandler()` | RP-Initiated Logout (see its page). | +| `checkSessionHandler()` | Session Management iframe (see its page). | + +## `idTokenSigner` + +The `createIdTokenSigner`-backed signer, exposed so your `createServer` and +this provider sign `id_token`s with **one** key: + +```js +const idToken = await provider.idTokenSigner.sign({ + subject: userId, + clientId, + issuer: provider.issuer, + nonce, + accessToken, // → at_hash +}); +``` + +## `metadata()` + +Returns the assembled discovery document (also what `discoveryHandler` +serves) — useful for tests or serving it yourself. diff --git a/web/content/oidc/session.mdx b/web/content/oidc/session.mdx new file mode 100644 index 00000000..0b4ae569 --- /dev/null +++ b/web/content/oidc/session.mdx @@ -0,0 +1,51 @@ +--- +title: '@exortek/oidc — Session Management' +sidebarTitle: 'Session Management' +--- + +import { Callout } from 'nextra/components'; + +# Session Management + +OpenID Connect **Session Management 1.0** lets a relying party detect that +the user's OP session changed — without a network round-trip. The OP derives +a `session_state` from its browser-scoped login state (the "OP browser +state") and returns it to the RP; the RP renders a hidden iframe pointing at +the OP's `check_session_iframe` and polls it with `postMessage`. + +## `session_state` + +```js +const ss = provider.sessionState({ + clientId, + origin: 'https://myapp.com', // the RP's origin + opBrowserState, // the OP's browser-state value (a cookie you set) +}); +// attach `ss` as session_state on the authorization response +``` + +Per §4.2, `session_state = base64url(sha256(client_id + " " + origin + " " + +op_browser_state + " " + salt)) + "." + salt`. It changes whenever the OP +browser state changes (i.e. on login / logout), which is what the RP +detects. + +## `check_session_iframe` + +```js +const provider = createProvider({ + // ... + session: { cookieName: 'op_browser_state' }, // names the OP browser-state cookie +}); +``` + +`checkSessionHandler()` serves the OP iframe document. Its browser-side +script re-runs the §4.2 calculation on each `postMessage` (reading the OP +browser state from `cookieName`) and replies `unchanged` / `changed` / +`error`. Configuring `session` auto-advertises `check_session_iframe` in +discovery. + + + `computeSessionState` and the iframe are server/browser primitives — the OP + browser-state cookie itself (setting it on login, rotating it on logout) is + yours to manage, since only you know when the session materially changes. + diff --git a/yarn.lock b/yarn.lock index f6040f99..43316160 100644 --- a/yarn.lock +++ b/yarn.lock @@ -423,7 +423,7 @@ __metadata: languageName: unknown linkType: soft -"@exortek/oauth2@workspace:packages/oauth2": +"@exortek/oauth2@workspace:^, @exortek/oauth2@workspace:packages/oauth2": version: 0.0.0-use.local resolution: "@exortek/oauth2@workspace:packages/oauth2" dependencies: @@ -450,6 +450,25 @@ __metadata: languageName: unknown linkType: soft +"@exortek/oidc@workspace:packages/oidc": + version: 0.0.0-use.local + resolution: "@exortek/oidc@workspace:packages/oidc" + dependencies: + "@exortek/jwk": "workspace:^" + "@exortek/jwks": "workspace:^" + "@exortek/jwt": "workspace:^" + "@exortek/oauth2": "workspace:^" + peerDependencies: + express: ">=4.0.0" + fastify: ">=4.0.0" + peerDependenciesMeta: + express: + optional: true + fastify: + optional: true + languageName: unknown + linkType: soft + "@exortek/opaque@workspace:packages/opaque": version: 0.0.0-use.local resolution: "@exortek/opaque@workspace:packages/opaque"