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/CLAUDE.md b/portals/developer-portal/CLAUDE.md deleted file mode 100644 index d26552f54..000000000 --- a/portals/developer-portal/CLAUDE.md +++ /dev/null @@ -1,137 +0,0 @@ -# Developer Portal — Claude Code Guide - -## Project overview - -Express.js + Handlebars (`.hbs`) server-rendered developer portal for WSO2 API Manager. -Two distinct UI surfaces share the same server: - -| Surface | Templates | CSS | -|---|---|---| -| **Developer portal** (public-facing) | `src/defaultContent/pages/` | `src/defaultContent/styles/` | -| **Portal management / admin** | `src/pages/` | `src/styles/` | - -These are separate concerns — do not mix their CSS files. - -## CSS architecture - -### Developer portal (`src/defaultContent/styles/`) - -``` -main.css - ├── @import components.css ← canonical shared component library - ├── @import home.css - ├── @import footer.css - ├── @import header.css - ├── @import api-listing.css - ├── @import api-content.css - ├── @import api-landing.css - ├── @import doc.css - ├── @import side-bar.css - └── @import default-api.css -``` - -**Standalone page CSS files** (loaded on their own route, not via `main.css`) must start with: -```css -@import "/styles/components.css"; -``` -Affected files: `subscriptions.css`, `login.css`, `os.css`. -This means `components.css` classes (`.page-header`, `.dp-btn`, `.dp-breadcrumb`, `.dp-empty`, etc.) are always available everywhere. - -### Never touch -`src/defaultContent/styles/async-tryout.css` and `src/styles/async-tryout.css` — third-party bundles. - -## Design tokens (CSS custom properties) - -Defined in `main.css` `:root`: - -```css ---wso2-gradient: linear-gradient(135deg, #ef4223 0%, #ff8636 100%); ---primary-gradient: linear-gradient(135deg, #3d6b8a, #1c3b52); ---primary-main-color: #1A4C6D; ---border-radius: 0.5rem; ---font-family-sans: "Montserrat", "Quicksand", "Noto Sans", "Poppins", sans-serif; -``` - -## CSS conventions - -- **Units**: `rem` for all sizes (divide px by 16). Exception: `border-width` and `box-shadow` stay in `px`. -- **Component prefix**: shared components use `dp-*` (e.g. `dp-btn`, `dp-breadcrumb`, `dp-empty`). -- **No debug borders**: never commit `border: 1px solid red`. - -## Canonical shared components (`components.css`) - -### Page header -```css -.page-header /* flex row, space-between, gap: 16px, margin-bottom: 26px */ -.page-title /* 1.5rem, font-weight: 700, color: #1a2433 */ -.page-desc /* 0.875rem, line-height: 1.5, color: #637282 */ -``` -Use these global classes everywhere. Do not invent page-specific header classes (e.g. `sub-page-header`, `apps-page-header` are legacy — migrate away when touching those pages). - -### Breadcrumb -```hbs - -``` -Top-level pages (APIs, MCP Servers, Subscriptions, API Workflows) have no parent — omit breadcrumb there. - -### Buttons -```css -.dp-btn /* base */ -.dp-btn--primary /* wso2-gradient fill */ -.dp-btn--secondary /* outlined */ -.dp-btn--icon /* square icon-only */ -``` - -### Empty state -```css -.dp-empty /* flex column, centered, padding: 3.75rem 0 5rem, NO border */ -.dp-empty-icon -.dp-empty-title -.dp-empty-desc -``` - -## Hard rules - -1. **Unimplemented action buttons** must always have: - ```html - disabled title="Backend not wired yet — UI preview only" - ``` - -2. **Do not change any file without asking first** unless it's the file directly under discussion. - -3. **Breadcrumb on sub-pages**: all API/MCP detail sub-pages (subscriptions, keys, docs, flows detail) must have a breadcrumb. - -## Handlebars template patterns - -- `{{baseUrl}}` — portal base URL available in all templates -- `{{apiMetadata.apiHandle}}` — API/MCP handle (slug) for URL construction -- `{{or apiMetadata.apiInfo.apiTitle apiMetadata.apiInfo.apiName}}` — display name pattern (title with name fallback) -- `{{apiName}}` — passed explicitly to docs pages from `apiContentController.js` -- `{{baseDocUrl}}` — API/MCP detail page URL, passed explicitly to docs pages - -## Controller locations - -| Controller | File | -|---|---| -| API/MCP docs pages | `src/controllers/apiContentController.js` | -| Application management views | `src/controllers/viewConfigureController.js` | - -`loadDocsPage` and `loadDocument` in `apiContentController.js` each have a design-mode and non-design-mode path — template context variables must be added in all four locations. - -## Key page files - -| Page | Template | CSS | -|---|---|---| -| API listing | `src/defaultContent/pages/apis/page.hbs` | `api-listing.css` | -| API detail | `src/defaultContent/pages/api-landing/page.hbs` | `api-landing.css` | -| API subscriptions | `src/defaultContent/pages/api-subscriptions/page.hbs` | `api-content.css` | -| API keys | `src/defaultContent/pages/api-keys/page.hbs` | `api-content.css` | -| MCP landing | `src/defaultContent/pages/mcp-landing/page.hbs` | `api-landing.css` | -| Docs | `src/defaultContent/pages/docs/page.hbs` | `doc.css` | -| Subscriptions | `src/defaultContent/pages/subscriptions/page.hbs` | `subscriptions.css` | -| API Flows | `src/defaultContent/pages/api-flows/page.hbs` | (via main.css) | -| Applications | `src/pages/applications/partials/applications-listing.hbs` | `src/styles/applications.css` | 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/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 6e16483cf..3725f2286 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 @@ -179,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 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/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/controllers/authController.js b/portals/developer-portal/src/controllers/authController.js index 4ea9facc3..db52142e5 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,15 +160,17 @@ const handleLogOut = async (req, res) => { }); } logUserAction('USER_LOGOUT', req, { orgName: req.params.orgName }); - req.session.destroy(() => { - res.set('Cache-Control', 'no-store'); + req.session.destroy((destroyErr) => { + if (destroyErr) { + logger.error('Session destroy failed on local-auth logout', { error: destroyErr.message }); + } res.redirect(req.originalUrl.replace('/logout', '/login')); }); }); } 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 +187,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 +201,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; + 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 +276,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 +326,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/controllers/devportalController.js b/portals/developer-portal/src/controllers/devportalController.js index f78061eee..cf6ba6ac3 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 = 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: orgID, error: error.message, stack: error.stack }); + util.handleError(res, error); + } +}; + const saveApplication = async (req, res) => { + const orgID = String(req.params.orgId || '').replace(/[\r\n]/g, ''); + 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 = String(req.params.orgId || '').replace(/[\r\n]/g, ''); + 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 userID = req.auth?.userId || req.user?.sub; + const applicationId = req.params.applicationId; + const orgID = String(req.params.orgId || '').replace(/[\r\n]/g, ''); 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); - res.status(200).send("Resouce Deleted Successfully"); + trackAppDeletion({ orgId: orgID, appId: applicationId, idpId: userID }, req); + res.status(200).send("Resource 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); - return res.status(200).send("Resouce Deleted Successfully"); + trackAppDeletion({ orgId: orgID, appId: applicationId, idpId: userID }, req); + return res.status(200).send("Resource 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/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/defaultContent/layout/main.hbs b/portals/developer-portal/src/defaultContent/layout/main.hbs index 890f29eef..4b6f3e44c 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}}} @@ -51,9 +51,9 @@ {{{slots.scripts}}} + {{#if devReloadEnabled}} + {{/if}} diff --git a/portals/developer-portal/src/middlewares/authMiddleware.js b/portals/developer-portal/src/middlewares/authMiddleware.js index d67f49d0f..8e0cfc5de 100644 --- a/portals/developer-portal/src/middlewares/authMiddleware.js +++ b/portals/developer-portal/src/middlewares/authMiddleware.js @@ -38,17 +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 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; -} - +const orgDao = require('../dao/organizationDao'); async function verifyJwksWithRefresh(token, jwksURL, req) { try { @@ -86,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; @@ -103,13 +93,49 @@ 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. + */ +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; + return err; + } + let orgDetails; + 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 (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; } /** @@ -132,10 +158,23 @@ 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) { + 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]; + 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, scopes: String(req.user.grantedScopes || '').split(' ').filter(Boolean), userId: req.user[constants.USER_ID], }; @@ -145,11 +184,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'); @@ -157,6 +191,18 @@ 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]; + const isolationErr = await checkOrgIsolation(pathOrgId, tokenOrgClaim); + if (isolationErr) return next(isolationErr); + } req[constants.USER_ID] = decoded[constants.USER_ID]; req.auth = { mode: 'oauth2', diff --git a/portals/developer-portal/src/middlewares/ensureAuthenticated.js b/portals/developer-portal/src/middlewares/ensureAuthenticated.js index 89cf1b229..ba6b25118 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,17 +186,17 @@ 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"); } } } 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 { @@ -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/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}}