From 0dd5ed602b998c3a5dd7be5733d15d728fc61861 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 31 Oct 2026 13:09:30 -0400 Subject: [PATCH 1/2] feat(oidc): send per-application token lifetimes to the provider service --- .changeset/oidc-client-token-ttls.md | 5 +++++ src/services/oidcClientRegistry.ts | 7 +++++++ tests/unit/services/oidcClientRegistry.spec.ts | 15 +++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 .changeset/oidc-client-token-ttls.md diff --git a/.changeset/oidc-client-token-ttls.md b/.changeset/oidc-client-token-ttls.md new file mode 100644 index 0000000..f2169d1 --- /dev/null +++ b/.changeset/oidc-client-token-ttls.md @@ -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. diff --git a/src/services/oidcClientRegistry.ts b/src/services/oidcClientRegistry.ts index 250fb1b..2817bb9 100644 --- a/src/services/oidcClientRegistry.ts +++ b/src/services/oidcClientRegistry.ts @@ -77,6 +77,13 @@ export async function getOidcClientMetadata(id: string): Promise { }); }); + it("carries the application's token lifetimes when they are set", async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue( + client({ + accessTokenTtlSeconds: 120, + idTokenTtlSeconds: 90, + refreshTokenTtlSeconds: 3600, + }) as never, + ); + expect(await getOidcClientMetadata('app')).toMatchObject({ + 'urn:seamless:access_token_ttl': 120, + 'urn:seamless:id_token_ttl': 90, + 'urn:seamless:refresh_token_ttl': 3600, + }); + }); + it('lists the device grant only for a first-party client with the flag on', async () => { const device = 'urn:ietf:params:oauth:grant-type:device_code'; const grantTypes = [device, 'refresh_token']; From 5d5d02a2e6be7903dce715bd41aa533ec2c5db7d Mon Sep 17 00:00:00 2001 From: Brandon Corbett <8196876+Bccorb@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:45:56 -0400 Subject: [PATCH 2/2] feat(oidc): per-application PKCE and nonce opt-outs for confidential clients (#540) * feat(oidc): let a confidential application opt out of PKCE * feat(oidc): per-application nonce requirement for clients without PKCE --------- Co-authored-by: Brandon Corbett --- .changeset/oidc-client-pkce-opt-out.md | 5 + docs/admin-operations.md | 4 +- docs/adr/0001-openid-provider.md | 4 +- openapi.json | 4 + src/controllers/oidcApplications.ts | 31 ++++ src/generated/api.ts | 4 + ...31120000-add-oidc-client-pkce-required.cjs | 30 ++++ src/models/oidcClients.ts | 8 + src/schemas/authEvent.types.ts | 2 + src/services/oidcApplicationService.ts | 11 +- src/services/oidcClientRegistry.ts | 6 + .../integration/oidc/oidcApplications.spec.ts | 149 ++++++++++++++++++ .../unit/services/oidcClientRegistry.spec.ts | 38 +++++ 13 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 .changeset/oidc-client-pkce-opt-out.md create mode 100644 src/migrations/20261031120000-add-oidc-client-pkce-required.cjs diff --git a/.changeset/oidc-client-pkce-opt-out.md b/.changeset/oidc-client-pkce-opt-out.md new file mode 100644 index 0000000..a5c17fc --- /dev/null +++ b/.changeset/oidc-client-pkce-opt-out.md @@ -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. diff --git a/docs/admin-operations.md b/docs/admin-operations.md index bc9c1a5..3728785 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -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 | @@ -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 diff --git a/docs/adr/0001-openid-provider.md b/docs/adr/0001-openid-provider.md index 2829512..6cd4966 100644 --- a/docs/adr/0001-openid-provider.md +++ b/docs/adr/0001-openid-provider.md @@ -85,7 +85,7 @@ 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. @@ -93,6 +93,8 @@ Writing our own provider was rejected. Each RFC above, and keeping a certified r 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 diff --git a/openapi.json b/openapi.json index 2223759..c087da3 100644 --- a/openapi.json +++ b/openapi.json @@ -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", @@ -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", diff --git a/src/controllers/oidcApplications.ts b/src/controllers/oidcApplications.ts index 36e926d..b82a05a 100644 --- a/src/controllers/oidcApplications.ts +++ b/src/controllers/oidcApplications.ts @@ -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); @@ -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: { diff --git a/src/generated/api.ts b/src/generated/api.ts index 0a405fc..af40eba 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -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' @@ -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' diff --git a/src/migrations/20261031120000-add-oidc-client-pkce-required.cjs b/src/migrations/20261031120000-add-oidc-client-pkce-required.cjs new file mode 100644 index 0000000..6170447 --- /dev/null +++ b/src/migrations/20261031120000-add-oidc-client-pkce-required.cjs @@ -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"; + `); + }, +}; diff --git a/src/models/oidcClients.ts b/src/models/oidcClients.ts index 89316dc..bbfbd8e 100644 --- a/src/models/oidcClients.ts +++ b/src/models/oidcClients.ts @@ -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; @@ -50,6 +52,8 @@ type OidcClientCreationAttributes = Optional< | 'refreshTokenTtlSeconds' | 'deviceFlowEnabled' | 'noticesEnabled' + | 'pkceRequired' + | 'nonceRequired' | 'disabledAt' >; @@ -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; @@ -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 }, }, { diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index c26e14d..a038280 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -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', diff --git a/src/services/oidcApplicationService.ts b/src/services/oidcApplicationService.ts index 27d5aef..1bd37a5 100644 --- a/src/services/oidcApplicationService.ts +++ b/src/services/oidcApplicationService.ts @@ -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), @@ -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 }; @@ -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, @@ -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, diff --git a/src/services/oidcClientRegistry.ts b/src/services/oidcClientRegistry.ts index 2817bb9..a4c4f13 100644 --- a/src/services/oidcClientRegistry.ts +++ b/src/services/oidcClientRegistry.ts @@ -84,6 +84,12 @@ export async function getOidcClientMetadata(id: string): Promise = {}): Row { refreshTokenTtlSeconds: null, deviceFlowEnabled: false, noticesEnabled: false, + pkceRequired: true, + nonceRequired: true, disabledAt: null, createdAt: new Date('2026-10-30T12:00:00.000Z'), updatedAt: new Date('2026-10-30T12:00:00.000Z'), @@ -185,6 +187,91 @@ describe('POST /admin/applications', () => { expect(res.body.application.jwks).toEqual({ keys: [jwk] }); }); + it('lets a confidential client opt out of PKCE, and audits the opt-out', async () => { + const res = await request(app) + .post('/admin/applications') + .send({ + name: 'Grafana', + clientType: 'confidential', + firstParty: true, + redirectUris: ['https://grafana.example.com/cb'], + jwks: { keys: [jwk] }, + pkceRequired: false, + }); + + expect(res.status).toBe(201); + expect(res.body.application.pkceRequired).toBe(false); + expect(OidcClient.create).toHaveBeenCalledWith( + expect.objectContaining({ pkceRequired: false }), + ); + expect(loggedTypes()).toEqual([ + 'admin_oidc_application_created', + 'admin_oidc_application_pkce_updated', + ]); + expect(vi.mocked(AuthEventService.log).mock.calls[1][0].metadata).toEqual({ + clientId: vi.mocked(OidcClient.create).mock.calls[0][0].id, + pkceRequired: false, + }); + }); + + it('lets a client without PKCE turn the nonce requirement off, and audits it', async () => { + const res = await request(app) + .post('/admin/applications') + .send({ + name: 'Conformance', + clientType: 'confidential', + firstParty: true, + redirectUris: ['https://conformance.example.com/cb'], + jwks: { keys: [jwk] }, + pkceRequired: false, + nonceRequired: false, + }); + + expect(res.status).toBe(201); + expect(res.body.application).toMatchObject({ pkceRequired: false, nonceRequired: false }); + expect(loggedTypes()).toEqual([ + 'admin_oidc_application_created', + 'admin_oidc_application_pkce_updated', + 'admin_oidc_application_nonce_updated', + ]); + expect(vi.mocked(AuthEventService.log).mock.calls[2][0].metadata).toEqual({ + clientId: vi.mocked(OidcClient.create).mock.calls[0][0].id, + nonceRequired: false, + }); + }); + + it('refuses to drop the nonce requirement while PKCE is required', async () => { + const res = await request(app) + .post('/admin/applications') + .send({ + name: 'Grafana', + clientType: 'confidential', + firstParty: true, + redirectUris: ['https://grafana.example.com/cb'], + jwks: { keys: [jwk] }, + nonceRequired: false, + }); + + expect(res.status).toBe(400); + expect(res.body.details.issues[0].path).toEqual(['nonceRequired']); + }); + + it('refuses a PKCE opt-out on a public client', async () => { + const res = await request(app) + .post('/admin/applications') + .send({ + name: 'Desktop', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/callback'], + pkceRequired: false, + }); + + expect(res.status).toBe(400); + expect(res.body.details.issues[0].path).toEqual(['pkceRequired']); + expect(OidcClient.create).not.toHaveBeenCalled(); + }); + it('stores the device grant for a first-party device client', async () => { const res = await request(app) .post('/admin/applications') @@ -314,6 +401,68 @@ describe('PATCH /admin/applications/:id', () => { }); }); + it('audits a PKCE change on its own, and not a patch that leaves it alone', async () => { + const existing = row(); + vi.mocked(OidcClient.findByPk).mockResolvedValue(existing as never); + + const off = await request(app).patch('/admin/applications/app-1').send({ pkceRequired: false }); + expect(off.status).toBe(200); + expect(off.body.application.pkceRequired).toBe(false); + expect(existing.update).toHaveBeenCalledWith(expect.objectContaining({ pkceRequired: false })); + expect(loggedTypes()).toEqual([ + 'admin_oidc_application_pkce_updated', + 'admin_oidc_application_updated', + ]); + + vi.mocked(AuthEventService.log).mockClear(); + await request(app).patch('/admin/applications/app-1').send({ pkceRequired: false }); + expect(loggedTypes()).toEqual(['admin_oidc_application_updated']); + + vi.mocked(AuthEventService.log).mockClear(); + await request(app).patch('/admin/applications/app-1').send({ pkceRequired: true }); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0]).toMatchObject({ + type: 'admin_oidc_application_pkce_updated', + metadata: { clientId: 'app-1', pkceRequired: true }, + }); + }); + + it('audits a nonce requirement change, and re-checks it against PKCE', async () => { + const existing = row({ pkceRequired: false }); + vi.mocked(OidcClient.findByPk).mockResolvedValue(existing as never); + + const off = await request(app) + .patch('/admin/applications/app-1') + .send({ nonceRequired: false }); + expect(off.status).toBe(200); + expect(off.body.application.nonceRequired).toBe(false); + expect(loggedTypes()).toEqual([ + 'admin_oidc_application_nonce_updated', + 'admin_oidc_application_updated', + ]); + + const pkceBack = await request(app) + .patch('/admin/applications/app-1') + .send({ pkceRequired: true }); + expect(pkceBack.status).toBe(400); + expect(pkceBack.body.details.issues[0].path).toEqual(['nonceRequired']); + }); + + it('refuses a PKCE opt-out on a public client', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue( + row({ + clientType: 'public', + tokenEndpointAuthMethod: 'none', + jwksUri: null, + redirectUris: ['http://127.0.0.1/callback'], + }) as never, + ); + + const res = await request(app).patch('/admin/applications/app-1').send({ pkceRequired: false }); + + expect(res.status).toBe(400); + expect(res.body.details.issues[0].path).toEqual(['pkceRequired']); + }); + it('re-checks the merged result', async () => { vi.mocked(OidcClient.findByPk).mockResolvedValue(row() as never); diff --git a/tests/unit/services/oidcClientRegistry.spec.ts b/tests/unit/services/oidcClientRegistry.spec.ts index 8f6a8d9..0d6fef0 100644 --- a/tests/unit/services/oidcClientRegistry.spec.ts +++ b/tests/unit/services/oidcClientRegistry.spec.ts @@ -91,6 +91,44 @@ describe('getOidcClientMetadata', () => { }); }); + it('sends a PKCE opt-out only for a confidential client', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue( + client({ clientType: 'confidential', pkceRequired: false }) as never, + ); + expect(await getOidcClientMetadata('app')).toMatchObject({ + 'urn:seamless:pkce_required': false, + }); + + vi.mocked(OidcClient.findByPk).mockResolvedValue( + client({ + clientType: 'public', + tokenEndpointAuthMethod: 'none', + pkceRequired: false, + }) as never, + ); + expect(await getOidcClientMetadata('app')).not.toHaveProperty('urn:seamless:pkce_required'); + + vi.mocked(OidcClient.findByPk).mockResolvedValue( + client({ clientType: 'confidential', pkceRequired: true }) as never, + ); + expect(await getOidcClientMetadata('app')).not.toHaveProperty('urn:seamless:pkce_required'); + }); + + it('sends a nonce opt-out only alongside a PKCE opt-out', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue( + client({ clientType: 'confidential', pkceRequired: false, nonceRequired: false }) as never, + ); + expect(await getOidcClientMetadata('app')).toMatchObject({ + 'urn:seamless:pkce_required': false, + 'urn:seamless:nonce_required': false, + }); + + vi.mocked(OidcClient.findByPk).mockResolvedValue( + client({ clientType: 'confidential', pkceRequired: true, nonceRequired: false }) as never, + ); + expect(await getOidcClientMetadata('app')).not.toHaveProperty('urn:seamless:nonce_required'); + }); + it('lists the device grant only for a first-party client with the flag on', async () => { const device = 'urn:ietf:params:oauth:grant-type:device_code'; const grantTypes = [device, 'refresh_token'];