From e87300939b76d6b0f2d2ac2e3e6cec5d0136c55b Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 21 Jun 2026 15:49:40 +0530 Subject: [PATCH 01/11] Update Asgardeo configuration and auth middleware for improved session handling and scope management --- .../configs/config.yaml.example | 8 +++- .../docs/administer/asgardeo-setup.md | 40 +++++++++++++++---- .../src/middlewares/authMiddleware.js | 6 ++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/portals/developer-portal/configs/config.yaml.example b/portals/developer-portal/configs/config.yaml.example index 9e890e7ff..2b053ade3 100644 --- a/portals/developer-portal/configs/config.yaml.example +++ b/portals/developer-portal/configs/config.yaml.example @@ -92,7 +92,11 @@ identityProvider: audience: "" # JWT aud claim to verify (usually the clientId); leave empty to skip # env: DP_IDENTITYPROVIDER_AUDIENCE callbackURL: "http://localhost:3000/default/callback" - scope: "openid profile email" # Space-separated OIDC scopes to request + scope: "openid profile email" # Space-separated OIDC scopes to request for browser login. + # dp:* scopes are not needed here — browser sessions are + # preauthorized so per-operation scope checks are skipped. + # Machine API clients that call /devportal/v1/* directly with + # a Bearer token still need the dp:* scopes in their tokens. # env: DP_IDENTITYPROVIDER_SCOPE signUpURL: "" logoutURL: "https://localhost:9443/oidc/logout" @@ -104,7 +108,7 @@ identityProvider: # ── Claim name mappings — adjust to match what your IDP puts in the JWT ────── # Dot-notation is supported for nested claims (e.g. "realm_access.roles" for Keycloak) roleClaim: "roles" # env: DP_IDENTITYPROVIDER_ROLECLAIM - orgIDClaim: "organization.uuid" # Asgardeo B2B uses "org_id" — env: DP_IDENTITYPROVIDER_ORGIDCLAIM + orgIDClaim: "org_name" # Asgardeo B2B: "org_name" matches ORGANIZATION_IDENTIFIER (the sub-org display name/handle) — env: DP_IDENTITYPROVIDER_ORGIDCLAIM groupsClaim: "groups" # env: DP_IDENTITYPROVIDER_GROUPSCLAIM adminRole: "admin" # env: DP_IDENTITYPROVIDER_ADMINROLE subscriberRole: "Internal/subscriber" # env: DP_IDENTITYPROVIDER_SUBSCRIBERROLE diff --git a/portals/developer-portal/docs/administer/asgardeo-setup.md b/portals/developer-portal/docs/administer/asgardeo-setup.md index 6e16483cf..8a17071ae 100644 --- a/portals/developer-portal/docs/administer/asgardeo-setup.md +++ b/portals/developer-portal/docs/administer/asgardeo-setup.md @@ -4,6 +4,22 @@ This guide walks through configuring WSO2 Asgardeo as the identity provider for A full configuration reference is in [authentication.md](authentication.md). +## Overview + +The Developer Portal uses Asgardeo's sub-organization model: each devportal organization maps to one Asgardeo sub-organization. A single Asgardeo application (Traditional Web App) is shared across all devportal orgs, but each login session is scoped to a specific sub-org. + +**How it works end-to-end:** + +1. A devportal org has an `ORGANIZATION_IDENTIFIER` set to its Asgardeo sub-org handle. +2. When a user clicks Login, the devportal redirects to Asgardeo with `org=`, scoping the authorization to that sub-org. +3. Asgardeo issues a JWT with `org_id` set to the sub-org's UUID. The devportal verifies this claim matches the org on every authenticated request. +4. Each login session is bound to one sub-org — accessing a different devportal org's protected pages requires logging out and back in on that org. + +**Key design decisions:** +- One Asgardeo application (confidential client) serves all devportal orgs — the callback URL is shared, with per-request routing via session state. +- Public pages (API catalog, docs) are always accessible without authentication. +- Protected pages (applications, subscriptions, API keys) enforce the org claim match. + ## Prerequisites - An Asgardeo account at [console.asgardeo.io](https://console.asgardeo.io) @@ -45,6 +61,8 @@ Note the **Client ID** and **Client Secret** from the Protocol tab. ## 3. Register dp:* Scopes +> The `dp:*` scopes are enforced per-operation by the devportal for machine API clients that call `/devportal/v1/*` directly with a Bearer token. Browser sessions (IDP login) are preauthorized — the portal trusts session-level authentication and skips per-operation scope checks for session users. The Platform API uses its own `ap:*` scopes and does not validate `dp:*`. + Create a system OIDC application (e.g. `DevPortal System`) and under **API Authorization** add: - **API Resource Management API** (Management APIs) - **Application Management API** (Management APIs) @@ -61,6 +79,8 @@ ASGARDEO_RESOURCE_IDENTIFIER=https:// \ For local development, the default `ASGARDEO_RESOURCE_IDENTIFIER=https://localhost:3000` works without changes. +> The system application is only needed to run this script. Once the `dp:*` API resource is registered in Asgardeo, the system app can be deleted. + --- ## 4. Link Scopes to the Application @@ -90,8 +110,8 @@ identityProvider: logoutURL: "https://api.asgardeo.io/t//oidc/logout" logoutRedirectURI: "https:///default" jwksURL: "https://api.asgardeo.io/t//oauth2/jwks" - scope: "openid profile email dp:org_manage dp:api_read dp:app_manage dp:subscription_manage" - orgIDClaim: "org_id" # Asgardeo B2B sub-org claim + scope: "openid profile email" # dp:* not needed — browser sessions are preauthorized + orgIDClaim: "org_name" # Asgardeo B2B: org_name matches ORGANIZATION_IDENTIFIER (sub-org display name) roleClaim: "roles" ``` @@ -134,7 +154,6 @@ issuer = ["https://api.asgardeo.io/t//oauth2/token"] audience = [""] [auth.idp.claim_mappings] -scope_claim_name = "scope" # Asgardeo uses "scope" (space-separated) organization_claim_name = "org_id" # Asgardeo B2B sub-org UUID org_handle_claim_name = "org_id" # Asgardeo does not emit "org_handle"; use org_id user_id_claim_name = "sub" @@ -147,11 +166,16 @@ AUTH_IDP_ENABLED=true AUTH_IDP_JWKS_URL=https://api.asgardeo.io/t//oauth2/jwks AUTH_IDP_ISSUER=https://api.asgardeo.io/t//oauth2/token AUTH_IDP_AUDIENCE= -AUTH_IDP_CLAIM_MAPPINGS_SCOPE_CLAIM_NAME=scope AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME=org_id AUTH_IDP_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME=org_id ``` +**Scope validation:** The Platform API's subscription and API key endpoints require `ap:*` scopes, but a devportal user's Asgardeo token carries `dp:*` scopes — not `ap:*`. With the default `enable_scope_validation: true`, these calls will be rejected with 403. Disable scope validation for the devportal integration path: + +```toml +enable_scope_validation = false +``` + > The devportal's `platformApi.jwtSecret` setting is only used in local auth mode (to verify Platform API-issued HMAC tokens on the devportal side). It has no effect when an external IDP is configured — leave it empty. --- @@ -161,11 +185,13 @@ AUTH_IDP_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME=org_id ``` Asgardeo token ├── sub → user identity - ├── org_id → organization UUID (→ orgIDClaim) - ├── roles → role list (→ roleClaim → isAdmin check) - └── scope → space-separated dp:* scopes enforced per API operation + ├── org_name → sub-org display name (→ orgIDClaim → compared to ORGANIZATION_IDENTIFIER) + ├── org_id → sub-org UUID (available but not used for org matching) + └── roles → role list (→ roleClaim → isAdmin check) ``` +> Browser sessions are preauthorized — the `scope` claim is not checked against per-operation requirements for session users. Machine API clients calling `/devportal/v1/*` directly with a Bearer token have their `scope` claim enforced as usual. + --- ## Relationship to ai-workspace Asgardeo Setup diff --git a/portals/developer-portal/src/middlewares/authMiddleware.js b/portals/developer-portal/src/middlewares/authMiddleware.js index d67f49d0f..cc132a960 100644 --- a/portals/developer-portal/src/middlewares/authMiddleware.js +++ b/portals/developer-portal/src/middlewares/authMiddleware.js @@ -132,10 +132,14 @@ async function authResolver(req, res, next) { return next(); } - // 2. Session fast-path: scopes captured at login — skip JWKS re-validation + // 2. Session fast-path: browser login via IDP — role check is done by ensureAuthenticated + // on page routes, so scope enforcement here is redundant and would require listing all + // dp:* scopes in the OIDC scope config. Set preauthorized to bypass the per-operation + // scope check for session users (same as API key and mTLS paths). if (req.isAuthenticated && req.isAuthenticated() && req.user?.grantedScopes !== undefined && config.identityProvider?.clientId) { req.auth = { mode: 'oauth2', + preauthorized: true, scopes: String(req.user.grantedScopes || '').split(' ').filter(Boolean), userId: req.user[constants.USER_ID], }; From b13ab0e35e8cba46117170a88c3054c067824494 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 21 Jun 2026 15:51:53 +0530 Subject: [PATCH 02/11] Add support for listing applications and update API paths for organization context --- docs/rest-apis/devportal/README.md | 7 +- docs/rest-apis/devportal/application-keys.md | 25 ++-- docs/rest-apis/devportal/applications.md | 128 +++++++++++++++--- .../docs/devportal-openapi-spec-v1.yaml | 50 +++++-- .../src/controllers/devportalController.js | 68 +++++----- .../api/handlers/applicationsHandler.js | 1 + 6 files changed, 199 insertions(+), 80 deletions(-) diff --git a/docs/rest-apis/devportal/README.md b/docs/rest-apis/devportal/README.md index 820fe61cc..daa2da1c7 100644 --- a/docs/rest-apis/devportal/README.md +++ b/docs/rest-apis/devportal/README.md @@ -72,9 +72,10 @@ Base URLs: ### [Applications](applications.md) -- [Create an application for the authenticated user's organization](applications.md#create-an-application-for-the-authenticated-users-organization) -- [Update an application for the authenticated user](applications.md#update-an-application-for-the-authenticated-user) -- [Delete an application for the authenticated user](applications.md#delete-an-application-for-the-authenticated-user) +- [List applications for the authenticated user](applications.md#list-applications-for-the-authenticated-user) +- [Create an application](applications.md#create-an-application) +- [Update an application](applications.md#update-an-application) +- [Delete an application](applications.md#delete-an-application) ### [Subscriptions](subscriptions.md) diff --git a/docs/rest-apis/devportal/application-keys.md b/docs/rest-apis/devportal/application-keys.md index ceaf7c560..7df9dc9d0 100644 --- a/docs/rest-apis/devportal/application-keys.md +++ b/docs/rest-apis/devportal/application-keys.md @@ -4,13 +4,13 @@ -`POST /applications/{applicationId}/generate-keys` +`POST /o/{orgId}/devportal/v1/applications/{applicationId}/generate-keys` > Code samples ```shell -curl -X POST https://devportal.api-platform.io/applications/{applicationId}/generate-keys \ +curl -X POST https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications/{applicationId}/generate-keys \ -u {username}:{password} \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ @@ -51,6 +51,7 @@ This operation requires Basic Auth authentication. |Name|In|Type|Required|Description| |---|---|---|---|---| |body|body|[AppKeyMappingRequest](schemas.md#schemaappkeymappingrequest)|true|OAuth key generation payload. The application is identified by the `applicationId` path parameter.| +|orgId|path|string|true|none| |applicationId|path|string|true|none| > Example responses @@ -144,13 +145,13 @@ This operation requires Basic Auth authentication. -`POST /applications/{applicationId}/oauth-keys/{keyMappingId}/generate-token` +`POST /o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}/generate-token` > Code samples ```shell -curl -X POST https://devportal.api-platform.io/applications/{applicationId}/oauth-keys/{keyMappingId}/generate-token \ +curl -X POST https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}/generate-token \ -u {username}:{password} \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ @@ -185,6 +186,7 @@ This operation requires Basic Auth authentication. |Name|In|Type|Required|Description| |---|---|---|---|---| |body|body|[OAuthGenerateTokenRequest](schemas.md#schemaoauthgeneratetokenrequest)|false|OAuth token generation payload. The portal calls the Authorization Server token endpoint directly.| +|orgId|path|string|true|none| |applicationId|path|string|true|none| |keyMappingId|path|string|true|none| @@ -262,13 +264,13 @@ This operation requires Basic Auth authentication. -`DELETE /applications/{applicationId}/oauth-keys/{keyMappingId}` +`DELETE /o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}` > Code samples ```shell -curl -X DELETE https://devportal.api-platform.io/applications/{applicationId}/oauth-keys/{keyMappingId} \ +curl -X DELETE https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId} \ -u {username}:{password} \ -H 'Accept: application/json' \ -H 'Authorization: Bearer {access-token}' @@ -288,6 +290,7 @@ This operation requires Basic Auth authentication. |Name|In|Type|Required|Description| |---|---|---|---|---| +|orgId|path|string|true|none| |applicationId|path|string|true|none| |keyMappingId|path|string|true|none| @@ -335,13 +338,13 @@ This operation requires Basic Auth authentication. -`PUT /applications/{applicationId}/oauth-keys/{keyMappingId}` +`PUT /o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}` > Code samples ```shell -curl -X PUT https://devportal.api-platform.io/applications/{applicationId}/oauth-keys/{keyMappingId} \ +curl -X PUT https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId} \ -u {username}:{password} \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ @@ -377,6 +380,7 @@ This operation requires Basic Auth authentication. |Name|In|Type|Required|Description| |---|---|---|---|---| |body|body|[OAuthKeyUpdateRequest](schemas.md#schemaoauthkeyupdaterequest)|false|OAuth key update payload forwarded to the configured key manager.| +|orgId|path|string|true|none| |applicationId|path|string|true|none| |keyMappingId|path|string|true|none| @@ -460,13 +464,13 @@ This operation requires Basic Auth authentication. -`POST /applications/{applicationId}/oauth-keys/{keyMappingId}/clean-up` +`POST /o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}/clean-up` > Code samples ```shell -curl -X POST https://devportal.api-platform.io/applications/{applicationId}/oauth-keys/{keyMappingId}/clean-up \ +curl -X POST https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}/clean-up \ -u {username}:{password} \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ @@ -498,6 +502,7 @@ This operation requires Basic Auth authentication. |Name|In|Type|Required|Description| |---|---|---|---|---| |body|body|[OAuthKeyCleanUpRequest](schemas.md#schemaoauthkeycleanuprequest)|false|OAuth cleanup payload forwarded to the configured key manager.| +|orgId|path|string|true|none| |applicationId|path|string|true|none| |keyMappingId|path|string|true|none| diff --git a/docs/rest-apis/devportal/applications.md b/docs/rest-apis/devportal/applications.md index e518ebdc2..4f43f2fb8 100644 --- a/docs/rest-apis/devportal/applications.md +++ b/docs/rest-apis/devportal/applications.md @@ -1,16 +1,103 @@

Applications

-## Create an application for the authenticated user's organization +## List applications for the authenticated user + + + +`GET /o/{orgId}/devportal/v1/applications` + +> Code samples + +```shell + +curl -X GET https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications \ + -u {username}:{password} \ + -H 'Accept: application/json' \ + -H 'Authorization: Bearer {access-token}' + +``` + +Returns all applications owned by the authenticated user in the specified organization. + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|orgId|path|string|true|none| + +> Example responses + +> 200 Response + +```json +[ + { + "id": "app-12345", + "name": "Weather App", + "description": "Application used to call Weather APIs.", + "type": "WEB", + "appMap": [ + { + "appRefID": "cp-app-98765", + "token": "OAUTH", + "shared": true + } + ] + } +] +``` + +> 500 Response + +```json +{ + "code": "500", + "message": "Internal Server Error", + "description": "Internal Server Error" +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|List of application DTOs.|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| +|---|---|---|---|---| +|*anonymous*|[[ApplicationResponse](schemas.md#schemaapplicationresponse)]|false|none|none| +|» id|string|false|none|none| +|» name|string|false|none|none| +|» description|string|false|none|none| +|» type|string|false|none|none| +|» appMap|[[ApplicationKeyMappingSummary](schemas.md#schemaapplicationkeymappingsummary)]|false|none|none| +|»» appRefID|string|false|none|none| +|»» token|string|false|none|none| +|»» shared|boolean|false|none|none| + +## Create an application -`POST /applications` +`POST /o/{orgId}/devportal/v1/applications` > Code samples ```shell -curl -X POST https://devportal.api-platform.io/applications \ +curl -X POST https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications \ -u {username}:{password} \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ @@ -19,7 +106,7 @@ curl -X POST https://devportal.api-platform.io/applications \ ``` -Creates a Developer Portal application in the organization resolved from the authenticated user's organization identifier. The request may be JSON, multipart form fields, or an application YAML file in the `application` multipart field. +Creates a Developer Portal application in the specified organization. The request may be JSON, multipart form fields, or an application YAML file in the `application` multipart field. > Payload @@ -45,11 +132,12 @@ This operation requires Basic Auth authentication. -

Parameters

+

Parameters

|Name|In|Type|Required|Description| |---|---|---|---|---| |body|body|[ApplicationRequest](schemas.md#schemaapplicationrequest)|true|Application payload. Send JSON, multipart form fields, or an application YAML file in the `application` field. When YAML is used, the service reads `spec.displayName` or `metadata.name` as the application name and `spec.description` as the description.| +|orgId|path|string|true|none| > Example responses @@ -110,7 +198,7 @@ This operation requires Basic Auth authentication. } ``` -

Responses

+

Responses

|Status|Meaning|Description|Schema| |---|---|---|---| @@ -119,19 +207,19 @@ This operation requires Basic Auth authentication. |409|[Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8)|Duplicate organization data conflicts with an existing record.|[ErrorResponse](schemas.md#schemaerrorresponse)| |500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error.|[ErrorResponse](schemas.md#schemaerrorresponse)| -

Response Schema

+

Response Schema

-## Update an application for the authenticated user +## Update an application -`PUT /applications/{applicationId}` +`PUT /o/{orgId}/devportal/v1/applications/{applicationId}` > Code samples ```shell -curl -X PUT https://devportal.api-platform.io/applications/{applicationId} \ +curl -X PUT https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications/{applicationId} \ -u {username}:{password} \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ @@ -140,7 +228,7 @@ curl -X PUT https://devportal.api-platform.io/applications/{applicationId} \ ``` -Updates an application owned by the authenticated user in the organization resolved from the authenticated user's organization identifier. The request may be JSON, multipart form fields, or an application YAML file in the `application` multipart field. +Updates an application owned by the authenticated user in the specified organization. The request may be JSON, multipart form fields, or an application YAML file in the `application` multipart field. > Payload @@ -166,11 +254,12 @@ This operation requires Basic Auth authentication. -

Parameters

+

Parameters

|Name|In|Type|Required|Description| |---|---|---|---|---| |body|body|[ApplicationRequest](schemas.md#schemaapplicationrequest)|true|Application payload. Send JSON, multipart form fields, or an application YAML file in the `application` field. When YAML is used, the service reads `spec.displayName` or `metadata.name` as the application name and `spec.description` as the description.| +|orgId|path|string|true|none| |applicationId|path|string|true|none| > Example responses @@ -242,7 +331,7 @@ This operation requires Basic Auth authentication. } ``` -

Responses

+

Responses

|Status|Meaning|Description|Schema| |---|---|---|---| @@ -252,19 +341,19 @@ This operation requires Basic Auth authentication. |409|[Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8)|Duplicate organization data conflicts with an existing record.|[ErrorResponse](schemas.md#schemaerrorresponse)| |500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error.|[ErrorResponse](schemas.md#schemaerrorresponse)| -

Response Schema

+

Response Schema

-## Delete an application for the authenticated user +## Delete an application -`DELETE /applications/{applicationId}` +`DELETE /o/{orgId}/devportal/v1/applications/{applicationId}` > Code samples ```shell -curl -X DELETE https://devportal.api-platform.io/applications/{applicationId} \ +curl -X DELETE https://devportal.api-platform.io/o/{orgId}/devportal/v1/applications/{applicationId} \ -u {username}:{password} \ -H 'Accept: text/plain' \ -H 'Authorization: Bearer {access-token}' @@ -280,10 +369,11 @@ This operation requires Basic Auth authentication. -

Parameters

+

Parameters

|Name|In|Type|Required|Description| |---|---|---|---|---| +|orgId|path|string|true|none| |applicationId|path|string|true|none| > Example responses @@ -314,7 +404,7 @@ This operation requires Basic Auth authentication. } ``` -

Responses

+

Responses

|Status|Meaning|Description|Schema| |---|---|---|---| diff --git a/portals/developer-portal/docs/devportal-openapi-spec-v1.yaml b/portals/developer-portal/docs/devportal-openapi-spec-v1.yaml index f8756a48d..7a43821e7 100644 --- a/portals/developer-portal/docs/devportal-openapi-spec-v1.yaml +++ b/portals/developer-portal/docs/devportal-openapi-spec-v1.yaml @@ -830,14 +830,31 @@ paths: - OAuth2Security: - dp:label_delete - dp:label_manage - /applications: + /o/{orgId}/devportal/v1/applications: + parameters: + - $ref: "#/components/parameters/OrgId" + get: + tags: + - Applications + summary: List applications for the authenticated user + description: Returns all applications owned by the authenticated user in the specified organization. + operationId: listApplications + responses: + "200": + $ref: "#/components/responses/ApplicationListResponse" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - OAuth2Security: + - dp:app_read + - dp:app_manage post: tags: - Applications - summary: Create an application for the authenticated user's organization + summary: Create an application description: >- - Creates a Developer Portal application in the organization resolved from the authenticated user's - organization identifier. The request may be JSON, multipart form fields, or an application YAML + Creates a Developer Portal application in the specified organization. + The request may be JSON, multipart form fields, or an application YAML file in the `application` multipart field. operationId: saveApplication requestBody: @@ -855,17 +872,18 @@ paths: - OAuth2Security: - dp:app_write - dp:app_manage - /applications/{applicationId}: + /o/{orgId}/devportal/v1/applications/{applicationId}: parameters: + - $ref: "#/components/parameters/OrgId" - $ref: "#/components/parameters/ApplicationId" put: tags: - Applications - summary: Update an application for the authenticated user + summary: Update an application description: >- - Updates an application owned by the authenticated user in the organization resolved from the - authenticated user's organization identifier. The request may be JSON, multipart form fields, or - an application YAML file in the `application` multipart field. + Updates an application owned by the authenticated user in the specified organization. + The request may be JSON, multipart form fields, or an application YAML file in the + `application` multipart field. operationId: updateApplication requestBody: $ref: "#/components/requestBodies/ApplicationMultipartBody" @@ -887,7 +905,7 @@ paths: delete: tags: - Applications - summary: Delete an application for the authenticated user + summary: Delete an application description: >- Deletes an application owned by the authenticated user. Before removing the application record the service will make a best-effort attempt to revoke registered OAuth clients with their respective key @@ -1232,8 +1250,9 @@ paths: - OAuth2Security: - dp:view_delete - dp:view_manage - /applications/{applicationId}/generate-keys: + /o/{orgId}/devportal/v1/applications/{applicationId}/generate-keys: parameters: + - $ref: "#/components/parameters/OrgId" - $ref: "#/components/parameters/ApplicationId" post: tags: @@ -1261,8 +1280,9 @@ paths: - OAuth2Security: - dp:app_key_write - dp:app_key_manage - /applications/{applicationId}/oauth-keys/{keyMappingId}/generate-token: + /o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}/generate-token: parameters: + - $ref: "#/components/parameters/OrgId" - $ref: "#/components/parameters/ApplicationId" - $ref: "#/components/parameters/KeyMappingId" post: @@ -1288,8 +1308,9 @@ paths: - OAuth2Security: - dp:app_key_write - dp:app_key_manage - /applications/{applicationId}/oauth-keys/{keyMappingId}: + /o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}: parameters: + - $ref: "#/components/parameters/OrgId" - $ref: "#/components/parameters/ApplicationId" - $ref: "#/components/parameters/KeyMappingId" delete: @@ -1330,8 +1351,9 @@ paths: - OAuth2Security: - dp:app_key_write - dp:app_key_manage - /applications/{applicationId}/oauth-keys/{keyMappingId}/clean-up: + /o/{orgId}/devportal/v1/applications/{applicationId}/oauth-keys/{keyMappingId}/clean-up: parameters: + - $ref: "#/components/parameters/OrgId" - $ref: "#/components/parameters/ApplicationId" - $ref: "#/components/parameters/KeyMappingId" post: diff --git a/portals/developer-portal/src/controllers/devportalController.js b/portals/developer-portal/src/controllers/devportalController.js index f78061eee..f2378c297 100644 --- a/portals/developer-portal/src/controllers/devportalController.js +++ b/portals/developer-portal/src/controllers/devportalController.js @@ -65,20 +65,29 @@ function parseApplicationDataFromRequest(req) { // ***** Save Application ***** +const listApplications = async (req, res) => { + const orgID = req.params.orgId; + const userID = req.auth?.userId || req.user?.sub; + try { + const applications = await appDao.list(orgID, userID); + return res.status(200).json(applications.map(a => new ApplicationDTO(a.dataValues))); + } catch (error) { + logger.error('Error occurred while listing applications', { orgId: orgID, error: error.message, stack: error.stack }); + util.handleError(res, error); + } +}; + const saveApplication = async (req, res) => { + const orgID = req.params.orgId; + const userID = req.auth?.userId || req.user?.sub; try { - const orgID = await orgDao.getId(req.user[constants.ORG_IDENTIFIER]); const applicationData = parseApplicationDataFromRequest(req); - trackAppCreationStart({ orgId: orgID, appName: applicationData.name, idpId: req.isAuthenticated() ? (req[constants.USER_ID] || req.user.sub) : undefined }, req); - const application = await appDao.create(orgID, req.user.sub, applicationData); - trackAppCreationEnd({ orgId: orgID, appName: applicationData.name, idpId: req.isAuthenticated() ? (req[constants.USER_ID] || req.user.sub) : undefined }, req); + trackAppCreationStart({ orgId: orgID, appName: applicationData.name, idpId: userID }, req); + const application = await appDao.create(orgID, userID, applicationData); + trackAppCreationEnd({ orgId: orgID, appName: applicationData.name, idpId: userID }, req); return res.status(201).json(new ApplicationDTO(application.dataValues)); } catch (error) { - logger.error('Error occurred while creating the application', { - orgId: req.user[constants.ORG_IDENTIFIER], - error: error.message, - stack: error.stack - }); + logger.error('Error occurred while creating the application', { orgId: orgID, error: error.message, stack: error.stack }); util.handleError(res, error); } }; @@ -86,20 +95,18 @@ const saveApplication = async (req, res) => { // ***** Update Application ***** const updateApplication = async (req, res) => { + const orgID = req.params.orgId; + const userID = req.auth?.userId || req.user?.sub; try { - const orgID = await orgDao.getId(req.user[constants.ORG_IDENTIFIER]); const appID = req.params.applicationId; const applicationData = parseApplicationDataFromRequest(req); - const [updatedRows, updatedApp] = await appDao.update(orgID, appID, req.user.sub, applicationData); + const [updatedRows, updatedApp] = await appDao.update(orgID, appID, userID, applicationData); if (!updatedRows) { throw new Sequelize.EmptyResultError("No record found to update"); } res.status(200).send(new ApplicationDTO(updatedApp[0].dataValues)); } catch (error) { - logger.error("Error occurred while updating the application", { - error: error.message, - stack: error.stack - }); + logger.error("Error occurred while updating the application", { orgId: orgID, error: error.message, stack: error.stack }); util.handleError(res, error); } }; @@ -144,43 +151,35 @@ const revokeAppKeyMappings = async (orgID, appID) => { }; const deleteApplication = async (req, res) => { + const orgID = req.params.orgId; + const userID = req.auth?.userId || req.user?.sub; + const applicationId = req.params.applicationId; try { - const orgID = await orgDao.getId(req.user[constants.ORG_IDENTIFIER]); - const applicationId = req.params.applicationId; try { await revokeAppKeyMappings(orgID, applicationId); - const appDeleteResponse = await appDao.delete(orgID, applicationId, req.user.sub); + const appDeleteResponse = await appDao.delete(orgID, applicationId, userID); if (appDeleteResponse === 0) { throw new Sequelize.EmptyResultError("Resource not found to delete"); } else { - trackAppDeletion({ orgId: orgID, appId: applicationId, idpId: req.isAuthenticated() ? (req[constants.USER_ID] || req.user.sub) : undefined }, req); + trackAppDeletion({ orgId: orgID, appId: applicationId, idpId: userID }, req); res.status(200).send("Resouce Deleted Successfully"); } } catch (error) { if (error.statusCode === 404) { await revokeAppKeyMappings(orgID, applicationId); - const appDeleteResponse = await appDao.delete(orgID, applicationId, req.user.sub); + const appDeleteResponse = await appDao.delete(orgID, applicationId, userID); if (appDeleteResponse === 0) { throw new Sequelize.EmptyResultError("Resource not found to delete"); } else { - trackAppDeletion({ orgId: orgID, appId: applicationId, idpId: req.isAuthenticated() ? (req[constants.USER_ID] || req.user.sub) : undefined }, req); + trackAppDeletion({ orgId: orgID, appId: applicationId, idpId: userID }, req); return res.status(200).send("Resouce Deleted Successfully"); } } - logger.error('Error occurred while deleting the application', { - orgId: orgID, - appId: applicationId, - error: error.message, - stack: error.stack - }); + logger.error('Error occurred while deleting the application', { orgId: orgID, appId: applicationId, error: error.message, stack: error.stack }); util.handleError(res, error); } } catch (error) { - logger.error('Error occurred while deleting the application', { - appId: req.params.appId, - error: error.message, - stack: error.stack - }); + logger.error('Error occurred while deleting the application', { appId: applicationId, error: error.message, stack: error.stack }); util.handleError(res, error); } } @@ -188,9 +187,9 @@ const deleteApplication = async (req, res) => { const generateKeys = async (req, res) => { let orgID, appID, userID; try { - orgID = await orgDao.getId(req.user[constants.ORG_IDENTIFIER]); + orgID = req.params.orgId; appID = req.params.applicationId; - userID = req[constants.USER_ID] || req.user?.sub; + userID = req.auth?.userId || req[constants.USER_ID] || req.user?.sub; logger.info('Initiate create application key mapping...', { orgId: orgID, appId: appID }); const { keyManager: kmName, @@ -479,6 +478,7 @@ const login = async (req, res) => { }); }; module.exports = { + listApplications, saveApplication, updateApplication, deleteApplication, diff --git a/portals/developer-portal/src/routes/api/handlers/applicationsHandler.js b/portals/developer-portal/src/routes/api/handlers/applicationsHandler.js index d4637907a..38ea21b8a 100644 --- a/portals/developer-portal/src/routes/api/handlers/applicationsHandler.js +++ b/portals/developer-portal/src/routes/api/handlers/applicationsHandler.js @@ -23,6 +23,7 @@ const devportalController = require('../../../controllers/devportalController'); module.exports = { + listApplications: devportalController.listApplications, saveApplication: devportalController.saveApplication, updateApplication: devportalController.updateApplication, deleteApplication: devportalController.deleteApplication, From 01359c393575b4e3e48270a35ac5311a10873473 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 21 Jun 2026 16:37:15 +0530 Subject: [PATCH 03/11] Use orgId from request parameters in application management functions --- .../src/controllers/devportalController.js | 10 ++-- .../src/middlewares/authMiddleware.js | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/portals/developer-portal/src/controllers/devportalController.js b/portals/developer-portal/src/controllers/devportalController.js index f2378c297..6ee52706d 100644 --- a/portals/developer-portal/src/controllers/devportalController.js +++ b/portals/developer-portal/src/controllers/devportalController.js @@ -72,7 +72,7 @@ const listApplications = async (req, res) => { const applications = await appDao.list(orgID, userID); return res.status(200).json(applications.map(a => new ApplicationDTO(a.dataValues))); } catch (error) { - logger.error('Error occurred while listing applications', { orgId: orgID, error: error.message, stack: error.stack }); + logger.error('Error occurred while listing applications', { orgId: req.params.orgId, error: error.message, stack: error.stack }); util.handleError(res, error); } }; @@ -87,7 +87,7 @@ const saveApplication = async (req, res) => { trackAppCreationEnd({ orgId: orgID, appName: applicationData.name, idpId: userID }, req); return res.status(201).json(new ApplicationDTO(application.dataValues)); } catch (error) { - logger.error('Error occurred while creating the application', { orgId: orgID, error: error.message, stack: error.stack }); + logger.error('Error occurred while creating the application', { orgId: req.params.orgId, error: error.message, stack: error.stack }); util.handleError(res, error); } }; @@ -106,7 +106,7 @@ const updateApplication = async (req, res) => { } res.status(200).send(new ApplicationDTO(updatedApp[0].dataValues)); } catch (error) { - logger.error("Error occurred while updating the application", { orgId: orgID, error: error.message, stack: error.stack }); + logger.error("Error occurred while updating the application", { orgId: req.params.orgId, error: error.message, stack: error.stack }); util.handleError(res, error); } }; @@ -151,9 +151,9 @@ const revokeAppKeyMappings = async (orgID, appID) => { }; const deleteApplication = async (req, res) => { - const orgID = req.params.orgId; const userID = req.auth?.userId || req.user?.sub; const applicationId = req.params.applicationId; + const orgID = req.params.orgId; try { try { await revokeAppKeyMappings(orgID, applicationId); @@ -175,7 +175,7 @@ const deleteApplication = async (req, res) => { return res.status(200).send("Resouce Deleted Successfully"); } } - logger.error('Error occurred while deleting the application', { orgId: orgID, appId: applicationId, error: error.message, stack: error.stack }); + logger.error('Error occurred while deleting the application', { orgId: req.params.orgId, appId: applicationId, error: error.message, stack: error.stack }); util.handleError(res, error); } } catch (error) { diff --git a/portals/developer-portal/src/middlewares/authMiddleware.js b/portals/developer-portal/src/middlewares/authMiddleware.js index cc132a960..f7d27fd5d 100644 --- a/portals/developer-portal/src/middlewares/authMiddleware.js +++ b/portals/developer-portal/src/middlewares/authMiddleware.js @@ -38,6 +38,7 @@ const constants = require('../utils/constants'); const logger = require('../config/logger'); const { extractPlatformJwtClaims } = require('../utils/platformJwt'); const { accessTokenPresent, refreshAccessToken, verifyWithCertificate, resolveOrgIdp } = require('../utils/tokenUtil'); +const orgDao = require('../dao/organizationDao'); const DEFAULT_TOKEN_REFRESH_TIMEOUT_MS = 10000; @@ -137,6 +138,26 @@ async function authResolver(req, res, next) { // dp:* scopes in the OIDC scope config. Set preauthorized to bypass the per-operation // scope check for session users (same as API key and mTLS paths). if (req.isAuthenticated && req.isAuthenticated() && req.user?.grantedScopes !== undefined && config.identityProvider?.clientId) { + const pathOrgMatch = req.path.match(/^\/o\/([^/]+)\//); + const pathOrgId = pathOrgMatch ? pathOrgMatch[1] : null; + const orgIDClaim = config.identityProvider?.orgIDClaim; + if (pathOrgId && orgIDClaim) { + const sessionOrgClaim = req.user[constants.ROLES.ORGANIZATION_CLAIM]; + if (sessionOrgClaim) { + try { + const orgDetails = await orgDao.get(pathOrgId); + const orgIdentifier = orgDetails?.ORGANIZATION_IDENTIFIER; + if (orgIdentifier && sessionOrgClaim !== orgIdentifier) { + logger.warn('Session org mismatch', { pathOrgId, orgIdentifier, sessionOrgClaim }); + const err = new Error('Token org does not match requested organization'); + err.status = 403; + return next(err); + } + } catch (e) { + // org not found — let the handler return 404 + } + } + } req.auth = { mode: 'oauth2', preauthorized: true, @@ -161,6 +182,34 @@ async function authResolver(req, res, next) { return next(err); } const decoded = jwt.decode(req.user?.[constants.ACCESS_TOKEN] || token) || {}; + // Org isolation: verify the token's org claim matches the org in the URL path. + // Only enforced in IDP mode — local-auth and platform-JWT tokens have no org claim. + // req.params is not yet populated here (authResolver runs before route matching), + // so extract orgId directly from the path: /o//devportal/v1/... + const pathOrgMatch = req.path.match(/^\/o\/([^/]+)\//); + const pathOrgId = pathOrgMatch ? pathOrgMatch[1] : null; + const orgIDClaim = config.identityProvider?.orgIDClaim; + if (pathOrgId && config.identityProvider?.clientId && orgIDClaim) { + const tokenOrgClaim = decoded[orgIDClaim]; + if (tokenOrgClaim) { + try { + const orgDetails = await orgDao.get(pathOrgId); + const orgIdentifier = orgDetails?.ORGANIZATION_IDENTIFIER; + if (orgIdentifier && tokenOrgClaim !== orgIdentifier) { + logger.warn('Bearer token org mismatch', { + pathOrgId, + orgIdentifier, + tokenOrgClaim, + }); + const err = new Error('Token org does not match requested organization'); + err.status = 403; + return next(err); + } + } catch (e) { + // org not found — let the handler return 404 + } + } + } req[constants.USER_ID] = decoded[constants.USER_ID]; req.auth = { mode: 'oauth2', From 3d43126133db92accdf820bdaf26211ecda2ffc6 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 21 Jun 2026 17:03:01 +0530 Subject: [PATCH 04/11] Refactor authentication middleware for improved error handling and organization isolation checks --- .../src/controllers/authController.js | 101 ++++++++++-------- .../src/middlewares/authMiddleware.js | 98 ++++++++--------- .../src/middlewares/ensureAuthenticated.js | 41 +++---- .../src/middlewares/passportConfig.js | 2 +- .../developer-portal/src/utils/tokenUtil.js | 5 +- 5 files changed, 120 insertions(+), 127 deletions(-) diff --git a/portals/developer-portal/src/controllers/authController.js b/portals/developer-portal/src/controllers/authController.js index 4ea9facc3..f38662bc6 100644 --- a/portals/developer-portal/src/controllers/authController.js +++ b/portals/developer-portal/src/controllers/authController.js @@ -25,9 +25,7 @@ const { config } = require('../config/configLoader'); const constants = require('../utils/constants'); const util = require('../utils/util'); const orgDao = require('../dao/organizationDao'); -const minimatch = require('minimatch'); const { validationResult } = require('express-validator'); -const { renderGivenTemplate } = require('../utils/util'); const { trackLoginTrigger, trackLogoutTrigger } = require('../utils/telemetryUtil'); const { extractPlatformJwtClaims } = require('../utils/platformJwt'); @@ -74,23 +72,13 @@ const login = async (req, res, next) => { const handleCallback = async (req, res, next) => { if (!config.identityProvider?.clientId) return next(); const rules = util.validateRequestParameters(); - const validationPromises = rules.map(validation => validation.run(req)); - Promise.all(validationPromises) - .then(() => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json(util.getErrors(errors)); - } - }) - .catch(error => { - logger.error("Error validating request parameters", { - error: error.message, - path: req.path, - method: req.method, - params: req.params - }); - return res.status(500).json({ message: 'Internal Server Error' }); - }); + for (const validation of rules) { + await validation.run(req); + } + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json(util.getErrors(errors)); + } await passport.authenticate( 'oauth2', { @@ -98,7 +86,7 @@ const handleCallback = async (req, res, next) => { }, (err, user) => { if (err || !user) { - if (err.name === 'AuthorizationError' && err.code === 'login_required') { + if (err?.name === 'AuthorizationError' && err?.code === 'login_required') { return res.redirect(req.session.returnTo); } else { return next(err || new Error('Authentication failed')); @@ -110,14 +98,18 @@ const handleCallback = async (req, res, next) => { } res.set('Cache-Control', 'no-store'); let returnTo = req.user.returnTo; - if (!config.advanced.disableOrgCallback && returnTo == null) { - returnTo = `/${req.params.orgName}`; + if (!config.advanced?.disableOrgCallback && returnTo == null) { + returnTo = `/${req.params.orgName}`; + } + returnTo = returnTo || `/${req.params.orgName}`; + delete req.session.returnTo; + // todo: track login success + req.session.save((saveErr) => { + if (saveErr) { + logger.error('Session save failed after login', { error: saveErr.message }); } - delete req.session.returnTo; - // todo: track login success - req.session.save(() => { - res.redirect(returnTo); - }) + res.redirect(returnTo); + }); }); })(req, res, next); }; @@ -132,7 +124,7 @@ const handleSignUp = async (req, res) => { return res.status(400).json(util.getErrors(errors)); } const authJsonContent = config.identityProvider; - if (authJsonContent.signUpURL) { + if (authJsonContent?.signUpURL) { res.redirect(authJsonContent.signUpURL); } else { const returnTo = req.session.returnTo || `/${req.params.orgName}`; @@ -168,7 +160,10 @@ const handleLogOut = async (req, res) => { }); } logUserAction('USER_LOGOUT', req, { orgName: req.params.orgName }); - req.session.destroy(() => { + req.session.destroy((destroyErr) => { + if (destroyErr) { + logger.error('Session destroy failed on local-auth logout', { error: destroyErr.message }); + } res.set('Cache-Control', 'no-store'); res.redirect(req.originalUrl.replace('/logout', '/login')); }); @@ -176,7 +171,7 @@ const handleLogOut = async (req, res) => { } else if (req.user && req.user.accessToken) { const referer = req.get('referer'); const regex = /(.+\/views\/[^\/]+)\/?/; - const match = referer.match(regex); + const match = referer ? referer.match(regex) : null; const logoutURL = match ? match[1] : null; req.logout((err) => { if (err) { @@ -193,7 +188,12 @@ const handleLogOut = async (req, res) => { }); trackLogoutTrigger({ orgName: req.params.orgName }, req); req.session.currentPathURI = currentPathURI; - res.redirect(`${authJsonContent.logoutURL}?post_logout_redirect_uri=${authJsonContent.logoutRedirectURI}&id_token_hint=${idToken}`); + req.session.save((saveErr) => { + if (saveErr) { + logger.error('Session save failed before IDP logout redirect', { error: saveErr.message }); + } + res.redirect(`${authJsonContent.logoutURL}?post_logout_redirect_uri=${authJsonContent.logoutRedirectURI}&id_token_hint=${idToken}`); + }); }); } else { // Unauthenticated or session already gone — original behaviour @@ -202,25 +202,31 @@ const handleLogOut = async (req, res) => { }; const handleLogOutLanding = async (req, res) => { - const currentPathURI = req.session.currentPathURI; - req.session.destroy(); - res.redirect(currentPathURI); + const currentPathURI = req.session.currentPathURI || '/'; + req.session.destroy((err) => { + if (err) { + logger.error('Session destroy failed on logout landing', { error: err.message }); + } + res.redirect(currentPathURI); + }); } const handleSilentSSO = async (req, res, next) => { - // Skip if no IDP configured or silent SSO is disabled if (!config.identityProvider?.clientId || config.advanced?.disableSilentSSO) return next(); - await req.session.save((err) => { - req.session.returnTo = req.originalUrl; + if (req.isAuthenticated() || req.session.silentAuthRedirected) { + return next(); + } - if (req.isAuthenticated() || req.session.silentAuthRedirected) { + req.session.returnTo = req.originalUrl; + req.session.silentAuthRedirected = true; + await req.session.save((err) => { + if (err) { + logger.error('Session save failed during silent SSO', { error: err.message }); return next(); - } else { - passport.authenticate('oauth2', { prompt: 'none' })(req, res, () => { }); - req.session.silentAuthRedirected = true; } + passport.authenticate('oauth2', { prompt: 'none' })(req, res, next); }); }; @@ -271,9 +277,9 @@ const handleLocalLogin = async (req, res) => { return res.redirect(`${baseUrl}/login?error=Login+failed%2C+please+try+again`); } - const adminRole = config.identityProvider.adminRole || 'admin'; - const superAdminRole = config.identityProvider.superAdminRole || 'superAdmin'; - const subscriberRole = config.identityProvider.subscriberRole || 'Internal/subscriber'; + const adminRole = config.identityProvider?.adminRole || 'admin'; + const superAdminRole = config.identityProvider?.superAdminRole || 'superAdmin'; + const subscriberRole = config.identityProvider?.subscriberRole || 'Internal/subscriber'; // Users with any _manage scope are treated as admins in the devportal const isAdmin = claims.scopes.some(s => s.endsWith('_manage')); const roles = isAdmin ? [adminRole] : [subscriberRole]; @@ -321,7 +327,12 @@ const handleLocalLogin = async (req, res) => { } res.set('Cache-Control', 'no-store'); const redirectTo = returnTo || baseUrl; - req.session.save(() => res.redirect(redirectTo)); + req.session.save((saveErr) => { + if (saveErr) { + logger.error('Session save failed after local login', { error: saveErr.message }); + } + res.redirect(redirectTo); + }); }); }); }; diff --git a/portals/developer-portal/src/middlewares/authMiddleware.js b/portals/developer-portal/src/middlewares/authMiddleware.js index f7d27fd5d..d7c441818 100644 --- a/portals/developer-portal/src/middlewares/authMiddleware.js +++ b/portals/developer-portal/src/middlewares/authMiddleware.js @@ -40,17 +40,6 @@ const { extractPlatformJwtClaims } = require('../utils/platformJwt'); const { accessTokenPresent, refreshAccessToken, verifyWithCertificate, resolveOrgIdp } = require('../utils/tokenUtil'); const orgDao = require('../dao/organizationDao'); -const DEFAULT_TOKEN_REFRESH_TIMEOUT_MS = 10000; - -function resolveTokenRefreshTimeoutMs() { - const timeout = Number(config.identityProvider?.tokenRefreshTimeoutMs); - if (Number.isFinite(timeout) && timeout > 0) { - return timeout; - } - return DEFAULT_TOKEN_REFRESH_TIMEOUT_MS; -} - - async function verifyJwksWithRefresh(token, jwksURL, req) { try { const jwks = await createRemoteJWKSet(new URL(jwksURL)); @@ -87,7 +76,7 @@ async function verifyJwksWithRefresh(token, jwksURL, req) { } async function verifyBearerToken(token, req) { - const idp = await resolveOrgIdp(req); + const idp = resolveOrgIdp(); if (!idp || !idp.clientId) { // Local auth mode: verify Platform API JWT with shared secret when configured. const jwtSecret = config.platformApi?.jwtSecret; @@ -104,13 +93,42 @@ async function verifyBearerToken(token, req) { return { valid: false, scopes: '' }; } -function checkOrgMembership(req) { - if (!req.user) return true; - const tokenOrg = req.user[constants.ROLES.ORGANIZATION_CLAIM]; - const targetOrg = req.user[constants.ORG_IDENTIFIER]; - if (!targetOrg || tokenOrg === targetOrg) return true; - const authorizedOrgs = req.user.authorizedOrgs; - return Array.isArray(authorizedOrgs) && authorizedOrgs.includes(targetOrg); +/** + * Verifies that `orgClaim` (from the token or session) matches the + * ORGANIZATION_IDENTIFIER of the org identified by `pathOrgId`. + * Returns an Error (with .status set) on failure, null on success. + */ +async function checkOrgIsolation(pathOrgId, orgClaim) { + if (!orgClaim) { + const err = new Error('Token org does not match requested organization'); + err.status = 403; + return err; + } + let orgDetails; + try { + orgDetails = await orgDao.get(pathOrgId); + } catch (e) { + logger.error('Org lookup failed during isolation check', { error: e.message, pathOrgId }); + const err = new Error('Internal Server Error'); + err.status = 500; + return err; + } + if (!orgDetails) { + const err = new Error('Organization not found'); + err.status = 404; + return err; + } + if (orgClaim !== orgDetails.ORGANIZATION_IDENTIFIER) { + logger.warn('Org isolation mismatch', { + pathOrgId, + orgIdentifier: orgDetails.ORGANIZATION_IDENTIFIER, + orgClaim, + }); + const err = new Error('Token org does not match requested organization'); + err.status = 403; + return err; + } + return null; } /** @@ -143,21 +161,10 @@ async function authResolver(req, res, next) { const orgIDClaim = config.identityProvider?.orgIDClaim; if (pathOrgId && orgIDClaim) { const sessionOrgClaim = req.user[constants.ROLES.ORGANIZATION_CLAIM]; - if (sessionOrgClaim) { - try { - const orgDetails = await orgDao.get(pathOrgId); - const orgIdentifier = orgDetails?.ORGANIZATION_IDENTIFIER; - if (orgIdentifier && sessionOrgClaim !== orgIdentifier) { - logger.warn('Session org mismatch', { pathOrgId, orgIdentifier, sessionOrgClaim }); - const err = new Error('Token org does not match requested organization'); - err.status = 403; - return next(err); - } - } catch (e) { - // org not found — let the handler return 404 - } - } + const isolationErr = await checkOrgIsolation(pathOrgId, sessionOrgClaim); + if (isolationErr) return next(isolationErr); } + req[constants.USER_ID] = req.user[constants.USER_ID]; req.auth = { mode: 'oauth2', preauthorized: true, @@ -170,11 +177,6 @@ async function authResolver(req, res, next) { // 3. Bearer token (session-attached or Authorization header) const token = accessTokenPresent(req); if (token) { - if (!checkOrgMembership(req)) { - const err = new Error('Authentication required'); - err.status = 401; - return next(err); - } const { valid, scopes } = await verifyBearerToken(token, req); if (!valid) { const err = new Error('Authentication required'); @@ -191,24 +193,8 @@ async function authResolver(req, res, next) { const orgIDClaim = config.identityProvider?.orgIDClaim; if (pathOrgId && config.identityProvider?.clientId && orgIDClaim) { const tokenOrgClaim = decoded[orgIDClaim]; - if (tokenOrgClaim) { - try { - const orgDetails = await orgDao.get(pathOrgId); - const orgIdentifier = orgDetails?.ORGANIZATION_IDENTIFIER; - if (orgIdentifier && tokenOrgClaim !== orgIdentifier) { - logger.warn('Bearer token org mismatch', { - pathOrgId, - orgIdentifier, - tokenOrgClaim, - }); - const err = new Error('Token org does not match requested organization'); - err.status = 403; - return next(err); - } - } catch (e) { - // org not found — let the handler return 404 - } - } + const isolationErr = await checkOrgIsolation(pathOrgId, tokenOrgClaim); + if (isolationErr) return next(isolationErr); } req[constants.USER_ID] = decoded[constants.USER_ID]; req.auth = { diff --git a/portals/developer-portal/src/middlewares/ensureAuthenticated.js b/portals/developer-portal/src/middlewares/ensureAuthenticated.js index 89cf1b229..a14b81651 100644 --- a/portals/developer-portal/src/middlewares/ensureAuthenticated.js +++ b/portals/developer-portal/src/middlewares/ensureAuthenticated.js @@ -49,11 +49,11 @@ function enforceSecurity(scope) { } const token = accessTokenPresent(req); if (token) { - if (req.user && req.user[constants.ROLES.ORGANIZATION_CLAIM] !== req.user[constants.ORG_IDENTIFIER]) { + if (req.user && req.user[constants.ORG_IDENTIFIER] && req.user[constants.ROLES.ORGANIZATION_CLAIM] !== req.user[constants.ORG_IDENTIFIER]) { const authorizedOrgs = req.user.authorizedOrgs; if ((authorizedOrgs && !(authorizedOrgs.includes(req.user[constants.ORG_IDENTIFIER]))) || !authorizedOrgs) { - const err = new Error('Authentication required'); - err.status = 401; + const err = new Error('Forbidden'); + err.status = 403; return next(err); } } @@ -98,9 +98,9 @@ const ensurePermission = (currentPage, role, req) => { } const ensureAuthenticated = async (req, res, next) => { - let adminRole = config.identityProvider.adminRole; - let superAdminRole = config.identityProvider.superAdminRole; - let subscriberRole = config.identityProvider.subscriberRole; + let adminRole = config.identityProvider?.adminRole; + let superAdminRole = config.identityProvider?.superAdminRole; + let subscriberRole = config.identityProvider?.subscriberRole; const rules = util.validateRequestParameters(); for (let validation of rules) { await validation.run(req); @@ -125,9 +125,9 @@ const ensureAuthenticated = async (req, res, next) => { let orgDetails; if (orgID !== undefined) { orgDetails = await orgDao.get(orgID); - adminRole = orgDetails.ADMIN_ROLE || adminRole; - superAdminRole = orgDetails.SUPER_ADMIN_ROLE || superAdminRole; - subscriberRole = orgDetails.SUBSCRIBER_ROLE || subscriberRole; + adminRole = orgDetails?.ADMIN_ROLE || adminRole; + superAdminRole = orgDetails?.SUPER_ADMIN_ROLE || superAdminRole; + subscriberRole = orgDetails?.SUBSCRIBER_ROLE || subscriberRole; } let role; logger.debug("Request authentication status", { isAuthenticated: req.isAuthenticated() }); @@ -159,7 +159,7 @@ const ensureAuthenticated = async (req, res, next) => { const token = accessTokenPresent(req); if (token) { const decodedAccessToken = jwt.decode(token); - req[constants.USER_ID] = decodedAccessToken[constants.USER_ID]; + req[constants.USER_ID] = decodedAccessToken?.[constants.USER_ID]; } if (config.authorizedPages.some(pattern => minimatch.minimatch(req.originalUrl, pattern))) { role = req.user[constants.ROLES.ROLE_CLAIM]; @@ -177,8 +177,8 @@ const ensureAuthenticated = async (req, res, next) => { const orgIdentifier = orgDetails?.ORGANIZATION_IDENTIFIER; const tokenOrgClaim = req.user[constants.ROLES.ORGANIZATION_CLAIM]; if (orgIdentifier && tokenOrgClaim && tokenOrgClaim !== orgIdentifier) { - const err = new Error('Authentication required'); - err.status = 401; + const err = new Error('Forbidden'); + err.status = 403; return next(err); } } @@ -186,7 +186,7 @@ const ensureAuthenticated = async (req, res, next) => { if (ensurePermission(req.originalUrl, role, req)) { return next(); } else { - return res.send("User unauthorized"); + return res.status(403).send("User unauthorized"); } } } @@ -205,12 +205,6 @@ const ensureAuthenticated = async (req, res, next) => { }); } } else { - if (req.isAuthenticated() && !(req.user?.isLocalAuth && !config.identityProvider?.clientId)) { - const token = accessTokenPresent(req); - if (token && config.identityProvider.jwksURL) { - await validateWithJwks(token, config.identityProvider.jwksURL, req); - } - } return next(); }; }; @@ -247,9 +241,6 @@ function validateAuthentication(scope) { if (String(scopes || '').split(' ').includes(scope)) { return next(); } - if (req.user) { - return res.redirect('login'); - } return util.handleError(res, new CustomError(403, constants.ERROR_CODE[403], constants.ERROR_MESSAGE.FORBIDDEN)); } if (req.user) { @@ -262,7 +253,10 @@ function validateAuthentication(scope) { const validateWithJwks = async (token, jwksURL, req) => { try { const jwks = await createRemoteJWKSet(new URL(jwksURL)); - const { payload } = await jwtVerify(token, jwks); + const jwtVerifyOptions = {}; + if (config.identityProvider?.issuer) jwtVerifyOptions.issuer = config.identityProvider.issuer; + if (config.identityProvider?.audience) jwtVerifyOptions.audience = config.identityProvider.audience; + const { payload } = await jwtVerify(token, jwks, jwtVerifyOptions); return { valid: true, scopes: payload.scope || '' }; } catch (err) { logger.error("Invalid token", { error: err.message, stack: err.stack, operation: "tokenValidation" }); @@ -274,7 +268,6 @@ const validateWithJwks = async (token, jwksURL, req) => { req.user[constants.REFRESH_TOKEN] = response.refresh_token; return { valid: true, scopes: response.scope || '' }; } catch (error) { - req.user = null; logger.error("Error refreshing access token", { error: error.message, stack: error.stack, operation: "refreshToken" }); return { valid: false, scopes: '' }; } diff --git a/portals/developer-portal/src/middlewares/passportConfig.js b/portals/developer-portal/src/middlewares/passportConfig.js index 776e8aba5..9239f2d5c 100644 --- a/portals/developer-portal/src/middlewares/passportConfig.js +++ b/portals/developer-portal/src/middlewares/passportConfig.js @@ -104,7 +104,7 @@ function configurePassport(SERVER_ID) { [constants.ROLES.GROUP_CLAIM]: groups, isAdmin, isSuperAdmin, - [constants.USER_ID]: decodedAccessToken[constants.USER_ID], + [constants.USER_ID]: decodedAccessToken?.[constants.USER_ID], serverId: SERVER_ID, imageURL, }; diff --git a/portals/developer-portal/src/utils/tokenUtil.js b/portals/developer-portal/src/utils/tokenUtil.js index 307320254..133776ea6 100644 --- a/portals/developer-portal/src/utils/tokenUtil.js +++ b/portals/developer-portal/src/utils/tokenUtil.js @@ -57,7 +57,10 @@ async function refreshAccessToken(refreshToken) { async function verifyWithCertificate(token, pemCertificate) { try { const publicKey = await importX509(pemCertificate, 'RS256'); - const { payload } = await jwtVerify(token, publicKey); + const jwtVerifyOptions = {}; + if (config.identityProvider?.issuer) jwtVerifyOptions.issuer = config.identityProvider.issuer; + if (config.identityProvider?.audience) jwtVerifyOptions.audience = config.identityProvider.audience; + const { payload } = await jwtVerify(token, publicKey, jwtVerifyOptions); return { valid: true, scopes: payload.scope || '' }; } catch (err) { logger.error('Bearer token cert validation failed', { error: err.message, operation: 'verifyWithCertificate' }); From 95c102c10c05d31dc38e0bc8fab103f7b4f5eec0 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 21 Jun 2026 17:18:41 +0530 Subject: [PATCH 05/11] Add IDP authentication guide and next steps for API testing --- portals/developer-portal/docs/README.md | 6 +- .../docs/administer/api-token-curl.md | 151 ++++++++++++++++++ .../docs/administer/asgardeo-setup.md | 6 + 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 portals/developer-portal/docs/administer/api-token-curl.md diff --git a/portals/developer-portal/docs/README.md b/portals/developer-portal/docs/README.md index 8b178672c..b0b055b52 100644 --- a/portals/developer-portal/docs/README.md +++ b/portals/developer-portal/docs/README.md @@ -7,7 +7,7 @@ The Developer Portal is a self-hosted, multi-tenant web application where API pu | Section | Audience | What you'll find | |---|---|---| | [Introduction](introduction/what-is-developer-portal.md) | Everyone | Overview, quick start, and core concepts | -| [Administer](administer/manage-organizations.md) | Admins / Operators | Organizations, views, subscription plans, gateway and key manager integration, theming, design mode | +| [Administer](administer/manage-organizations.md) | Admins / Operators | Organizations, views, subscription plans, gateway and key manager integration, theming, design mode, IDP authentication | | [Publish APIs](publish-apis/publishing-apis.md) | API Publishers / Admins | Registering APIs, uploading definitions and docs, managing API workflows | | [Discover APIs](discover-apis/search-apis.md) | Developers | Searching the API catalog, reading documentation, AI agent discovery | | [Consume an API](consume-an-api/subscriptions.md) | Developers | Subscribing to APIs, generating API keys and OAuth2 credentials | @@ -24,6 +24,10 @@ The Developer Portal is a self-hosted, multi-tenant web application where API pu 6. [Key Manager Integration](administer/key-manager-integration.md) 7. [Publish APIs](publish-apis/publishing-apis.md) +**Configuring IDP authentication (Asgardeo)** +1. [Asgardeo Setup](administer/asgardeo-setup.md) +2. [Get a Bearer Token via curl](administer/api-token-curl.md) + **As a developer consuming APIs** 1. [What is the Developer Portal?](introduction/what-is-developer-portal.md) 2. [Core Concepts](introduction/concepts.md) diff --git a/portals/developer-portal/docs/administer/api-token-curl.md b/portals/developer-portal/docs/administer/api-token-curl.md new file mode 100644 index 000000000..a3318e2f2 --- /dev/null +++ b/portals/developer-portal/docs/administer/api-token-curl.md @@ -0,0 +1,151 @@ +# Getting a Bearer Token via curl (IDP Mode) + +When the Developer Portal is configured with an external IDP (e.g. Asgardeo), REST API calls to `/o/{orgId}/devportal/v1/*` must include an `Authorization: Bearer ` header. This guide shows how to obtain that token from the terminal without a browser. + +## Prerequisites + +- IDP is configured (`identityProvider.clientId` is set in `config.yaml`) +- The `dp:*` scopes are registered in the IDP and assigned to your user (see [asgardeo-setup.md](asgardeo-setup.md) sections 3–4) +- You have the **client ID** and **client secret** from your IDP application +- You know your org's UUID (the `ORG_ID` column in the `DP_ORGANIZATION` table, or ask the admin) + +--- + +## Flow: Authorization Code + PKCE + +The devportal application is a confidential Traditional Web App — it uses authorization code flow with PKCE and a client secret. You need to: + +1. Generate a PKCE code verifier and challenge +2. Open the authorization URL (paste into browser or use a redirect capture trick) +3. Exchange the authorization code for a token + +--- + +## Step 1 — Generate PKCE values + +```bash +# Code verifier: 43–128 random URL-safe characters +CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-64) + +# Code challenge: SHA-256 of the verifier, base64url-encoded +CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=') + +echo "CODE_VERIFIER=$CODE_VERIFIER" +echo "CODE_CHALLENGE=$CODE_CHALLENGE" +``` + +--- + +## Step 2 — Start a local redirect listener + +The IDP will redirect back to a callback URI with the authorization code. Use `nc` to capture it: + +```bash +PORT=8080 +nc -l $PORT & +NC_PID=$! +``` + +> **Note:** Register `http://localhost:8080` as an authorized redirect URI in your IDP application before proceeding. + +--- + +## Step 3 — Build the authorization URL and open it + +Set your IDP and application values: + +```bash +TENANT= # e.g. dev1234 +CLIENT_ID= +ORG_IDENTIFIER= # ORGANIZATION_IDENTIFIER value, e.g. "sub" +STATE=$(openssl rand -hex 16) + +AUTH_URL="https://api.asgardeo.io/t/${TENANT}/oauth2/authorize\ +?response_type=code\ +&client_id=${CLIENT_ID}\ +&redirect_uri=http://localhost:${PORT}\ +&scope=openid%20profile%20email%20dp:api_manage%20dp:app_manage%20dp:org_manage%20dp:subscription_manage\ +&code_challenge=${CODE_CHALLENGE}\ +&code_challenge_method=S256\ +&state=${STATE}\ +&org=${ORG_IDENTIFIER}" + +echo "Open this URL in your browser:" +echo "$AUTH_URL" +``` + +Open the URL in a browser, log in, and approve. The browser is redirected to `http://localhost:8080?code=...&state=...`. The `nc` process captures the raw HTTP request. + +--- + +## Step 4 — Extract the authorization code + +From the `nc` output, copy the `code` parameter value: + +```bash +# nc prints something like: +# GET /?code=abc123xyz&state=... HTTP/1.1 + +CODE= +kill $NC_PID 2>/dev/null +``` + +--- + +## Step 5 — Exchange the code for a token + +```bash +TOKEN_URL="https://api.asgardeo.io/t/${TENANT}/oauth2/token" +CLIENT_SECRET= + +RESPONSE=$(curl -s -X POST "$TOKEN_URL" \ + -u "${CLIENT_ID}:${CLIENT_SECRET}" \ + -d "grant_type=authorization_code" \ + -d "code=${CODE}" \ + -d "redirect_uri=http://localhost:${PORT}" \ + -d "code_verifier=${CODE_VERIFIER}") + +echo "$RESPONSE" | jq . + +TOKEN=$(echo "$RESPONSE" | jq -r '.access_token') +echo "TOKEN=$TOKEN" +``` + +--- + +## Step 6 — Call the API + +```bash +ORG_UUID= # ORG_ID from the DP_ORGANIZATION table, e.g. 65789d2d-0238-412a-995c-5ce74c82e169 +BASE="https://localhost:3000/o/${ORG_UUID}/devportal/v1" + +# List APIs +curl -sk "${BASE}/apis" -H "Authorization: Bearer $TOKEN" | jq . + +# List applications +curl -sk "${BASE}/applications" -H "Authorization: Bearer $TOKEN" | jq . + +# Create an application +curl -sk -X POST "${BASE}/applications" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "My CLI App", "description": "Created via API", "type": "WEB"}' | jq . +``` + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `403 Token org does not match requested organization` | Token's `org_name` claim doesn't match the org UUID's `ORGANIZATION_IDENTIFIER` | Make sure you logged in with `org=` in the auth URL and are using the matching org UUID | +| `403 Forbidden` (scope error) | Token is missing required `dp:*` scopes | Complete [asgardeo-setup.md](asgardeo-setup.md) sections 3–4: register scopes and assign role to your user | +| `401 Authentication required` | Token expired or invalid | Re-run steps 1–5 to get a fresh token | +| Token has no `dp:*` scopes | Role not assigned to user in the sub-org | In Asgardeo console, go to the sub-org → Users → assign the `dp_admin` role | +| `nc` gets no output | Redirect URI not registered in IDP | Add `http://localhost:8080` to authorized redirect URIs in the Asgardeo application | + +--- + +## Token lifetime + +Asgardeo access tokens typically expire in 3600 seconds (1 hour). Re-run steps 1–5 to get a new token. The devportal also supports refresh tokens — but from the terminal, it's simpler to just re-authenticate. diff --git a/portals/developer-portal/docs/administer/asgardeo-setup.md b/portals/developer-portal/docs/administer/asgardeo-setup.md index 8a17071ae..3725f2286 100644 --- a/portals/developer-portal/docs/administer/asgardeo-setup.md +++ b/portals/developer-portal/docs/administer/asgardeo-setup.md @@ -205,3 +205,9 @@ If you are also running ai-workspace and platform-api with Asgardeo, the setups | **platform-api** | — (validates tokens via JWKS; same `ap:*` scopes as ai-workspace) | — | — | Each application is registered separately in Asgardeo with its own client ID and scopes. + +--- + +## Next Steps + +- [Get a Bearer Token via curl](api-token-curl.md) — test the devportal REST API from the terminal once IDP is set up From 8e134876d013dda96c581b5fcb44390c334cd418 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 21 Jun 2026 18:13:20 +0530 Subject: [PATCH 06/11] Add organization ID handling and improve API calls for organization context --- .../src/controllers/applicationsContentController.js | 1 + .../developer-portal/src/defaultContent/layout/main.hbs | 2 +- portals/developer-portal/src/middlewares/authMiddleware.js | 7 +++++++ .../src/middlewares/ensureAuthenticated.js | 6 +++--- .../developer-portal/src/scripts/add-application-form.js | 2 +- portals/developer-portal/src/scripts/common.js | 6 ++++-- .../developer-portal/src/scripts/edit-application-form.js | 2 +- .../developer-portal/src/scripts/oauth2-key-generation.js | 6 +++--- portals/developer-portal/src/scripts/warning.js | 2 +- portals/developer-portal/src/services/apiFlowService.js | 4 +++- .../developer-portal/src/services/apiMetadataService.js | 1 - 11 files changed, 25 insertions(+), 14 deletions(-) diff --git a/portals/developer-portal/src/controllers/applicationsContentController.js b/portals/developer-portal/src/controllers/applicationsContentController.js index 8f1402293..84f811311 100644 --- a/portals/developer-portal/src/controllers/applicationsContentController.js +++ b/portals/developer-portal/src/controllers/applicationsContentController.js @@ -231,6 +231,7 @@ const loadApplications = async (req, res) => { } templateContent = { + orgID, applicationsMetadata: metaData, baseUrl: '/' + orgName + constants.ROUTE.VIEWS_PATH + viewName, profile: req.isAuthenticated() ? profile : null, diff --git a/portals/developer-portal/src/defaultContent/layout/main.hbs b/portals/developer-portal/src/defaultContent/layout/main.hbs index 890f29eef..3a4cc7f24 100644 --- a/portals/developer-portal/src/defaultContent/layout/main.hbs +++ b/portals/developer-portal/src/defaultContent/layout/main.hbs @@ -15,7 +15,7 @@ {{{slots.head}}} diff --git a/portals/developer-portal/src/middlewares/authMiddleware.js b/portals/developer-portal/src/middlewares/authMiddleware.js index d7c441818..b80610301 100644 --- a/portals/developer-portal/src/middlewares/authMiddleware.js +++ b/portals/developer-portal/src/middlewares/authMiddleware.js @@ -98,7 +98,14 @@ async function verifyBearerToken(token, req) { * ORGANIZATION_IDENTIFIER of the org identified by `pathOrgId`. * Returns an Error (with .status set) on failure, null on success. */ +const ORG_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + async function checkOrgIsolation(pathOrgId, orgClaim) { + if (!ORG_UUID_RE.test(pathOrgId)) { + const err = new Error('Invalid organization ID: must be a UUID'); + err.status = 400; + return err; + } if (!orgClaim) { const err = new Error('Token org does not match requested organization'); err.status = 403; diff --git a/portals/developer-portal/src/middlewares/ensureAuthenticated.js b/portals/developer-portal/src/middlewares/ensureAuthenticated.js index a14b81651..ba6b25118 100644 --- a/portals/developer-portal/src/middlewares/ensureAuthenticated.js +++ b/portals/developer-portal/src/middlewares/ensureAuthenticated.js @@ -192,11 +192,11 @@ const ensureAuthenticated = async (req, res, next) => { } return next(); } else { - await req.session.save(async (err) => { + req.session.returnTo = req.originalUrl || `/${req.params.orgName}`; + req.session.save((err) => { if (err) { - return res.status(500).send('Internal Server Error'); + logger.error('Session save failed before login redirect', { error: err.message }); } - req.session.returnTo = req.originalUrl || `/${req.params.orgName}`; if (req.params.orgName) { res.redirect(`/${req.params.orgName}/views/${req.params.viewName}/login`); } else { diff --git a/portals/developer-portal/src/scripts/add-application-form.js b/portals/developer-portal/src/scripts/add-application-form.js index ad31ca24a..f9a00e73d 100644 --- a/portals/developer-portal/src/scripts/add-application-form.js +++ b/portals/developer-portal/src/scripts/add-application-form.js @@ -152,7 +152,7 @@ applicationForm.addEventListener('submit', async (e) => { showCreateButtonLoading(saveButton); try { - const response = await fetch(devportalApi.root('/applications'), { + const response = await fetch(devportalApi.org(devportalApi.orgId, '/applications'), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/portals/developer-portal/src/scripts/common.js b/portals/developer-portal/src/scripts/common.js index 75378b1dc..9a7782cee 100644 --- a/portals/developer-portal/src/scripts/common.js +++ b/portals/developer-portal/src/scripts/common.js @@ -6,6 +6,8 @@ (function () { var cfg = window.__DEVPORTAL_API__ || { base: 'devportal', version: 'v1' }; window.devportalApi = { + // Current page's org UUID, injected by the layout (window.__DEVPORTAL_API__.orgId). + orgId: cfg.orgId || '', // Org-scoped resource: org('abc', '/subscriptions') => '/o/abc/devportal/v1/subscriptions' org: function (orgId, path) { return '/o/' + encodeURIComponent(orgId) + '/' + cfg.base + '/' + cfg.version + (path || ''); @@ -483,7 +485,7 @@ document.addEventListener("DOMContentLoaded", function () { // Function to create application directly via API async function createApplicationDirectly(name, description = '') { try { - const response = await fetch(devportalApi.root('/applications'), { + const response = await fetch(devportalApi.org(devportalApi.orgId, '/applications'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -778,7 +780,7 @@ document.addEventListener("DOMContentLoaded", function () { // Function to create application directly via API async function createApplicationDirectly(name, description = '') { try { - const response = await fetch(devportalApi.root('/applications'), { + const response = await fetch(devportalApi.org(devportalApi.orgId, '/applications'), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/portals/developer-portal/src/scripts/edit-application-form.js b/portals/developer-portal/src/scripts/edit-application-form.js index e46e9e182..496571c05 100644 --- a/portals/developer-portal/src/scripts/edit-application-form.js +++ b/portals/developer-portal/src/scripts/edit-application-form.js @@ -146,7 +146,7 @@ document.addEventListener('DOMContentLoaded', () => { errorElement.style.display = 'none'; } - const response = await fetch(devportalApi.root(`/applications/${applicationId}`), { + const response = await fetch(devportalApi.org(devportalApi.orgId, `/applications/${applicationId}`), { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/portals/developer-portal/src/scripts/oauth2-key-generation.js b/portals/developer-portal/src/scripts/oauth2-key-generation.js index cdfe5df5b..6331e6a30 100644 --- a/portals/developer-portal/src/scripts/oauth2-key-generation.js +++ b/portals/developer-portal/src/scripts/oauth2-key-generation.js @@ -85,7 +85,7 @@ async function generateApplicationKey(formId, appId, keyType, keyManager, client "additionalProperties": jsonObject.additionalProperties, }) try { - const response = await fetch(`/devportal/applications/${appId}/generate-keys`, { + const response = await fetch(devportalApi.org(orgID, `/applications/${appId}/generate-keys`), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -416,7 +416,7 @@ async function selectKmAndGenerate(keyType, appId, appName, orgID, itemEl) { }); try { - const response = await fetch(`/devportal/applications/${appId}/generate-keys`, { + const response = await fetch(devportalApi.org(orgID, `/applications/${appId}/generate-keys`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, @@ -519,7 +519,7 @@ async function saveInlineKeyConfig(kmId, keyType, appId, keyManager, keyMappingI }; try { - const response = await fetch(`/devportal/applications/${appId}/oauth-keys/${keyMappingId}`, { + const response = await fetch(devportalApi.org(devportalApi.orgId, `/applications/${appId}/oauth-keys/${keyMappingId}`), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: payload, diff --git a/portals/developer-portal/src/scripts/warning.js b/portals/developer-portal/src/scripts/warning.js index 3f5fdbe42..d5e49e84d 100644 --- a/portals/developer-portal/src/scripts/warning.js +++ b/portals/developer-portal/src/scripts/warning.js @@ -182,7 +182,7 @@ async function deleteApplication() { ' Deleting...'; trashButton.disabled = true; } - const response = await fetch(devportalApi.root(`/applications/${applicationId}`), { + const response = await fetch(devportalApi.org(devportalApi.orgId, `/applications/${applicationId}`), { method: 'DELETE', }); if (response.ok) { diff --git a/portals/developer-portal/src/services/apiFlowService.js b/portals/developer-portal/src/services/apiFlowService.js index 732732fab..9034840b2 100644 --- a/portals/developer-portal/src/services/apiFlowService.js +++ b/portals/developer-portal/src/services/apiFlowService.js @@ -17,6 +17,7 @@ */ const apiFlowDao = require('../dao/apiFlowDao'); const viewDao = require('../dao/viewDao'); +const orgDao = require('../dao/organizationDao'); const sequelize = require('../db/sequelizeConfig'); const { UniqueConstraintError } = require('sequelize'); const logger = require('../config/logger'); @@ -182,12 +183,13 @@ const createAPIFlow = async (req, res) => { if (resolvedContentType !== 'MD' && resolvedContent === null) { return res.status(400).json({ message: 'Invalid API flow definition: content could not be parsed as valid JSON or YAML.' }); } + const orgDetails = await orgDao.get(orgID); const t = await sequelize.transaction(); try { const viewId = await resolveViewId(orgID, viewName); const resolvedPrompt = agentPrompt && agentPrompt.trim() ? agentPrompt.trim() - : generateAgentPrompt(name, description, [], req.params.orgName, viewName, '', resolvedHandle); + : generateAgentPrompt(name, description, [], orgDetails.ORGANIZATION_IDENTIFIER || '', viewName, '', resolvedHandle); const apiFlow = await apiFlowDao.create(orgID, viewId, { name, diff --git a/portals/developer-portal/src/services/apiMetadataService.js b/portals/developer-portal/src/services/apiMetadataService.js index 8c3d24a62..cd5d9c1a8 100644 --- a/portals/developer-portal/src/services/apiMetadataService.js +++ b/portals/developer-portal/src/services/apiMetadataService.js @@ -339,7 +339,6 @@ const getMetadataListFromDB = async (orgID, groups, searchTerm, tags, apiName, a }; const updateAPIMetadata = async (req, res) => { - //TODO: Get orgId from the orgName const { orgId, apiId } = req.params; logger.info('Updating API metadata', { orgId, From e572d167f2a7ded332f4a19085ab452d76828847 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 21 Jun 2026 18:20:34 +0530 Subject: [PATCH 07/11] Fix minor issues --- .../src/controllers/authController.js | 3 +-- .../src/controllers/devportalController.js | 20 +++++++++---------- .../src/middlewares/authMiddleware.js | 10 +++++----- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/portals/developer-portal/src/controllers/authController.js b/portals/developer-portal/src/controllers/authController.js index f38662bc6..db52142e5 100644 --- a/portals/developer-portal/src/controllers/authController.js +++ b/portals/developer-portal/src/controllers/authController.js @@ -164,7 +164,6 @@ const handleLogOut = async (req, res) => { if (destroyErr) { logger.error('Session destroy failed on local-auth logout', { error: destroyErr.message }); } - res.set('Cache-Control', 'no-store'); res.redirect(req.originalUrl.replace('/logout', '/login')); }); }); @@ -221,7 +220,7 @@ const handleSilentSSO = async (req, res, next) => { req.session.returnTo = req.originalUrl; req.session.silentAuthRedirected = true; - await req.session.save((err) => { + req.session.save((err) => { if (err) { logger.error('Session save failed during silent SSO', { error: err.message }); return next(); diff --git a/portals/developer-portal/src/controllers/devportalController.js b/portals/developer-portal/src/controllers/devportalController.js index 6ee52706d..cf6ba6ac3 100644 --- a/portals/developer-portal/src/controllers/devportalController.js +++ b/portals/developer-portal/src/controllers/devportalController.js @@ -66,19 +66,19 @@ function parseApplicationDataFromRequest(req) { // ***** Save Application ***** const listApplications = async (req, res) => { - const orgID = req.params.orgId; + const orgID = String(req.params.orgId || '').replace(/[\r\n]/g, ''); const userID = req.auth?.userId || req.user?.sub; try { const applications = await appDao.list(orgID, userID); return res.status(200).json(applications.map(a => new ApplicationDTO(a.dataValues))); } catch (error) { - logger.error('Error occurred while listing applications', { orgId: req.params.orgId, error: error.message, stack: error.stack }); + logger.error('Error occurred while listing applications', { orgId: orgID, error: error.message, stack: error.stack }); util.handleError(res, error); } }; const saveApplication = async (req, res) => { - const orgID = req.params.orgId; + const orgID = String(req.params.orgId || '').replace(/[\r\n]/g, ''); const userID = req.auth?.userId || req.user?.sub; try { const applicationData = parseApplicationDataFromRequest(req); @@ -87,7 +87,7 @@ const saveApplication = async (req, res) => { trackAppCreationEnd({ orgId: orgID, appName: applicationData.name, idpId: userID }, req); return res.status(201).json(new ApplicationDTO(application.dataValues)); } catch (error) { - logger.error('Error occurred while creating the application', { orgId: req.params.orgId, error: error.message, stack: error.stack }); + logger.error('Error occurred while creating the application', { orgId: orgID, error: error.message, stack: error.stack }); util.handleError(res, error); } }; @@ -95,7 +95,7 @@ const saveApplication = async (req, res) => { // ***** Update Application ***** const updateApplication = async (req, res) => { - const orgID = req.params.orgId; + const orgID = String(req.params.orgId || '').replace(/[\r\n]/g, ''); const userID = req.auth?.userId || req.user?.sub; try { const appID = req.params.applicationId; @@ -106,7 +106,7 @@ const updateApplication = async (req, res) => { } res.status(200).send(new ApplicationDTO(updatedApp[0].dataValues)); } catch (error) { - logger.error("Error occurred while updating the application", { orgId: req.params.orgId, error: error.message, stack: error.stack }); + logger.error("Error occurred while updating the application", { orgId: orgID, error: error.message, stack: error.stack }); util.handleError(res, error); } }; @@ -153,7 +153,7 @@ const revokeAppKeyMappings = async (orgID, appID) => { const deleteApplication = async (req, res) => { const userID = req.auth?.userId || req.user?.sub; const applicationId = req.params.applicationId; - const orgID = req.params.orgId; + const orgID = String(req.params.orgId || '').replace(/[\r\n]/g, ''); try { try { await revokeAppKeyMappings(orgID, applicationId); @@ -162,7 +162,7 @@ const deleteApplication = async (req, res) => { throw new Sequelize.EmptyResultError("Resource not found to delete"); } else { trackAppDeletion({ orgId: orgID, appId: applicationId, idpId: userID }, req); - res.status(200).send("Resouce Deleted Successfully"); + res.status(200).send("Resource Deleted Successfully"); } } catch (error) { if (error.statusCode === 404) { @@ -172,10 +172,10 @@ const deleteApplication = async (req, res) => { throw new Sequelize.EmptyResultError("Resource not found to delete"); } else { trackAppDeletion({ orgId: orgID, appId: applicationId, idpId: userID }, req); - return res.status(200).send("Resouce Deleted Successfully"); + return res.status(200).send("Resource Deleted Successfully"); } } - logger.error('Error occurred while deleting the application', { orgId: req.params.orgId, appId: applicationId, error: error.message, stack: error.stack }); + logger.error('Error occurred while deleting the application', { orgId: orgID, appId: applicationId, error: error.message, stack: error.stack }); util.handleError(res, error); } } catch (error) { diff --git a/portals/developer-portal/src/middlewares/authMiddleware.js b/portals/developer-portal/src/middlewares/authMiddleware.js index b80610301..8e0cfc5de 100644 --- a/portals/developer-portal/src/middlewares/authMiddleware.js +++ b/portals/developer-portal/src/middlewares/authMiddleware.js @@ -115,16 +115,16 @@ async function checkOrgIsolation(pathOrgId, orgClaim) { try { orgDetails = await orgDao.get(pathOrgId); } catch (e) { + if (e.name === 'SequelizeEmptyResultError') { + const err = new Error('Organization not found'); + err.status = 404; + return err; + } logger.error('Org lookup failed during isolation check', { error: e.message, pathOrgId }); const err = new Error('Internal Server Error'); err.status = 500; return err; } - if (!orgDetails) { - const err = new Error('Organization not found'); - err.status = 404; - return err; - } if (orgClaim !== orgDetails.ORGANIZATION_IDENTIFIER) { logger.warn('Org isolation mismatch', { pathOrgId, From a0c969ed8dd8d23974ef228b488c27452fa06efe Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 21 Jun 2026 21:11:02 +0530 Subject: [PATCH 08/11] Refactor createAPIFlow to initialize transaction only if orgDetails are retrieved --- portals/developer-portal/src/services/apiFlowService.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/portals/developer-portal/src/services/apiFlowService.js b/portals/developer-portal/src/services/apiFlowService.js index 9034840b2..6be997d09 100644 --- a/portals/developer-portal/src/services/apiFlowService.js +++ b/portals/developer-portal/src/services/apiFlowService.js @@ -183,9 +183,10 @@ const createAPIFlow = async (req, res) => { if (resolvedContentType !== 'MD' && resolvedContent === null) { return res.status(400).json({ message: 'Invalid API flow definition: content could not be parsed as valid JSON or YAML.' }); } - const orgDetails = await orgDao.get(orgID); - const t = await sequelize.transaction(); + let t; try { + const orgDetails = await orgDao.get(orgID); + t = await sequelize.transaction(); const viewId = await resolveViewId(orgID, viewName); const resolvedPrompt = agentPrompt && agentPrompt.trim() ? agentPrompt.trim() @@ -211,7 +212,7 @@ const createAPIFlow = async (req, res) => { status: apiFlow.STATUS }); } catch (error) { - await t.rollback(); + if (t) await t.rollback(); if (error instanceof UniqueConstraintError) { return res.status(409).json({ message: 'An API workflow with this handle already exists. Please use a different handle.' }); } From 596fae4b62ab3b001c544ff199b6ff6849bf4864 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Mon, 22 Jun 2026 09:16:39 +0530 Subject: [PATCH 09/11] Fix application creation --- portals/developer-portal/src/dao/applicationDao.js | 11 +++++++---- .../applications/partials/applications-listing.hbs | 7 ++----- .../src/scripts/oauth2-key-generation.js | 8 ++++---- portals/developer-portal/src/styles/applications.css | 9 --------- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/portals/developer-portal/src/dao/applicationDao.js b/portals/developer-portal/src/dao/applicationDao.js index e15523644..9889d8efe 100644 --- a/portals/developer-portal/src/dao/applicationDao.js +++ b/portals/developer-portal/src/dao/applicationDao.js @@ -40,7 +40,7 @@ const create = async (orgID, userID, appData) => { const update = async (orgID, appID, userID, appData) => { try { - const [updatedRowsCount, appContent] = await Application.update( + const [updatedRowsCount] = await Application.update( { NAME: appData.name, DESCRIPTION: appData.description, @@ -51,11 +51,14 @@ const update = async (orgID, appID, userID, appData) => { ORG_ID: orgID, APP_ID: appID, CREATED_BY: userID - }, - returning: true + } } ); - return [updatedRowsCount, appContent]; + if (!updatedRowsCount) { + return [updatedRowsCount, null]; + } + const updatedApp = await Application.findOne({ where: { ORG_ID: orgID, APP_ID: appID } }); + return [updatedRowsCount, [updatedApp]]; } catch (error) { if (error instanceof Sequelize.UniqueConstraintError) { throw error; diff --git a/portals/developer-portal/src/pages/applications/partials/applications-listing.hbs b/portals/developer-portal/src/pages/applications/partials/applications-listing.hbs index 2125a3dfd..aa4c01352 100644 --- a/portals/developer-portal/src/pages/applications/partials/applications-listing.hbs +++ b/portals/developer-portal/src/pages/applications/partials/applications-listing.hbs @@ -48,9 +48,6 @@

{{description}}