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 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/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 2cf4eec629..41857a69df 100644 --- a/portals/api-portal/src/config/roleScopeMap.test.js +++ b/portals/api-portal/src/config/roleScopeMap.test.js @@ -181,9 +181,30 @@ 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 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', () => { diff --git a/portals/api-portal/src/middlewares/passportConfig.js b/portals/api-portal/src/middlewares/passportConfig.js index 7cdb625147..0587f25d0c 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,51 @@ 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 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) { + 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 = 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; + const { payload } = await jwtVerify(token, jwks, options); + return payload; +} + /** * Checks an IDP-asserted organization claim against the organization this instance * serves. @@ -97,8 +143,33 @@ 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) { + // 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('Login failed: token verification error')); + } const firstName = decodedJWT['given_name'] || decodedJWT['nickname']; const lastName = decodedJWT['family_name']; const organizationId = getNestedClaim(decodedJWT, config.auth.claimMappings.organization) ?? ''; 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); +});