Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/django/common/cors.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ def __call__(self, request):
"X-Device-Fingerprint",
"X-Request-Id",
"X-IdentityCore-Session-Scope",
"X-Client-Id",
"Idempotency-Key",
],
)
)
Expand Down
12 changes: 9 additions & 3 deletions backend/django/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions backend/django/config/test_api.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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"),
)
34 changes: 34 additions & 0 deletions backend/django/config/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)
23 changes: 6 additions & 17 deletions docs/openapi/identitycore-public-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ servers:
description: Local development
security:
- apiClient: []
apiClientId: []
components:
securitySchemes:
apiClient:
type: http
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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -1004,7 +1000,6 @@ paths:
get:
summary: List verifications.
parameters:
- $ref: "#/components/parameters/ClientIdHeader"
- name: status
in: query
schema:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1088,7 +1082,6 @@ paths:
get:
summary: Retrieve a verification.
parameters:
- $ref: "#/components/parameters/ClientIdHeader"
- name: verification_id
in: path
required: true
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1144,7 +1136,6 @@ paths:
post:
summary: Cancel a verification.
parameters:
- $ref: "#/components/parameters/ClientIdHeader"
- name: verification_id
in: path
required: true
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1241,7 +1231,6 @@ paths:
get:
summary: Get evidence-report download URLs.
parameters:
- $ref: "#/components/parameters/ClientIdHeader"
- name: verification_id
in: path
required: true
Expand Down
3 changes: 2 additions & 1 deletion frontend/developer-portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}}
Expand Down
44 changes: 44 additions & 0 deletions frontend/developer-portal/src/lib/api-origin.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
34 changes: 34 additions & 0 deletions frontend/developer-portal/src/lib/api-origin.ts
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
quarj0 marked this conversation as resolved.
) {
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;
}
20 changes: 3 additions & 17 deletions frontend/developer-portal/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -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",
});
Expand Down
Loading