diff --git a/docs/rest-apis/devportal/README.md b/docs/rest-apis/devportal/README.md
index 2b716329a..ffd71c347 100644
--- a/docs/rest-apis/devportal/README.md
+++ b/docs/rest-apis/devportal/README.md
@@ -106,6 +106,7 @@ Base URLs:
### [API Keys](api-keys.md)
+- [List all API keys for the current user](api-keys.md#list-all-api-keys-for-the-current-user)
- [Generate an API key](api-keys.md#generate-an-api-key)
- [List API keys](api-keys.md#list-api-keys)
- [Regenerate an API key](api-keys.md#regenerate-an-api-key)
diff --git a/docs/rest-apis/devportal/api-keys.md b/docs/rest-apis/devportal/api-keys.md
index 98a3d90a7..ae19b81a9 100644
--- a/docs/rest-apis/devportal/api-keys.md
+++ b/docs/rest-apis/devportal/api-keys.md
@@ -1,5 +1,135 @@
API Keys
+## List all API keys for the current user
+
+
+
+`GET /api-keys`
+
+> Code samples
+
+```shell
+
+curl -X GET https://localhost:3000/api/v0.9/api-keys \
+ -u {username}:{password} \
+ -H 'Accept: application/json' \
+ -H 'Authorization: Bearer {access-token}'
+
+```
+
+Lists every API key created by the authenticated user across all APIs in the organization. Powers the Developer Portal's global "API Keys" page. Each item additionally carries the owning API's name, version, and type. Secret material is never returned.
+
+### Authentication
+
+
+
+
Parameters
+
+|Name|In|Type|Required|Description|
+|---|---|---|---|---|
+|limit|query|integer|false|Maximum number of records to return.|
+|offset|query|integer|false|Number of records to skip before returning results.|
+
+> Example responses
+
+> 200 Response
+
+```json
+{
+ "list": [
+ {
+ "id": "weather_prod_key",
+ "displayName": "Weather Prod Key",
+ "apiId": "weather-api-v1",
+ "appId": "my-weather-app",
+ "appDisplayName": "My Mobile App",
+ "status": "ACTIVE",
+ "expiresAt": "2026-12-31T23:59:59Z",
+ "createdAt": "2019-08-24T14:15:22Z",
+ "revokedAt": "2019-08-24T14:15:22Z"
+ }
+ ],
+ "count": 1,
+ "pagination": {
+ "total": 42,
+ "limit": 20,
+ "offset": 0
+ }
+}
+```
+
+> Bad request. Validation and other bad-request errors are returned as a standard error object (field-level details, when present, are carried in its `errors` array); some legacy handlers return a message-only object.
+
+```json
+{
+ "status": "error",
+ "code": "MISSING_REQUIRED_PARAMETER",
+ "message": "Missing required parameter."
+}
+```
+
+```json
+{
+ "message": "Missing or invalid fields in the request payload"
+}
+```
+
+> 500 Response
+
+```json
+{
+ "status": "error",
+ "code": "INTERNAL_SERVER_ERROR",
+ "message": "An unexpected error occurred."
+}
+```
+
+
Responses
+
+|Status|Meaning|Description|Schema|
+|---|---|---|---|
+|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|List of API key metadata records.|Inline|
+|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Bad request. Validation and other bad-request errors are returned as a standard error object (field-level details, when present, are carried in its `errors` array); some legacy handlers return a message-only object.|Inline|
+|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error.|[ErrorResponse](schemas.md#schemaerrorresponse)|
+
+
Response Schema
+
+Status Code **200**
+
+|Name|Type|Required|Restrictions|Description|
+|---|---|---|---|---|
+|» list|[[ApiKeyMetadataResponse](schemas.md#schemaapikeymetadataresponse)]|false|none|[API key metadata returned by list operations. Secret material is omitted.]|
+|»» id|string|false|none|none|
+|»» displayName|string|false|none|none|
+|»» apiId|string|false|none|Developer Portal API ID the key belongs to.|
+|»» appId|string¦null|false|none|ID of the application this key is associated with, if any. Analytics attribution only.|
+|»» appDisplayName|string¦null|false|none|Display name of the associated application, if any.|
+|»» status|string|false|none|none|
+|»» expiresAt|string(date-time)¦null|false|none|none|
+|»» createdAt|string(date-time)|false|none|none|
+|»» revokedAt|string(date-time)¦null|false|none|none|
+|» count|integer|false|none|Number of items returned in this page.|
+|» pagination|[Pagination](schemas.md#schemapagination)|false|none|Standard pagination metadata returned with collection responses.|
+|»» total|integer|true|none|Total number of records matching the query.|
+|»» limit|integer|true|none|Maximum number of records returned in this response.|
+|»» offset|integer|true|none|Number of records skipped before this page.|
+
+#### Enumerated Values
+
+|Property|Value|
+|---|---|
+|status|ACTIVE|
+|status|REVOKED|
+
+#### Enumerated Values
+
+|Property|Value|
+|---|---|
+|status|error|
+
## Generate an API key
@@ -50,7 +180,6 @@ This operation requires Basic Auth authentication.
```json
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ",
@@ -172,7 +301,6 @@ This operation requires Basic Auth authentication.
{
"list": [
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"apiId": "weather-api-v1",
@@ -234,7 +362,6 @@ Status Code **200**
|Name|Type|Required|Restrictions|Description|
|---|---|---|---|---|
|» list|[[ApiKeyMetadataResponse](schemas.md#schemaapikeymetadataresponse)]|false|none|[API key metadata returned by list operations. Secret material is omitted.]|
-|»» keyId|string|false|none|Developer Portal key identifier.|
|»» id|string|false|none|none|
|»» displayName|string|false|none|none|
|»» apiId|string|false|none|Developer Portal API ID the key belongs to.|
@@ -317,7 +444,6 @@ This operation requires Basic Auth authentication.
```json
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ",
@@ -524,7 +650,6 @@ This operation requires Basic Auth authentication.
```json
{
- "keyId": "key-12345",
"application": {
"id": "my-weather-app",
"displayName": "My Mobile App"
@@ -734,7 +859,6 @@ This operation requires Basic Auth authentication.
{
"list": [
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"apiId": "weather-api-v1",
@@ -790,7 +914,6 @@ Status Code **200**
|Name|Type|Required|Restrictions|Description|
|---|---|---|---|---|
|» list|[[ApiKeyMetadataResponse](schemas.md#schemaapikeymetadataresponse)]|false|none|[API key metadata returned by list operations. Secret material is omitted.]|
-|»» keyId|string|false|none|Developer Portal key identifier.|
|»» id|string|false|none|none|
|»» displayName|string|false|none|none|
|»» apiId|string|false|none|Developer Portal API ID the key belongs to.|
diff --git a/docs/rest-apis/devportal/mcp-server-keys.md b/docs/rest-apis/devportal/mcp-server-keys.md
index c9da63629..f0e278399 100644
--- a/docs/rest-apis/devportal/mcp-server-keys.md
+++ b/docs/rest-apis/devportal/mcp-server-keys.md
@@ -50,7 +50,6 @@ This operation requires Basic Auth authentication.
```json
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ",
@@ -172,7 +171,6 @@ This operation requires Basic Auth authentication.
{
"list": [
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"apiId": "weather-api-v1",
@@ -234,7 +232,6 @@ Status Code **200**
|Name|Type|Required|Restrictions|Description|
|---|---|---|---|---|
|» list|[[ApiKeyMetadataResponse](schemas.md#schemaapikeymetadataresponse)]|false|none|[API key metadata returned by list operations. Secret material is omitted.]|
-|»» keyId|string|false|none|Developer Portal key identifier.|
|»» id|string|false|none|none|
|»» displayName|string|false|none|none|
|»» apiId|string|false|none|Developer Portal API ID the key belongs to.|
@@ -317,7 +314,6 @@ This operation requires Basic Auth authentication.
```json
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ",
@@ -524,7 +520,6 @@ This operation requires Basic Auth authentication.
```json
{
- "keyId": "key-12345",
"application": {
"id": "my-weather-app",
"displayName": "My Mobile App"
diff --git a/docs/rest-apis/devportal/schemas.md b/docs/rest-apis/devportal/schemas.md
index 078088c50..70984a58c 100644
--- a/docs/rest-apis/devportal/schemas.md
+++ b/docs/rest-apis/devportal/schemas.md
@@ -1184,7 +1184,6 @@ xor
```json
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"apiId": "weather-api-v1",
@@ -1204,7 +1203,6 @@ API key metadata returned by list operations. Secret material is omitted.
|Name|Type|Required|Restrictions|Description|
|---|---|---|---|---|
-|keyId|string|false|none|Developer Portal key identifier.|
|id|string|false|none|none|
|displayName|string|false|none|none|
|apiId|string|false|none|Developer Portal API ID the key belongs to.|
@@ -1231,7 +1229,6 @@ API key metadata returned by list operations. Secret material is omitted.
```json
{
- "keyId": "key-12345",
"id": "weather_prod_key",
"displayName": "Weather Prod Key",
"key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ",
@@ -1241,13 +1238,12 @@ API key metadata returned by list operations. Secret material is omitted.
```
-API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResponse, this does not include apiId, appId, appDisplayName, createdAt, or revokedAt — generate/regenerate return only these six fields.
+API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResponse, this does not include apiId, appId, appDisplayName, createdAt, or revokedAt — generate/regenerate return only these five fields.
### Properties
|Name|Type|Required|Restrictions|Description|
|---|---|---|---|---|
-|keyId|string|false|none|Developer Portal key identifier.|
|id|string|false|none|none|
|displayName|string|false|none|none|
|key|string|false|none|One-time plaintext API key secret.|
@@ -1270,7 +1266,6 @@ API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResp
```json
{
- "keyId": "key-12345",
"application": {
"id": "my-weather-app",
"displayName": "My Mobile App"
@@ -1283,7 +1278,6 @@ API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResp
|Name|Type|Required|Restrictions|Description|
|---|---|---|---|---|
-|keyId|string|false|none|none|
|application|object|false|none|none|
|» id|string|false|none|none|
|» displayName|string|false|none|none|
diff --git a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml
index 9f451a32e..64cb8596a 100644
--- a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml
+++ b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml
@@ -1079,6 +1079,31 @@ paths:
security:
- OAuth2Security:
- dp:subscription_manage
+ /api-keys:
+ get:
+ tags:
+ - API Keys
+ operationId: listAllApiKeys
+ summary: List all API keys for the current user
+ description: >-
+ Lists every API key created by the authenticated user across all APIs in the
+ organization. Powers the Developer Portal's global "API Keys" page. Each item
+ additionally carries the owning API's name, version, and type. Secret material
+ is never returned.
+ parameters:
+ - $ref: "#/components/parameters/limit"
+ - $ref: "#/components/parameters/offset"
+ responses:
+ "200":
+ $ref: "#/components/responses/ApiKeyListResponse"
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
+ security:
+ - OAuth2Security:
+ - dp:api_key_read
+ - dp:api_key_manage
/apis/{apiId}/api-keys/generate:
parameters:
- $ref: "#/components/parameters/apiId"
@@ -1109,7 +1134,6 @@ paths:
schema:
$ref: "#/components/schemas/ApiKeyResponse"
example:
- keyId: key-12345
id: weather_prod_key
displayName: Weather Prod Key
key: ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ
@@ -3894,7 +3918,6 @@ components:
schema:
$ref: "#/components/schemas/ApiKeyResponse"
example:
- keyId: key-12345
id: weather_prod_key
displayName: Weather Prod Key
key: ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ
@@ -4970,10 +4993,6 @@ components:
type: object
description: API key metadata returned by list operations. Secret material is omitted.
properties:
- keyId:
- type: string
- description: Developer Portal key identifier.
- example: key-12345
id:
type: string
example: weather_prod_key
@@ -5017,12 +5036,8 @@ components:
type: object
description: >-
API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResponse, this does not include
- apiId, appId, appDisplayName, createdAt, or revokedAt — generate/regenerate return only these six fields.
+ apiId, appId, appDisplayName, createdAt, or revokedAt — generate/regenerate return only these five fields.
properties:
- keyId:
- type: string
- description: Developer Portal key identifier.
- example: key-12345
id:
type: string
example: weather_prod_key
@@ -5048,9 +5063,6 @@ components:
ApiKeyApplicationResponse:
type: object
properties:
- keyId:
- type: string
- example: key-12345
application:
type: object
properties:
diff --git a/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js b/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js
index 2d2cf157d..6dd3be352 100644
--- a/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js
+++ b/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js
@@ -44,6 +44,7 @@ describe('API keys', () => {
expect(res.body.id).toBe(id);
expect(res.body.key).toBeDefined();
expect(res.body.status).toBe('ACTIVE');
+ expect(res.body.keyId).toBeUndefined();
});
it('lists API keys for an API', async () => {
@@ -52,7 +53,9 @@ describe('API keys', () => {
const res = await client.as('publisher').get(`/apis/${api.id}/api-keys`);
expect(res.status).toBe(200);
- expect(res.body.list.some((k) => k.id === id)).toBe(true);
+ const key = res.body.list.find((k) => k.id === id);
+ expect(key).toBeDefined();
+ expect(key.keyId).toBeUndefined();
});
it('regenerates an API key', async () => {
@@ -63,6 +66,7 @@ describe('API keys', () => {
expect(res.status).toBe(200);
expect(res.body.key).toBeDefined();
expect(res.body.key).not.toBe(create.body.key);
+ expect(res.body.keyId).toBeUndefined();
});
it('revokes an API key', async () => {
@@ -107,6 +111,8 @@ describe('API keys', () => {
const res = await client.as('developer').post(`/apis/${api.id}/api-keys/associate`, { keyId, appId });
expect(res.status).toBe(200);
+ expect(res.body.application.id).toBe(appId);
+ expect(res.body.keyId).toBeUndefined();
});
it('dissociates an API key from an application', async () => {
@@ -125,4 +131,31 @@ describe('API keys', () => {
expect(res.status).toBe(400);
});
+ // GET /api-keys — lists every key created by the authenticated caller across all
+ // APIs in the org (powers the developer portal's global "API Keys" page). Each item
+ // carries the owning API's name/version/type so the page renders without a second call.
+ describe('GET /api-keys (all keys for the caller)', () => {
+ it('lists the caller\'s keys across APIs with owning-API metadata', async () => {
+ const id = uniqueHandle('key').toLowerCase();
+ await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id });
+
+ const res = await client.as('publisher').get('/api-keys');
+ expect(res.status).toBe(200);
+
+ const key = res.body.list.find((k) => k.id === id);
+ expect(key).toBeDefined();
+ expect(key.apiName).toBe(api.name);
+ expect(key.apiVersion).toBe(api.version);
+ expect(key.apiType).toBe(api.type);
+ // The internal uuid is never exposed, and secret material is never listed.
+ expect(key.keyId).toBeUndefined();
+ expect(key.key).toBeUndefined();
+ });
+
+ it('rejects an invalid status filter', async () => {
+ const res = await client.as('publisher').get('/api-keys?status=BOGUS');
+ expect(res.status).toBe(400);
+ });
+ });
+
});
diff --git a/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js b/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js
index bd1b6d320..094838430 100644
--- a/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js
+++ b/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js
@@ -79,7 +79,9 @@ describe('api-keys webhook events', () => {
// fields rather than the plaintext ever appearing in `data`.
expect(received.body.encrypted_fields).toEqual([]);
expect(received.body.data).toEqual({
- key_id: res.body.keyId,
+ // key_id is the key's internal uuid — not exposed in the REST response, so pin it
+ // against the persisted event's aggregate_uuid (published with aggregateId: keyId).
+ key_id: event.aggregate_uuid,
handle: keyId,
display_name: keyId,
expires_at: null,
@@ -90,7 +92,7 @@ describe('api-keys webhook events', () => {
it('publishes and delivers apikey.regenerated', async () => {
const keyId = uniqueHandle('key').toLowerCase();
- const generated = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId });
+ await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId });
const since = new Date();
const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/regenerate`, { keyId });
@@ -106,7 +108,7 @@ describe('api-keys webhook events', () => {
expect(received).toBeDefined();
expect(received.body.encrypted_fields).toEqual([]);
expect(received.body.data).toEqual({
- key_id: generated.body.keyId,
+ key_id: event.aggregate_uuid, // key's internal uuid; not exposed in the REST response
handle: keyId,
display_name: keyId,
expires_at: null,
@@ -117,7 +119,7 @@ describe('api-keys webhook events', () => {
it('publishes and delivers apikey.revoked', async () => {
const keyId = uniqueHandle('key').toLowerCase();
- const generated = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId });
+ await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId });
const since = new Date();
const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/revoke`, { keyId });
@@ -132,7 +134,7 @@ describe('api-keys webhook events', () => {
const received = sink.findDeliveryFor('apikey.revoked');
expect(received).toBeDefined();
expect(received.body.data).toEqual({
- key_id: generated.body.keyId,
+ key_id: event.aggregate_uuid, // key's internal uuid; not exposed in the REST response
handle: keyId,
display_name: keyId,
api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type },
@@ -163,10 +165,12 @@ describe('api-keys webhook events', () => {
// `application.id` here is the app's internal uuid (apiKeyService.resolveApp),
// never exposed over REST — only handle/display_name are checkable directly.
expect(received.body.data).toEqual({
- key_id: expect.any(String),
+ key_id: event.aggregate_uuid, // key's internal uuid; not exposed in the REST response
handle: keyId,
display_name: keyId,
api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type },
+ // application.id is the app's internal uuid (never exposed over REST); its handle
+ // is the checkable external identifier, so pin id only by shape.
application: { id: expect.any(String), display_name: 'Assoc App', handle: appId },
});
});
diff --git a/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js b/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js
index 7c39d520e..f12f72ad2 100644
--- a/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js
+++ b/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js
@@ -197,6 +197,7 @@ describe('MCP servers', () => {
expect(res.status).toBe(201);
expect(res.body.id).toBe(keyId);
expect(res.body.key).toBeDefined();
+ expect(res.body.keyId).toBeUndefined(); // internal uuid must not leak
});
// An MCP server's contract is its tools schema, supplied via the single `definition`
diff --git a/portals/developer-portal/src/app.js b/portals/developer-portal/src/app.js
index 6e2e40b55..c9fd468b6 100644
--- a/portals/developer-portal/src/app.js
+++ b/portals/developer-portal/src/app.js
@@ -29,6 +29,7 @@ const apiContent = require('./routes/pages/apiContentRoute');
const applicationContent = require('./routes/pages/applicationsContentRoute');
const customContent = require('./routes/pages/customPageRoute');
const subscriptionsContent = require('./routes/pages/subscriptionsContentRoute');
+const apiKeysOverviewContent = require('./routes/pages/apiKeysOverviewRoute');
const mcpRegistryRoute = require('./routes/pages/mcpRegistryRoute');
const { config } = require('./config/configLoader');
const Handlebars = require('handlebars');
@@ -177,6 +178,7 @@ if (config.designMode?.enabled) {
app.use(constants.ROUTE.DEFAULT, settingsRoute);
app.use(constants.ROUTE.DEFAULT, apiWorkflowsRoute);
app.use(constants.ROUTE.DEFAULT, subscriptionsContent);
+ app.use(constants.ROUTE.DEFAULT, apiKeysOverviewContent);
app.use(constants.ROUTE.DEFAULT, customContent);
}
diff --git a/portals/developer-portal/src/controllers/apiContentController.js b/portals/developer-portal/src/controllers/apiContentController.js
index 60cf116f9..045d52c7a 100644
--- a/portals/developer-portal/src/controllers/apiContentController.js
+++ b/portals/developer-portal/src/controllers/apiContentController.js
@@ -206,6 +206,7 @@ const loadAPIContent = async (req, res, next) => {
}
}
+ const definitionResponse = await getAPIDefinition(orgName, viewName, apiHandle);
const templateContent = {
devMode: true,
apiContent: '',
@@ -216,7 +217,7 @@ const loadAPIContent = async (req, res, next) => {
subscriptionPlans: metaData.subscriptionPlans,
baseUrl: config.server.baseUrl + constants.ROUTE.VIEWS_PATH + viewName,
schemaUrl: `/mock/${apiHandle}/definition.yml`,
- showApiKeysNav: apiUsesApiKeySecurity(metaData),
+ showApiKeysNav: await resolveShowApiKeysNav(null, null, apiType, metaData, definitionResponse.swagger ?? null),
showSubscriptionsNav: (metaData.subscriptionPlans || []).length > 0,
}
const landingPage = isMCP ? 'pages/mcp-landing' : 'pages/api-landing';
@@ -424,7 +425,7 @@ const loadAPIContent = async (req, res, next) => {
profile: req.isAuthenticated() ? profile : null,
isReadOnlyMode: config.server.readOnlyMode,
};
- templateContent.showApiKeysNav = apiUsesApiKeySecurity(metaData, apiDefinitionForNav);
+ templateContent.showApiKeysNav = await resolveShowApiKeysNav(orgId, apiId, metaData.type, metaData, apiDefinitionForNav);
templateContent.showSubscriptionsNav = (metaData?.subscriptionPlans || []).length > 0;
templateContent.hasSubscriptionToken = !!findSubscriptionTokenHeader(apiDefinitionForNav);
if (metaData.type == constants.API_TYPE.MCP) {
@@ -514,6 +515,10 @@ const loadDocsPage = async (req, res, next) => {
const metaForNav = {
refId: apiMetadata.refId,
};
+ // Load the definition so the API Keys nav is computed the same way as every
+ // other API-scoped page — without it the check always returns false and the
+ // API Keys item is hidden on the documentation page.
+ const definitionResponse = await getAPIDefinition(orgName, viewName, apiHandle);
const templateContent = {
apiMD: '',
baseUrl: config.server.baseUrl + constants.ROUTE.VIEWS_PATH + viewName + '/api/' + apiHandle,
@@ -521,7 +526,7 @@ const loadDocsPage = async (req, res, next) => {
docTypes: docNames,
apiType: apiMetadata.type,
apiName: apiMetadata.name || '',
- showApiKeysNav: apiUsesApiKeySecurity(metaForNav),
+ showApiKeysNav: await resolveShowApiKeysNav(null, null, apiMetadata.type, metaForNav, definitionResponse.swagger ?? null),
}
html = renderTemplate(layoutPath + 'pages/docs/page.hbs', layoutPath + 'layout/main.hbs', templateContent, false);
} else {
@@ -551,19 +556,6 @@ const loadDocsPage = async (req, res, next) => {
refId: apiMetadata[0].dataValues.ref_id,
};
- let apiDefinitionForNav = null;
- if (apiType !== constants.API_TYPE.GRAPHQL && apiType !== constants.API_TYPE.MCP) {
- try {
- apiDefinitionForNav = await getApiDefinitionFileContent(orgId, apiId);
- } catch (definitionErr) {
- logger.debug('Could not load API definition for API keys nav check', {
- orgId,
- apiId,
- error: definitionErr.message
- });
- }
- }
-
const templateContent = {
baseUrl: '/' + orgName + '/views/' + viewName + "/api/" + apiHandle,
baseDocUrl: '/' + orgName + '/views/' + viewName + "/api/" + apiHandle,
@@ -572,7 +564,9 @@ const loadDocsPage = async (req, res, next) => {
apiName: apiMetadata[0].dataValues.name || '',
profile: req.isAuthenticated() ? profile : null,
devportalMode: devportalMode,
- showApiKeysNav: apiUsesApiKeySecurity(metaForNav, apiDefinitionForNav),
+ // resolveShowApiKeysNav returns false early for GraphQL/MCP/SOAP and lazily
+ // fetches the definition itself for the remaining types, so no preload here.
+ showApiKeysNav: await resolveShowApiKeysNav(orgId, apiId, apiType, metaForNav),
};
html = await renderTemplateFromAPI(templateContent, orgId, orgName, "pages/docs", viewName);
} catch (error) {
@@ -636,7 +630,10 @@ const loadDocument = async (req, res, next) => {
templateContent.currentDocType = docType || null;
templateContent.apiName = metaData.name || '';
const metaForNav = { refId: metaData.refId };
- templateContent.showApiKeysNav = apiUsesApiKeySecurity(metaForNav);
+ // Pass the definition so the API Keys nav is computed the same way as every
+ // other API-scoped page — without it the check always returns false and the
+ // API Keys item is hidden on the documentation page.
+ templateContent.showApiKeysNav = await resolveShowApiKeysNav(null, null, definitionResponse.apiType, metaForNav, definitionResponse.swagger ?? null);
const html = renderTemplate(layoutPath + 'pages/docs/page.hbs', layoutPath + 'layout/main.hbs', templateContent, false);
res.send(html);
return;
@@ -782,7 +779,8 @@ const loadDocument = async (req, res, next) => {
const metaForNav = {
refId: row.ref_id,
};
- templateContent.showApiKeysNav = apiUsesApiKeySecurity(metaForNav);
+ // Compute the API Keys nav the same way as every other API-scoped page.
+ templateContent.showApiKeysNav = await resolveShowApiKeysNav(orgId, apiId, apiType, metaForNav, definitionResponse.swagger ?? null);
html = await renderTemplateFromAPI(templateContent, orgId, orgName, "pages/docs", viewName);
} catch (error) {
logger.error('Failed to load api content', {
@@ -881,6 +879,49 @@ async function getApiDefinitionFileContent(orgId, apiId) {
throw new Error('API definition file not found');
}
+/**
+ * Single source of truth for the "API Keys" sidebar item visibility on any
+ * API-scoped page (landing, documentation, api-keys). It loads the stored API
+ * definition and inspects its security schemes so every page derives the flag
+ * the same way — previously each page computed it separately and some (e.g. the
+ * documentation page) omitted the definition, hiding the nav item inconsistently.
+ *
+ * GraphQL/MCP/SOAP definitions are not OpenAPI security-scheme documents we can
+ * inspect for apiKey security, so they resolve to false without a definition load.
+ *
+ * @param {string} orgId
+ * @param {string} apiId
+ * @param {string} apiType - constants.API_TYPE.*
+ * @param {object} metaData - any truthy metadata object for the API
+ * @param {string|object} [preloadedDefinition] - the API definition if the caller
+ * already loaded it (pass to avoid a second read); omit to load from storage.
+ * @returns {Promise}
+ */
+async function resolveShowApiKeysNav(orgId, apiId, apiType, metaData, preloadedDefinition) {
+ if (!metaData) {
+ return false;
+ }
+ if (apiType === constants.API_TYPE.GRAPHQL
+ || apiType === constants.API_TYPE.MCP
+ || apiType === constants.API_TYPE.SOAP) {
+ return false;
+ }
+ let apiDefinition = preloadedDefinition;
+ if (apiDefinition === undefined) {
+ apiDefinition = null;
+ try {
+ apiDefinition = await getApiDefinitionFileContent(orgId, apiId);
+ } catch (definitionErr) {
+ logger.debug('Could not load API definition for API keys nav check', {
+ orgId,
+ apiId,
+ error: definitionErr.message
+ });
+ }
+ }
+ return apiUsesApiKeySecurity(metaData, apiDefinition);
+}
+
function parseApiDefinitionContent(apiDefinitionContent) {
if (!apiDefinitionContent || typeof apiDefinitionContent !== 'string') {
throw new Error('Invalid API definition content');
@@ -1511,4 +1552,5 @@ module.exports = {
loadAPIContentMd,
loadDocumentMd,
loadSpecificationRaw: loadAPIDefinitionRaw,
+ resolveShowApiKeysNav,
};
diff --git a/portals/developer-portal/src/controllers/apiKeyController.js b/portals/developer-portal/src/controllers/apiKeyController.js
index 69dd89121..9d76ab922 100644
--- a/portals/developer-portal/src/controllers/apiKeyController.js
+++ b/portals/developer-portal/src/controllers/apiKeyController.js
@@ -85,7 +85,6 @@ async function resolveKeyIdOrRespond(orgId, apiId, keyHandle, res) {
function mapKey(k, audit) {
const app = k.dp_api_key_app_mapping?.dp_application;
return {
- keyId: k.uuid,
id: k.handle,
displayName: k.display_name,
status: k.status,
@@ -130,7 +129,8 @@ async function generateApiKey(req, res) {
actor: util.resolveActor(req), userToken: req.user?.accessToken,
});
logUserAction('API_KEY_GENERATED', req, { orgId, apiId: apiHandle, keyId: result.keyId, resourceUuid: result.keyId, resourceType: 'api_key' });
- return res.status(201).json(result);
+ const { keyId: _uuid, ...body } = result;
+ return res.status(201).json(body);
} catch (err) {
logger.error('Failed to generate API key', { error: err.message, orgId, apiId: apiHandle });
return util.sendError(res, errorStatus(err), 'Failed to generate API key');
@@ -193,6 +193,46 @@ async function listApiKeys(req, res) {
}
}
+/**
+ * GET /api/v0.9/api-keys
+ * Lists every API key created by the authenticated user across all APIs in the org.
+ * Powers the developer portal's global "API Keys" page (client-rendered). Each item
+ * carries the owning API's name/version/type in addition to the standard metadata so
+ * the page can render and link without a second call. Secret material is never returned.
+ */
+async function listAllApiKeys(req, res) {
+ const orgId = req.orgId;
+ const { status } = req.query;
+
+ if (status && !Object.values(constants.API_KEY_STATUS).includes(status)) {
+ return res.status(400).json({
+ status: 'error', code: 'COMMON_VALIDATION_ERROR', message: 'Bad Request',
+ errors: [{ field: 'status', message: `status must be one of: ${Object.values(constants.API_KEY_STATUS).join(', ')}` }],
+ });
+ }
+
+ try {
+ const keys = await apiKeyService.list(orgId, {
+ status: status || undefined,
+ createdBy: util.resolveActor(req),
+ });
+ const auditList = await userIdpReferenceDao.buildListAuditFields(keys);
+ const mapped = keys.map((k, i) => {
+ const m = mapKey(k, auditList[i]);
+ return {
+ ...m,
+ apiName: k.dp_api_metadata?.name || '',
+ apiVersion: k.dp_api_metadata?.version || '',
+ apiType: k.dp_api_metadata?.type || '',
+ };
+ });
+ return res.status(200).json(util.toPaginatedList(mapped, req));
+ } catch (err) {
+ logger.error('Failed to list all API keys', { error: err.message, orgId });
+ return util.sendError(res, errorStatus(err), 'Failed to list API keys');
+ }
+}
+
/**
* POST /api/v0.9/apis/:apiId/api-keys/regenerate
* Body: { keyId, expiresAt? } — keyId is the key's handle (the `id` returned by generate/list).
@@ -215,7 +255,8 @@ async function regenerateApiKey(req, res) {
orgId, apiId, keyId, expiresAt, actor: util.resolveActor(req), userToken: req.user?.accessToken,
});
logUserAction('API_KEY_REGENERATED', req, { orgId, apiId: apiHandle, keyId, resourceUuid: keyId, resourceType: 'api_key' });
- return res.status(200).json(result);
+ const { keyId: _uuid, ...body } = result;
+ return res.status(200).json(body);
} catch (err) {
logger.error('Failed to regenerate API key', { error: err.message, orgId, apiId: apiHandle, keyHandle });
return util.sendError(res, errorStatus(err), 'Failed to regenerate API key');
@@ -278,7 +319,8 @@ async function associateApiKeyApplication(req, res) {
orgId, apiId, keyId, appId, actor: util.resolveActor(req),
});
logUserAction('API_KEY_APP_ASSOCIATED', req, { orgId, apiId: apiHandle, keyId, appId: appHandle, resourceUuid: keyId, resourceType: 'api_key' });
- return res.status(200).json(result);
+ const { keyId: _uuid, ...body } = result;
+ return res.status(200).json(body);
} catch (err) {
logger.error('Failed to associate application with API key', { error: err.message, orgId, apiId: apiHandle, keyHandle });
return util.sendError(res, errorStatus(err), 'Failed to associate application with API key');
@@ -340,6 +382,6 @@ async function listApplicationApiKeys(req, res) {
}
module.exports = {
- generateApiKey, listApiKeys, regenerateApiKey, revokeApiKey,
+ generateApiKey, listApiKeys, listAllApiKeys, regenerateApiKey, revokeApiKey,
associateApiKeyApplication, removeApiKeyApplication, listApplicationApiKeys
};
diff --git a/portals/developer-portal/src/controllers/apiKeysOverviewController.js b/portals/developer-portal/src/controllers/apiKeysOverviewController.js
new file mode 100644
index 000000000..e83604309
--- /dev/null
+++ b/portals/developer-portal/src/controllers/apiKeysOverviewController.js
@@ -0,0 +1,101 @@
+/*
+ * 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.
+ */
+/* eslint-disable no-undef */
+const { renderTemplateWithView, resolveActor } = require('../utils/util');
+const { config } = require('../config/configLoader');
+const logger = require('../config/logger');
+const constants = require('../utils/constants');
+const orgDao = require('../dao/organizationDao');
+const apiKeyService = require('../services/apiKeyService');
+
+/**
+ * Renders the org-wide "API Keys" page — every API key the signed-in user has
+ * created, across all APIs and MCP servers. The list is loaded server-side (like the
+ * global Subscriptions page) so all keys render without client-side pagination. Per-key
+ * management (regenerate / revoke / associate) is handled inline by
+ * /technical-scripts/api-keys-overview.js, which calls the existing per-API/-MCP REST
+ * endpoints and reloads on success.
+ */
+const loadApiKeysOverview = async (req, res, next) => {
+ let html;
+ const { orgName, viewName } = req.params;
+
+ try {
+ const orgDetails = await orgDao.get(orgName);
+ const orgId = orgDetails.uuid;
+
+ if (!req.user) {
+ return res.redirect(`/${orgName}${constants.ROUTE.VIEWS_PATH}${viewName}/login`);
+ }
+ const devportalMode = orgDetails.configuration?.devportalMode || constants.DEVPORTAL_MODE.DEFAULT;
+
+ let apiKeys = [];
+ let apiKeysLoadError = false;
+ try {
+ const keys = await apiKeyService.list(orgId, { createdBy: resolveActor(req) });
+ apiKeys = (keys || []).map((k) => ({
+ id: k.handle,
+ displayName: k.display_name,
+ status: String(k.status || 'ACTIVE'),
+ expiresAt: k.expires_at,
+ apiName: k.dp_api_metadata?.name || '',
+ apiVersion: k.dp_api_metadata?.version || '',
+ // apiHandle is the path segment the per-API / per-MCP endpoints expect.
+ apiHandle: k.dp_api_metadata?.handle || k.api_uuid,
+ apiType: k.dp_api_metadata?.type || '',
+ appId: k.dp_api_key_app_mapping?.dp_application?.handle || '',
+ appDisplayName: k.dp_api_key_app_mapping?.dp_application?.display_name || '',
+ }));
+ } catch (dbError) {
+ apiKeysLoadError = true;
+ logger.warn('Failed to load API keys overview', { error: dbError.message, orgId });
+ }
+
+ const profile = {
+ firstName: req.user.firstName,
+ lastName: req.user.lastName,
+ email: req.user.email,
+ imageURL: req.user.picture || req.user.imageURL || '/images/default-profile.png',
+ isAdmin: req.user.isAdmin,
+ };
+
+ const templateContent = {
+ baseUrl: '/' + orgName + constants.ROUTE.VIEWS_PATH + viewName,
+ profile: profile,
+ devportalMode: devportalMode,
+ orgId: orgId,
+ apiKeys: apiKeys,
+ apiKeysCount: apiKeys.length,
+ apiKeysLoadError,
+ isReadOnlyMode: config.server.readOnlyMode,
+ };
+
+ html = await renderTemplateWithView('../pages/api-keys-overview/page.hbs', './src/defaultContent/layout/main.hbs', templateContent, true, orgId, viewName);
+ res.send(html);
+ } catch (error) {
+ logger.error('Error loading API keys overview page', {
+ error: error.message,
+ stack: error.stack,
+ orgName,
+ });
+ error.status = 500;
+ return next(error);
+ }
+};
+
+module.exports = { loadApiKeysOverview };
diff --git a/portals/developer-portal/src/defaultContent/partials/sidebar.hbs b/portals/developer-portal/src/defaultContent/partials/sidebar.hbs
index bea8c18f8..99539dea9 100644
--- a/portals/developer-portal/src/defaultContent/partials/sidebar.hbs
+++ b/portals/developer-portal/src/defaultContent/partials/sidebar.hbs
@@ -44,12 +44,10 @@
{{/in}}
- {{#if showApiWorkflowsNav}}
API Workflows
- {{/if}}
Applications
@@ -58,6 +56,10 @@
Subscriptions
+
+
+ API Keys
+
{{#if profile.isAdmin}}
diff --git a/portals/developer-portal/src/pages/api-keys-overview/page.hbs b/portals/developer-portal/src/pages/api-keys-overview/page.hbs
new file mode 100644
index 000000000..30c2669c5
--- /dev/null
+++ b/portals/developer-portal/src/pages/api-keys-overview/page.hbs
@@ -0,0 +1,208 @@
+
+
+{{#pageHead}}
+
+{{/pageHead}}
+{{#pageScripts}}
+
+{{/pageScripts}}
+
+
+
+
+
API Keys
+
API keys you've generated across all APIs. Manage a key inline — regenerate or revoke it.
+
+
+
+ {{!-- The list is rendered server-side (all keys, no client pagination). Per-key
+ management happens inline through the modals below (mirrors the Subscriptions
+ page); api-keys-overview.js reads each row's data-* attributes and calls the
+ per-API / per-MCP REST endpoints. --}}
+
+
+ {{#if apiKeysLoadError}}
+
+ Unable to load API keys. Refresh the page or try again later.
+
+ {{else if apiKeys.length}}
+
+
+
+
+
API
Name
Status
Expires
Application
Actions
+
+
+
+ {{#each apiKeys}}
+ {{!-- The key handle is the external identifier; the mutation endpoints expect it
+ as `keyId`. The uuid is internal and never rendered. --}}
+