Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
21 changes: 21 additions & 0 deletions portals/api-portal/resources/role-to-scope-mapping.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions portals/api-portal/src/config/authorizationConfig.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
8 changes: 6 additions & 2 deletions portals/api-portal/src/config/roleScopeMap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

test('the shipped admin role covers every resource the shipped subscriber role touches', () => {
Expand Down
58 changes: 55 additions & 3 deletions portals/api-portal/src/middlewares/passportConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,46 @@

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');
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 {};
}
Comment thread
dushaniw marked this conversation as resolved.
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 };
Comment thread
dushaniw marked this conversation as resolved.
Outdated
if (config.auth.idp?.issuer) options.issuer = config.auth.idp.issuer;
if (audience) options.audience = audience;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const { payload } = await jwtVerify(token, jwks, options);
return payload;
}

/**
* Checks an IDP-asserted organization claim against the organization this instance
* serves.
Expand Down Expand Up @@ -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}`));
Comment thread
dushaniw marked this conversation as resolved.
Outdated
}
const firstName = decodedJWT['given_name'] || decodedJWT['nickname'];
const lastName = decodedJWT['family_name'];
const organizationId = getNestedClaim(decodedJWT, config.auth.claimMappings.organization) ?? '';
Expand Down
133 changes: 133 additions & 0 deletions portals/api-portal/src/utils/platformJwt.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading