Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/oidc-client-pkce-opt-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': minor
---

OpenID Provider applications gain `pkceRequired`, `true` by default and for every existing application. An admin can set it to `false` on a confidential client only (RFC 9700 section 2.1.1), through `POST` or `PATCH /admin/applications`. A public client is refused. The provider service then receives `urn:seamless:pkce_required: false` for that client and requires `nonce` in place of PKCE whenever it asks for an ID token. Applications also gain `nonceRequired`, `true` by default, which can be turned off only once `pkceRequired` is `false`; the provider service then receives `urn:seamless:nonce_required: false` and accepts code requests with neither. Each change is recorded as `admin_oidc_application_pkce_updated` or `admin_oidc_application_nonce_updated`. ADR 0001 D1 is amended to match.
5 changes: 5 additions & 0 deletions .changeset/oidc-client-token-ttls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': minor
---

Client metadata for the OpenID Provider service now carries an application's `accessTokenTtlSeconds`, `idTokenTtlSeconds` and `refreshTokenTtlSeconds` as `urn:seamless:access_token_ttl`, `urn:seamless:id_token_ttl` and `urn:seamless:refresh_token_ttl` (`@seamless-auth/types` 0.34.0-next.4). They were stored and editable through the admin routes but never reached the provider, so they had no effect. Unset values are left out and the provider's defaults apply.
4 changes: 3 additions & 1 deletion docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ The API refuses these on create and on the merged result of every update (#410):
| -------------------------------------------------------------------------------------------- | ------------------------ |
| Only the `authorization_code`, `refresh_token`, `client_credentials` and device grants exist | Schema |
| Public clients authenticate with PKCE alone: no secret, no keys, no `client_credentials` | Schema |
| Only a confidential client may set `pkceRequired: false` | Schema, provider service |
| `nonceRequired: false` only once `pkceRequired` is `false` | Schema, provider service |
| `client_secret_jwt` cannot be chosen | Schema |
| Redirects are exact, except the RFC 8252 loopback port (`127.0.0.1`, `[::1]`) | Schema, provider service |
| `localhost`, fragments and embedded credentials are refused as redirects | Schema |
Expand All @@ -366,7 +368,7 @@ The API refuses these on create and on the merged result of every update (#410):
| Shared secrets need `allow_client_secrets` | API, provider service |
| Third-party clients need `third_party_clients_enabled` | API, provider service |

PKCE with `S256` for every client, `code` as the only response type, and refresh token rotation are fixed in the provider service's configuration and cannot be changed per client.
PKCE with `S256` is required for every client unless a confidential client sets `pkceRequired: false` (RFC 9700 section 2.1.1). An opted-out client must send `nonce` whenever it asks for an ID token unless `nonceRequired` is also `false`. Each change is recorded as `admin_oidc_application_pkce_updated` or `admin_oidc_application_nonce_updated`. Both fields are in every application response, so a client with both off can be flagged: it relies on `state` and client authentication alone against code injection. Like every application write, either change needs step-up. `code` as the only response type and refresh token rotation are fixed in the provider service's configuration and cannot be changed per client.

## Audit Events

Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0001-openid-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,16 @@ Writing our own provider was rejected. Each RFC above, and keeping a certified r
**Locked configuration (#410).**

- Only the `code` response type: no implicit or hybrid.
- PKCE required for every client.
- PKCE required by default for every client, with a per-client opt-out for confidential clients only that keeps the nonce on unless separately disabled (amended 2026-10-31, below).
- Exact redirect URI matching, except the RFC 8252 loopback port rule.
- Refresh tokens always rotated.
- `devInteractions` and dynamic registration off.
- HTTP POST to the authorization and logout endpoints off.

DPoP and PAR are available. The API's admin routes refuse client settings that would loosen this list (D12).

**Amendment, 2026-10-31: per-client PKCE opt-out.** PKCE stays required by default. An admin can set `pkceRequired: false` on a confidential client (`private_key_jwt` or a shared secret), as RFC 9700 section 2.1.1 allows when the client authenticates and binds the code to the transaction another way. The API and the edge both refuse the opt-out on a public client. An opted-out client must send `nonce` on every authorization request that asks for an ID token, which binds the code to the RP's transaction in place of PKCE. A second flag, `nonceRequired`, is on by default and can be turned off only on a client that has already opted out of PKCE. A client with both off relies on `state` and client authentication alone against code injection, so each flag is a separate admin write with step-up, recorded as `admin_oidc_application_pkce_updated` and `admin_oidc_application_nonce_updated`, and both are in every application response so the dashboard and CLI can warn about it. The reason is OpenID certification: the conformance suite's Basic OP, Config OP and Form Post OP plans send code requests without PKCE, and one of their modules sends one without a nonce and expects it to succeed.

**Issuer.** The edge's public URL, for example `https://login.example.com`, is stored in `system_config` as `oidc_issuer`. Discovery is at `/.well-known/openid-configuration` on the edge, and the provider JWKS is at `/jwks`. The API refuses an `oidc_issuer` equal to `ISSUER`.

## D2. The API stays private
Expand Down
4 changes: 4 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5281,6 +5281,8 @@
"admin_oidc_application_created",
"admin_oidc_application_deleted",
"admin_oidc_application_keys_updated",
"admin_oidc_application_nonce_updated",
"admin_oidc_application_pkce_updated",
"admin_oidc_application_resources_updated",
"admin_oidc_application_secret_rotated",
"admin_oidc_application_updated",
Expand Down Expand Up @@ -5402,6 +5404,8 @@
"admin_oidc_application_created",
"admin_oidc_application_deleted",
"admin_oidc_application_keys_updated",
"admin_oidc_application_nonce_updated",
"admin_oidc_application_pkce_updated",
"admin_oidc_application_resources_updated",
"admin_oidc_application_secret_rotated",
"admin_oidc_application_updated",
Expand Down
31 changes: 31 additions & 0 deletions src/controllers/oidcApplications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,25 @@ export async function createApplication(req: Request, res: Response) {
tokenEndpointAuthMethod: application.tokenEndpointAuthMethod,
grantTypes: application.grantTypes,
deviceFlowEnabled: application.deviceFlowEnabled,
pkceRequired: application.pkceRequired,
nonceRequired: application.nonceRequired,
redirectUris: application.redirectUris,
},
});

if (!application.pkceRequired) {
await audit(req, {
type: 'admin_oidc_application_pkce_updated',
metadata: { clientId: client.id, pkceRequired: false },
});
}
if (!application.nonceRequired) {
await audit(req, {
type: 'admin_oidc_application_nonce_updated',
metadata: { clientId: client.id, nonceRequired: false },
});
}

return res.status(201).json({ application, ...(clientSecret ? { clientSecret } : {}) });
} catch (error) {
return sendError(res, error);
Expand All @@ -88,8 +103,24 @@ export async function createApplication(req: Request, res: Response) {

export async function updateApplication(req: Request, res: Response) {
try {
const { pkceRequired: pkceBefore, nonceRequired: nonceBefore } = await findOidcApplication(
req.params.id as string,
);
const client = await updateOidcApplication(req.params.id as string, req.body);

if (client.pkceRequired !== pkceBefore) {
await audit(req, {
type: 'admin_oidc_application_pkce_updated',
metadata: { clientId: client.id, pkceRequired: client.pkceRequired },
});
}
if (client.nonceRequired !== nonceBefore) {
await audit(req, {
type: 'admin_oidc_application_nonce_updated',
metadata: { clientId: client.id, nonceRequired: client.nonceRequired },
});
}

await audit(req, {
type: 'admin_oidc_application_updated',
metadata: {
Expand Down
4 changes: 4 additions & 0 deletions src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4970,6 +4970,8 @@ export interface paths {
| 'admin_oidc_application_created'
| 'admin_oidc_application_deleted'
| 'admin_oidc_application_keys_updated'
| 'admin_oidc_application_nonce_updated'
| 'admin_oidc_application_pkce_updated'
| 'admin_oidc_application_resources_updated'
| 'admin_oidc_application_secret_rotated'
| 'admin_oidc_application_updated'
Expand Down Expand Up @@ -5085,6 +5087,8 @@ export interface paths {
| 'admin_oidc_application_created'
| 'admin_oidc_application_deleted'
| 'admin_oidc_application_keys_updated'
| 'admin_oidc_application_nonce_updated'
| 'admin_oidc_application_pkce_updated'
| 'admin_oidc_application_resources_updated'
| 'admin_oidc_application_secret_rotated'
| 'admin_oidc_application_updated'
Expand Down
30 changes: 30 additions & 0 deletions src/migrations/20261031120000-add-oidc-client-pkce-required.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/

'use strict';

/**
* PKCE and the nonce stay required for every existing client. Only a confidential client
* may opt out of PKCE, and only then of the nonce (ADR 0001, D1).
*
* @type {import('sequelize-cli').Migration}
*/
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE public.oidc_clients
ADD COLUMN IF NOT EXISTS "pkceRequired" boolean DEFAULT true NOT NULL,
ADD COLUMN IF NOT EXISTS "nonceRequired" boolean DEFAULT true NOT NULL;
`);
},

async down(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE public.oidc_clients DROP COLUMN IF EXISTS "pkceRequired",
DROP COLUMN IF EXISTS "nonceRequired";
`);
},
};
8 changes: 8 additions & 0 deletions src/models/oidcClients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface OidcClientAttributes {
refreshTokenTtlSeconds?: number | null;
deviceFlowEnabled: boolean;
noticesEnabled: boolean;
pkceRequired: boolean;
nonceRequired: boolean;
disabledAt?: Date | null;
createdAt?: Date;
updatedAt?: Date;
Expand All @@ -50,6 +52,8 @@ type OidcClientCreationAttributes = Optional<
| 'refreshTokenTtlSeconds'
| 'deviceFlowEnabled'
| 'noticesEnabled'
| 'pkceRequired'
| 'nonceRequired'
| 'disabledAt'
>;

Expand All @@ -74,6 +78,8 @@ export class OidcClient
declare refreshTokenTtlSeconds: number | null;
declare deviceFlowEnabled: boolean;
declare noticesEnabled: boolean;
declare pkceRequired: boolean;
declare nonceRequired: boolean;
declare disabledAt: Date | null;
declare readonly createdAt: Date;
declare readonly updatedAt: Date;
Expand Down Expand Up @@ -103,6 +109,8 @@ const initializeOidcClientModel = (sequelize: Sequelize) => {
refreshTokenTtlSeconds: { type: DataTypes.INTEGER, allowNull: true },
deviceFlowEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
noticesEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
pkceRequired: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
nonceRequired: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
disabledAt: { type: DataTypes.DATE, allowNull: true },
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/schemas/authEvent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const AUTH_EVENT_TYPES = [
'admin_oidc_application_created',
'admin_oidc_application_deleted',
'admin_oidc_application_keys_updated',
'admin_oidc_application_nonce_updated',
'admin_oidc_application_pkce_updated',
'admin_oidc_application_resources_updated',
'admin_oidc_application_secret_rotated',
'admin_oidc_application_updated',
Expand Down
11 changes: 8 additions & 3 deletions src/services/oidcApplicationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ export function serializeOidcApplication(client: OidcClient): OidcApplication {
),
deviceFlowEnabled: client.deviceFlowEnabled,
noticesEnabled: client.noticesEnabled,
// Not stored yet: every application still needs PKCE and a nonce.
pkceRequired: true,
nonceRequired: true,
pkceRequired: client.pkceRequired,
nonceRequired: client.nonceRequired,
jwks: (client.jwks as OidcApplication['jwks']) ?? null,
jwksUri: client.jwksUri ?? null,
hasSecret: Boolean(client.secretLookup),
Expand Down Expand Up @@ -154,6 +153,8 @@ export async function createOidcApplication(input: OidcApplicationCreateRequest)
refreshTokenTtlSeconds: input.refreshTokenTtlSeconds ?? null,
deviceFlowEnabled: input.deviceFlowEnabled,
noticesEnabled: input.noticesEnabled,
pkceRequired: input.pkceRequired,
nonceRequired: input.nonceRequired,
});

return { client, clientSecret: issued?.secret };
Expand Down Expand Up @@ -184,6 +185,8 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp
grantTypes: current.grantTypes,
deviceFlowEnabled: current.deviceFlowEnabled,
noticesEnabled: current.noticesEnabled,
pkceRequired: current.pkceRequired,
nonceRequired: current.nonceRequired,
backchannelLogoutUri: current.backchannelLogoutUri,
accessTokenTtlSeconds: current.accessTokenTtlSeconds,
idTokenTtlSeconds: current.idTokenTtlSeconds,
Expand All @@ -207,6 +210,8 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp
grantTypes: storedGrantTypes(next.grantTypes, next.deviceFlowEnabled),
deviceFlowEnabled: next.deviceFlowEnabled,
noticesEnabled: next.noticesEnabled,
pkceRequired: next.pkceRequired,
nonceRequired: next.nonceRequired,
backchannelLogoutUri: next.backchannelLogoutUri ?? null,
accessTokenTtlSeconds: next.accessTokenTtlSeconds ?? null,
idTokenTtlSeconds: next.idTokenTtlSeconds ?? null,
Expand Down
13 changes: 13 additions & 0 deletions src/services/oidcClientRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ export async function getOidcClientMetadata(id: string): Promise<OidcClientMetad
}
: {}),
'urn:seamless:first_party': client.firstParty,
...(client.accessTokenTtlSeconds
? { 'urn:seamless:access_token_ttl': client.accessTokenTtlSeconds }
: {}),
...(client.idTokenTtlSeconds ? { 'urn:seamless:id_token_ttl': client.idTokenTtlSeconds } : {}),
...(client.refreshTokenTtlSeconds
? { 'urn:seamless:refresh_token_ttl': client.refreshTokenTtlSeconds }
: {}),
...(client.pkceRequired === false && client.clientType === 'confidential'
? {
'urn:seamless:pkce_required': false,
...(client.nonceRequired === false ? { 'urn:seamless:nonce_required': false } : {}),
}
: {}),
};
}

Expand Down
Loading
Loading