From 857ea0f03bfe96048c7c32663e24d28c89700c4a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:40:48 +0000 Subject: [PATCH 1/5] fix: guard DPoP fallback URL construction Co-authored-by: Codex --- apps/server/src/auth/dpop.test.ts | 16 +++++++++++++++- apps/server/src/auth/dpop.ts | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index fa75c407b0c..5fbe207ad59 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { SecretStorePersistError } from "./ServerSecretStore.ts"; -import { mapDpopReplayStoreError } from "./dpop.ts"; +import { mapDpopReplayStoreError, requestAbsoluteUrl } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ @@ -35,3 +36,16 @@ describe("mapDpopReplayStoreError", () => { } }); }); + +describe("requestAbsoluteUrl", () => { + it("returns null when fallback host URL construction is invalid", () => { + const request = { + originalUrl: "/api/dpop", + headers: { + host: "bad host", + }, + } as unknown as HttpServerRequest.HttpServerRequest; + + expect(requestAbsoluteUrl(request)).toBeNull(); + }); +}); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 87dc0c263e2..edec029a6f5 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -18,14 +18,18 @@ function firstHeaderValue(value: string | undefined): string | undefined { return first && first.length > 0 ? first : undefined; } -export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string { +export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string | null { try { return new URL(request.originalUrl).href; } catch { const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1"; const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]); const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http"; - return new URL(request.originalUrl, `${proto}://${host}`).href; + try { + return new URL(request.originalUrl, `${proto}://${host}`).href; + } catch { + return null; + } } } @@ -48,11 +52,17 @@ export const verifyRequestDpopProof = (input: { }) => Effect.gen(function* () { const proof = input.request.headers.dpop; + const url = requestAbsoluteUrl(input.request); + if (url === null) { + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: "Invalid DPoP request URL.", + }); + } const now = yield* DateTime.now; const result = verifyDpopProof({ proof, method: input.request.method, - url: requestAbsoluteUrl(input.request), + url, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), From 502ea79094cc834221974e3706881bd58ce69b11 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:57:04 +0000 Subject: [PATCH 2/5] fix: use request URL helper for DPoP Co-authored-by: Codex --- apps/server/src/auth/dpop.test.ts | 4 ++-- apps/server/src/auth/dpop.ts | 22 ++++------------------ 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 5fbe207ad59..ba0d0255924 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -38,9 +38,9 @@ describe("mapDpopReplayStoreError", () => { }); describe("requestAbsoluteUrl", () => { - it("returns null when fallback host URL construction is invalid", () => { + it("returns null when the request URL cannot be resolved", () => { const request = { - originalUrl: "/api/dpop", + url: "/api/dpop", headers: { host: "bad host", }, diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index edec029a6f5..0b9a0881fed 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -3,7 +3,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; -import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Option from "effect/Option"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { ServerAuthDpopReplayKeyCalculationError, @@ -13,24 +14,9 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -function firstHeaderValue(value: string | undefined): string | undefined { - const first = value?.split(",")[0]?.trim(); - return first && first.length > 0 ? first : undefined; -} - export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string | null { - try { - return new URL(request.originalUrl).href; - } catch { - const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1"; - const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]); - const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http"; - try { - return new URL(request.originalUrl, `${proto}://${host}`).href; - } catch { - return null; - } - } + const url = HttpServerRequest.toURL(request); + return Option.isSome(url) ? url.value.href : null; } export const mapDpopReplayStoreError = ( From 3fc5ca0a2d1a914f86f4778a6a6da9b5662be2bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 10:11:38 -0700 Subject: [PATCH 3/5] Discard changes to apps/server/src/auth/dpop.test.ts --- apps/server/src/auth/dpop.test.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index ba0d0255924..fa75c407b0c 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; -import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { SecretStorePersistError } from "./ServerSecretStore.ts"; -import { mapDpopReplayStoreError, requestAbsoluteUrl } from "./dpop.ts"; +import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ @@ -36,16 +35,3 @@ describe("mapDpopReplayStoreError", () => { } }); }); - -describe("requestAbsoluteUrl", () => { - it("returns null when the request URL cannot be resolved", () => { - const request = { - url: "/api/dpop", - headers: { - host: "bad host", - }, - } as unknown as HttpServerRequest.HttpServerRequest; - - expect(requestAbsoluteUrl(request)).toBeNull(); - }); -}); From 004527cc3b483ac7c2ae3f37f4469ad57eb3a16a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 10:12:43 -0700 Subject: [PATCH 4/5] keep Option typing --- apps/server/src/auth/dpop.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 0b9a0881fed..aa65c384871 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -14,11 +14,6 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string | null { - const url = HttpServerRequest.toURL(request); - return Option.isSome(url) ? url.value.href : null; -} - export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError => @@ -38,8 +33,8 @@ export const verifyRequestDpopProof = (input: { }) => Effect.gen(function* () { const proof = input.request.headers.dpop; - const url = requestAbsoluteUrl(input.request); - if (url === null) { + const url = HttpServerRequest.toURL(input.request) + if (Option.isNone(url)) { return yield* new ServerAuthInvalidCredentialError({ diagnostic: "Invalid DPoP request URL.", }); @@ -48,7 +43,7 @@ export const verifyRequestDpopProof = (input: { const result = verifyDpopProof({ proof, method: input.request.method, - url, + url: url.value, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), From f1e04ed36dd62952f65956767a99acc38859fb16 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:33:56 +0000 Subject: [PATCH 5/5] fix: satisfy DPoP URL guard checks Co-authored-by: Codex --- apps/server/src/auth/dpop.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index aa65c384871..f19984eb369 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -33,7 +33,7 @@ export const verifyRequestDpopProof = (input: { }) => Effect.gen(function* () { const proof = input.request.headers.dpop; - const url = HttpServerRequest.toURL(input.request) + const url = HttpServerRequest.toURL(input.request); if (Option.isNone(url)) { return yield* new ServerAuthInvalidCredentialError({ diagnostic: "Invalid DPoP request URL.", @@ -43,7 +43,7 @@ export const verifyRequestDpopProof = (input: { const result = verifyDpopProof({ proof, method: input.request.method, - url: url.value, + url: url.value.href, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),