Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions portals/api-portal/configs/config-template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <raw>`; 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
# =============================================================================
Expand Down
8 changes: 8 additions & 0 deletions portals/api-portal/configs/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }}'
Expand Down
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
5 changes: 3 additions & 2 deletions portals/api-portal/src/config/authorizationConfig.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ session_secret = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba98765432

[api_portal.organization]
handle = "default"
portal_id = "test-portal"
`;

let tmpDir;
Expand Down Expand Up @@ -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', () => {
Expand Down
12 changes: 12 additions & 0 deletions portals/api-portal/src/config/configDefaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <raw>`; 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.
Expand Down
54 changes: 45 additions & 9 deletions portals/api-portal/src/config/configLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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())
Expand Down
152 changes: 152 additions & 0 deletions portals/api-portal/src/config/internalAuthConfig.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading
Loading