From 3f8c4ba5477d2705cf186e7689e587cab0200d44 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 19:31:51 +0530 Subject: [PATCH 1/5] =?UTF-8?q?Add=20platform-api-system=20role=20to=20API?= =?UTF-8?q?=20Portal=20role=E2=86=92scope=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform API mints an outbound token carrying roles=["platform-api-system"] when it calls the portal's admin REST to publish APIs, MCP servers, their content, and subscription plans. In role-authorization mode (the default), the portal looks that role up in this file and expands to the corresponding dp:* scopes. Without an entry the token authenticates but the request is denied for lack of scopes. The role is added as a distinct entry rather than a dp_admin alias: it's a service identity (not a human persona) and its grant is a strict subset of dp_admin's — publishing scopes only, no access to organization settings, applications, subscriptions, webhooks, or key managers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/role-to-scope-mapping.yaml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/portals/api-portal/resources/role-to-scope-mapping.yaml b/portals/api-portal/resources/role-to-scope-mapping.yaml index 920c62d692..72eef420d7 100644 --- a/portals/api-portal/resources/role-to-scope-mapping.yaml +++ b/portals/api-portal/resources/role-to-scope-mapping.yaml @@ -144,3 +144,24 @@ roles: - name: ap_subscriber scopes: *subscriber_grant + + # --- Service identity for outbound Platform API publish calls -------------- + # + # Platform API uses OAuth2 client_credentials (or a self-minted JWT in local + # auth mode) to publish APIs, MCP servers, their content, and subscription + # plans to this portal's admin REST. The outbound token carries + # roles=["platform-api-system"] — either from the STS-side role assigned to + # the DCR app (cloud), or minted directly by Platform API in local mode. + # + # This is a service identity, not a human persona. Its grant is narrower + # than dp_admin's: it can publish and manage the artifacts Platform API + # produces, but has no access to organization settings, applications, + # subscriptions, webhooks, or key managers. Not aliased — the scope set is + # a strict subset of dp_admin's and doesn't map to either page-access tier. + - name: platform-api-system + scopes: + - dp:api:manage + - dp:api_content:manage + - dp:mcp_server:manage + - dp:mcp_server_content:manage + - dp:subscription_plan:manage From f5ff3d8ddf6700d72a61a1659b2bf68cc8933e25 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 19:40:35 +0530 Subject: [PATCH 2/5] Verify IDP tokens during passport-oauth2 login instead of just decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The passport-oauth2 callback used safeDecodeJwt on the id_token and access_token freshly returned by the IDP. safeDecodeJwt reads the payload without any signature, issuer, audience, or expiry checks — so a tampered token or one issued for a different audience by the same IDP would still be accepted as the login identity. Replace with a verifyIdpJwt helper that uses jose's jwtVerify against the same JWKS URL the OAuth strategy is already configured with: - id_token audience defaults to auth.idp.clientId (OIDC Core §3.1.3.7). - access_token audience uses auth.idp.audience when configured; otherwise aud validation is skipped for the access token while signature / issuer / expiry checks still run. - Verification failure surfaces as a login failure (done(err)) rather than a silently-accepted forged token. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/middlewares/passportConfig.js | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/portals/api-portal/src/middlewares/passportConfig.js b/portals/api-portal/src/middlewares/passportConfig.js index 7cdb625147..7d2214cc3d 100644 --- a/portals/api-portal/src/middlewares/passportConfig.js +++ b/portals/api-portal/src/middlewares/passportConfig.js @@ -18,7 +18,8 @@ const passport = require('passport'); const OAuth2Strategy = require('passport-oauth2'); -const { safeDecodeJwt, getNestedClaim } = require('../utils/jwtDecode'); +const { jwtVerify, createRemoteJWKSet } = require('jose'); +const { getNestedClaim } = require('../utils/jwtDecode'); const { config } = require('../config/configLoader'); const { portalRoles } = require('./authorization'); const constants = require('../utils/constants'); @@ -26,6 +27,37 @@ const logger = require('../config/logger'); const orgContext = require('../utils/orgContext'); const { CustomError } = require('../utils/errors/customErrors'); +/** + * Verifies an IDP-issued JWT against the configured JWKS. Returns the parsed + * payload on success, or throws when signature, algorithm, issuer, audience, + * or expiry checks fail. + * + * The passport-oauth2 callback previously decoded the id_token and access_token + * without verification, so a tampered token — or one issued for a different + * audience by the same IDP — would still be accepted as the login identity. + * This helper closes that gap by using jose's jwtVerify against the same JWKS + * URL the OAuth strategy is configured with. + * + * `audience` is optional so the caller can decide the appropriate audience + * per token type (id_token → clientId per OpenID Connect Core §3.1.3.7; access + * token → whatever the IDP is configured to stamp for this deployment). + */ +async function verifyIdpJwt(token, audience) { + if (!token) { + return {}; + } + const jwksURL = config.auth.idp?.jwksUrl; + if (!jwksURL) { + throw new Error('IDP jwksUrl is not configured; cannot verify token'); + } + const jwks = createRemoteJWKSet(new URL(jwksURL)); + const options = { algorithms: constants.JWT_ASYMMETRIC_ALGORITHMS }; + if (config.auth.idp?.issuer) options.issuer = config.auth.idp.issuer; + if (audience) options.audience = audience; + const { payload } = await jwtVerify(token, jwks, options); + return payload; +} + /** * Checks an IDP-asserted organization claim against the organization this instance * serves. @@ -97,8 +129,28 @@ function configurePassport(SERVER_ID) { return done(new Error('Access token missing')); } let isAdmin = false; - const decodedJWT = safeDecodeJwt(params.id_token) || {}; - const decodedAccessToken = safeDecodeJwt(accessToken); + // Verify the id_token and access_token against the IDP's JWKS + // before trusting any claim in them. Prior code called safeDecodeJwt + // which only decoded the payload, leaving signature / issuer / + // audience / expiry checks entirely unenforced. + // + // id_token: audience is the client_id per OIDC Core §3.1.3.7. + // access_token: audience defaults to the IDP-configured value when + // present; some IDPs (e.g. Asgardeo default) stamp the client_id + // there too. When not configured, skip aud validation for the + // access_token — the signature + issuer + expiry checks still run. + let decodedJWT = {}; + let decodedAccessToken = {}; + try { + decodedJWT = await verifyIdpJwt(params.id_token, config.auth.idp?.clientId); + decodedAccessToken = await verifyIdpJwt(accessToken, config.auth.idp?.audience); + } catch (err) { + logger.error('IDP token verification failed during login', { + error: err.message, + code: err.code, + }); + return done(new Error(`IDP token verification failed: ${err.message}`)); + } const firstName = decodedJWT['given_name'] || decodedJWT['nickname']; const lastName = decodedJWT['family_name']; const organizationId = getNestedClaim(decodedJWT, config.auth.claimMappings.organization) ?? ''; From 2192b38d745fd80c5cdc530f8b1ea33a7adf0593 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 19:55:57 +0530 Subject: [PATCH 3/5] Update role-count test assertions for platform-api-system role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two assertions hard-coded the shipped role-to-scope-mapping.yaml's role count. Adding platform-api-system flips 4 → 5 in both places. --- portals/api-portal/src/config/authorizationConfig.test.js | 4 ++-- portals/api-portal/src/config/roleScopeMap.test.js | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/portals/api-portal/src/config/authorizationConfig.test.js b/portals/api-portal/src/config/authorizationConfig.test.js index eb9ee0e8d2..ead323a225 100644 --- a/portals/api-portal/src/config/authorizationConfig.test.js +++ b/portals/api-portal/src/config/authorizationConfig.test.js @@ -182,14 +182,14 @@ role_to_scope_mapping = ${JSON.stringify(SHIPPED_MAPPING_PATH)} assert.match(stderr, /requires auth\.claim_mappings\.roles/); }); -test('role mode with the shipped mapping starts and loads its two roles', () => { +test('role mode with the shipped mapping starts and loads its five roles', () => { const { status, stderr } = loadConfig(` [api_portal.auth.authorization] mode = "role" role_to_scope_mapping = ${JSON.stringify(SHIPPED_MAPPING_PATH)} `); assert.equal(status, 0, stderr); - assert.match(stderr, /loaded 4 role\(s\)/); + assert.match(stderr, /loaded 5 role\(s\)/); }); test('a mapping file is loaded and validated even in scope mode', () => { diff --git a/portals/api-portal/src/config/roleScopeMap.test.js b/portals/api-portal/src/config/roleScopeMap.test.js index 2cf4eec629..045ba730ae 100644 --- a/portals/api-portal/src/config/roleScopeMap.test.js +++ b/portals/api-portal/src/config/roleScopeMap.test.js @@ -181,9 +181,13 @@ test('the shipped role-to-scope-mapping.yaml validates against the shipped OpenA const map = roleScopeMap.loadRoleScopeMap(SHIPPED_MAPPING_PATH, SPEC_PATH); // Two grants by design — the portal recognises an administrator and a consumer, // which is exactly what its page gate has tiers for — plus aliases for the role - // names other components mint. The publisher/operator/viewer personas belong to + // names other components mint, and a service identity used by Platform API for + // outbound publish calls. The publisher/operator/viewer personas belong to // platform-api's own grant table. - assert.deepEqual([...map.keys()], ['dp_admin', 'dp_subscriber', 'ap_admin', 'ap_subscriber']); + assert.deepEqual( + [...map.keys()], + ['dp_admin', 'dp_subscriber', 'ap_admin', 'ap_subscriber', 'platform-api-system'], + ); }); test('the shipped admin role covers every resource the shipped subscriber role touches', () => { From 58143b39256be2a24aa9a42bd9e3dbb7938a8f44 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Mon, 17 Aug 2026 19:56:03 +0530 Subject: [PATCH 4/5] Add unit tests for platformJwt verify/decode helpers Covers verifyPlatformJwtClaims (signature match, wrong-key rejection, expired-token rejection, malformed input, missing key file, empty-scope handling) and decodePlatformJwtClaims (parses without verifying, returns null for malformed input). Uses node:test and jose helpers to build tokens rather than relying on external fixtures. --- .../api-portal/src/utils/platformJwt.test.js | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 portals/api-portal/src/utils/platformJwt.test.js diff --git a/portals/api-portal/src/utils/platformJwt.test.js b/portals/api-portal/src/utils/platformJwt.test.js new file mode 100644 index 0000000000..13805b6467 --- /dev/null +++ b/portals/api-portal/src/utils/platformJwt.test.js @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { generateKeyPair, exportSPKI, SignJWT } = require('jose'); + +const { verifyPlatformJwtClaims, decodePlatformJwtClaims } = require('./platformJwt'); +const constants = require('./constants'); + +const ALG = constants.JWT_ASYMMETRIC_ALGORITHMS[0]; + +let tmpDir; +function writeKeyFile(name, contents) { + if (!tmpDir) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-platformjwt-')); + const p = path.join(tmpDir, name); + fs.writeFileSync(p, contents); + return p; +} + +test.after(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function makeSignedToken(privateKey, claims = {}) { + return new SignJWT({ sub: 'platform-api-system', ...claims }) + .setProtectedHeader({ alg: ALG }) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); +} + +test('verifyPlatformJwtClaims accepts a token signed by the paired key and parses scopes', async () => { + const { publicKey, privateKey } = await generateKeyPair(ALG); + const pubPath = writeKeyFile('happy.pub.pem', await exportSPKI(publicKey)); + const token = await makeSignedToken(privateKey, { + scope: 'dp:api:manage dp:api_content:manage', + roles: ['platform-api-system'], + }); + + const claims = await verifyPlatformJwtClaims(token, pubPath); + assert.ok(claims, 'expected claims for a valid token'); + assert.equal(claims.sub, 'platform-api-system'); + assert.deepEqual(claims.roles, ['platform-api-system']); + assert.deepEqual(claims.scopes, ['dp:api:manage', 'dp:api_content:manage']); +}); + +test('verifyPlatformJwtClaims rejects a token signed by a different key', async () => { + const signer = await generateKeyPair(ALG); + const verifier = await generateKeyPair(ALG); + const wrongPubPath = writeKeyFile('wrong.pub.pem', await exportSPKI(verifier.publicKey)); + const token = await makeSignedToken(signer.privateKey); + + const claims = await verifyPlatformJwtClaims(token, wrongPubPath); + assert.equal(claims, null, 'expected null when signature does not match the configured public key'); +}); + +test('verifyPlatformJwtClaims rejects an expired token', async () => { + const { publicKey, privateKey } = await generateKeyPair(ALG); + const pubPath = writeKeyFile('expired.pub.pem', await exportSPKI(publicKey)); + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ sub: 'platform-api-system' }) + .setProtectedHeader({ alg: ALG }) + .setIssuedAt(now - 3600) + .setExpirationTime(now - 60) + .sign(privateKey); + + const claims = await verifyPlatformJwtClaims(token, pubPath); + assert.equal(claims, null, 'expected null for an expired token'); +}); + +test('verifyPlatformJwtClaims returns null for malformed input', async () => { + const { publicKey } = await generateKeyPair(ALG); + const pubPath = writeKeyFile('malformed.pub.pem', await exportSPKI(publicKey)); + assert.equal(await verifyPlatformJwtClaims('not.a.jwt.token', pubPath), null); + assert.equal(await verifyPlatformJwtClaims('', pubPath), null); +}); + +test('verifyPlatformJwtClaims returns null when the key file cannot be read', async () => { + const { privateKey } = await generateKeyPair(ALG); + const token = await makeSignedToken(privateKey); + // Ensure tmpDir is materialized without writing a real key file to it. + writeKeyFile('.marker', ''); + const missing = path.join(tmpDir, 'does-not-exist.pem'); + + const claims = await verifyPlatformJwtClaims(token, missing); + assert.equal(claims, null, 'expected null when the configured public key file is missing'); +}); + +test('verifyPlatformJwtClaims returns empty scopes when the scope claim is absent', async () => { + const { publicKey, privateKey } = await generateKeyPair(ALG); + const pubPath = writeKeyFile('noscope.pub.pem', await exportSPKI(publicKey)); + const token = await makeSignedToken(privateKey); // no scope claim + + const claims = await verifyPlatformJwtClaims(token, pubPath); + assert.ok(claims); + assert.deepEqual(claims.scopes, []); +}); + +test('decodePlatformJwtClaims parses the scope claim without verifying', async () => { + const { privateKey } = await generateKeyPair(ALG); + const token = await makeSignedToken(privateKey, { scope: 'a b c' }); + + const claims = decodePlatformJwtClaims(token); + assert.ok(claims); + assert.deepEqual(claims.scopes, ['a', 'b', 'c']); +}); + +test('decodePlatformJwtClaims returns null for malformed input', () => { + assert.equal(decodePlatformJwtClaims('not.a.jwt'), null); + assert.equal(decodePlatformJwtClaims(''), null); +}); From 408b9dde8a3622c54cc73f6cab132390716b73d1 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Tue, 18 Aug 2026 10:07:37 +0530 Subject: [PATCH 5/5] Address PR #3243 review comments on JWKS verification and role scope test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five follow-ups from CodeRabbit + Copilot review of the previous commits: 1. Cache JWKS resolver at module scope, keyed by URL. createRemoteJWKSet keeps an internal key cache and rate-limits refreshes; recreating it on every verifyIdpJwt call (twice per login) threw that state away and pushed the JWKS endpoint on every login. 2. Fail closed in verifyIdpJwt when the token argument is falsy. The helper previously returned {} on a missing token, which let the OAuth2 callback continue with empty claims and land the user in a session that 403'd on every subsequent request. The docstring already said "throws when checks fail" — this makes the code match. 3. Return a generic error to Passport (Login failed: token verification error) and keep the underlying jose message in the log. Depending on how the callback route renders the error, the raw message could leak JWKS URL parse failures, network errors, etc. to the browser. 4. Require auth.idp.jwks_url in configLoader when auth.mode = "idp". Both the login callback (passportConfig.js) and the REST bearer verifier (authMiddleware.js) already require it at request time; fail closed at startup instead of at first login. 5. Assert the shipped platform-api-system role's exact scope list in roleScopeMap.test.js (in addition to the role-name-only assertion). Pins the least-privilege contract so a silent scope widening or narrowing fails this test. Co-Authored-By: Claude Opus 4.7 (1M context) --- portals/api-portal/src/config/configLoader.js | 25 +++++++---- .../src/config/roleScopeMap.test.js | 17 ++++++++ .../src/middlewares/passportConfig.js | 41 ++++++++++++++----- 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/portals/api-portal/src/config/configLoader.js b/portals/api-portal/src/config/configLoader.js index 7d4dc1b98d..87b7595965 100644 --- a/portals/api-portal/src/config/configLoader.js +++ b/portals/api-portal/src/config/configLoader.js @@ -591,18 +591,24 @@ function resolveOrganizationConfig(cfg, tomlOrg) { resolveOrganizationConfig(config, interpolatedTomlConfig.organization); /** - * Refuses to start when auth.mode = "idp" is selected without the endpoints OIDC login + * Refuses to start when auth.mode = "idp" is selected without the settings OIDC login * actually needs. * - * These four have no default (see configDefaults.js) because no default could be right, - * and passport-oauth2 throws on each of them anyway — this only turns that into a message - * that names the missing key instead of a constructor stack trace. Validating the - * *effective* config rather than trusting a per-field default is the same fail-closed rule - * the Go services follow (authentication_authorization.md, GO-AUTH-011). + * These have no default (see configDefaults.js) because no default could be right, and + * passport-oauth2 / the login callback throw on each of them anyway — this only turns + * that into a message that names the missing key instead of a constructor stack trace or + * a runtime rejection on the first login. Validating the *effective* config rather than + * trusting a per-field default is the same fail-closed rule the Go services follow + * (authentication_authorization.md, GO-AUTH-011). * - * Deliberately not required here: jwks_url / certificate (token verification can also be - * satisfied by an issuer-derived JWKS), and logout_url / sign_up_url, which are optional - * features rather than prerequisites for logging in. + * jwks_url is required because the login callback and the REST bearer-token path both + * verify tokens against it (passportConfig.js's verifyIdpJwt; authMiddleware.js's + * verifyJwksWithRefresh). Without it, the callback and every subsequent request would + * fail at runtime with an "IDP jwksUrl is not configured" error — surface that at + * startup instead. + * + * Deliberately not required here: logout_url / sign_up_url, which are optional features + * rather than prerequisites for logging in. */ function validateIdpConfig(cfg) { if (cfg.auth?.mode !== 'idp') return; @@ -611,6 +617,7 @@ function validateIdpConfig(cfg) { 'auth.idp.authorization_url': cfg.auth.idp?.authorizationUrl, 'auth.idp.token_url': cfg.auth.idp?.tokenUrl, 'auth.idp.callback_url': cfg.auth.idp?.callbackUrl, + 'auth.idp.jwks_url': cfg.auth.idp?.jwksUrl, }; const missing = Object.entries(required) .filter(([, value]) => !String(value ?? '').trim()) diff --git a/portals/api-portal/src/config/roleScopeMap.test.js b/portals/api-portal/src/config/roleScopeMap.test.js index 045ba730ae..41857a69df 100644 --- a/portals/api-portal/src/config/roleScopeMap.test.js +++ b/portals/api-portal/src/config/roleScopeMap.test.js @@ -190,6 +190,23 @@ test('the shipped role-to-scope-mapping.yaml validates against the shipped OpenA ); }); +test('the shipped platform-api-system role grants exactly the five publishing scopes', () => { + // Pinned scope list, not just presence: this role is granted to Platform API's + // outbound publish caller, so silently widening it (accidentally adding + // application/subscription scopes, say) would hand a service identity powers + // meant for a human admin. Silently narrowing it would leave publishing + // broken for whichever resource lost its scope, which the role-name-only + // assertion above would miss. + const map = roleScopeMap.loadRoleScopeMap(SHIPPED_MAPPING_PATH, SPEC_PATH); + assert.deepEqual(map.get('platform-api-system'), [ + 'dp:api:manage', + 'dp:api_content:manage', + 'dp:mcp_server:manage', + 'dp:mcp_server_content:manage', + 'dp:subscription_plan:manage', + ]); +}); + test('the shipped admin role covers every resource the shipped subscriber role touches', () => { // A narrower admin than consumer would be a packaging mistake, not a policy: an // administrator who cannot see what a subscriber can manage is never intended. diff --git a/portals/api-portal/src/middlewares/passportConfig.js b/portals/api-portal/src/middlewares/passportConfig.js index 7d2214cc3d..0587f25d0c 100644 --- a/portals/api-portal/src/middlewares/passportConfig.js +++ b/portals/api-portal/src/middlewares/passportConfig.js @@ -27,30 +27,44 @@ const logger = require('../config/logger'); const orgContext = require('../utils/orgContext'); const { CustomError } = require('../utils/errors/customErrors'); +// One JWKS resolver per URL, kept at module scope. `createRemoteJWKSet` +// keeps an in-memory key cache + rate-limits refreshes; recreating it per +// call throws that state away and pushes the JWKS endpoint on every login. +// Keyed by URL so a config change (or a test overriding the URL) creates a +// new resolver rather than serving stale keys from another endpoint. +const jwksResolvers = new Map(); +function getJwksResolver(jwksURL) { + let resolver = jwksResolvers.get(jwksURL); + if (!resolver) { + resolver = createRemoteJWKSet(new URL(jwksURL)); + jwksResolvers.set(jwksURL, resolver); + } + return resolver; +} + /** * Verifies an IDP-issued JWT against the configured JWKS. Returns the parsed - * payload on success, or throws when signature, algorithm, issuer, audience, - * or expiry checks fail. - * - * The passport-oauth2 callback previously decoded the id_token and access_token - * without verification, so a tampered token — or one issued for a different - * audience by the same IDP — would still be accepted as the login identity. - * This helper closes that gap by using jose's jwtVerify against the same JWKS - * URL the OAuth strategy is configured with. + * payload on success, or throws when the token is missing / malformed, or + * when signature, algorithm, issuer, audience, or expiry checks fail. * * `audience` is optional so the caller can decide the appropriate audience * per token type (id_token → clientId per OpenID Connect Core §3.1.3.7; access * token → whatever the IDP is configured to stamp for this deployment). + * + * Fails closed on a falsy `token`: an OAuth2 code-flow callback that reaches + * here without a token would otherwise continue with empty claims and land the + * user in a session that 403s on every subsequent request. Better to refuse + * the login than to create the empty session. */ async function verifyIdpJwt(token, audience) { if (!token) { - return {}; + throw new Error('token is required'); } const jwksURL = config.auth.idp?.jwksUrl; if (!jwksURL) { throw new Error('IDP jwksUrl is not configured; cannot verify token'); } - const jwks = createRemoteJWKSet(new URL(jwksURL)); + const jwks = getJwksResolver(jwksURL); const options = { algorithms: constants.JWT_ASYMMETRIC_ALGORITHMS }; if (config.auth.idp?.issuer) options.issuer = config.auth.idp.issuer; if (audience) options.audience = audience; @@ -145,11 +159,16 @@ function configurePassport(SERVER_ID) { decodedJWT = await verifyIdpJwt(params.id_token, config.auth.idp?.clientId); decodedAccessToken = await verifyIdpJwt(accessToken, config.auth.idp?.audience); } catch (err) { + // Full detail (jose error code, JWKS URL parse failures, + // network errors) stays in the log; the message handed back + // to Passport — and potentially rendered by the callback + // route — is a fixed string, so operational details cannot + // reach the browser. logger.error('IDP token verification failed during login', { error: err.message, code: err.code, }); - return done(new Error(`IDP token verification failed: ${err.message}`)); + return done(new Error('Login failed: token verification error')); } const firstName = decodedJWT['given_name'] || decodedJWT['nickname']; const lastName = decodedJWT['family_name'];