From 0c0d2693663500220a2ce820f0cf44649b0165a5 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 1 May 2026 00:10:25 +0200 Subject: [PATCH 01/11] enhancement/improve error display in create-bank form and proxy request logging --- .gitignore | 2 + .../src/lib/components/Toast.svelte | 2 +- .../account-directory/+page.svelte | 2 +- .../(protected)/banks/create/+page.svelte | 26 ++++ .../src/routes/proxy/[...path]/+server.ts | 34 ++++- apps/portal/package.json | 2 +- package-lock.json | 118 +----------------- 7 files changed, 70 insertions(+), 116 deletions(-) diff --git a/.gitignore b/.gitignore index 2fbe10d2..a75d4fb2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ build .claude security-scan.log security-fix.log +.idea +*.code-workspace \ No newline at end of file diff --git a/apps/api-manager/src/lib/components/Toast.svelte b/apps/api-manager/src/lib/components/Toast.svelte index b6888896..4025d52d 100644 --- a/apps/api-manager/src/lib/components/Toast.svelte +++ b/apps/api-manager/src/lib/components/Toast.svelte @@ -3,7 +3,7 @@ import { toaster } from '$lib/utils/toastService'; - + {#snippet children(toast)} diff --git a/apps/api-manager/src/routes/(protected)/account-access/account-directory/+page.svelte b/apps/api-manager/src/routes/(protected)/account-access/account-directory/+page.svelte index 5da69e00..a1ad9a2e 100644 --- a/apps/api-manager/src/routes/(protected)/account-access/account-directory/+page.svelte +++ b/apps/api-manager/src/routes/(protected)/account-access/account-directory/+page.svelte @@ -161,7 +161,7 @@ {#each filteredAccounts as account} - diff --git a/apps/api-manager/src/routes/(protected)/banks/create/+page.svelte b/apps/api-manager/src/routes/(protected)/banks/create/+page.svelte index 69b277dd..290c3c7a 100644 --- a/apps/api-manager/src/routes/(protected)/banks/create/+page.svelte +++ b/apps/api-manager/src/routes/(protected)/banks/create/+page.svelte @@ -9,6 +9,7 @@ let logo = $state(""); let website = $state(""); let isSubmitting = $state(false); + let submitError = $state(null); // Bank routings - dynamic list let routings = $state>([]); @@ -35,6 +36,7 @@ } isSubmitting = true; + submitError = null; try { const requestBody: Record = { @@ -90,6 +92,7 @@ } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to create bank"; + submitError = errorMessage; toast.error("Error", errorMessage); } finally { isSubmitting = false; @@ -290,6 +293,29 @@ + + {#if submitError} +
+ + + +
+

Error

+

{submitError}

+
+ +
+ {/if} +
= { ...headers }; + if (loggableHeaders["Authorization"]) { + const token = loggableHeaders["Authorization"]; + loggableHeaders["Authorization"] = token.length > 30 + ? `${token.substring(0, 20)}...[truncated]` + : token; + } + + logger.warn(`Request failed — details: + URL: ${obpUrl} + Method: ${request.method} + Headers: ${JSON.stringify(loggableHeaders, null, 2)} + Body: ${requestBody ? requestBody : "(none)"} + ---- + Status: ${obpResponse.status} + Response: ${responseBodyText}`); + + return new Response(responseBodyText, { + status: obpResponse.status, + headers: { + "content-type": obpResponse.headers.get("content-type") || "application/json", + ...(correlationId ? { "Correlation-Id": correlationId } : {}), + }, + }); + } + // Stream the OBP response back unchanged return new Response(obpResponse.body, { status: obpResponse.status, diff --git a/apps/portal/package.json b/apps/portal/package.json index ce08537d..9613de57 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -60,7 +60,7 @@ "@types/dompurify": "^3.0.5", "arctic": "^3.7.0", "bits-ui": "^2.8.10", - "dompurify": "^3.3.3", + "dompurify": "^3.4.1", "ioredis": "^5.6.1", "jwt-decode": "^4.0.0", "langchain": "^0.3.27", diff --git a/package-lock.json b/package-lock.json index 3417d279..02374379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,7 @@ "@types/dompurify": "^3.0.5", "arctic": "^3.7.0", "bits-ui": "^2.8.10", - "dompurify": "^3.3.3", + "dompurify": "^3.4.1", "ioredis": "^5.6.1", "jwt-decode": "^4.0.0", "langchain": "^0.3.27", @@ -125,7 +125,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -312,7 +311,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -329,7 +327,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -346,7 +343,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -363,7 +359,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -380,7 +375,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -397,7 +391,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -414,7 +407,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -431,7 +423,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -448,7 +439,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -465,7 +455,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -482,7 +471,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -499,7 +487,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -516,7 +503,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -533,7 +519,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -550,7 +535,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -567,7 +551,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -584,7 +567,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -601,7 +583,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -618,7 +599,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -635,7 +615,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -652,7 +631,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -669,7 +647,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -686,7 +663,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -703,7 +679,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -720,7 +695,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -737,7 +711,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -831,7 +804,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -853,7 +825,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -863,14 +834,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1034,7 +1003,6 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, "license": "MIT" }, "node_modules/@protobufjs/aspromise": { @@ -1263,7 +1231,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1277,7 +1244,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1291,7 +1257,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1305,7 +1270,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1319,7 +1283,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1333,7 +1296,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1347,7 +1309,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1361,7 +1322,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1375,7 +1335,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1389,7 +1348,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1403,7 +1361,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1417,7 +1374,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1431,7 +1387,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1445,7 +1400,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1459,7 +1413,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1473,7 +1426,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1487,7 +1439,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1501,7 +1452,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1515,7 +1465,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1529,7 +1478,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1543,7 +1491,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1557,7 +1504,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1571,7 +1517,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1585,7 +1530,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1599,7 +1543,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1665,14 +1608,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1708,7 +1649,6 @@ "version": "2.58.0", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz", "integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==", - "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1750,7 +1690,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", - "dev": true, "license": "MIT", "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", @@ -1772,7 +1711,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.7" @@ -2212,7 +2150,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3": { @@ -2488,7 +2425,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/geojson": { @@ -3256,7 +3192,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3321,7 +3256,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3378,7 +3312,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3679,7 +3612,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4187,7 +4119,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4224,7 +4155,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4234,7 +4165,6 @@ "version": "5.6.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", - "dev": true, "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -4316,7 +4246,6 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4373,7 +4302,6 @@ "version": "1.4.9", "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz", "integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -4406,7 +4334,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4438,7 +4365,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4661,7 +4587,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -4757,7 +4683,6 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4922,7 +4847,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -4955,7 +4880,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4976,7 +4900,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4997,7 +4920,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5018,7 +4940,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5039,7 +4960,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5060,7 +4980,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5081,7 +5000,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5102,7 +5020,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5123,7 +5040,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5144,7 +5060,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5165,7 +5080,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5192,7 +5106,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -5246,7 +5159,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -5309,7 +5221,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5334,7 +5245,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -5498,14 +5408,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5550,7 +5458,6 @@ "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5891,7 +5798,6 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -5993,7 +5899,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "dev": true, "license": "MIT" }, "node_modules/siginfo": { @@ -6013,7 +5918,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -6028,7 +5932,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6151,7 +6054,6 @@ "version": "5.33.2", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.2.tgz", "integrity": "sha512-uiyusx2rUa9NmVMaIcShnZyDhOfFXxgkn5eXOcgjDBL3RYQGR1+7TctPcI6AWNbu4gHWF5xZ/TlFM7nnw5H+JQ==", - "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -6311,7 +6213,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -6346,7 +6247,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { @@ -6381,7 +6281,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -6448,7 +6347,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6490,7 +6388,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6568,7 +6466,6 @@ "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -6666,7 +6563,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6681,7 +6577,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "dev": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -6884,7 +6779,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6980,7 +6875,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" }, "node_modules/zod": { From 4cb74bb3ee9b1b68e1a1a50faf748f0460c26a19 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 1 May 2026 00:35:31 +0200 Subject: [PATCH 02/11] bugfix/show OBP error message instead of generic fallback in account-access pages --- .../(protected)/account-access/accounts/+page.svelte | 2 +- .../[bank_id]/[account_id]/[view_id]/+page.svelte | 10 +++++----- .../[account_id]/[view_id]/counterparties/+page.svelte | 2 +- .../counterparties/[counterparty_id]/+page.svelte | 2 +- .../[account_id]/[view_id]/transactions/+page.svelte | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/api-manager/src/routes/(protected)/account-access/accounts/+page.svelte b/apps/api-manager/src/routes/(protected)/account-access/accounts/+page.svelte index e8f06330..a0a4be6e 100644 --- a/apps/api-manager/src/routes/(protected)/account-access/accounts/+page.svelte +++ b/apps/api-manager/src/routes/(protected)/account-access/accounts/+page.svelte @@ -19,7 +19,7 @@ const res = await trackedFetch(`/proxy/obp/v6.0.0/banks/${encodeURIComponent(bankId)}/accounts`); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.message ?? `HTTP ${res.status}`); + throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch accounts`); } const data = await res.json(); accounts = data.accounts || []; diff --git a/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/+page.svelte b/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/+page.svelte index e32a4e72..9fdc4d5a 100644 --- a/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/+page.svelte +++ b/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/+page.svelte @@ -122,7 +122,8 @@ ); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.message ?? `HTTP ${res.status}`); + const msg = data.message || data.error || `HTTP ${res.status}`; + throw new Error(msg); } account = await res.json(); } catch (err) { @@ -147,8 +148,7 @@ ); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.message ?? `HTTP ${res.status}`); - } + throw new Error(data.message || data.error || `Failed to fetch users with access for view ${vid}`); } const data = await res.json(); return { viewId: vid, users: data.users || [] }; }) @@ -221,7 +221,7 @@ ); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.message ?? `HTTP ${res.status}`); + throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch customer account links`); } const data = await res.json(); customerAccountLinks = data.links || []; @@ -264,7 +264,7 @@ if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.message ?? `HTTP ${res.status}`); + throw new Error(data.message || data.error || `HTTP ${res.status} Failed to create account attribute`); } const created = await res.json(); diff --git a/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/counterparties/+page.svelte b/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/counterparties/+page.svelte index b20bab72..1d6861fe 100644 --- a/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/counterparties/+page.svelte +++ b/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/counterparties/+page.svelte @@ -31,7 +31,7 @@ ); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.message ?? `HTTP ${res.status}`); + throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch counterparties`); } const data = await res.json(); counterparties = data.counterparties || []; diff --git a/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/counterparties/[counterparty_id]/+page.svelte b/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/counterparties/[counterparty_id]/+page.svelte index 4ee7e3e0..d40babba 100644 --- a/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/counterparties/[counterparty_id]/+page.svelte +++ b/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/counterparties/[counterparty_id]/+page.svelte @@ -79,7 +79,7 @@ ); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.message ?? `HTTP ${res.status}`); + throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch counterparty details`); } counterparty = await res.json(); } catch (err) { diff --git a/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/transactions/+page.svelte b/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/transactions/+page.svelte index dfde45b5..ea755c98 100644 --- a/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/transactions/+page.svelte +++ b/apps/api-manager/src/routes/(protected)/account-access/accounts/[bank_id]/[account_id]/[view_id]/transactions/+page.svelte @@ -32,7 +32,7 @@ ); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.message ?? `HTTP ${res.status}`); + throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch transactions`); } const data = await res.json(); transactions = data.transactions || []; From 3224c0b0ab41aca8ce36544a0ea4f363d30624a1 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 10 Jul 2026 10:31:48 +0200 Subject: [PATCH 03/11] fix: harden OAuth/session/proxy security surface across portal and api-manager - add path-traversal, absolute-URL, and timeout guards to the api-manager generic proxy route, matching the portal proxy's existing protections - validate oidc_return_url against configured OIDC provider origins before storing it, closing an open-redirect / consent-data leak path - reject protocol-relative redirect_to values in api-manager login/callback routes instead of only checking for a leading slash - escape markdown link hrefs and restrict them to http(s)/relative targets on the featured page to prevent attribute-breakout and javascript: XSS - stop treating a missing id_token on refresh as a hard failure; OIDC refresh responses are not required to reissue one - move email-validation off the page load into an explicit form action so a GET (link scanner, prefetch) can no longer consume the single-use token - set Referrer-Policy and skip analytics injection on token-in-URL routes to stop the reset token leaking via Referer/analytics - redact refresh_token/code/client_secret/password fields before logging OAuth request bodies, and drop the access-token preview log line - treat an undecodable or missing-exp access token as expired instead of throwing, so a bad token triggers a refresh instead of a 500 --- apps/api-manager/src/lib/oauth/client.ts | 15 ++-- .../src/lib/oauth/sessionHelper.ts | 7 -- .../src/routes/login/[provider]/+server.ts | 4 +- .../login/[provider]/callback/+server.ts | 6 +- .../src/routes/login/obp/callback/+server.ts | 6 +- .../src/routes/proxy/[...path]/+server.ts | 44 ++++++++++- apps/portal/src/hooks.server.ts | 16 +++- apps/portal/src/lib/oauth/client.ts | 27 ++++--- apps/portal/src/lib/oauth/providerFactory.ts | 25 ++++++ .../obp-consent-request/+page.server.ts | 13 ++- apps/portal/src/routes/featured/+page.svelte | 15 +++- .../src/routes/login/[provider]/+server.ts | 13 ++- .../routes/user-validation/+page.server.ts | 79 +++++++++++-------- .../src/routes/user-validation/+page.svelte | 57 ++++++++++--- .../shared/src/lib/server/oauth/client.ts | 16 ++-- packages/shared/src/lib/utils/index.ts | 2 + packages/shared/src/lib/utils/redact.ts | 13 +++ packages/shared/src/lib/utils/redirect.ts | 7 ++ 18 files changed, 279 insertions(+), 86 deletions(-) create mode 100644 packages/shared/src/lib/utils/redact.ts create mode 100644 packages/shared/src/lib/utils/redirect.ts diff --git a/apps/api-manager/src/lib/oauth/client.ts b/apps/api-manager/src/lib/oauth/client.ts index 70675ba9..e7767cc2 100644 --- a/apps/api-manager/src/lib/oauth/client.ts +++ b/apps/api-manager/src/lib/oauth/client.ts @@ -1,4 +1,4 @@ -import { createLogger } from "@obp/shared/utils"; +import { createLogger, redactUrlEncodedBody } from "@obp/shared/utils"; const logger = createLogger("OAuth2Client"); import { OAuth2Client } from "arctic"; import type { @@ -61,20 +61,23 @@ export class OAuth2ClientWithConfig extends OAuth2Client { } async checkAccessTokenExpiration(accessToken: string): Promise { - // Returns true if the access token is expired, false if it is valid + // Returns true if the access token is expired (or its validity can't be + // determined — treated as expired so the caller attempts a refresh rather + // than either 500ing or treating an unverifiable token as eternally valid), + // false if it is confirmed valid. logger.debug("Checking access token expiration..."); try { const payload = jwtDecode(accessToken) as OAuth2AccessTokenPayload; if (!payload || !payload.exp) { logger.warn("Access token payload is invalid or missing expiration."); - return false; + return true; } const isExpired = Date.now() >= payload.exp * 1000; logger.debug(`Access token is ${isExpired ? "expired" : "valid"}.`); return isExpired; } catch (error) { - logger.error("Error decoding access token:", error); - throw error; + logger.warn("Could not decode access token; treating as expired:", error); + return true; } } @@ -107,7 +110,7 @@ export class OAuth2ClientWithConfig extends OAuth2Client { body.set("code_verifier", codeVerifier); } - logger.debug(`Token request body: ${body.toString()}`); + logger.debug(`Token request body: ${redactUrlEncodedBody(body)}`); const response = await fetch(tokenEndpoint, { method: "POST", diff --git a/apps/api-manager/src/lib/oauth/sessionHelper.ts b/apps/api-manager/src/lib/oauth/sessionHelper.ts index 9baa1709..7109697c 100644 --- a/apps/api-manager/src/lib/oauth/sessionHelper.ts +++ b/apps/api-manager/src/lib/oauth/sessionHelper.ts @@ -26,13 +26,6 @@ export class SessionOAuthHelper { logger.debug(` Access token present: ${!!oauthData?.access_token}`); logger.debug(` Refresh token present: ${!!oauthData?.refresh_token}`); - if (oauthData?.access_token) { - logger.debug(` Access token length: ${oauthData.access_token.length}`); - logger.debug( - ` Access token preview: ${oauthData.access_token.substring(0, 30)}...`, - ); - } - if (!oauthData?.provider || !oauthData?.access_token) { logger.warn("🔐 SESSION OAUTH - Missing provider or access token"); return null; diff --git a/apps/api-manager/src/routes/login/[provider]/+server.ts b/apps/api-manager/src/routes/login/[provider]/+server.ts index 5d02829a..572cbe28 100644 --- a/apps/api-manager/src/routes/login/[provider]/+server.ts +++ b/apps/api-manager/src/routes/login/[provider]/+server.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@obp/shared/utils'; +import { createLogger, isSafeRelativeRedirect } from '@obp/shared/utils'; const logger = createLogger('ProviderLogin'); import { generateState } from 'arctic' import { oauth2ProviderFactory } from '$lib/oauth/providerFactory' @@ -57,7 +57,7 @@ export function GET(event: RequestEvent) { // Store redirect_to in a separate cookie so we can redirect after login const redirectTo = event.url.searchParams.get('redirect_to'); - if (redirectTo && redirectTo.startsWith('/')) { + if (isSafeRelativeRedirect(redirectTo)) { event.cookies.set('obp_redirect_to', redirectTo, { httpOnly: true, maxAge: 60 * 10, diff --git a/apps/api-manager/src/routes/login/[provider]/callback/+server.ts b/apps/api-manager/src/routes/login/[provider]/callback/+server.ts index b469d036..063eb926 100644 --- a/apps/api-manager/src/routes/login/[provider]/callback/+server.ts +++ b/apps/api-manager/src/routes/login/[provider]/callback/+server.ts @@ -1,4 +1,4 @@ -import { createLogger } from "@obp/shared/utils"; +import { createLogger, isSafeRelativeRedirect } from "@obp/shared/utils"; const logger = createLogger("ProviderLoginCallback"); import { oauth2ProviderFactory } from "$lib/oauth/providerFactory"; import type { OAuth2Tokens } from "arctic"; @@ -243,8 +243,8 @@ export async function GET(event: RequestEvent): Promise { // Redirect to the originally requested URL if available let redirectTo = event.cookies.get("obp_redirect_to") || "/"; event.cookies.delete("obp_redirect_to", { path: "/" }); - // Validate: must start with / to prevent open redirect - if (!redirectTo.startsWith("/")) { + // Validate: must be a same-origin relative path to prevent open redirect + if (!isSafeRelativeRedirect(redirectTo)) { redirectTo = "/"; } diff --git a/apps/api-manager/src/routes/login/obp/callback/+server.ts b/apps/api-manager/src/routes/login/obp/callback/+server.ts index b72d4dc9..9c27ba69 100644 --- a/apps/api-manager/src/routes/login/obp/callback/+server.ts +++ b/apps/api-manager/src/routes/login/obp/callback/+server.ts @@ -1,4 +1,4 @@ -import { createLogger } from "@obp/shared/utils"; +import { createLogger, isSafeRelativeRedirect } from "@obp/shared/utils"; const logger = createLogger("OBPLoginCallback"); import { oauth2ProviderFactory } from "$lib/oauth/providerFactory"; import type { OAuth2Tokens } from "arctic"; @@ -226,8 +226,8 @@ export async function GET(event: RequestEvent): Promise { // Redirect to the originally requested URL if available let redirectTo = event.cookies.get("obp_redirect_to") || "/"; event.cookies.delete("obp_redirect_to", { path: "/" }); - // Validate: must start with / to prevent open redirect - if (!redirectTo.startsWith("/")) { + // Validate: must be a same-origin relative path to prevent open redirect + if (!isSafeRelativeRedirect(redirectTo)) { redirectTo = "/"; } diff --git a/apps/api-manager/src/routes/proxy/[...path]/+server.ts b/apps/api-manager/src/routes/proxy/[...path]/+server.ts index 6cdb12a4..9da27658 100644 --- a/apps/api-manager/src/routes/proxy/[...path]/+server.ts +++ b/apps/api-manager/src/routes/proxy/[...path]/+server.ts @@ -5,6 +5,8 @@ import { createLogger } from '@obp/shared/utils'; const logger = createLogger("Proxy"); +const TIMEOUT_MS = 15_000; + /** * Generic catch-all proxy for OBP API. * @@ -34,7 +36,23 @@ export const fallback: RequestHandler = async ({ params, request, locals, url }) }); } - const obpPath = params.path; + const obpPath = params.path ?? ''; + + // Reject path-traversal and absolute-URL attempts before joining into the OBP URL. + if ( + obpPath === '' || + obpPath.includes('..') || + obpPath.startsWith('/') || + obpPath.includes('://') || + obpPath.includes('\0') + ) { + logger.warn(`Rejected proxy path: ${obpPath}`); + return new Response(JSON.stringify({ message: "Invalid path" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + const queryString = url.search; const obpUrl = `${env.PUBLIC_OBP_BASE_URL}/${obpPath}${queryString}`; @@ -62,7 +80,29 @@ export const fallback: RequestHandler = async ({ params, request, locals, url }) const startTime = performance.now(); - const obpResponse = await fetch(obpUrl, fetchOptions); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + fetchOptions.signal = controller.signal; + + let obpResponse: Response; + try { + obpResponse = await fetch(obpUrl, fetchOptions); + } catch (error: any) { + if (error.name === 'AbortError') { + logger.error(`Timeout: ${request.method} /${obpPath}`); + return new Response(JSON.stringify({ message: `Request to ${obpPath} timed out` }), { + status: 504, + headers: { "content-type": "application/json" }, + }); + } + logger.error(`Error: ${request.method} /${obpPath}:`, error); + return new Response(JSON.stringify({ message: error.message || 'Bad Gateway' }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } finally { + clearTimeout(timeout); + } const duration = performance.now() - startTime; const correlationId = obpResponse.headers.get("Correlation-Id") || ""; diff --git a/apps/portal/src/hooks.server.ts b/apps/portal/src/hooks.server.ts index 2e789205..5c8536cc 100644 --- a/apps/portal/src/hooks.server.ts +++ b/apps/portal/src/hooks.server.ts @@ -235,8 +235,17 @@ const checkAuthorization: Handle = async ({ event, resolve }) => { return response; }; +// Routes that carry a single-use secret token in the URL path — analytics must not +// see these paths (gtag reports page_location, which would exfiltrate the token). +const TOKEN_IN_URL_ROUTE_PREFIXES = ['/reset-password/', '/user_mgt/reset_password/']; + +function pathCarriesUrlToken(pathname: string): boolean { + return TOKEN_IN_URL_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} + const transformHTML: Handle = async ({ event, resolve }) => { - const analyticsScript = env.ENABLE_ANALYTICS === "true" && env.GTAG_ID + const skipAnalytics = pathCarriesUrlToken(event.url.pathname); + const analyticsScript = env.ENABLE_ANALYTICS === "true" && env.GTAG_ID && !skipAnalytics ? `
Email Validation
- {#if data.success} + + + {#if !data.token} +
+
+ +
+

Validation Failed

+

No validation token provided. Please check your email for the validation link.

+
+ {:else if !form} +
+ +

Validating your email...

+
+ {:else if form.success}

Success!

-

{data.message}

+

{form.message}

Your email has been successfully validated. You can now log in to your account.

@@ -38,10 +75,10 @@

Validation Failed

-

{data.message}

- {#if data.errorCode} +

{form.message}

+ {#if form.errorCode}

- Error Code: {data.errorCode} + Error Code: {form.errorCode}

{/if}
@@ -71,14 +108,14 @@
{/if} - {#if data.data} + {#if form?.data}
View Details
    - {#each Object.entries(data.data) as [key, value]} + {#each Object.entries(form.data) as [key, value]}
  • {key}: {#if typeof value === 'object' && value !== null} @@ -93,4 +130,4 @@
{/if}
-
\ No newline at end of file +
diff --git a/packages/shared/src/lib/server/oauth/client.ts b/packages/shared/src/lib/server/oauth/client.ts index 384f4b59..40bbb088 100644 --- a/packages/shared/src/lib/server/oauth/client.ts +++ b/packages/shared/src/lib/server/oauth/client.ts @@ -1,4 +1,5 @@ import { createLogger } from '$shared/utils/logger'; +import { redactUrlEncodedBody } from '$shared/utils/redact'; const logger = createLogger('OAuth2Client'); import { OAuth2Client } from 'arctic'; import type { OpenIdConnectConfiguration, OAuth2AccessTokenPayload } from './types'; @@ -58,20 +59,23 @@ export class OAuth2ClientWithConfig extends OAuth2Client { } async checkAccessTokenExpiration(accessToken: string): Promise { - // Returns true if the access token is expired, false if it is valid + // Returns true if the access token is expired (or its validity can't be + // determined — treated as expired so the caller attempts a refresh rather + // than either 500ing or treating an unverifiable token as eternally valid), + // false if it is confirmed valid. logger.debug('Checking access token expiration...'); try { const payload = jwtDecode(accessToken) as OAuth2AccessTokenPayload; if (!payload || !payload.exp) { logger.warn('Access token payload is invalid or missing expiration.'); - return false; + return true; } const isExpired = Date.now() >= payload.exp * 1000; logger.debug(`Access token is ${isExpired ? 'expired' : 'valid'}.`); return isExpired; } catch (error) { - logger.error('Error decoding access token:', error); - throw error; + logger.warn('Could not decode access token; treating as expired:', error); + return true; } } @@ -110,7 +114,7 @@ export class OAuth2ClientWithConfig extends OAuth2Client { body.set('code_verifier', codeVerifier); } - logger.debug(`Token request body: ${body.toString()}`); + logger.debug(`Token request body: ${redactUrlEncodedBody(body)}`); const response = await fetch(tokenEndpoint, { method: 'POST', @@ -173,7 +177,7 @@ export class OAuth2ClientWithConfig extends OAuth2Client { body.set('code_verifier', codeVerifier); } - logger.debug(`Token request body: ${body.toString()}`); + logger.debug(`Token request body: ${redactUrlEncodedBody(body)}`); const response = await fetch(tokenEndpoint, { method: 'POST', diff --git a/packages/shared/src/lib/utils/index.ts b/packages/shared/src/lib/utils/index.ts index 6f765d86..9341dd39 100644 --- a/packages/shared/src/lib/utils/index.ts +++ b/packages/shared/src/lib/utils/index.ts @@ -4,3 +4,5 @@ export { extractUsernameFromJWT, isJWTExpired, getJWTPayload } from './jwt.js'; export type { JWTPayload } from './jwt.js'; export { toaster, toast } from './toastService.js'; export { getLegalMarkdownFromWebUIProps } from './loadLegalDocumentFromApi.js'; +export { isSafeRelativeRedirect } from './redirect.js'; +export { redactUrlEncodedBody } from './redact.js'; diff --git a/packages/shared/src/lib/utils/redact.ts b/packages/shared/src/lib/utils/redact.ts new file mode 100644 index 00000000..d33bfd20 --- /dev/null +++ b/packages/shared/src/lib/utils/redact.ts @@ -0,0 +1,13 @@ +const SECRET_BODY_FIELDS = ['refresh_token', 'code', 'client_secret', 'password', 'access_token', 'id_token']; + +// Renders a URL-encoded OAuth request body for logging with secret fields redacted, +// since these bodies carry refresh tokens / auth codes / client secrets verbatim. +export function redactUrlEncodedBody(body: URLSearchParams): string { + const redacted = new URLSearchParams(body); + for (const field of SECRET_BODY_FIELDS) { + if (redacted.has(field)) { + redacted.set(field, '[redacted]'); + } + } + return redacted.toString(); +} diff --git a/packages/shared/src/lib/utils/redirect.ts b/packages/shared/src/lib/utils/redirect.ts new file mode 100644 index 00000000..7a4af4fb --- /dev/null +++ b/packages/shared/src/lib/utils/redirect.ts @@ -0,0 +1,7 @@ +// A same-page-origin relative path is safe to redirect to; protocol-relative +// (`//host`) and backslash (`/\host`) values are browser-resolved as absolute +// URLs to a different host and must be rejected. +export function isSafeRelativeRedirect(path: string | null | undefined): path is string { + if (!path) return false; + return path.startsWith('/') && !path.startsWith('//') && !path.startsWith('/\\'); +} From 5dc9f79f0e4f47534a83d2f7bbcefeca1e1da46e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 10 Jul 2026 15:25:04 +0200 Subject: [PATCH 04/11] test: cover security-fix behavior with unit tests and block protocol-relative markdown links Lock in the behavior of the OAuth/redirect/redaction security fixes with unit tests, and close a gap the tests exposed on the featured page. - reject protocol-relative (//host) and backslash (/\host) hrefs in the featured-page markdown sanitizer, mirroring the shared isSafeHref guard - extract the featured-page markdown helpers into apps/portal/src/lib/markdown/featuredMarkdown.ts so the sanitizer and renderer are importable and testable; the page imports them back - add tests for isSafeRelativeRedirect and redactUrlEncodedBody - add XSS-payload tests for markdownToHtml (javascript:/data:/protocol-relative hrefs collapse to '#', attribute breakout is neutralized, text is escaped) - add OAuth2ProviderFactory trusted-origin tests via an injected fake strategy - update client.test.ts to the fail-closed checkAccessTokenExpiration behavior (bad/missing-exp/undecodable token -> expired, never throws) and to the current createLogger two-arg console format --- .../src/lib/markdown/featuredMarkdown.test.ts | 76 +++++++++++++ .../src/lib/markdown/featuredMarkdown.ts | 61 +++++++++++ apps/portal/src/routes/featured/+page.svelte | 49 +-------- apps/portal/test/unit/oauth/client.test.ts | 89 +++++++++------- .../test/unit/oauth/providerFactory.test.ts | 100 ++++++++++++++++++ packages/shared/src/lib/utils/redact.test.ts | 57 ++++++++++ .../shared/src/lib/utils/redirect.test.ts | 44 ++++++++ 7 files changed, 387 insertions(+), 89 deletions(-) create mode 100644 apps/portal/src/lib/markdown/featuredMarkdown.test.ts create mode 100644 apps/portal/src/lib/markdown/featuredMarkdown.ts create mode 100644 apps/portal/test/unit/oauth/providerFactory.test.ts create mode 100644 packages/shared/src/lib/utils/redact.test.ts create mode 100644 packages/shared/src/lib/utils/redirect.test.ts diff --git a/apps/portal/src/lib/markdown/featuredMarkdown.test.ts b/apps/portal/src/lib/markdown/featuredMarkdown.test.ts new file mode 100644 index 00000000..4992557a --- /dev/null +++ b/apps/portal/src/lib/markdown/featuredMarkdown.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { markdownToHtml, sanitizeLinkHref } from './featuredMarkdown'; + +describe('markdownToHtml link sanitization', () => { + it('neutralizes an attribute-breakout attempt in a link href', () => { + const html = markdownToHtml('[hi](x" onmouseover=alert`1`)'); + expect(html).toContain('href="#"'); + expect(html).not.toContain('onmouseover'); + // The stray double-quote must never survive as an unescaped attribute delimiter. + expect(html).not.toContain('href="x"'); + }); + + it('rewrites a javascript: link to a safe anchor', () => { + const html = markdownToHtml('[x](javascript:alert(1))'); + expect(html).toContain('href="#"'); + expect(html).not.toContain('javascript:'); + }); + + it('rewrites a data: link to a safe anchor', () => { + const html = markdownToHtml('[x](data:text/html,)'); + expect(html).toContain('href="#"'); + expect(html).not.toContain('data:text/html'); + }); + + it('rejects a protocol-relative link', () => { + const html = markdownToHtml('[x](//evil.com)'); + expect(html).toContain('href="#"'); + expect(html).not.toContain('href="//evil.com"'); + }); + + it('rejects a backslash-prefixed link', () => { + const html = markdownToHtml('[x](/\\evil.com)'); + expect(html).toContain('href="#"'); + }); + + it('keeps an absolute https link', () => { + const html = markdownToHtml('[ok](https://example.com/path)'); + expect(html).toContain('href="https://example.com/path"'); + }); + + it('keeps a site-relative link', () => { + const html = markdownToHtml('[rel](/docs)'); + expect(html).toContain('href="/docs"'); + }); + + it('keeps a fragment link', () => { + const html = markdownToHtml('[frag](#section)'); + expect(html).toContain('href="#section"'); + }); +}); + +describe('sanitizeLinkHref', () => { + it('escapes ampersands, quotes, and angle brackets in a safe href', () => { + const result = sanitizeLinkHref('/search?a=1&b=2">"]/); + expect(result).not.toContain('&b'); // bare ampersand is escaped + }); + + it('collapses unsafe schemes to a hash', () => { + expect(sanitizeLinkHref('vbscript:msgbox(1)')).toBe('#'); + expect(sanitizeLinkHref('//host')).toBe('#'); + expect(sanitizeLinkHref('/\\host')).toBe('#'); + }); + + it('trims surrounding whitespace before validating', () => { + expect(sanitizeLinkHref(' https://example.com ')).toBe('https://example.com'); + }); +}); + +describe('markdownToHtml text escaping', () => { + it('escapes raw HTML in the markdown body', () => { + const html = markdownToHtml('an tag'); + expect(html).toContain('<img'); + expect(html).not.toContain('/g, '>'); +} + +// Convert markdown to HTML (for rendering) +export function markdownToHtml(markdown: string): string { + return ( + markdown + // Escape HTML first + .replace(/&/g, '&') + .replace(//g, '>') + // Then apply markdown formatting + .replace(/\*\*([^*]+)\*\*/g, '$1') // Bold + .replace(/\*([^*]+)\*/g, '$1') // Italic + .replace(/`([^`]+)`/g, '$1') // Inline code + .replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_match, text, href) => + `${text}` + ) // Links + .replace(/^#{1,6}\s+(.+)$/gm, '$1') // Headings to bold + .replace(/\n\n+/g, '

') // Paragraph breaks + .replace(/\n/g, ' ') // Single newlines to spaces + .replace(/^/, '

') // Wrap in paragraph + .replace(/$/, '

') + .replace(/

<\/p>/g, '') + ); // Remove empty paragraphs +} diff --git a/apps/portal/src/routes/featured/+page.svelte b/apps/portal/src/routes/featured/+page.svelte index 2c0a3196..9cd88c2a 100644 --- a/apps/portal/src/routes/featured/+page.svelte +++ b/apps/portal/src/routes/featured/+page.svelte @@ -1,5 +1,6 @@ + +

+

+ UK Open Banking Consent +

+ + {#if data.loadError} +
+

{data.loadError}

+
+ {:else} +

+ An application is requesting access to your account information via UK Open Banking. + Please review the details below and confirm if you agree. +

+ + {#if form?.message} +
+

{form.message}

+
+ {/if} + + +
+

+ Consent Details +

+ +
+
+ Standard: + UK Open Banking +
+ {#if data.bankId} +
+ Bank: + {data.bankId} +
+ {/if} +
+ Consent ID: + {data.consentId} +
+
+
+ + +
+
+ + + + +
+
+ + +
+
+ {/if} +
diff --git a/apps/portal/src/routes/login/[provider]/+server.ts b/apps/portal/src/routes/login/[provider]/+server.ts index b64443f8..1db0d44d 100644 --- a/apps/portal/src/routes/login/[provider]/+server.ts +++ b/apps/portal/src/routes/login/[provider]/+server.ts @@ -46,6 +46,10 @@ export function GET(event: RequestEvent) { // Preserve OBP consent flow params across the OAuth login round-trip const consentRequestId = event.url.searchParams.get('consent_request_id'); + // UK Open Banking flow: the TPP already lodged an account-access consent and passes its + // consent_id (plus api_standard) instead of a consent_request_id. + const consentId = event.url.searchParams.get('consent_id'); + const apiStandard = event.url.searchParams.get('api_standard'); const bankId = event.url.searchParams.get('bank_id'); const requestedOidcReturnUrl = event.url.searchParams.get('oidc_return_url'); @@ -60,11 +64,13 @@ export function GET(event: RequestEvent) { } } - logger.info(`Login request for provider: ${provider}, URL params: consent_request_id=${consentRequestId}, bank_id=${bankId}, oidc_return_url=${oidcReturnUrl}`); + logger.info(`Login request for provider: ${provider}, URL params: consent_request_id=${consentRequestId}, consent_id=${consentId}, api_standard=${apiStandard}, bank_id=${bankId}, oidc_return_url=${oidcReturnUrl}`); - if (consentRequestId) { + if (consentRequestId || consentId) { const consentFlowData = JSON.stringify({ - consent_request_id: consentRequestId, + consent_request_id: consentRequestId || '', + consent_id: consentId || '', + api_standard: apiStandard || '', bank_id: bankId || '', oidc_return_url: oidcReturnUrl || '', }); @@ -77,7 +83,7 @@ export function GET(event: RequestEvent) { }); logger.info(`Set obp_consent_flow cookie: ${consentFlowData}`); } else { - logger.info('No consent_request_id in login request - normal login flow'); + logger.info('No consent flow params in login request - normal login flow'); } try { diff --git a/apps/portal/src/routes/login/[provider]/callback/+server.ts b/apps/portal/src/routes/login/[provider]/callback/+server.ts index 501cf4fa..f830e76c 100644 --- a/apps/portal/src/routes/login/[provider]/callback/+server.ts +++ b/apps/portal/src/routes/login/[provider]/callback/+server.ts @@ -238,6 +238,26 @@ export async function GET(event: RequestEvent): Promise { // Clean up the cookie event.cookies.delete('obp_consent_flow', { path: '/' }); + // UK Open Banking: the consent already exists; route to the UK approval page + // which authorises it via OBP-API. Distinguished by api_standard + consent_id. + if (consentFlow.consent_id && consentFlow.api_standard === 'UKOpenBanking') { + const redirectUrl = new URL('/uk-consent-request', event.url.origin); + redirectUrl.searchParams.set('CONSENT_ID', consentFlow.consent_id); + if (consentFlow.bank_id) { + redirectUrl.searchParams.set('bank_id', consentFlow.bank_id); + } + if (consentFlow.oidc_return_url) { + redirectUrl.searchParams.set('oidc_return_url', consentFlow.oidc_return_url); + } + logger.info(`Redirecting to UK consent flow: ${redirectUrl.pathname}${redirectUrl.search}`); + return new Response(null, { + status: 302, + headers: { + Location: redirectUrl.pathname + redirectUrl.search + } + }); + } + if (consentFlow.consent_request_id) { const redirectUrl = new URL('/obp-consent-request', event.url.origin); redirectUrl.searchParams.set('CONSENT_REQUEST_ID', consentFlow.consent_request_id); diff --git a/apps/portal/src/routes/login/obp/callback/+server.ts b/apps/portal/src/routes/login/obp/callback/+server.ts index d0f96777..bd9f8c80 100644 --- a/apps/portal/src/routes/login/obp/callback/+server.ts +++ b/apps/portal/src/routes/login/obp/callback/+server.ts @@ -227,6 +227,26 @@ export async function GET(event: RequestEvent): Promise { // Clean up the cookie event.cookies.delete('obp_consent_flow', { path: '/' }); + // UK Open Banking: the consent already exists; route to the UK approval page + // which authorises it via OBP-API. Distinguished by api_standard + consent_id. + if (consentFlow.consent_id && consentFlow.api_standard === 'UKOpenBanking') { + const redirectUrl = new URL('/uk-consent-request', event.url.origin); + redirectUrl.searchParams.set('CONSENT_ID', consentFlow.consent_id); + if (consentFlow.bank_id) { + redirectUrl.searchParams.set('bank_id', consentFlow.bank_id); + } + if (consentFlow.oidc_return_url) { + redirectUrl.searchParams.set('oidc_return_url', consentFlow.oidc_return_url); + } + logger.info(`Redirecting to UK consent flow: ${redirectUrl.pathname}${redirectUrl.search}`); + return new Response(null, { + status: 302, + headers: { + Location: redirectUrl.pathname + redirectUrl.search + } + }); + } + if (consentFlow.consent_request_id) { const redirectUrl = new URL('/obp-consent-request', event.url.origin); redirectUrl.searchParams.set('CONSENT_REQUEST_ID', consentFlow.consent_request_id); From c2684b285faa1d3df270f82667aa2572bdebc08f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 09:45:26 +0200 Subject: [PATCH 10/11] feat: add SCA (OTP) step to UK Open Banking consent approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On approval, /uk-consent-request now starts an SCA challenge via OBP-API and routes to a new /uk-consent-request-sca page to collect the OTP. The consent is only authorised — and control returned to OBP-OIDC — once the correct OTP is submitted; a wrong OTP is rejected and the consent stays unauthorised. --- .../uk-consent-request-sca/+page.server.ts | 102 ++++++++++++++++++ .../uk-consent-request-sca/+page.svelte | 49 +++++++++ .../uk-consent-request/+page.server.ts | 50 +++++---- 3 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.server.ts create mode 100644 apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.svelte diff --git a/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.server.ts b/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.server.ts new file mode 100644 index 00000000..f1b9b06d --- /dev/null +++ b/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.server.ts @@ -0,0 +1,102 @@ +import { createLogger } from '@obp/shared/utils'; +const logger = createLogger('UKConsentRequestSCA'); +import type { RequestEvent, Actions } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import { obp_requests } from '$lib/obp/requests'; +import { OBPRequestError } from '@obp/shared/obp'; +import { oauth2ProviderFactory } from '$lib/oauth/providerFactory'; + +/** + * UK Open Banking consent SCA (OTP entry). + * + * Reached after /uk-consent-request started a challenge. The PSU enters the OTP; on a valid answer + * OBP-API authorises the consent (status -> AUTHORISED, bound to this user) and we hand control back + * to OBP-OIDC (with username + provider) so it mints a consent-bound auth code. + */ +export async function load(event: RequestEvent) { + const consentId = event.url.searchParams.get('CONSENT_ID'); + const bankId = event.url.searchParams.get('bank_id'); + const challengeId = event.url.searchParams.get('challenge_id'); + const requestedOidcReturnUrl = event.url.searchParams.get('oidc_return_url'); + + if (!consentId || !bankId || !challengeId) { + return { + loadError: 'Missing required parameter (CONSENT_ID, bank_id or challenge_id).', + consentId: consentId || '', + bankId: bankId || '', + challengeId: challengeId || '', + oidcReturnUrl: '' + }; + } + + const token = event.locals.session.data.oauth?.access_token; + if (!token) { + return { loadError: 'Unauthorized: No access token found in session.', consentId, bankId, challengeId, oidcReturnUrl: '' }; + } + + const oidcReturnUrl = + requestedOidcReturnUrl && oauth2ProviderFactory.isTrustedOidcReturnUrl(requestedOidcReturnUrl) + ? requestedOidcReturnUrl + : ''; + if (requestedOidcReturnUrl && !oidcReturnUrl) { + logger.warn(`Rejected untrusted oidc_return_url: ${requestedOidcReturnUrl}`); + } + + return { consentId, bankId, challengeId, oidcReturnUrl }; +} + +export const actions = { + default: async ({ request, locals }) => { + const formData = await request.formData(); + const otp = formData.get('otp') as string; + const consentId = formData.get('consentId') as string; + const bankId = formData.get('bankId') as string; + const challengeId = formData.get('challengeId') as string; + const oidcReturnUrlRaw = formData.get('oidcReturnUrl') as string; + + if (!otp) { + return { message: 'Please enter the OTP code.' }; + } + + const token = locals.session.data.oauth?.access_token; + if (!token) { + return { message: 'No access token found in session.' }; + } + + const oidcReturnUrl = + oidcReturnUrlRaw && oauth2ProviderFactory.isTrustedOidcReturnUrl(oidcReturnUrlRaw) + ? oidcReturnUrlRaw + : ''; + if (!oidcReturnUrl) { + return { message: 'No valid return URL was provided to complete the flow.' }; + } + + try { + // Verify the OTP and authorise the consent. OBP-API flips it to AUTHORISED and binds it to + // the PSU only if the challenge answer is correct; a wrong answer leaves it unauthorised. + await obp_requests.post( + `/obp/v5.1.0/banks/${bankId}/consents/${consentId}/authorise`, + { challenge_id: challengeId, answer: otp }, + token + ); + } catch (e) { + logger.error('Error verifying UK consent SCA:', e); + let errorMessage = 'Failed to verify OTP.'; + if (e instanceof OBPRequestError) { + errorMessage = e.message; + } + return { message: errorMessage }; + } + + // Hand control back to OBP-OIDC so it mints a consent-bound auth code. username + provider let + // OBP-OIDC's consent callback resolve the PSU (via GET /users/provider/{provider}/username/{username}). + const returnUrl = new URL(oidcReturnUrl); + returnUrl.searchParams.set('consent_id', consentId); + returnUrl.searchParams.set('consent_status', 'ACCEPTED'); + const username = locals.session.data.user?.username; + const provider = locals.session.data.user?.provider; + if (username) returnUrl.searchParams.set('username', username); + if (provider) returnUrl.searchParams.set('provider', provider); + redirect(303, returnUrl.toString()); + } +} satisfies Actions; diff --git a/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.svelte b/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.svelte new file mode 100644 index 00000000..9b7df79f --- /dev/null +++ b/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.svelte @@ -0,0 +1,49 @@ + + +
+

+ UK Open Banking — Verify +

+ + {#if data.loadError} +
+

{data.loadError}

+
+ {:else} +

+ A one-time passcode (OTP) has been sent to authorise this consent. Enter it below to complete + Strong Customer Authentication. +

+ + {#if form?.message} +
+

{form.message}

+
+ {/if} + +
+ + + + + + + + + +
+ {/if} +
diff --git a/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts b/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts index 7143bb2d..6b6899e7 100644 --- a/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts +++ b/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts @@ -9,10 +9,11 @@ import { oauth2ProviderFactory } from '$lib/oauth/providerFactory'; /** * UK Open Banking consent approval. * - * The TPP has already lodged an account-access consent (status AWAITINGAUTHORISATION) and - * routed the PSU here via OBP-OIDC. On approval we call OBP-API to authorise the consent as - * the current PSU (status -> AUTHORISED, bound to this user), then hand control back to - * OBP-OIDC so it mints an auth code whose token carries the consent_id claim. + * The TPP has already lodged an account-access consent (status AWAITINGAUTHORISATION) and routed + * the PSU here via OBP-OIDC. On approval we start Strong Customer Authentication (OBP-API issues a + * one-time challenge to the PSU) and route to /uk-consent-request-sca to collect the OTP; the + * consent is only authorised (status -> AUTHORISED, bound to this user) once the correct OTP is + * submitted, after which control returns to OBP-OIDC to mint a consent-bound auth code. */ export async function load(event: RequestEvent) { const consentId = event.url.searchParams.get('CONSENT_ID'); @@ -64,40 +65,37 @@ export const actions = { oidcReturnUrlRaw && oauth2ProviderFactory.isTrustedOidcReturnUrl(oidcReturnUrlRaw) ? oidcReturnUrlRaw : ''; + if (!oidcReturnUrl) { + logger.warn('No trusted OIDC return URL for UK consent flow'); + return { message: 'No valid return URL was provided to complete the flow.' }; + } + let challengeId = ''; try { - // Authorise the UK consent as the current PSU: OBP-API flips it to AUTHORISED and binds - // it to this user. This is the missing "authorisation binding" step of the UK flow. - await obp_requests.post( - `/obp/v5.1.0/banks/${bankId}/consents/${consentId}/authorise`, + // Start SCA: OBP-API issues a one-time challenge (OTP) to the PSU. The consent is only + // authorised on the /uk-consent-request-sca page once the correct OTP is submitted. + const challengeResponse = await obp_requests.post( + `/obp/v5.1.0/banks/${bankId}/consents/${consentId}/authorise/challenge`, {}, token ); + challengeId = challengeResponse.challenge_id; } catch (e) { - logger.error('Error authorising UK consent:', e); - let errorMessage = 'Failed to authorise consent.'; + logger.error('Error starting UK consent SCA challenge:', e); + let errorMessage = 'Failed to start Strong Customer Authentication.'; if (e instanceof OBPRequestError) { errorMessage = e.message; } return { message: errorMessage }; } - if (!oidcReturnUrl) { - logger.warn('No trusted OIDC return URL for UK consent redirect'); - return { message: 'Consent authorised, but no valid return URL was provided to complete the flow.' }; - } - - // Hand control back to OBP-OIDC so it mints an auth code bound to this consent_id. We pass - // username + provider so OBP-OIDC's consent callback can resolve the PSU (via - // GET /users/provider/{provider}/username/{username}) and issue the code. - const returnUrl = new URL(oidcReturnUrl); - returnUrl.searchParams.set('consent_id', consentId); - returnUrl.searchParams.set('consent_status', 'ACCEPTED'); - const username = locals.session.data.user?.username; - const provider = locals.session.data.user?.provider; - if (username) returnUrl.searchParams.set('username', username); - if (provider) returnUrl.searchParams.set('provider', provider); - redirect(303, returnUrl.toString()); + const params = new URLSearchParams({ + CONSENT_ID: consentId, + bank_id: bankId, + challenge_id: challengeId, + oidc_return_url: oidcReturnUrl + }); + redirect(303, `/uk-consent-request-sca?${params.toString()}`); }, deny: async ({ request }) => { From 042ed411e48c86bd49d03a4eca62fe35806be54a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 15:34:53 +0200 Subject: [PATCH 11/11] fix: route UK v4.0.1 consents to the approval page and show real consent details The OIDC callback only matched api_standard === 'UKOpenBanking' (v3.1), so a v4.0.1 consent (api_standard === 'UKOpenBankingV401') silently fell through past the UK branch instead of reaching /uk-consent-request. Match both standards and thread api_standard through as a query param so the approval page knows which version's GET-consent endpoint to call. Both UK consent pages previously showed a generic approval with no permission detail, or (on the OBP consent page) dumped the raw internal JSON payload with empty account_access/entitlements arrays. Render the actual requested permissions, status and expiry instead: - uk-consent-request: fetch GET /open-banking/{v3.1|v4.0.1/aisp}/account-access-consents/{id} (no ownership check pre-authorisation, matching the UK spec's PSU-facing consent-status lookup) and show Status/Expires/Permissions. - obp-consent-request: render account_access and entitlements as readable lists instead of a raw JSON dump. --- .../obp-consent-request/+page.svelte | 47 ++++++++++++++++--- .../uk-consent-request/+page.server.ts | 36 +++++++++++--- .../uk-consent-request/+page.svelte | 29 +++++++++++- .../login/[provider]/callback/+server.ts | 12 +++-- .../src/routes/login/obp/callback/+server.ts | 12 +++-- 5 files changed, 117 insertions(+), 19 deletions(-) diff --git a/apps/portal/src/routes/(protected)/obp-consent-request/+page.svelte b/apps/portal/src/routes/(protected)/obp-consent-request/+page.svelte index a1496d7f..4c71cda0 100644 --- a/apps/portal/src/routes/(protected)/obp-consent-request/+page.svelte +++ b/apps/portal/src/routes/(protected)/obp-consent-request/+page.svelte @@ -34,17 +34,12 @@ Consent Details -
-
{JSON.stringify(data.payload, null, 2)}
-
-
{#if data.payload.everything !== undefined}
Access Level: - {data.payload.everything ? 'Full access' : 'Limited access'} + {data.payload.everything ? 'Full access to all your accounts' : 'Limited access'}
{/if} @@ -72,6 +67,46 @@
{/if} + + {#if !data.payload.everything && data.payload.account_access?.length} +
+

+ Accounts requested ({data.payload.account_access.length}) +

+
    + {#each data.payload.account_access as access} +
  • +
    + Account: + {access.account_routing?.address} + ({access.account_routing?.scheme}) +
    +
    + Permission: + {access.view_id} +
    +
  • + {/each} +
+
+ {/if} + + {#if data.payload.entitlements?.length} +
+

+ Roles requested ({data.payload.entitlements.length}) +

+
    + {#each data.payload.entitlements as entitlement} +
  • + {entitlement.role_name} ({entitlement.bank_id}) +
  • + {/each} +
+
+ {/if} diff --git a/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts b/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts index 6b6899e7..4038acbe 100644 --- a/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts +++ b/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts @@ -15,21 +15,31 @@ import { oauth2ProviderFactory } from '$lib/oauth/providerFactory'; * consent is only authorised (status -> AUTHORISED, bound to this user) once the correct OTP is * submitted, after which control returns to OBP-OIDC to mint a consent-bound auth code. */ +// UK consent GET endpoints are version-specific (v3.1 vs v4.0.1 aisp), but return the same +// {Data: {Status, Permissions, ExpirationDateTime, ...}} shape — see OBP-API +// Http4sUKOBv310AccountAccess#getAccountAccessConsentsConsentId / Http4sUKOBv401AccountInfo#getAccountAccessConsentsConsentId. +function ukConsentGetPath(apiStandard: string, consentId: string): string { + return apiStandard === 'UKOpenBankingV401' + ? `/open-banking/v4.0.1/aisp/account-access-consents/${consentId}` + : `/open-banking/v3.1/account-access-consents/${consentId}`; +} + export async function load(event: RequestEvent) { const consentId = event.url.searchParams.get('CONSENT_ID'); const bankId = event.url.searchParams.get('bank_id'); + const apiStandard = event.url.searchParams.get('api_standard') || 'UKOpenBanking'; const requestedOidcReturnUrl = event.url.searchParams.get('oidc_return_url'); if (!consentId) { - return { loadError: 'Missing required parameter: CONSENT_ID.', consentId: '', bankId: bankId || '', oidcReturnUrl: '' }; + return { loadError: 'Missing required parameter: CONSENT_ID.', consentId: '', bankId: bankId || '', apiStandard, oidcReturnUrl: '' }; } if (!bankId) { - return { loadError: 'Missing required parameter: bank_id.', consentId, bankId: '', oidcReturnUrl: '' }; + return { loadError: 'Missing required parameter: bank_id.', consentId, bankId: '', apiStandard, oidcReturnUrl: '' }; } const token = event.locals.session.data.oauth?.access_token; if (!token) { - return { loadError: 'Unauthorized: No access token found in session.', consentId, bankId, oidcReturnUrl: '' }; + return { loadError: 'Unauthorized: No access token found in session.', consentId, bankId, apiStandard, oidcReturnUrl: '' }; } // oidc_return_url must point back to a configured OIDC provider host — otherwise it becomes @@ -43,9 +53,23 @@ export async function load(event: RequestEvent) { } // The UK consent is still awaiting authorisation and not yet bound to this user, so it can't - // be fetched via user/current/consents. We show a generic approval; the requested permissions - // live inside the consent and are enforced by OBP-API on data access. - return { consentId, bankId, oidcReturnUrl }; + // be fetched via user/current/consents (which requires ownership). The UK GET-consent-by-id + // endpoint has no ownership check — any authenticated caller may inspect a consent by its ID, + // matching the UK Open Banking spec's PSU-facing consent-status check before authorisation. + let status = ''; + let permissions: string[] = []; + let expirationDateTime = ''; + try { + const consent = await obp_requests.get(ukConsentGetPath(apiStandard, consentId), token); + status = consent?.Data?.Status || ''; + permissions = consent?.Data?.Permissions || []; + expirationDateTime = consent?.Data?.ExpirationDateTime || ''; + } catch (e) { + // Non-fatal: fall back to the generic approval screen without permission detail. + logger.warn('Could not fetch UK consent details:', e); + } + + return { consentId, bankId, apiStandard, oidcReturnUrl, status, permissions, expirationDateTime }; } export const actions = { diff --git a/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte b/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte index a0cdbf47..2f83c74b 100644 --- a/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte +++ b/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte @@ -32,7 +32,9 @@
Standard: - UK Open Banking + + {data.apiStandard === 'UKOpenBankingV401' ? 'UK Open Banking v4.0.1' : 'UK Open Banking'} +
{#if data.bankId}
@@ -44,7 +46,32 @@ Consent ID: {data.consentId}
+ {#if data.status} +
+ Status: + {data.status} +
+ {/if} + {#if data.expirationDateTime} +
+ Expires: + {data.expirationDateTime} +
+ {/if}
+ + {#if data.permissions?.length} +
+

+ Permissions requested ({data.permissions.length}) +

+
    + {#each data.permissions as permission} +
  • {permission}
  • + {/each} +
+
+ {/if} diff --git a/apps/portal/src/routes/login/[provider]/callback/+server.ts b/apps/portal/src/routes/login/[provider]/callback/+server.ts index f830e76c..6019faab 100644 --- a/apps/portal/src/routes/login/[provider]/callback/+server.ts +++ b/apps/portal/src/routes/login/[provider]/callback/+server.ts @@ -238,11 +238,17 @@ export async function GET(event: RequestEvent): Promise { // Clean up the cookie event.cookies.delete('obp_consent_flow', { path: '/' }); - // UK Open Banking: the consent already exists; route to the UK approval page - // which authorises it via OBP-API. Distinguished by api_standard + consent_id. - if (consentFlow.consent_id && consentFlow.api_standard === 'UKOpenBanking') { + // UK Open Banking (v3.1 or v4.0.1): the consent already exists; route to the UK + // approval page which authorises it via OBP-API. Distinguished by api_standard + + // consent_id. + if ( + consentFlow.consent_id && + (consentFlow.api_standard === 'UKOpenBanking' || + consentFlow.api_standard === 'UKOpenBankingV401') + ) { const redirectUrl = new URL('/uk-consent-request', event.url.origin); redirectUrl.searchParams.set('CONSENT_ID', consentFlow.consent_id); + redirectUrl.searchParams.set('api_standard', consentFlow.api_standard); if (consentFlow.bank_id) { redirectUrl.searchParams.set('bank_id', consentFlow.bank_id); } diff --git a/apps/portal/src/routes/login/obp/callback/+server.ts b/apps/portal/src/routes/login/obp/callback/+server.ts index bd9f8c80..566059ad 100644 --- a/apps/portal/src/routes/login/obp/callback/+server.ts +++ b/apps/portal/src/routes/login/obp/callback/+server.ts @@ -227,11 +227,17 @@ export async function GET(event: RequestEvent): Promise { // Clean up the cookie event.cookies.delete('obp_consent_flow', { path: '/' }); - // UK Open Banking: the consent already exists; route to the UK approval page - // which authorises it via OBP-API. Distinguished by api_standard + consent_id. - if (consentFlow.consent_id && consentFlow.api_standard === 'UKOpenBanking') { + // UK Open Banking (v3.1 or v4.0.1): the consent already exists; route to the UK + // approval page which authorises it via OBP-API. Distinguished by api_standard + + // consent_id. + if ( + consentFlow.consent_id && + (consentFlow.api_standard === 'UKOpenBanking' || + consentFlow.api_standard === 'UKOpenBankingV401') + ) { const redirectUrl = new URL('/uk-consent-request', event.url.origin); redirectUrl.searchParams.set('CONSENT_ID', consentFlow.consent_id); + redirectUrl.searchParams.set('api_standard', consentFlow.api_standard); if (consentFlow.bank_id) { redirectUrl.searchParams.set('bank_id', consentFlow.bank_id); }