From b9795b6e9349addf1a787e54ad6452a1f4bb526f Mon Sep 17 00:00:00 2001 From: quarj0 Date: Thu, 13 Aug 2026 13:12:20 +0000 Subject: [PATCH] Fix interactive API explorer authentication --- backend/django/common/cors.py | 2 + backend/django/config/settings/base.py | 12 +++-- backend/django/config/test_api.py | 45 +++++++++++++++++++ backend/django/config/tests.py | 34 ++++++++++++++ docs/openapi/identitycore-public-api.yaml | 23 +++------- frontend/developer-portal/package.json | 3 +- .../docs/interactive-api-reference.tsx | 3 +- .../src/lib/api-origin.test.js | 44 ++++++++++++++++++ .../developer-portal/src/lib/api-origin.ts | 34 ++++++++++++++ frontend/developer-portal/src/proxy.ts | 20 ++------- 10 files changed, 180 insertions(+), 40 deletions(-) create mode 100644 frontend/developer-portal/src/lib/api-origin.test.js create mode 100644 frontend/developer-portal/src/lib/api-origin.ts diff --git a/backend/django/common/cors.py b/backend/django/common/cors.py index f218f411..8c16a761 100644 --- a/backend/django/common/cors.py +++ b/backend/django/common/cors.py @@ -50,6 +50,8 @@ def __call__(self, request): "X-Device-Fingerprint", "X-Request-Id", "X-IdentityCore-Session-Scope", + "X-Client-Id", + "Idempotency-Key", ], ) ) diff --git a/backend/django/config/settings/base.py b/backend/django/config/settings/base.py index 7a7df156..0f331390 100644 --- a/backend/django/config/settings/base.py +++ b/backend/django/config/settings/base.py @@ -66,10 +66,16 @@ def env_one_of(name: str, fallback_names: list[str], default: str = "") -> str: "X-Device-Fingerprint", "X-Request-Id", "X-IdentityCore-Session-Scope", + "X-Client-Id", + "Idempotency-Key", ] -CORS_ALLOW_HEADERS = list( - dict.fromkeys(DEFAULT_CORS_ALLOW_HEADERS + env_list("DJANGO_CORS_ALLOW_HEADERS")) -) + + +def merge_cors_allow_headers(additional_headers: list[str]) -> list[str]: + return list(dict.fromkeys(DEFAULT_CORS_ALLOW_HEADERS + additional_headers)) + + +CORS_ALLOW_HEADERS = merge_cors_allow_headers(env_list("DJANGO_CORS_ALLOW_HEADERS")) CORS_ALLOW_METHODS = env_list( "DJANGO_CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS", diff --git a/backend/django/config/test_api.py b/backend/django/config/test_api.py index 9681e9e2..9e57cfcd 100644 --- a/backend/django/config/test_api.py +++ b/backend/django/config/test_api.py @@ -1,7 +1,10 @@ +import yaml from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase +from config.api_views import OPENAPI_SPEC_PATH + class CatalogEndpointTests(APITestCase): def test_health_identifies_the_public_api_service(self): @@ -106,3 +109,45 @@ def test_newly_documented_backend_routes_are_registered(self): with self.subTest(method=method, path=path): response = getattr(self.client, method)(path, {}, format="json") self.assertNotEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class OpenApiAuthenticationContractTests(APITestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.contract = yaml.safe_load(OPENAPI_SPEC_PATH.read_text(encoding="utf-8")) + + def test_api_client_auth_requires_bearer_secret_and_client_id(self): + schemes = self.contract["components"]["securitySchemes"] + + self.assertEqual( + self.contract["security"], + [{"apiClient": [], "apiClientId": []}], + ) + self.assertEqual( + schemes["apiClientId"], + { + "type": "apiKey", + "in": "header", + "name": "X-Client-Id", + "description": "Send the API client ID with the bearer API client secret.", + }, + ) + + def test_authentication_exceptions_remain_explicit(self): + paths = self.contract["paths"] + + self.assertEqual(paths["/health"]["get"]["security"], []) + self.assertEqual( + paths["/uploads/"]["post"]["security"], + [{"platformUserSession": []}, {"platformUserBearer": []}], + ) + + def test_client_id_is_not_duplicated_as_an_operation_parameter(self): + parameters = self.contract["components"]["parameters"] + + self.assertNotIn("ClientIdHeader", parameters) + self.assertNotIn( + "#/components/parameters/ClientIdHeader", + OPENAPI_SPEC_PATH.read_text(encoding="utf-8"), + ) diff --git a/backend/django/config/tests.py b/backend/django/config/tests.py index eb22777b..e6dda985 100644 --- a/backend/django/config/tests.py +++ b/backend/django/config/tests.py @@ -19,6 +19,7 @@ deliver_webhook_event_task, process_pending_webhook_events_task, ) +from config.settings.base import merge_cors_allow_headers class CeleryConfigurationTests(SimpleTestCase): @@ -146,3 +147,36 @@ def test_login_preflight_allows_session_scope_header(self): "X-IdentityCore-Session-Scope", response["Access-Control-Allow-Headers"], ) + + def test_api_client_preflight_allows_authentication_and_idempotency_headers(self): + response = self.client.options( + "/api/v1/verifications/", + HTTP_ORIGIN="http://localhost:3003", + HTTP_ACCESS_CONTROL_REQUEST_METHOD="POST", + HTTP_ACCESS_CONTROL_REQUEST_HEADERS=( + "authorization,x-client-id,idempotency-key,content-type" + ), + ) + + self.assertEqual(response.status_code, 200) + allowed_headers = { + header.strip().lower() + for header in response["Access-Control-Allow-Headers"].split(",") + } + self.assertTrue( + {"authorization", "x-client-id", "idempotency-key", "content-type"} + <= allowed_headers + ) + + def test_environment_cors_headers_extend_required_defaults(self): + headers = merge_cors_allow_headers( + ["X-Custom-Integration", "X-IdentityCore-Session-Scope"] + ) + + self.assertIn("X-Client-Id", headers) + self.assertIn("Idempotency-Key", headers) + self.assertIn("X-Custom-Integration", headers) + self.assertEqual( + headers.count("X-IdentityCore-Session-Scope"), + 1, + ) diff --git a/docs/openapi/identitycore-public-api.yaml b/docs/openapi/identitycore-public-api.yaml index 6439a616..ba0fbefc 100644 --- a/docs/openapi/identitycore-public-api.yaml +++ b/docs/openapi/identitycore-public-api.yaml @@ -10,6 +10,7 @@ servers: description: Local development security: - apiClient: [] + apiClientId: [] components: securitySchemes: apiClient: @@ -17,6 +18,11 @@ components: scheme: bearer bearerFormat: API client secret description: Send the API client secret as a bearer token and the client ID in `X-Client-Id`. + apiClientId: + type: apiKey + in: header + name: X-Client-Id + description: Send the API client ID with the bearer API client secret. platformUserSession: type: apiKey in: cookie @@ -27,13 +33,6 @@ components: scheme: bearer description: Portal JWT authentication used by authenticated workspace users. parameters: - ClientIdHeader: - name: X-Client-Id - in: header - required: true - schema: - type: string - description: API client ID. IdempotencyKeyHeader: name: Idempotency-Key in: header @@ -952,8 +951,6 @@ paths: get: summary: List active verification policies/templates. description: API clients need `policies:read` and receive active policies only. - parameters: - - $ref: "#/components/parameters/ClientIdHeader" responses: "200": description: Active policies. @@ -976,7 +973,6 @@ paths: get: summary: Retrieve an active verification policy/template. parameters: - - $ref: "#/components/parameters/ClientIdHeader" - name: policy_id in: path required: true @@ -1004,7 +1000,6 @@ paths: get: summary: List verifications. parameters: - - $ref: "#/components/parameters/ClientIdHeader" - name: status in: query schema: @@ -1052,7 +1047,6 @@ paths: summary: Create a hosted verification request. description: Requires `verifications:create`; API-client requests must include an active `policy_id` and an idempotency key. parameters: - - $ref: "#/components/parameters/ClientIdHeader" - $ref: "#/components/parameters/IdempotencyKeyHeader" requestBody: required: true @@ -1088,7 +1082,6 @@ paths: get: summary: Retrieve a verification. parameters: - - $ref: "#/components/parameters/ClientIdHeader" - name: verification_id in: path required: true @@ -1116,7 +1109,6 @@ paths: get: summary: Retrieve the stable versioned verification result. parameters: - - $ref: "#/components/parameters/ClientIdHeader" - name: verification_id in: path required: true @@ -1144,7 +1136,6 @@ paths: post: summary: Cancel a verification. parameters: - - $ref: "#/components/parameters/ClientIdHeader" - name: verification_id in: path required: true @@ -1187,7 +1178,6 @@ paths: post: summary: Reissue and resend a verification link. parameters: - - $ref: "#/components/parameters/ClientIdHeader" - name: verification_id in: path required: true @@ -1241,7 +1231,6 @@ paths: get: summary: Get evidence-report download URLs. parameters: - - $ref: "#/components/parameters/ClientIdHeader" - name: verification_id in: path required: true diff --git a/frontend/developer-portal/package.json b/frontend/developer-portal/package.json index 0cd0d813..e652e1ef 100644 --- a/frontend/developer-portal/package.json +++ b/frontend/developer-portal/package.json @@ -6,7 +6,8 @@ "dev": "next dev --webpack --port 3003", "build": "next build --webpack", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "node --test src/lib/*.test.js" }, "dependencies": { "@identitycore/api-client": "workspace:*", diff --git a/frontend/developer-portal/src/components/docs/interactive-api-reference.tsx b/frontend/developer-portal/src/components/docs/interactive-api-reference.tsx index 928d4c6d..29c96c81 100644 --- a/frontend/developer-portal/src/components/docs/interactive-api-reference.tsx +++ b/frontend/developer-portal/src/components/docs/interactive-api-reference.tsx @@ -21,10 +21,9 @@ export function InteractiveApiReference() { displayRequestDuration docExpansion="list" filter - persistAuthorization tryItOutEnabled defaultModelsExpandDepth={1} - requestInterceptor={(request: { headers: { [x: string]: string; }; }) => { + requestInterceptor={(request: { headers: { [x: string]: string } }) => { request.headers["X-Request-Id"] ??= crypto.randomUUID(); return request; }} diff --git a/frontend/developer-portal/src/lib/api-origin.test.js b/frontend/developer-portal/src/lib/api-origin.test.js new file mode 100644 index 00000000..7bc6afc9 --- /dev/null +++ b/frontend/developer-portal/src/lib/api-origin.test.js @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { configuredApiOrigin } from "./api-origin.ts"; + +test("prefers the current API origin setting", () => { + assert.equal( + configuredApiOrigin({ + NODE_ENV: "production", + NEXT_PUBLIC_API_ORIGIN: "https://api.identitycore.example/v1", + NEXT_PUBLIC_API_URL: "https://legacy.identitycore.example/v1", + }), + "https://api.identitycore.example", + ); +}); + +test("uses the legacy API URL as the CSP origin", () => { + assert.equal( + configuredApiOrigin({ + NODE_ENV: "production", + NEXT_PUBLIC_API_URL: "https://legacy.identitycore.example/api/v1", + }), + "https://legacy.identitycore.example", + ); +}); + +test("rejects missing or insecure production API configuration", () => { + assert.throws( + () => configuredApiOrigin({ NODE_ENV: "production" }), + /must be configured/, + ); + assert.throws( + () => + configuredApiOrigin({ + NODE_ENV: "production", + NEXT_PUBLIC_API_URL: "http://api.identitycore.example", + }), + /must use HTTPS/, + ); +}); + +test("permits an unconfigured development environment", () => { + assert.equal(configuredApiOrigin({ NODE_ENV: "development" }), undefined); +}); diff --git a/frontend/developer-portal/src/lib/api-origin.ts b/frontend/developer-portal/src/lib/api-origin.ts new file mode 100644 index 00000000..6adc1683 --- /dev/null +++ b/frontend/developer-portal/src/lib/api-origin.ts @@ -0,0 +1,34 @@ +export type ApiOriginEnvironment = { + NODE_ENV?: string; + NEXT_PUBLIC_API_ORIGIN?: string; + NEXT_PUBLIC_API_URL?: string; +}; + +export function configuredApiOrigin( + environment: ApiOriginEnvironment = process.env, +) { + const configuredApiUrl = + environment.NEXT_PUBLIC_API_ORIGIN?.trim() || + environment.NEXT_PUBLIC_API_URL?.trim(); + + if (!configuredApiUrl) { + if (environment.NODE_ENV === "production") { + throw new Error( + "NEXT_PUBLIC_API_ORIGIN must be configured for the developer portal in production.", + ); + } + return undefined; + } + + const parsedApiUrl = new URL(configuredApiUrl); + if ( + environment.NODE_ENV === "production" && + parsedApiUrl.protocol !== "https:" + ) { + throw new Error( + "The developer portal API URL must use HTTPS in production.", + ); + } + + return parsedApiUrl.origin; +} diff --git a/frontend/developer-portal/src/proxy.ts b/frontend/developer-portal/src/proxy.ts index cd1ea70e..63a94f56 100644 --- a/frontend/developer-portal/src/proxy.ts +++ b/frontend/developer-portal/src/proxy.ts @@ -1,28 +1,14 @@ import { type NextRequest, NextResponse } from "next/server"; import { securityHeaders } from "../../security-headers"; +import { configuredApiOrigin } from "./lib/api-origin"; export function proxy(request: NextRequest) { - if (process.env.NODE_ENV === "production") { - const configuredApiUrl = - process.env.NEXT_PUBLIC_API_ORIGIN ?? process.env.NEXT_PUBLIC_API_URL; - if (!configuredApiUrl) { - throw new Error( - "NEXT_PUBLIC_API_ORIGIN must be configured for the developer portal in production.", - ); - } - - const parsedApiUrl = new URL(configuredApiUrl); - if (parsedApiUrl.protocol !== "https:") { - throw new Error( - "The developer portal API URL must use HTTPS in production.", - ); - } - } + const apiOrigin = configuredApiOrigin(); const nonce = crypto.randomUUID().replaceAll("-", ""); const headers = securityHeaders(nonce, { - apiOrigin: process.env.NEXT_PUBLIC_API_ORIGIN, + apiOrigin, camera: false, development: process.env.NODE_ENV === "development", });