diff --git a/portals/api-portal/configs/config-template.toml b/portals/api-portal/configs/config-template.toml index b4360193bf..de12f1ba35 100644 --- a/portals/api-portal/configs/config-template.toml +++ b/portals/api-portal/configs/config-template.toml @@ -184,6 +184,26 @@ pool_request_timeout_ms = 30000 # MSSQL only - per-query execution timeo encryption_key = "" # 64-char hex — AES-256-GCM key for encrypting secrets at rest session_secret = "" # 64-char hex — express-session signing secret +# ============================================================================= +# INTERNAL SERVICE-TO-SERVICE AUTHENTICATION +# ============================================================================= +# Shared-key authentication for platform-api's outbound publishing calls (publish +# / update / delete API, API content, MCP Server, MCP Server content, Subscription +# Plan). Platform-API sends `Authorization: SharedKey `; the portal computes +# sha256(raw) and constant-time compares against `hash` below. On match the caller +# is granted only the five dp:*:manage scopes (via the platform-api-system role in +# role-to-scope-mapping.yaml), so a valid shared-key call cannot reach any +# consumer or UI endpoint. +# +# The hash file is generated once by portals/scripts/setup.sh alongside +# encryption_key and session_secret; the accompanying raw file is one-time-read +# (the operator copies it into platform-api's Create API Portal call, then +# deletes it). If `hash` is empty, shared-key auth is disabled and every SharedKey +# request is rejected 401 — the OAuth / session paths keep working. + +[api_portal.internal_auth] +hash = "" # 64-char hex — sha256 of the platform-api → portal shared key + # ============================================================================= # AUTHENTICATION # ============================================================================= diff --git a/portals/api-portal/configs/config.toml b/portals/api-portal/configs/config.toml index cce55e083b..e522bc30a9 100644 --- a/portals/api-portal/configs/config.toml +++ b/portals/api-portal/configs/config.toml @@ -38,6 +38,14 @@ page_role_validation = true admin = "ap_admin" subscriber = "ap_subscriber" +[api_portal.internal_auth] +# Empty disables shared-key auth (per configLoader.js line 406: "When `hash` +# is empty the section is treated as absent"). Local-only mod for the cloud +# portal setup: we don't ship a shared-key hash file yet (workaround #4 +# vault only stores encryption.key + session-secret). Revert to the {{ file +# ... }} form once the plugin generates + stages this third value. +hash = "" + [api_portal.organization] handle = '{{ env "APIP_AP_ORGANIZATION_HANDLE" "default" }}' display_name = '{{ env "APIP_AP_ORGANIZATION_DISPLAY_NAME" "Default" }}' 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..bb14f94fdd 100644 --- a/portals/api-portal/src/config/authorizationConfig.test.js +++ b/portals/api-portal/src/config/authorizationConfig.test.js @@ -47,6 +47,7 @@ session_secret = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba98765432 [api_portal.organization] handle = "default" +portal_id = "test-portal" `; let tmpDir; @@ -182,14 +183,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/configDefaults.js b/portals/api-portal/src/config/configDefaults.js index 4c4208294f..df1bd57dc9 100644 --- a/portals/api-portal/src/config/configDefaults.js +++ b/portals/api-portal/src/config/configDefaults.js @@ -125,6 +125,18 @@ const DEFAULTS = { encryptionKey: '', sessionSecret: '', }, + // Internal service-to-service authentication for platform-api's outbound + // publishing calls (publish / update / delete API, API content, MCP Server, + // MCP Server content, Subscription Plan). Platform-API sends + // `Authorization: SharedKey `; the portal computes sha256(raw) and + // constant-time compares against `hash` below. On match, the caller is + // granted only the five dp:*:manage scopes via the platform-api-system role + // in role-to-scope-mapping.yaml — nothing else. Empty means shared-key auth + // is disabled: every SharedKey request is rejected 401 while OAuth / session + // paths keep working. + internalAuth: { + hash: '', + }, // Authentication — HOW a token is verified: a mode gate plus the two backends it // selects between, local (default) and idp. What a verified token may DO is // authorization, which lives in its own mode-independent section below. diff --git a/portals/api-portal/src/config/configLoader.js b/portals/api-portal/src/config/configLoader.js index 8de9f99ec4..5af17dee0a 100644 --- a/portals/api-portal/src/config/configLoader.js +++ b/portals/api-portal/src/config/configLoader.js @@ -395,6 +395,35 @@ if (config.designMode?.enabled) { requireHexSecret(config.security.sessionSecret, 'sessionSecret'); } +/** + * Fail-closed check for the internal_auth section. + * + * When `hash` is configured, it must be a 64-char hex string (the sha256 of the + * shared key platform-api sends). A malformed value would silently degrade to + * "shared-key auth never accepts anything" while leaving the section present — + * exactly the kind of mismatch this loader exists to catch, so fail here instead. + * + * When `hash` is empty the section is treated as absent: shared-key auth is + * disabled at request time (see src/middlewares/sharedKeyAuth.js), the OAuth and + * session paths keep working, and no fatal is raised. + */ +function validateInternalAuthConfig(cfg) { + const hash = cfg.internalAuth?.hash; + if (!hash) return; + if (typeof hash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(hash)) { + process.stderr.write( + '[FATAL] internal_auth.hash did not resolve to a 64-character hex string. ' + + 'Refusing to start with a malformed shared-key hash. Regenerate the hash file ' + + 'with portals/scripts/setup.sh (or --rotate-internal-key) and reference it from ' + + "configs/config.toml, e.g. hash = '{{ file \"/etc/api-portal/keys/api-portal-internal-key-hash\" }}'. " + + 'Leave the value empty to disable shared-key auth.\n' + ); + process.exit(1); + } +} + +validateInternalAuthConfig(config); + /** * Fail-closed startup check: database.driver must be a recognised spelling, and * is rewritten in place to its canonical dialect before anything reads it. @@ -622,18 +651,24 @@ function resolvePortalIdConfig(cfg) { resolvePortalIdConfig(config); /** - * 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). + * + * 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: 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. + * 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; @@ -642,6 +677,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/internalAuthConfig.test.js b/portals/api-portal/src/config/internalAuthConfig.test.js new file mode 100644 index 0000000000..f7f49fad0e --- /dev/null +++ b/portals/api-portal/src/config/internalAuthConfig.test.js @@ -0,0 +1,152 @@ +/* + * 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'; + +/* + * Startup validation for [api_portal.internal_auth] (configLoader.js). + * + * Driven through a child process rather than by calling the validator directly: + * the check is fail-closed via process.exit, and configLoader runs it as a side + * effect of module load. Spawning is what lets the test assert the thing that + * actually matters, that the portal REFUSES TO START, rather than that a + * function returned an error object. Follows the same pattern as + * authorizationConfig.test.js. + */ + +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 { spawnSync } = require('node:child_process'); + +const PROJECT_ROOT = path.join(__dirname, '..', '..'); +const VALID_HEX_HASH = 'a'.repeat(64); + +// A config carrying only what the unrelated startup checks demand, so anything +// this suite observes comes from the internal-auth validation and nothing else. +const BASE_CONFIG = ` +[api_portal.security] +encryption_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +session_secret = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + +[api_portal.organization] +handle = "default" +portal_id = "test-portal" +`; + +let tmpDir; +function fixture(name, contents) { + if (!tmpDir) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-internalauth-config-')); + const file = path.join(tmpDir, name); + fs.writeFileSync(file, contents); + return file; +} + +// Stable per-content fixture name so concurrent tests don't clobber each other's overlay. +function hash(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return h; +} + +function loadConfig(overlayToml) { + const base = fixture('base.toml', BASE_CONFIG); + const args = ['--config', base]; + if (overlayToml !== undefined) { + args.push('--config', fixture(`overlay-${Math.abs(hash(overlayToml))}.toml`, overlayToml)); + } + const runner = fixture('runner.js', ` + const { config } = require(${JSON.stringify(path.join(__dirname, 'configLoader.js'))}); + process.stdout.write('\\nINTERNAL_AUTH_JSON:' + JSON.stringify(config.internalAuth || null) + '\\n'); + `); + const result = spawnSync(process.execPath, [runner, ...args], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + env: { PATH: process.env.PATH, HOME: process.env.HOME }, + }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function parseInternalAuth(stdout) { + const line = String(stdout).split('\n').find(l => l.startsWith('INTERNAL_AUTH_JSON:')); + assert.ok(line, `no INTERNAL_AUTH_JSON line in child stdout: ${stdout}`); + return JSON.parse(line.slice('INTERNAL_AUTH_JSON:'.length)); +} + +test.after(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('defaults resolve to an empty hash (shared-key auth disabled)', () => { + const { status, stdout } = loadConfig(); + assert.equal(status, 0); + const internalAuth = parseInternalAuth(stdout); + // Empty is fine, it means shared-key attempts are rejected at request time + // and the OAuth / session paths keep working as if the feature was not enabled. + assert.deepEqual(internalAuth, { hash: '' }); +}); + +test('an explicitly empty hash boots (shared-key auth disabled)', () => { + const { status, stdout } = loadConfig('[api_portal.internal_auth]\nhash = ""\n'); + assert.equal(status, 0); + const internalAuth = parseInternalAuth(stdout); + assert.equal(internalAuth.hash, ''); +}); + +test('a valid 64-char hex hash boots and lands in config.internalAuth.hash', () => { + const { status, stdout } = loadConfig( + `[api_portal.internal_auth]\nhash = "${VALID_HEX_HASH}"\n`); + assert.equal(status, 0); + const internalAuth = parseInternalAuth(stdout); + assert.equal(internalAuth.hash, VALID_HEX_HASH); +}); + +test('a hash shorter than 64 chars refuses to start', () => { + const shortHash = 'abcd'; + const { status, stderr } = loadConfig( + `[api_portal.internal_auth]\nhash = "${shortHash}"\n`); + assert.equal(status, 1); + assert.match(stderr, /internal_auth\.hash did not resolve to a 64-character hex string/); +}); + +test('a hash longer than 64 chars refuses to start', () => { + const longHash = 'a'.repeat(65); + const { status, stderr } = loadConfig( + `[api_portal.internal_auth]\nhash = "${longHash}"\n`); + assert.equal(status, 1); + assert.match(stderr, /internal_auth\.hash did not resolve to a 64-character hex string/); +}); + +test('a hash containing non-hex characters refuses to start', () => { + const badHash = 'z'.repeat(64); + const { status, stderr } = loadConfig( + `[api_portal.internal_auth]\nhash = "${badHash}"\n`); + assert.equal(status, 1); + assert.match(stderr, /internal_auth\.hash did not resolve to a 64-character hex string/); +}); + +test('the fatal message names the setup script as the recovery path', () => { + // A malformed value is surfaced with actionable guidance rather than just a validation + // failure, so an operator hitting this at boot knows what to do. + const { status, stderr } = loadConfig('[api_portal.internal_auth]\nhash = "xyz"\n'); + assert.equal(status, 1); + assert.match(stderr, /portals\/scripts\/setup\.sh/); + assert.match(stderr, /--rotate-internal-key/); +}); diff --git a/portals/api-portal/src/config/roleScopeMap.test.js b/portals/api-portal/src/config/roleScopeMap.test.js index 2cf4eec629..e3eb4f1d4f 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', () => { @@ -279,3 +300,99 @@ test('role mode is usable by the shipped local-auth quickstart out of the box', assert.ok(scopes.includes('dp:organization:manage'), 'ap_admin must reach admin scopes'); assert.ok(scopes.includes('dp:api:manage')); }); + +// --------------------------------------------------------------------------- +// Reach guard: the operations a verified shared-key caller can invoke +// --------------------------------------------------------------------------- +// +// Whereas the assertion above pins the SCOPES granted to the +// platform-api-system role, this one pins the set of OpenAPI OPERATIONS those +// scopes actually let a shared-key caller reach. The two together bracket the +// caller's authority from both sides: what the role is granted, and what those +// grants add up to at the wire. +// +// Why guard the reach set explicitly: +// +// - Silent widening. Adding one of the five dp:*:manage scopes to a new +// operation's `security` block (a copy-paste from a neighbouring op, say) +// hands that operation to the shared-key caller without any code change +// visible in a review of sharedKeyAuth.js or role-to-scope-mapping.yaml. +// A drift here means Platform-API's outbound identity can suddenly reach +// a route it was never meant to. +// - Silent narrowing. Renaming a scope on an existing publish operation +// (e.g. dp:api:manage -> dp:api:publish) drops it out of the reach set; +// Platform-API's publish calls start 403ing at runtime with no test +// failure until someone tries to publish. +// - There is no per-handler wrap list in this architecture (unlike the +// original design sketch's "wrap-list drift" risk): the whole gate is +// "does one of the role's scopes match one of the operation's declared +// scopes." This test is the equivalent guard for that model. +// +// The pinned set below reflects the intent that these five scopes carry +// *manage* over the five publish-touching resources — which today means the +// full CRUD surface on each (list, get, create, update, delete, plus the +// content-blob variants). If a review-approved change alters the set, update +// this list in the same commit; that is the whole point of the guard. +test('the shipped platform-api-system role reaches exactly the pinned publish-operation set', () => { + const yaml = require('js-yaml'); + const spec = yaml.load(fs.readFileSync(SPEC_PATH, 'utf8')); + roleScopeMap.init(SHIPPED_MAPPING_PATH, SPEC_PATH); + const granted = new Set(roleScopeMap.expandRoles(['platform-api-system'])); + + const reachable = []; + for (const [pathKey, pathItem] of Object.entries(spec.paths)) { + for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']) { + const op = pathItem[method]; + if (!op || !op.security) continue; + const scopes = new Set(); + for (const req of op.security) { + for (const scopeList of Object.values(req)) { + for (const sc of scopeList) scopes.add(sc); + } + } + // Reachable iff any of the operation's declared scopes intersects + // the role's grants — the OAuth2Security check is a plain OR. + for (const sc of scopes) { + if (granted.has(sc)) { + reachable.push(op.operationId); + break; + } + } + } + } + reachable.sort(); + + // Update in lockstep with any intentional widening / narrowing of the + // five dp:*:manage grants OR the operation `security` blocks. Deliberately + // exhaustive rather than a count check, so a rename or a swap between two + // operations still tripping the same count is caught. + assert.deepEqual(reachable, [ + // APIs (dp:api:manage) + 'createApiMetadata', + 'deleteApiMetadata', + 'getAllApiMetadataForOrganization', + 'getApiMetadata', + 'updateApiMetadata', + // API content (dp:api_content:manage) + 'createApiContent', + 'deleteApiContentFile', + 'replaceApiContent', + // MCP servers (dp:mcp_server:manage) + 'createMcpServer', + 'deleteMcpServer', + 'getAllMcpServersForOrganization', + 'getMcpServer', + 'updateMcpServer', + // MCP server content (dp:mcp_server_content:manage) + 'createMcpServerContent', + 'deleteMcpServerContentFile', + 'getMcpServerContentFile', + 'replaceMcpServerContent', + // Subscription plans (dp:subscription_plan:manage) + 'addSubscriptionPlans', + 'deleteSubscriptionPlan', + 'getSubscriptionPlan', + 'listSubscriptionPlans', + 'putSubscriptionPlans', + ].sort()); +}); diff --git a/portals/api-portal/src/middlewares/authMiddleware.js b/portals/api-portal/src/middlewares/authMiddleware.js index e352658077..bfa3626759 100644 --- a/portals/api-portal/src/middlewares/authMiddleware.js +++ b/portals/api-portal/src/middlewares/authMiddleware.js @@ -47,6 +47,7 @@ const userIdpReferenceDao = require('../dao/userIdpReferenceDao'); const { effectiveScopes, isAuthorizationEnabled, isRoleMode } = require('./authorization'); const { NotFoundError } = require('../utils/errors/customErrors'); const userOrganizationMappingDao = require('../dao/userOrganizationMappingDao'); +const sharedKeyAuth = require('./sharedKeyAuth'); // In-process cache so an already-known (sub, org) pair doesn't re-hit the DB on // every request from the same session — resolveUserUuid runs on every @@ -279,6 +280,29 @@ async function resolvePortalOrg(req) { */ async function authResolver(req, res, next) { try { + // 0. Shared-key S2S (platform-api → this portal). Runs before every other + // path so a user token can never accidentally satisfy a SharedKey + // attempt, and a bad SharedKey token can never quietly retry against + // the OAuth path. Only requests carrying `Authorization: SharedKey ...` + // are handled here; anything else falls through. + // Skips the portal-isolation gate below because shared-key traffic + // is a service identity, not a portal session. + const sharedKeyResult = sharedKeyAuth.tryAuthenticate(req); + if (sharedKeyResult.matched) { + if (!sharedKeyResult.auth) { + const err = new Error('Authentication required'); + err.status = 401; + return next(err); + } + // Shared-key is a service-to-service call against this portal instance; + // the organization is this instance's own, resolved the same way the + // mTLS path resolves it. + const orgErr = await resolvePortalOrg(req); + if (orgErr) return next(orgErr); + req.auth = sharedKeyResult.auth; + return next(); + } + // Portal isolation: any session-authenticated request must have been issued by this // portal's login flow. if (req.isAuthenticated && req.isAuthenticated()) { @@ -454,7 +478,11 @@ async function OAuth2Security(req /* , requiredScopes, schema */) { throw err; } if (req.auth.preauthorized) return true; - if (req.auth.mode !== 'oauth2' && req.auth.mode !== 'platform-jwt') { + // Shared-key runs the normal per-operation scope check like oauth2 and + // platform-jwt — its synthesised scope list only carries the five + // dp:*:manage scopes, so the check itself is what limits the mechanism to + // publishing write operations. No preauthorized bypass. + if (req.auth.mode !== 'oauth2' && req.auth.mode !== 'platform-jwt' && req.auth.mode !== sharedKeyAuth.SHARED_KEY_AUTH_MODE) { const err = new Error('Authentication required'); err.status = 401; throw err; diff --git a/portals/api-portal/src/middlewares/csrfProtection.js b/portals/api-portal/src/middlewares/csrfProtection.js index e9e8cda993..4947a6ffd0 100644 --- a/portals/api-portal/src/middlewares/csrfProtection.js +++ b/portals/api-portal/src/middlewares/csrfProtection.js @@ -18,6 +18,8 @@ const crypto = require('crypto'); +const { isSharedKeyRequest } = require('./sharedKeyAuth'); + const CSRF_HMAC_LABEL = 'api-portal-api-keys-csrf'; function ensureCsrfSecret(req) { @@ -77,6 +79,13 @@ function requireCsrfForMutatingApi(req, res, next) { if (hasBearerAuthorization(req)) { return next(); } + // Shared-key S2S callers (Authorization: SharedKey ...) are non-browser + // clients that never touch cookies or CSRF tokens; skip CSRF for them the + // same way we skip it for Bearer. The shared-key middleware in + // authResolver is what enforces authenticity here. + if (isSharedKeyRequest(req)) { + return next(); + } if (hasMTLSClient(req)) { return next(); } 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/middlewares/sharedKeyAuth.js b/portals/api-portal/src/middlewares/sharedKeyAuth.js new file mode 100644 index 0000000000..618192273f --- /dev/null +++ b/portals/api-portal/src/middlewares/sharedKeyAuth.js @@ -0,0 +1,156 @@ +/* + * 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 crypto = require('crypto'); + +const { config } = require('../config/configLoader'); +const roleScopeMap = require('../config/roleScopeMap'); + +// The role name synthesised for a verified shared-key caller. The corresponding +// entry in role-to-scope-mapping.yaml grants the five dp:*:manage scopes +// platform-api needs to publish APIs / API content / MCP servers / MCP server +// content / subscription plans, and nothing else. Keeping the scope grant in the +// YAML (not hard-coded here) means the shipped grant table stays the single +// source of truth for what any role, including this service identity, may do. +const SHARED_KEY_ROLE = 'platform-api-system'; + +// The auth mode string used in req.auth when a request authenticates via a +// verified SharedKey scheme. Distinct from 'oauth2' and 'platform-jwt' so an +// audit log or downstream check can tell shared-key traffic from user/session +// traffic. OAuth2Security in authMiddleware.js allows this mode alongside the +// other two. +const SHARED_KEY_AUTH_MODE = 'shared-key'; + +// HTTP Authorization scheme name (case-insensitive) reserved for this mechanism. +// A custom scheme rather than reusing `Bearer` — RFC 6750 defines Bearer for +// OAuth 2.0 access tokens, and a static pre-shared secret isn't one. RFC 7235 +// explicitly permits custom schemes on the Authorization header, and using one +// lets the request-time dispatcher (authResolver / csrf) discriminate by scheme +// name instead of sniffing for a magic prefix inside a bearer value. +const SHARED_KEY_SCHEME = 'sharedkey'; + +function parseAuthorizationScheme(header) { + if (typeof header !== 'string') return null; + const trimmed = header.trim(); + const space = trimmed.indexOf(' '); + if (space <= 0) return null; + return { + scheme: trimmed.slice(0, space).toLowerCase(), + value: trimmed.slice(space + 1).trim(), + }; +} + +function isSharedKeyRequest(req) { + const parsed = parseAuthorizationScheme(req?.headers?.authorization); + return parsed !== null && parsed.scheme === SHARED_KEY_SCHEME; +} + +function hexToBuffer(hex) { + if (typeof hex !== 'string' || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) { + return null; + } + return Buffer.from(hex, 'hex'); +} + +/** + * Constant-time check: does sha256(raw) equal the configured hex hash? + * + * Both branches of the compare run in fixed time relative to the input length, + * so an attacker can't distinguish "wrong length" from "wrong bytes" from timing. + * A configured hash that isn't 64-char hex (guarded at boot in configLoader) or + * an unset hash both short-circuit to false: a caller sending SharedKey against + * a portal that disabled the feature always fails to verify. + */ +function verifyHash(rawToken, configuredHashHex) { + if (!rawToken || !configuredHashHex) return false; + const expected = hexToBuffer(configuredHashHex); + if (!expected) return false; + const actual = crypto.createHash('sha256').update(rawToken, 'utf8').digest(); + if (expected.length !== actual.length) return false; + return crypto.timingSafeEqual(expected, actual); +} + +/** + * Returns the fixed req.auth shape a verified shared-key call is granted. + * + * `mode` marks the request as shared-key for downstream checks / audit logs. + * `preauthorized = false` on purpose: OAuth2Security still runs the scope check + * against the operation's declared security, and shared-key only carries the + * five dp:*:manage scopes — so a valid shared-key call can reach the publishing + * write operations and nothing else, without any per-route wrapping. + * `scopes` come from expanding the `platform-api-system` role through the + * shipped role-to-scope map, so the grant stays defined in one file (YAML). + * `userId` is null — no portal user represents this identity. + * `rawSub` is the role name so audit logs record the service identity. + */ +function synthesiseSharedKeyPrincipal() { + const scopes = roleScopeMap.expandRoles([SHARED_KEY_ROLE]); + return { + mode: SHARED_KEY_AUTH_MODE, + preauthorized: false, + scopes, + userId: null, + rawSub: SHARED_KEY_ROLE, + }; +} + +/** + * Attempts to authenticate an incoming request via the shared-key mechanism. + * + * Returns one of: + * { matched: false } — Authorization is missing or uses a + * different scheme; caller should + * continue to the next auth path. + * { matched: true, auth: } — verified; caller should assign + * `req.auth = result.auth` and pass + * control on. + * { matched: true, auth: null } — SharedKey scheme was sent but the + * value failed to verify (or the + * portal has no hash configured); + * caller should reject with 401. + * + * Splitting the "no scheme sent" and "scheme sent but wrong" cases lets the + * caller (authResolver) treat only the second as a hard 401 rather than + * falling through to another auth path — a valid non-SharedKey token can't + * accidentally satisfy a SharedKey attempt, and a bad SharedKey token can't + * be quietly retried against the OAuth path. + */ +function tryAuthenticate(req) { + const parsed = parseAuthorizationScheme(req?.headers?.authorization); + if (!parsed || parsed.scheme !== SHARED_KEY_SCHEME) { + return { matched: false }; + } + const configuredHash = config.internalAuth?.hash; + if (!verifyHash(parsed.value, configuredHash)) { + return { matched: true, auth: null }; + } + return { matched: true, auth: synthesiseSharedKeyPrincipal() }; +} + +module.exports = { + SHARED_KEY_AUTH_MODE, + SHARED_KEY_ROLE, + SHARED_KEY_SCHEME, + isSharedKeyRequest, + parseAuthorizationScheme, + tryAuthenticate, + verifyHash, + synthesiseSharedKeyPrincipal, +}; diff --git a/portals/api-portal/src/middlewares/sharedKeyAuth.test.js b/portals/api-portal/src/middlewares/sharedKeyAuth.test.js new file mode 100644 index 0000000000..f7c5909901 --- /dev/null +++ b/portals/api-portal/src/middlewares/sharedKeyAuth.test.js @@ -0,0 +1,313 @@ +/* + * 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'; + +/* + * Unit tests for src/middlewares/sharedKeyAuth.js. + * + * Driven through a child process (same pattern as authorizationConfig.test.js) + * because sharedKeyAuth requires configLoader, which fail-closes on module load + * without a --config argument. Each test spawns a node child, loads the module + * under a chosen fixture config, runs a probe, and emits a marker-prefixed + * JSON line the parent parses. + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const PROJECT_ROOT = path.join(__dirname, '..', '..'); +const SHIPPED_MAPPING_PATH = path.join(PROJECT_ROOT, 'resources', 'role-to-scope-mapping.yaml'); +const SHARED_KEY_MODULE = path.join(__dirname, 'sharedKeyAuth.js'); + +// A stable raw value and its corresponding sha256 hex, precomputed here so both +// the parent (asserting) and the child (probing) can reference the same pair +// without recomputing anything. +const KNOWN_RAW = 'raw-value-for-shared-key-tests-xxxxxxxxxxxxxxxxxxxxxx'; +const KNOWN_HASH = crypto.createHash('sha256').update(KNOWN_RAW, 'utf8').digest('hex'); + +// A config carrying only what the unrelated startup checks demand, plus role-mode +// authorization backed by the shipped mapping so the platform-api-system role +// resolves to its five dp:*:manage scopes. +const BASE_CONFIG = ` +[api_portal.security] +encryption_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +session_secret = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + +[api_portal.organization] +handle = "default" +portal_id = "test-portal" + +[api_portal.auth.claim_mappings] +roles = "roles" + +[api_portal.auth.authorization] +mode = "role" +role_to_scope_mapping = ${JSON.stringify(SHIPPED_MAPPING_PATH)} +`; + +let tmpDir; +function fixture(name, contents) { + if (!tmpDir) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-sharedkey-')); + const file = path.join(tmpDir, name); + fs.writeFileSync(file, contents); + return file; +} + +function hash(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return h; +} + +/** + * Runs `probeBody` inside a child that has loaded the module under the given + * hash config. The body has `sharedKeyAuth` bound to the loaded module and + * `emit(v)` bound to a marker-prefixed JSON emitter. Returns the parsed value. + */ +function runProbe(hashHex, probeBody) { + const base = fixture('base.toml', BASE_CONFIG); + const overlay = fixture( + `overlay-${Math.abs(hash(hashHex + probeBody))}.toml`, + `[api_portal.internal_auth]\nhash = "${hashHex}"\n` + ); + const runner = fixture(`probe-${Math.abs(hash(probeBody))}.js`, ` + const sharedKeyAuth = require(${JSON.stringify(SHARED_KEY_MODULE)}); + const emit = (v) => process.stdout.write('\\nPROBE_JSON:' + JSON.stringify(v) + '\\n'); + ${probeBody} + `); + const result = spawnSync(process.execPath, [runner, '--config', base, '--config', overlay], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + env: { PATH: process.env.PATH, HOME: process.env.HOME }, + }); + assert.equal(result.status, 0, `child failed: ${result.stderr}`); + const line = result.stdout.split('\n').find(l => l.startsWith('PROBE_JSON:')); + assert.ok(line, `no PROBE_JSON in stdout: ${result.stdout}`); + return JSON.parse(line.slice('PROBE_JSON:'.length)); +} + +test.after(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// parseAuthorizationScheme +// --------------------------------------------------------------------------- + +test('parseAuthorizationScheme returns lowercase scheme and trimmed value for well-formed headers', () => { + const result = runProbe(KNOWN_HASH, ` + emit([ + sharedKeyAuth.parseAuthorizationScheme('SharedKey abc123'), + sharedKeyAuth.parseAuthorizationScheme('sharedkey abc123'), + sharedKeyAuth.parseAuthorizationScheme('Bearer xyz'), + sharedKeyAuth.parseAuthorizationScheme(' SharedKey value-with-spaces '), + ]); + `); + assert.deepEqual(result[0], { scheme: 'sharedkey', value: 'abc123' }); + assert.deepEqual(result[1], { scheme: 'sharedkey', value: 'abc123' }); + assert.deepEqual(result[2], { scheme: 'bearer', value: 'xyz' }); + // Leading/trailing whitespace in the header is trimmed, but internal spaces in + // the value are preserved (openssl-generated hex has no internal spaces, so this + // is just being conservative). + assert.equal(result[3].scheme, 'sharedkey'); + assert.equal(result[3].value, 'value-with-spaces'); +}); + +test('parseAuthorizationScheme returns null for missing or malformed headers', () => { + const result = runProbe(KNOWN_HASH, ` + emit([ + sharedKeyAuth.parseAuthorizationScheme(undefined), + sharedKeyAuth.parseAuthorizationScheme(''), + sharedKeyAuth.parseAuthorizationScheme(' '), + sharedKeyAuth.parseAuthorizationScheme('NoSpaces'), + sharedKeyAuth.parseAuthorizationScheme(123), + ]); + `); + assert.equal(result[0], null); + assert.equal(result[1], null); + assert.equal(result[2], null); + // A scheme with no value (no space) does not parse. Callers treat this as + // "not authenticated" rather than "empty-string authenticated". + assert.equal(result[3], null); + assert.equal(result[4], null); +}); + +// --------------------------------------------------------------------------- +// isSharedKeyRequest +// --------------------------------------------------------------------------- + +test('isSharedKeyRequest is true only for the SharedKey scheme', () => { + const result = runProbe(KNOWN_HASH, ` + emit([ + sharedKeyAuth.isSharedKeyRequest({ headers: { authorization: 'SharedKey abc' } }), + sharedKeyAuth.isSharedKeyRequest({ headers: { authorization: 'sharedkey abc' } }), + sharedKeyAuth.isSharedKeyRequest({ headers: { authorization: 'Bearer abc' } }), + sharedKeyAuth.isSharedKeyRequest({ headers: { authorization: '' } }), + sharedKeyAuth.isSharedKeyRequest({ headers: {} }), + sharedKeyAuth.isSharedKeyRequest({}), + ]); + `); + assert.deepEqual(result, [true, true, false, false, false, false]); +}); + +// --------------------------------------------------------------------------- +// verifyHash +// --------------------------------------------------------------------------- + +test('verifyHash matches the raw value against its precomputed sha256 hex', () => { + const result = runProbe(KNOWN_HASH, ` + emit(sharedKeyAuth.verifyHash(${JSON.stringify(KNOWN_RAW)}, ${JSON.stringify(KNOWN_HASH)})); + `); + assert.equal(result, true); +}); + +test('verifyHash rejects the wrong raw value against a real hash', () => { + const result = runProbe(KNOWN_HASH, ` + emit(sharedKeyAuth.verifyHash('completely-different-value', ${JSON.stringify(KNOWN_HASH)})); + `); + assert.equal(result, false); +}); + +test('verifyHash rejects empty inputs on either side', () => { + const result = runProbe(KNOWN_HASH, ` + emit([ + sharedKeyAuth.verifyHash('', ${JSON.stringify(KNOWN_HASH)}), + sharedKeyAuth.verifyHash(${JSON.stringify(KNOWN_RAW)}, ''), + sharedKeyAuth.verifyHash(null, ${JSON.stringify(KNOWN_HASH)}), + sharedKeyAuth.verifyHash(${JSON.stringify(KNOWN_RAW)}, null), + ]); + `); + assert.deepEqual(result, [false, false, false, false]); +}); + +test('verifyHash rejects a non-hex configured hash without throwing', () => { + const result = runProbe(KNOWN_HASH, ` + emit([ + sharedKeyAuth.verifyHash(${JSON.stringify(KNOWN_RAW)}, 'not-hex-at-all-just-some-garbage'), + sharedKeyAuth.verifyHash(${JSON.stringify(KNOWN_RAW)}, 'abc'), + ]); + `); + assert.deepEqual(result, [false, false]); +}); + +// --------------------------------------------------------------------------- +// synthesiseSharedKeyPrincipal +// --------------------------------------------------------------------------- + +test('synthesiseSharedKeyPrincipal returns a fixed shape carrying the five dp:*:manage scopes', () => { + const principal = runProbe(KNOWN_HASH, ` + emit(sharedKeyAuth.synthesiseSharedKeyPrincipal()); + `); + // Distinct auth mode: audit / downstream code can tell shared-key requests apart + // from oauth / session traffic. + assert.equal(principal.mode, 'shared-key'); + // No preauthorized shortcut: the OpenAPI validator still runs the per-operation + // scope check against `scopes`, which is what limits shared-key to the five admin + // write operations rather than any hand-maintained list of routes. + assert.equal(principal.preauthorized, false); + // No portal user represents this identity, so userId is null and rawSub records + // the service role name for audit-log purposes. + assert.equal(principal.userId, null); + assert.equal(principal.rawSub, 'platform-api-system'); + // The five scopes come from expanding the platform-api-system role through the + // shipped role-to-scope-mapping.yaml. Ordering is not stable across map iterations, + // so compare as sets. + assert.deepEqual([...principal.scopes].sort(), [ + 'dp:api:manage', + 'dp:api_content:manage', + 'dp:mcp_server:manage', + 'dp:mcp_server_content:manage', + 'dp:subscription_plan:manage', + ]); +}); + +// --------------------------------------------------------------------------- +// tryAuthenticate — the composite that authResolver calls +// --------------------------------------------------------------------------- + +test('tryAuthenticate returns {matched:false} when no Authorization header is present', () => { + const result = runProbe(KNOWN_HASH, ` + emit(sharedKeyAuth.tryAuthenticate({ headers: {} })); + `); + // Caller falls through to the next auth path (session / bearer / mTLS) rather + // than treating an absent header as a shared-key attempt. + assert.deepEqual(result, { matched: false }); +}); + +test('tryAuthenticate returns {matched:false} for a Bearer request even when the token happens to hash to the configured value', () => { + // The scheme name is the sole discriminator: a request that carries a Bearer + // token whose value happens to hash to the configured shared-key hash must not + // be accepted as a shared-key call. Scheme-based dispatch is what makes the + // "distinct wire discriminator" claim in the design doc real. + const result = runProbe(KNOWN_HASH, ` + emit(sharedKeyAuth.tryAuthenticate({ + headers: { authorization: 'Bearer ${KNOWN_RAW}' } + })); + `); + assert.deepEqual(result, { matched: false }); +}); + +test('tryAuthenticate returns {matched:true, auth:null} when the SharedKey value does not match the configured hash', () => { + const result = runProbe(KNOWN_HASH, ` + emit(sharedKeyAuth.tryAuthenticate({ + headers: { authorization: 'SharedKey wrong-value-that-hashes-to-nothing' } + })); + `); + // Matched (scheme is SharedKey) but auth is null: the caller (authResolver) turns + // this into a hard 401 rather than falling through, so a bad SharedKey can never + // quietly retry against the OAuth path with the same request. + assert.deepEqual(result, { matched: true, auth: null }); +}); + +test('tryAuthenticate verifies against the configured hash and returns the full principal on match', () => { + const result = runProbe(KNOWN_HASH, ` + emit(sharedKeyAuth.tryAuthenticate({ + headers: { authorization: 'SharedKey ${KNOWN_RAW}' } + })); + `); + assert.equal(result.matched, true); + assert.ok(result.auth, 'expected a populated principal for a verified SharedKey request'); + assert.equal(result.auth.mode, 'shared-key'); + assert.equal(result.auth.preauthorized, false); + assert.equal(result.auth.rawSub, 'platform-api-system'); + assert.deepEqual([...result.auth.scopes].sort(), [ + 'dp:api:manage', + 'dp:api_content:manage', + 'dp:mcp_server:manage', + 'dp:mcp_server_content:manage', + 'dp:subscription_plan:manage', + ]); +}); + +test('tryAuthenticate returns {matched:true, auth:null} when the portal has no configured hash', () => { + // Sending SharedKey against a portal that has shared-key disabled must fail: + // silent fall-through would let a client accidentally satisfy a different auth + // path with the SharedKey value it was expecting the portal to hash. + const result = runProbe('', ` + emit(sharedKeyAuth.tryAuthenticate({ + headers: { authorization: 'SharedKey ${KNOWN_RAW}' } + })); + `); + assert.deepEqual(result, { matched: true, auth: null }); +}); 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); +}); diff --git a/portals/scripts/setup.sh b/portals/scripts/setup.sh index 62d72bd351..92887d618f 100755 --- a/portals/scripts/setup.sh +++ b/portals/scripts/setup.sh @@ -74,6 +74,7 @@ set -euo pipefail FORCE=false CERTS_ONLY=false ROTATE_ENCRYPTION_KEY=false +ROTATE_INTERNAL_KEY=false # The comma-separated COMPOSE_PROFILES value this script writes to .env, so # that a plain `docker compose up` (no --profile flag) starts the right @@ -101,15 +102,17 @@ for arg in "$@"; do --force) FORCE=true ;; --certs-only) CERTS_ONLY=true ;; --rotate-encryption-key) ROTATE_ENCRYPTION_KEY=true ;; + --rotate-internal-key) ROTATE_INTERNAL_KEY=true ;; --profiles=*) PROFILES_OVERRIDE="${arg#*=}" ;; -h|--help) cat <<'EOF' -Usage: ./setup.sh [--force] [--certs-only] [--rotate-encryption-key] [--profiles=] +Usage: ./setup.sh [--force] [--certs-only] [--rotate-encryption-key] [--rotate-internal-key] [--profiles=] --force regenerate TLS cert, JWT signing keypair, and admin credentials. Never rotates the at-rest - encryption key on its own — see - --rotate-encryption-key. + encryption key or the API Portal internal key on + its own — see --rotate-encryption-key and + --rotate-internal-key. --certs-only generate only the TLS certificate (used by `make bff-run`) --rotate-encryption-key DESTRUCTIVE: replace resources/keys/encryption.key @@ -120,6 +123,13 @@ Usage: ./setup.sh [--force] [--certs-only] [--rotate-encryption-key] [--profiles confirmation unless ADMIN_USERNAME/ADMIN_PASSWORD are set (CI), in which case passing this flag is itself treated as confirmation. + --rotate-internal-key Replace the API Portal internal shared key (hash file + at resources/keys/api-portal-internal-key-hash and + raw file at resources/keys/api-portal-internal-key.raw) + even if they already exist. Platform-API can no + longer publish to this portal until its stored copy + is updated with the new raw value (PUT /api-portals/{id} + with the new sharedKey), so rotate deliberately. --profiles= override the default COMPOSE_PROFILES value this script writes to .env. Valid profiles: ai-workspace, api-portal, platform-api — e.g. @@ -541,6 +551,46 @@ else log " - API Portal session secret generated at $KEYS_DIR/api-portal-session-secret" fi +log "Provisioning API Portal internal service-to-service key ..." +# Two files: +# - api-portal-internal-key-hash — the SHA-256 hash of the raw key, read by +# config.toml via {{ file }} into config.internalAuth.hash. The API Portal +# middleware verifies incoming SharedKey-scheme requests against this hash. +# Follows the same pattern as api-portal-encryption.key: mounted into the +# container at /etc/api-portal/keys, restricted with restrict_secret_file so +# it is not world-readable. +# - api-portal-internal-key.raw — the raw key value the operator copies into +# Platform-API's Create API Portal call. Mode 600 (host-owner-only), never +# needed by the container. Meant to be deleted after copying: only the hash +# needs to survive rotation. Nothing reads this file at runtime. +# +# Rotating this key severs Platform-API's ability to publish to the portal until +# Platform-API's stored copy is updated too (PUT /api-portals/{id} with the new +# sharedKey), so it's on its own --rotate-internal-key flag rather than --force +# (which never touches it). +generate_internal_key_pair() { + local raw hash + raw=$(openssl rand -hex 32) + hash=$(printf '%s' "$raw" | openssl dgst -sha256 | awk '{print $NF}') + printf '%s' "$hash" > "$KEYS_DIR/api-portal-internal-key-hash" + restrict_secret_file "$KEYS_DIR/api-portal-internal-key-hash" + printf '%s\n' "$raw" > "$KEYS_DIR/api-portal-internal-key.raw" + chmod 600 "$KEYS_DIR/api-portal-internal-key.raw" +} +if [[ -f "$KEYS_DIR/api-portal-internal-key-hash" && "$ROTATE_INTERNAL_KEY" == true ]]; then + mkdir -p "$KEYS_DIR" + generate_internal_key_pair + log " - API Portal internal key ROTATED. Hash: $KEYS_DIR/api-portal-internal-key-hash" + log " - New raw value at $KEYS_DIR/api-portal-internal-key.raw (mode 600). Copy it into a PUT /api-portals/{id} call with the new sharedKey field, then delete the raw file. Platform-API will 401 until you do." +elif [[ -f "$KEYS_DIR/api-portal-internal-key-hash" ]]; then + log " - $KEYS_DIR/api-portal-internal-key-hash already exists, leaving as-is (pass --rotate-internal-key to replace it)" +else + mkdir -p "$KEYS_DIR" + generate_internal_key_pair + log " - API Portal internal key generated. Hash: $KEYS_DIR/api-portal-internal-key-hash" + log " - Raw value one-time-read at $KEYS_DIR/api-portal-internal-key.raw (mode 600). Copy it into Platform-API's Create API Portal call (sharedKey field), then delete the raw file." +fi + log "Provisioning Platform API JWT signing keypair (RS256) ..." # Tokens are signed asymmetrically (RS256), not with a shared HMAC secret. The # Platform API mints login tokens with the RSA private key and verifies every