Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
86c3553
fix(oauth2): cap granted scope at client ∩ server scopes
ExorTek Oct 24, 2026
bf7f38e
fix(oauth2): restrict what a dynamic registration may request
ExorTek Oct 24, 2026
6d16ff3
fix(oauth2): validate client redirect URIs at definition time
ExorTek Oct 24, 2026
7473a4d
fix(oauth2): carry the PAR flag out of band
ExorTek Oct 24, 2026
e1e0dcf
fix(oauth2): require a verified chain for tls_client_auth
ExorTek Oct 24, 2026
03e9102
fix(shared): keep the TTL when a record-store update has none
ExorTek Oct 24, 2026
b39b67f
fix(oauth2): rotated refresh tokens keep the family expiry
ExorTek Oct 24, 2026
9e7a308
feat(shared): setIfAbsent redis helper
ExorTek Oct 24, 2026
a82e2fd
fix(oauth2): make refresh rotation a compare-and-set
ExorTek Oct 24, 2026
d37626e
fix(oauth2): keep the granted scope on a rotated refresh token
ExorTek Oct 24, 2026
7ae9c53
fix(oauth2): token exchange checks who may exchange the subject token
ExorTek Oct 24, 2026
9685edb
fix(oauth2): keep certificate binding on device and refresh grants
ExorTek Oct 24, 2026
2e174b2
fix(oauth2): device codes keep their original expiry and redeem once
ExorTek Oct 24, 2026
f0bf23d
perf(oauth2): cache client JWKS resolvers; refuse private jwks_uri hosts
ExorTek Oct 24, 2026
dab7420
feat(oauth2): injectable replay store and stateless DPoP nonces
ExorTek Oct 24, 2026
68c2f9f
fix(oauth2): a replayed authorization code revokes its refresh family
ExorTek Oct 24, 2026
3a827ba
fix(oauth2): introspection requires a confidential client
ExorTek Oct 24, 2026
ef6e4ba
fix(oidc): verify the id_token_hint before trusting its subject
ExorTek Oct 24, 2026
c080000
fix(oidc): enforce the requested max_age and acr_values
ExorTek Oct 24, 2026
0891622
fix(oidc): session cookie name must be an RFC 6265 token
ExorTek Oct 24, 2026
7d2f6a1
fix(oidc): userinfo only reads the access token from the header
ExorTek Oct 24, 2026
8f1dde2
fix(oauth2): authorize params cannot override flow-managed values
ExorTek Oct 24, 2026
33526ab
fix(oauth2): api-mode login requires a 32-byte flow-session secret
ExorTek Oct 24, 2026
45aafb2
fix(oauth2): an OIDC provider must declare its issuer
ExorTek Oct 24, 2026
d4bc442
fix(oauth2): flag emails the provider does not report as verified
ExorTek Oct 24, 2026
23f4499
feat(oauth2): createRedisReplayStore for multi-node jti replay checks
ExorTek Oct 24, 2026
2fb6497
docs(oauth2): document the hardened server and RP defaults
ExorTek Oct 24, 2026
d34852b
docs(oidc): verified logout hints, max_age / acr enforcement
ExorTek Oct 24, 2026
aa9bc16
chore: changesets for oauth2 and oidc hardening
ExorTek Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/oauth2-server-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@exortek/oauth2': major
---

Authorization-server and RP hardening. Several defaults are stricter, so this is a major release.

**Security fixes**

- Every grant caps the granted scope at the client's registered `scope` ∩ the server's `scopes`; previously a client's own `scope` replaced the server list, and the device endpoint did not validate scope at all.
- Dynamic registration only accepts `grant_types` from `registration.allowedGrants` (default `authorization_code` + `refresh_token`), scopes the server advertises, and auth methods from `registration.allowedAuthMethods` (default: every non-mTLS method). Redirect URIs must be `https`, loopback `http`, or a reverse-domain private-use scheme, with no fragment.
- `?_fromPar=1` no longer bypasses a PAR requirement; `request_uri` must be redeemed with the `client_id` that pushed it, and a request object pushed to PAR is verified at push time.
- `tls_client_auth` requires a chain the TLS layer verified (`socket.authorized`) and compares the subject DN attribute by attribute.
- Rotated refresh tokens keep the family's expiry (they previously never expired), rotation is a compare-and-set (a lost race revokes the family), and a narrowed refresh request no longer narrows the successor token.
- Token exchange checks that the subject token was issued to / for the exchanging client (`client_id`, `aud` or `may_act`), requires the bound key for a DPoP- or certificate-bound subject token, and only exchanges for targets in `tokenExchange.audiences`.
- Device codes keep their original expiry while polled and are redeemed exactly once.
- A replayed authorization code revokes the refresh family it produced; introspection requires a confidential client.
- Client JWKS resolvers are cached per server; with registration enabled, `jwks_uri` hosts on loopback / private / link-local addresses are refused unless `security.allowJwksHost` says otherwise.
- RP: reserved authorize params (`state`, `nonce`, `redirect_uri`, `scope`, PKCE, …) can no longer be overridden via `params`; api-mode login requires a flow-session secret and every such secret must be ≥ 32 bytes; an OIDC provider must declare an `issuer`; `EMAIL_UNVERIFIED` also fires when a provider omits `email_verified`.

**New**

- `stores.replay` (any `incr(key, ttlMs)` store) and `createRedisReplayStore(redis)` share DPoP / client-assertion / request-object jti replay checks across nodes; `verifyDpopForResource` accepts a `replayStore`.
- DPoP nonces are stateless; set the same `security.dpop.nonceSecret` (≥ 32 bytes) on every node.
- `DeviceStore.redeem()` (optional) for atomic device-code redemption; `introspection.allowPublicClients`.

**Migration**

- List every scope a client may receive in the server's `scopes`.
- If self-registered clients need `client_credentials`, device or token exchange, add them to `registration.allowedGrants`.
- Replace plain-`http` (non-loopback) or single-word custom-scheme redirect URIs.
- Add `tokenExchange: { audiences: [...] }` if you exchange tokens for a `resource` / `audience`.
- On a bare `node:http` listener with `tls_client_auth`, pass `clientCertificateAuthorized: socket.authorized` in the request descriptor.
- Custom refresh stores: `rotate` should resolve `false` when the token was already rotated (resolving nothing keeps the old behaviour).
- Behind a load balancer with `dpop.nonce: true`, configure `security.dpop.nonceSecret`; add `stores.replay` for cross-node replay checks.
- RP: give api-mode login a `secret`, make every flow-session secret ≥ 32 bytes, add an `issuer` to custom OIDC providers, and stop passing reserved keys in `authorize(..., { params })`.
16 changes: 16 additions & 0 deletions .changeset/oidc-logout-auth-requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@exortek/oidc': major
---

OpenID Provider and relying-party hardening.

- `endSessionHandler` verifies `id_token_hint` against the OP's published JWKS (or signing key) before using its `sub`; previously an unsigned hint could name any user and force their logout. Expired hints are accepted for `logout.idTokenHintMaxAge` (default `'30d'`). A `client_id` parameter must match the hint's audience, `onLogout` also receives `clientId`, and `postLogoutRedirectUris` may be a `{ [clientId]: uris }` map.
- `handleCallback` enforces requested `max_age` (`auth_time`) and `acr_values` (`acr`), throwing the new `AUTHENTICATION_REQUIREMENTS_NOT_MET` (401). `createClient` accepts `maxAge` / `acrValues` defaults that are sent and enforced on every flow.
- The UserInfo endpoint only reads the access token from the `Authorization` header (no `access_token` query parameter).
- `session.cookieName` must be an RFC 6265 token and is regex-escaped in the check-session iframe.

**Migration**

- Publish the OP's verification keys via `jwks` (or keep the signing key), and handle `onLogout` calls with `sub: undefined` for unverifiable hints.
- Send UserInfo requests with `Authorization: Bearer`.
- If you pass `maxAge` / `acrValues` to `authorize()` per call, pass the same values to `handleCallback()` (or set them on `createClient`).
38 changes: 34 additions & 4 deletions packages/oauth2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,28 @@ challenge), PKCE, PAR (RFC 9126), resource indicators (RFC 8707), RAR
profile. Resource servers get `verifyDpopForResource` for the `ath` /
`cnf.jkt` check.

A few of those are deliberately strict:

- **Scope ceiling.** Every grant caps the scope at the client's registered
`scope` ∩ the server's `scopes`.
- **Token exchange** only accepts a subject token issued to the exchanging
client, naming it in `aud`, or permitting it via `may_act`; a DPoP- or
certificate-bound subject token needs the same key; and the target must be
listed in `tokenExchange: { audiences: [...] }`.
- **`tls_client_auth`** requires a chain the TLS layer verified. The express /
fastify adapters pass `socket.authorized` through; on a bare `node:http`
listener set `clientCertificateAuthorized: socket.authorized` on the request
descriptor yourself.
- **Redirect URIs** must be `https`, loopback `http`, or a reverse-domain
private-use scheme (`com.example.app:/cb`), with no fragment.

### Storage (read this before you run two instances)

`createServer` defaults every store to memory: authorization codes, PAR
request URIs, refresh families, device codes, and the DPoP replay cache. That
is fine for one process and wrong for two — a code issued on instance A cannot
be redeemed on instance B, and a DPoP proof replayed against a different
instance is not seen as a replay.
request URIs, refresh families, device codes, and the jti replay checks (DPoP
proofs, client assertions, request objects). That is fine for one process and
wrong for two — a code issued on instance A cannot be redeemed on instance B,
and a DPoP proof replayed against a different instance is not seen as a replay.

Pass Redis-backed stores for any deployment with more than one process:

Expand All @@ -178,6 +193,7 @@ import {
createRedisRefreshStore,
createRedisParStore,
createRedisDeviceStore,
createRedisReplayStore,
} from '@exortek/oauth2/server';

const server = createServer({
Expand All @@ -187,10 +203,18 @@ const server = createServer({
refresh: createRedisRefreshStore(redis),
par: createRedisParStore(redis),
device: createRedisDeviceStore(redis),
replay: createRedisReplayStore(redis), // or any store with incr(key, ttlMs)
},
// With DPoP nonces on, every instance must share the nonce secret (≥ 32 bytes).
security: { dpop: { nonce: true, nonceSecret: process.env.DPOP_NONCE_SECRET } },
});
```

Refresh rotation and device-code redemption are compare-and-set in the
built-in stores: of two simultaneous redemptions exactly one wins (a lost
refresh race is treated as reuse and revokes the family). A custom refresh
store's `rotate` should resolve `false` when the token was already rotated.

`redis` is your own client — `ioredis` or `node-redis`, both supported and
both covered by the integration suite. They are peer dependencies, so install
whichever you already use. Any object matching the store interface works too,
Expand Down Expand Up @@ -238,6 +262,12 @@ const server = createServer({
// → 201 { client_id, client_secret, … }
```

A registrant may only ask for `authorization_code` + `refresh_token` unless you
widen `registration.allowedGrants`, only for scopes the server advertises, and
only for the non-mTLS auth methods (`registration.allowedAuthMethods`). With
registration enabled and no `allowJwksHost`, `jwks_uri` hosts on loopback,
private or link-local addresses are refused.

## Modules

| Subpath | Status | Purpose |
Expand Down
14 changes: 14 additions & 0 deletions packages/oauth2/src/middleware/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ const DEFAULT_MAX_AGE_SEC = 600; // 10 minutes — matches the default maxAuthAg
* @property {string} [attach='oauth']
*/

const MIN_SECRET_BYTES = 32;

/**
* @param {LoginConfig} config
*/
Expand All @@ -72,6 +74,18 @@ export function normalizeLoginConfig(config) {
if (seal === 'jwe' && !isNonEmptyString(secret)) {
throw new OAuth2Error(ErrorCode.INVALID_ARGUMENT, "seal: 'jwe' requires a secret");
}
// api mode hands the flow session (PKCE verifier, nonce, state,
// createdAt) to the client to bring back — unsigned, the client could
// rewrite any of it, including the createdAt the freshness check trusts.
if (mode === 'api' && !isNonEmptyString(secret)) {
throw new OAuth2Error(ErrorCode.INVALID_ARGUMENT, "mode: 'api' requires a secret to sign the flow session");
}
if (isNonEmptyString(secret) && Buffer.byteLength(secret, 'utf8') < MIN_SECRET_BYTES) {
throw new OAuth2Error(
ErrorCode.INVALID_ARGUMENT,
`the flow-session secret must be at least ${MIN_SECRET_BYTES} bytes`,
);
}

return Object.freeze({
oauth: config.oauth,
Expand Down
40 changes: 37 additions & 3 deletions packages/oauth2/src/providers/_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export function defineProvider(def) {
if (!isFunction(def.mapUser)) {
throw new OAuth2Error(ErrorCode.INVALID_ARGUMENT, `provider ${def.id} must supply a mapUser function`);
}
// An OIDC provider's id_token is verified against its issuer; with none
// configured the `iss` check would silently be skipped.
if (def.kind === 'oidc' && !isNonEmptyString(def.issuer) && !isFunction(def.issuer)) {
throw new OAuth2Error(ErrorCode.INVALID_ARGUMENT, `OIDC provider ${def.id} requires an issuer`);
}

return function createProvider(appOptions = {}) {
if (!isNonEmptyString(appOptions.clientId)) {
Expand Down Expand Up @@ -165,6 +170,17 @@ export async function resolveEndpoints(provider) {
return ep;
}

const RESERVED_AUTHORIZATION_PARAMS = new Set([
'response_type',
'client_id',
'redirect_uri',
'state',
'nonce',
'scope',
'code_challenge',
'code_challenge_method',
]);

/**
* Build the authorization-request URL and the flow session.
*
Expand Down Expand Up @@ -219,6 +235,15 @@ export async function buildAuthorization(provider, opts) {
params.set(k, v);
}
for (const [k, v] of Object.entries(opts.params ?? {})) {
if (RESERVED_AUTHORIZATION_PARAMS.has(k)) {
// These carry the flow's security bindings — overriding `state`,
// `nonce`, `redirect_uri` or the PKCE challenge from caller input
// would silently detach the request from the session that checks it.
throw new OAuth2Error(
ErrorCode.INVALID_ARGUMENT,
`authorize params must not set ${JSON.stringify(k)}; it is managed by the flow`,
);
}
params.set(k, v);
}

Expand Down Expand Up @@ -395,9 +420,18 @@ export async function handleCallback(provider, opts) {
throw new OAuth2Error(ErrorCode.SUB_MISMATCH, `${provider.id} did not yield a stable subject id`);
}

// surface an unverified email rather than trusting it.
if (isNonEmptyString(mapped.email) && mapped.emailVerified === false) {
warnings.push(warn(WarningCode.EMAIL_UNVERIFIED, `${provider.id} reports the email as unverified`));
// Surface an email that is not positively verified rather than trusting
// it. A provider that simply omits the flag (Entra's mutable `email`, the
// nOAuth account-takeover vector) counts as unverified too.
if (isNonEmptyString(mapped.email) && mapped.emailVerified !== true) {
warnings.push(
warn(
WarningCode.EMAIL_UNVERIFIED,
mapped.emailVerified === false
? `${provider.id} reports the email as unverified`
: `${provider.id} does not report the email as verified`,
),
);
}

/** @type {NormalizedUser} */
Expand Down
52 changes: 48 additions & 4 deletions packages/oauth2/src/server/clients.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const AUTH_METHODS = Object.freeze([
* @typedef {Object} ClientConfig
* @property {string} clientId
* @property {string} [clientSecret] confidential clients only
* @property {string[]} redirectUris exact-match allowlist (RFC 6749 §3.1.2)
* @property {string[]} redirectUris exact-match allowlist (RFC 6749 §3.1.2); https, loopback http, or a reverse-domain private-use scheme
* @property {string[]} [grantTypes] default `['authorization_code','refresh_token']`
* @property {string[]} [responseTypes] default `['code']`
* @property {string} [tokenEndpointAuthMethod] one of {@link AUTH_METHODS}
Expand Down Expand Up @@ -76,9 +76,7 @@ export function defineClient(config) {
);
}
for (const uri of config.redirectUris) {
if (!isNonEmptyString(uri)) {
throw new OAuth2Error(ErrorCode.INVALID_ARGUMENT, `client ${config.clientId} has an invalid redirectUri`);
}
assertRedirectUri(uri, config.clientId);
}

const authMethod =
Expand Down Expand Up @@ -132,6 +130,52 @@ export function defineClient(config) {
});
}

const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);

/**
* A redirect URI must be absolute with no fragment (RFC 6749 §3.1.2) and use
* a transport that cannot be intercepted (OAuth 2.1 §2.3.1 / RFC 8252):
* `https`, `http` on a loopback host, or a reverse-domain private-use scheme
* such as `com.example.app:/cb`. Anything else — `javascript:`, `data:`,
* plain `http`, a fragment — is refused at definition time, so neither a
* static client nor a dynamic registration can turn the authorize error
* redirect into an open redirect.
*
* @param {unknown} uri
* @param {string} clientId
*/
function assertRedirectUri(uri, clientId) {
const invalid = reason =>
new OAuth2Error(ErrorCode.INVALID_ARGUMENT, `client ${clientId} has an invalid redirectUri: ${reason}`);
if (!isNonEmptyString(uri)) {
throw invalid('must be a non-empty string');
}
let url;
try {
url = new URL(uri);
} catch {
throw invalid(`${JSON.stringify(uri)} is not an absolute URI`);
}
if (uri.includes('#')) {
throw invalid('must not contain a fragment');
}
const scheme = url.protocol.slice(0, -1);
if (scheme === 'https') {
return;
}
if (scheme === 'http') {
if (LOOPBACK_HOSTS.has(url.hostname)) {
return;
}
throw invalid('http is only allowed for a loopback host');
}
// RFC 8252 §7.1: a private-use scheme is a reverse domain name.
if (scheme.includes('.')) {
return;
}
throw invalid(`scheme ${JSON.stringify(scheme)} is not allowed`);
}

/**
* @typedef {Object} ClientRegistry
* @property {(clientId: string) => (Client | undefined) | Promise<Client | undefined>} getClient
Expand Down
19 changes: 18 additions & 1 deletion packages/oauth2/src/server/grants/authorization-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import { isNonEmptyString } from '@exortek/shared/predicates';

import { randomState } from '../../internal/state.js';
import { ProtocolError, ServerError } from '../errors.js';
import { verifyCodeVerifier } from '../security/pkce.js';
import { resolveTokenAudience } from '../security/resource.js';
Expand All @@ -29,8 +30,19 @@ export async function authorizationCodeGrant(req, ctx) {
throw new ServerError(ProtocolError.INVALID_REQUEST, 'missing code');
}

// Single-use: consuming the code here means a replay finds nothing.
// Single-use: consuming the code here is what makes redemption atomic.
// A redeemed code leaves a short-lived marker under the same key, so a
// replay finds it and revokes the refresh family the code produced
// (RFC 6749 §4.1.2: tokens issued for a reused code SHOULD be revoked).
// Refresh tokens are revocable; self-contained access tokens simply run
// out their (short) lifetime.
const record = await config.stores.authCode.consume(code);
if (record?.redeemed === true) {
if (isNonEmptyString(record.familyId)) {
await config.stores.refresh.revokeFamily(record.familyId);
}
throw new ServerError(ProtocolError.INVALID_GRANT, 'authorization code has already been used');
}
if (!record) {
throw new ServerError(ProtocolError.INVALID_GRANT, 'authorization code is invalid, expired, or already used');
}
Expand Down Expand Up @@ -81,15 +93,20 @@ export async function authorizationCodeGrant(req, ctx) {
authTime: record.authTime,
});

/** @type {string | undefined} */
let familyId;
if (refreshAllowed(config, client)) {
familyId = randomState();
body.refresh_token = await mintRefreshToken(config, {
familyId,
subject: record.subject,
clientId: client.clientId,
scope: record.scope,
resource: record.resource,
dpopJkt,
});
}
await config.stores.authCode.save(code, { redeemed: true, familyId }, config.authorizationCodeTtlMs);

return body;
}
Expand Down
27 changes: 10 additions & 17 deletions packages/oauth2/src/server/grants/client-credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isArray, isNonEmptyString } from '@exortek/shared/predicates';

import { ProtocolError, ServerError } from '../errors.js';
import { resolveTokenAudience } from '../security/resource.js';
import { assertWithinCeiling, parseScope, scopeCeiling } from '../security/scope.js';
import { buildAccessResponse } from './_issue.js';

/**
Expand Down Expand Up @@ -40,31 +41,23 @@ export async function clientCredentialsGrant(req, ctx) {
}

/**
* A requested scope is validated against the client's registered `scope` (or
* the server's advertised `scopes` when the client declares none). With no
* `scope` requested, the client's registered scope is granted — or an empty
* scope when it registers none; never the full server scope list by default.
* A requested scope is validated against the client's scope ceiling (its
* registered `scope` ∩ the server's advertised `scopes`). With no `scope`
* requested, the client's registered scope (within that ceiling) is granted —
* or an empty scope when it registers none; never the full server scope list
* by default.
*
* @param {string | undefined} requested
* @param {import('../clients.js').Client} client
* @param {Record<string, any>} config
* @returns {string[]}
*/
function resolveScope(requested, client, config) {
const ceiling = scopeCeiling(client, config);
if (!isNonEmptyString(requested)) {
return isArray(client.scope) ? client.scope : [];
}
const asked = requested.split(/\s+/).filter(Boolean);
const allowed = isArray(client.scope) ? client.scope : config.scopes;
if (isArray(allowed)) {
for (const s of asked) {
if (!allowed.includes(s)) {
throw new ServerError(
ProtocolError.INVALID_SCOPE,
`scope ${JSON.stringify(s)} is not permitted for this client`,
);
}
}
return isArray(client.scope) ? (ceiling ?? []) : [];
}
const asked = parseScope(requested);
assertWithinCeiling(asked, ceiling);
return asked;
}
Loading
Loading