authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-08-29 00:14:32+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 14:14:32-07:00
log94f1948166620c804937f5548a68f5739d76c9fb
tree49cf7cdcf1ece76695c741ffb7caaddc388caa26
parent45c0dff8ec33a023c79ccb4e06494309253d1c25
signature Signed by PGP key B5690EEEBB952194

fix(connect): explain DPoP connection failures (#8351)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

33 files changed, 859 insertions(+), 164 deletions(-)

apps/mobile/src/features/cloud/linkEnvironment.test.ts+78-3
......@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import { EnvironmentId } from "@t3tools/contracts";
66import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import { ManagedRelay } from "@t3tools/client-runtime/relay";
7import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";
88import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
99import { HttpClient } from "effect/unstable/http";
1010import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
......@@ -1089,13 +1089,88 @@ describe("mobile cloud link environment client", () => {
10891089 ).pipe(Effect.flip);
10901090 expect(error).toMatchObject({
10911091 _tag: "CloudEnvironmentLinkError",
1092 message:
1093 "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.",
1092 message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
10941093 traceId: "trace-connect",
10951094 });
10961095 }),
10971096 );
10981097
1098 it.effect(
1099 "presents clock skew as one possible cause when an older environment rejects DPoP",
1100 () =>
1101 Effect.gen(function* () {
1102 vi.stubGlobal(
1103 "fetch",
1104 vi.fn((url: string | URL) => {
1105 const value = String(url);
1106 if (value.endsWith("/v1/client/dpop-token")) {
1107 return Promise.resolve(
1108 Response.json(validDpopAccessTokenResponse("environment:connect")),
1109 );
1110 }
1111 if (value.endsWith("/v1/environments/env-1/connect")) {
1112 return Promise.resolve(
1113 Response.json({
1114 environmentId: "env-1",
1115 endpoint: {
1116 httpBaseUrl: "https://desktop.example.test/",
1117 wsBaseUrl: "wss://desktop.example.test/ws",
1118 providerKind: "cloudflare_tunnel",
1119 },
1120 credential: "one-time-cloud-credential",
1121 expiresAt: "2026-05-25T00:05:00.000Z",
1122 }),
1123 );
1124 }
1125 if (value.endsWith("/.well-known/t3/environment")) {
1126 return Promise.resolve(
1127 Response.json({
1128 environmentId: "env-1",
1129 label: "Desktop",
1130 platform: { os: "darwin", arch: "arm64" },
1131 serverVersion: "0.0.0-test",
1132 capabilities: { repositoryIdentity: true },
1133 }),
1134 );
1135 }
1136 return Promise.resolve(
1137 Response.json(
1138 {
1139 _tag: "EnvironmentAuthInvalidError",
1140 code: "auth_invalid",
1141 reason: "invalid_credential",
1142 traceId: "trace-environment",
1143 },
1144 { status: 401 },
1145 ),
1146 );
1147 }),
1148 );
1149
1150 const error = yield* withCloudServices(
1151 connectCloudEnvironment({
1152 clerkToken: "clerk-token",
1153 environment: {
1154 environmentId: EnvironmentId.make("env-1"),
1155 label: "Desktop",
1156 endpoint: {
1157 httpBaseUrl: "https://desktop.example.test/",
1158 wsBaseUrl: "wss://desktop.example.test/ws",
1159 providerKind: "cloudflare_tunnel",
1160 },
1161 linkedAt: "2026-05-25T00:00:00.000Z",
1162 },
1163 }),
1164 ).pipe(Effect.flip);
1165
1166 expect(error).toMatchObject({
1167 _tag: "CloudEnvironmentLinkError",
1168 message: `Could not exchange a managed endpoint DPoP access token. ${DPOP_UNKNOWN_HINT}`,
1169 traceId: "trace-environment",
1170 });
1171 }),
1172 );
1173
10991174 it.effect("rejects relay connect responses for a different endpoint", () =>
11001175 Effect.gen(function* () {
11011176 vi.stubGlobal(
apps/mobile/src/features/cloud/linkEnvironment.ts+29-51
......@@ -4,6 +4,7 @@ import * as Schema from "effect/Schema";
44import { HttpClient } from "effect/unstable/http";
55import {
66 EnvironmentCloudEndpointUnavailableError,
7 EnvironmentAuthInvalidError,
78 EnvironmentHttpBadRequestError,
89 EnvironmentHttpConflictError,
910 EnvironmentHttpForbiddenError,
......@@ -17,7 +18,6 @@ import {
1718 RelayEnvironmentConnectScope,
1819 RelayEnvironmentStatusScope,
1920 type RelayDpopAccessTokenScope,
20 type RelayProtectedError as RelayProtectedErrorType,
2121 type RelayClientEnvironmentRecord,
2222 type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
2323 type RelayManagedEndpointProviderKind,
......@@ -25,7 +25,11 @@ import {
2525import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
2626import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
2727import { findErrorTraceId } from "@t3tools/client-runtime/errors";
28import { ManagedRelay } from "@t3tools/client-runtime/relay";
28import {
29 dpopFailureMessage,
30 ManagedRelay,
31 relayProtectedErrorMessage,
32} from "@t3tools/client-runtime/relay";
2933import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3034
3135import { authClientMetadata } from "../../lib/authClientMetadata";
......@@ -73,18 +77,24 @@ const isEnvironmentCloudApiError = Schema.is(
7377 EnvironmentCloudEndpointUnavailableError,
7478 ]),
7579);
80const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
7681
7782const MANAGED_ENDPOINT_PROVIDER_KIND =
7883 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
7984
80function cloudEnvironmentLinkError(message: string) {
85function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) {
8186 return (cause: unknown) => {
8287 const environmentError = findEnvironmentCloudApiError(cause);
8388 const traceId = findErrorTraceId(cause);
89 const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null;
90 const detail = environmentError
91 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
92 : withDevCause(message, cause);
8493 return new CloudEnvironmentLinkError({
85 message: environmentError
86 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
87 : withDevCause(message, cause),
94 message:
95 dpopAuthError?.reason === "invalid_credential"
96 ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason)
97 : detail,
8898 cause,
8999 ...(traceId === null ? {} : { traceId }),
90100 });
......@@ -117,50 +127,6 @@ function withDevCause(message: string, cause: unknown): string {
117127 return detail ? `${message} (${detail})` : message;
118128}
119129
120function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
121 switch (error._tag) {
122 case "RelayAuthInvalidError":
123 switch (error.reason) {
124 case "missing_bearer":
125 case "invalid_bearer":
126 return "Relay rejected the cloud session token.";
127 case "invalid_dpop":
128 return "Relay rejected the DPoP proof.";
129 case "not_authorized":
130 return "Relay rejected the authenticated request.";
131 }
132 case "RelayEnvironmentLinkProofExpiredError":
133 return "Relay rejected an expired environment link proof.";
134 case "RelayEnvironmentLinkProofInvalidError":
135 return `Relay rejected the environment link proof (${error.reason}).`;
136 case "RelayEnvironmentConnectNotAuthorizedError":
137 // "Not authorized" covers non-auth causes too; surface the reason so a
138 // missing link doesn't read as a credential problem.
139 if (error.reason === "environment_link_not_found") {
140 return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
141 }
142 return error.reason
143 ? `Relay rejected the environment connection request (${error.reason}).`
144 : "Relay rejected the environment connection request.";
145 case "RelayEnvironmentEndpointUnavailableError":
146 return `Relay could not reach the environment endpoint (${error.reason}).`;
147 case "RelayEnvironmentEndpointTimedOutError":
148 return "Relay timed out while contacting the environment endpoint.";
149 case "RelayEnvironmentLinkFailedError":
150 return `Relay could not link the environment (${error.reason}).`;
151 case "RelayEnvironmentLinkUnavailableError":
152 return `Relay cannot provision the managed endpoint (${error.reason}).`;
153 case "RelayEnvironmentLinkLimitExceededError":
154 return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
155 case "RelayAgentActivityPublishProofExpiredError":
156 return "Relay rejected an expired agent activity publish proof.";
157 case "RelayAgentActivityPublishProofInvalidError":
158 return `Relay rejected the agent activity publish proof (${error.reason}).`;
159 case "RelayInternalError":
160 return `Relay encountered an internal error (${error.reason}).`;
161 }
162}
163
164130function decodedRelayClientError(message: string) {
165131 return (cause: ManagedRelay.ManagedRelayClientError) => {
166132 const relayError =
......@@ -185,6 +151,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
185151 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
186152}
187153
154function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null {
155 if (isEnvironmentAuthInvalidError(cause)) {
156 return cause;
157 }
158 if (typeof cause !== "object" || cause === null) {
159 return null;
160 }
161 return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null;
162}
163
188164function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {
189165 const relayUrl = readRelayUrl();
190166 return relayUrl
......@@ -560,7 +536,9 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
560536 clientMetadata: authClientMetadata(),
561537 }).pipe(
562538 Effect.mapError(
563 cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."),
539 cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", {
540 dpop: true,
541 }),
564542 ),
565543 );
566544 const pairingUrl = new URL(connect.endpoint.httpBaseUrl);
apps/server/src/auth/EnvironmentAuth.ts+10
......@@ -16,6 +16,8 @@ import {
1616 type ServerAuthDescriptor,
1717 type ServerAuthSessionMethod,
1818 type AuthWebSocketTicketResult,
19 DpopFailureReason,
20 type DpopFailureReason as DpopFailureReasonType,
1921} from "@t3tools/contracts";
2022import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
2123import * as Context from "effect/Context";
......@@ -347,6 +349,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass<Se
347349 "ServerAuthInvalidCredentialError",
348350 {
349351 diagnostic: Schema.optional(Schema.String),
352 dpopFailureReason: Schema.optionalKey(DpopFailureReason),
350353 cause: Schema.optional(Schema.Defect()),
351354 },
352355) {
......@@ -366,6 +369,11 @@ export const serverAuthCredentialReason = (
366369): "missing_credential" | "invalid_credential" =>
367370 error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential";
368371
372export const serverAuthDpopFailureReason = (
373 error: ServerAuthCredentialError,
374): DpopFailureReasonType | undefined =>
375 error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined;
376
369377export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass<ServerAuthInvalidScopeError>()(
370378 "ServerAuthInvalidScopeError",
371379 {},
......@@ -606,6 +614,7 @@ export const make = Effect.gen(function* () {
606614 return Effect.fail(
607615 new ServerAuthInvalidCredentialError({
608616 diagnostic: "DPoP-bound access token requires DPoP authorization.",
617 dpopFailureReason: "invalid_proof",
609618 }),
610619 );
611620 }
......@@ -623,6 +632,7 @@ export const make = Effect.gen(function* () {
623632 return Effect.fail(
624633 new ServerAuthInvalidCredentialError({
625634 diagnostic: "DPoP authorization requires a proof-bound access token.",
635 dpopFailureReason: "invalid_proof",
626636 }),
627637 );
628638 }
apps/server/src/auth/dpop.test.ts+22-1
......@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22import * as PlatformError from "effect/PlatformError";
33
44import { SecretStorePersistError } from "./ServerSecretStore.ts";
5import { mapDpopReplayStoreError } from "./dpop.ts";
5import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts";
66
77const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
88 new SecretStorePersistError({
......@@ -23,6 +23,7 @@ describe("mapDpopReplayStoreError", () => {
2323 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
2424 if (error._tag === "ServerAuthInvalidCredentialError") {
2525 expect(error.cause).toBe(cause);
26 expect(error.dpopFailureReason).toBe("replay");
2627 }
2728 });
2829
......@@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => {
3536 }
3637 });
3738});
39
40describe("mapDpopFailureReason", () => {
41 it("maps verifier failures to safe client-facing categories", () => {
42 const mappings = [
43 ["time_window", "time_window"],
44 ["key_mismatch", "key_mismatch"],
45 ["method_mismatch", "request_mismatch"],
46 ["url_mismatch", "request_mismatch"],
47 ["access_token_hash_mismatch", "token_mismatch"],
48 ["missing_proof", "invalid_proof"],
49 ["malformed_proof", "invalid_proof"],
50 ["invalid_signature", "invalid_proof"],
51 ["invalid_proof", "invalid_proof"],
52 ] as const;
53
54 for (const [code, expected] of mappings) {
55 expect(mapDpopFailureReason(code)).toBe(expected);
56 }
57 });
58});
apps/server/src/auth/dpop.ts+38-2
......@@ -1,4 +1,8 @@
1import { verifyDpopProof } from "@t3tools/shared/dpop";
1import {
2 type DpopVerificationFailureCode as DpopVerificationFailureCodeType,
3 verifyDpopProof,
4} from "@t3tools/shared/dpop";
5import type { DpopFailureReason } from "@t3tools/contracts";
26import * as Crypto from "effect/Crypto";
37import * as DateTime from "effect/DateTime";
48import * as Effect from "effect/Effect";
......@@ -14,12 +18,32 @@ import {
1418} from "./EnvironmentAuth.ts";
1519import * as ServerSecretStore from "./ServerSecretStore.ts";
1620
21export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => {
22 switch (code) {
23 case "time_window":
24 return "time_window";
25 case "key_mismatch":
26 return "key_mismatch";
27 case "method_mismatch":
28 case "url_mismatch":
29 return "request_mismatch";
30 case "access_token_hash_mismatch":
31 return "token_mismatch";
32 case "missing_proof":
33 case "malformed_proof":
34 case "invalid_signature":
35 case "invalid_proof":
36 return "invalid_proof";
37 }
38};
39
1740export const mapDpopReplayStoreError = (
1841 error: ServerSecretStore.SecretStoreError,
1942): ServerAuthInvalidCredentialError | ServerAuthInternalError =>
2043 ServerSecretStore.isSecretAlreadyExistsError(error)
2144 ? new ServerAuthInvalidCredentialError({
2245 diagnostic: "DPoP proof replayed.",
46 dpopFailureReason: "replay",
2347 cause: error,
2448 })
2549 : new ServerAuthDpopReplayStateRecordError({
......@@ -49,8 +73,12 @@ export const verifyRequestDpopProof = (input: {
4973 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
5074 });
5175 if (!result.ok) {
76 yield* Effect.annotateCurrentSpan({
77 "environment.dpop.failure_code": result.code,
78 });
5279 return yield* new ServerAuthInvalidCredentialError({
5380 diagnostic: result.reason,
81 dpopFailureReason: mapDpopFailureReason(result.code),
5482 });
5583 }
5684 const secretStore = yield* ServerSecretStore.ServerSecretStore;
......@@ -80,7 +108,15 @@ export const verifyRequestDpopProof = (input: {
80108 )
81109 .pipe(
82110 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
83 Effect.fail(mapDpopReplayStoreError(error)),
111 Effect.gen(function* () {
112 const mapped = mapDpopReplayStoreError(error);
113 if (mapped._tag === "ServerAuthInvalidCredentialError") {
114 yield* Effect.annotateCurrentSpan({
115 "environment.dpop.failure_code": mapped.dpopFailureReason,
116 });
117 }
118 return yield* Effect.fail(mapped);
119 }),
84120 ),
85121 );
86122 return result.thumbprint;
apps/server/src/auth/http.ts+32-8
......@@ -22,7 +22,7 @@ import {
2222 EnvironmentAuthenticatedAuth,
2323 EnvironmentAuthenticatedPrincipal,
2424} from "@t3tools/contracts";
25import type { AuthEnvironmentScope } from "@t3tools/contracts";
25import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
2626import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
2727import { causeErrorTag } from "@t3tools/shared/observability";
2828import * as DateTime from "effect/DateTime";
......@@ -95,10 +95,20 @@ export function annotateEnvironmentRequest(endpoint: string) {
9595 });
9696}
9797
98export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) {
98export function failEnvironmentAuthInvalid(
99 reason: EnvironmentAuthInvalidReason,
100 dpopFailureReason?: DpopFailureReason,
101) {
99102 return currentEnvironmentTraceId.pipe(
100103 Effect.flatMap((traceId) =>
101 Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })),
104 Effect.fail(
105 new EnvironmentAuthInvalidError({
106 code: "auth_invalid",
107 reason,
108 ...(dpopFailureReason === undefined ? {} : { dpopFailureReason }),
109 traceId,
110 }),
111 ),
102112 ),
103113 );
104114}
......@@ -180,7 +190,10 @@ export const environmentAuthenticatedAuthLayer = Layer.effect(
180190 const request = yield* HttpServerRequest.HttpServerRequest;
181191 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
182192 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
183 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
193 failEnvironmentAuthInvalid(
194 EnvironmentAuth.serverAuthCredentialReason(error),
195 EnvironmentAuth.serverAuthDpopFailureReason(error),
196 ),
184197 ),
185198 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
186199 failEnvironmentInternal("internal_error", error),
......@@ -244,7 +257,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
244257 return result.response;
245258 },
246259 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
247 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
260 failEnvironmentAuthInvalid(
261 EnvironmentAuth.serverAuthCredentialReason(error),
262 EnvironmentAuth.serverAuthDpopFailureReason(error),
263 ),
248264 ),
249265 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
250266 failEnvironmentInternal("browser_session_issuance_failed", error),
......@@ -278,9 +294,14 @@ export const authHttpApiLayer = HttpApiBuilder.group(
278294 }
279295 const proofKeyThumbprint = args.headers.dpop
280296 ? yield* verifyRequestDpopProof({ request }).pipe(
281 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () =>
297 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
282298 appendDpopChallengeHeader.pipe(
283 Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
299 Effect.andThen(
300 failEnvironmentAuthInvalid(
301 "invalid_credential",
302 EnvironmentAuth.serverAuthDpopFailureReason(error),
303 ),
304 ),
284305 ),
285306 ),
286307 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
......@@ -307,7 +328,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
307328 },
308329 traceRelayRequest,
309330 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
310 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
331 failEnvironmentAuthInvalid(
332 EnvironmentAuth.serverAuthCredentialReason(error),
333 EnvironmentAuth.serverAuthDpopFailureReason(error),
334 ),
311335 ),
312336 Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) =>
313337 failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)),
apps/server/src/http.ts+4-1
......@@ -160,7 +160,10 @@ const authenticateRawRouteWithScope = (
160160 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
161161 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
162162 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
163 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
163 failEnvironmentAuthInvalid(
164 EnvironmentAuth.serverAuthCredentialReason(error),
165 EnvironmentAuth.serverAuthDpopFailureReason(error),
166 ),
164167 ),
165168 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
166169 failEnvironmentInternal("internal_error", error),
apps/server/src/server.test.ts+36
......@@ -10,6 +10,7 @@ import {
1010 AuthTokenExchangeGrantType,
1111 CommandId,
1212 DEFAULT_SERVER_SETTINGS,
13 type DpopFailureReason,
1314 EnvironmentId,
1415 EventId,
1516 GitCommandError,
......@@ -1115,6 +1116,7 @@ const exchangeAccessToken = (
11151116 readonly _tag?: string;
11161117 readonly code?: string;
11171118 readonly reason?: string;
1119 readonly dpopFailureReason?: DpopFailureReason;
11181120 readonly traceId?: string;
11191121 }>(response);
11201122 return {
......@@ -1797,6 +1799,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17971799 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
17981800 );
17991801
1802 it.effect("reports clock skew for a future-dated DPoP token exchange proof", () =>
1803 Effect.gen(function* () {
1804 yield* buildAppUnderTest();
1805
1806 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1807 const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1808 headers: { cookie: ownerCookie },
1809 body: yield* HttpBody.json({}),
1810 });
1811 const credential = (yield* credentialResponse.json) as { readonly credential: string };
1812 const tokenUrl = yield* getHttpServerUrl("/oauth/token");
1813 const now = yield* DateTime.now;
1814 const dpop = makeDpopProof({
1815 method: "POST",
1816 url: tokenUrl,
1817 iat: Math.floor(now.epochMilliseconds / 1_000) + 25,
1818 });
1819
1820 const exchange = yield* exchangeAccessToken(credential.credential, {
1821 headers: { dpop: dpop.proof },
1822 scope: "orchestration:read orchestration:operate terminal:operate review:write",
1823 });
1824
1825 assert.equal(exchange.response.status, 401);
1826 assert.equal(exchange.body._tag, "EnvironmentAuthInvalidError");
1827 assert.equal(exchange.body.code, "auth_invalid");
1828 assert.equal(exchange.body.reason, "invalid_credential");
1829 assert.equal(exchange.body.dpopFailureReason, "time_window");
1830 assert.equal(typeof exchange.body.traceId, "string");
1831 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1832 );
1833
18001834 it.effect("rejects replayed DPoP proofs across token exchanges", () =>
18011835 Effect.gen(function* () {
18021836 yield* buildAppUnderTest();
......@@ -1846,6 +1880,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
18461880 assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError");
18471881 assert.equal(replayBootstrap.body.code, "auth_invalid");
18481882 assert.equal(replayBootstrap.body.reason, "invalid_credential");
1883 assert.equal(replayBootstrap.body.dpopFailureReason, "replay");
18491884 assert.equal(typeof replayBootstrap.body.traceId, "string");
18501885 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
18511886 );
......@@ -1921,6 +1956,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19211956 assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError");
19221957 assert.equal(bootstrap.body.code, "auth_invalid");
19231958 assert.equal(bootstrap.body.reason, "invalid_credential");
1959 assert.equal(bootstrap.body.dpopFailureReason, "request_mismatch");
19241960 assert.equal(typeof bootstrap.body.traceId, "string");
19251961 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
19261962 );
apps/server/src/ws.ts+4-1
......@@ -2500,7 +2500,10 @@ export const websocketRpcRouteLayer = Layer.unwrap(
25002500 const analytics = yield* AnalyticsService.AnalyticsService;
25012501 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
25022502 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
2503 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
2503 failEnvironmentAuthInvalid(
2504 EnvironmentAuth.serverAuthCredentialReason(error),
2505 EnvironmentAuth.serverAuthDpopFailureReason(error),
2506 ),
25042507 ),
25052508 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
25062509 failEnvironmentInternal("internal_error", error),
apps/web/src/cloud/linkEnvironment.ts+1-46
......@@ -19,13 +19,12 @@ import {
1919 type RelayClientDeviceRecord,
2020 type RelayClientEnvironmentRecord,
2121 type RelayEnvironmentLinkResponse,
22 type RelayProtectedError as RelayProtectedErrorType,
2322 type RelayManagedEndpointProviderKind,
2423} from "@t3tools/contracts/relay";
2524import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2625import { request, runStream } from "@t3tools/client-runtime/rpc";
2726import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
28import { ManagedRelay } from "@t3tools/client-runtime/relay";
27import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";
2928
3029import {
3130 readPrimaryEnvironmentDescriptor,
......@@ -128,50 +127,6 @@ const isEnvironmentCloudApiError = Schema.is(
128127 ]),
129128);
130129
131function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
132 switch (error._tag) {
133 case "RelayAuthInvalidError":
134 switch (error.reason) {
135 case "missing_bearer":
136 case "invalid_bearer":
137 return "Relay rejected the cloud session token.";
138 case "invalid_dpop":
139 return "Relay rejected the DPoP proof.";
140 case "not_authorized":
141 return "Relay rejected the authenticated request.";
142 }
143 case "RelayEnvironmentLinkProofExpiredError":
144 return "Relay rejected an expired environment link proof.";
145 case "RelayEnvironmentLinkProofInvalidError":
146 return `Relay rejected the environment link proof (${error.reason}).`;
147 case "RelayEnvironmentConnectNotAuthorizedError":
148 // "Not authorized" covers non-auth causes too; surface the reason so a
149 // missing link doesn't read as a credential problem.
150 if (error.reason === "environment_link_not_found") {
151 return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
152 }
153 return error.reason
154 ? `Relay rejected the environment connection request (${error.reason}).`
155 : "Relay rejected the environment connection request.";
156 case "RelayEnvironmentEndpointUnavailableError":
157 return `Relay could not reach the environment endpoint (${error.reason}).`;
158 case "RelayEnvironmentEndpointTimedOutError":
159 return "Relay timed out while contacting the environment endpoint.";
160 case "RelayEnvironmentLinkFailedError":
161 return `Relay could not link the environment (${error.reason}).`;
162 case "RelayEnvironmentLinkUnavailableError":
163 return `Relay cannot provision the managed endpoint (${error.reason}).`;
164 case "RelayEnvironmentLinkLimitExceededError":
165 return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
166 case "RelayAgentActivityPublishProofExpiredError":
167 return "Relay rejected an expired agent activity publish proof.";
168 case "RelayAgentActivityPublishProofInvalidError":
169 return `Relay rejected the agent activity publish proof (${error.reason}).`;
170 case "RelayInternalError":
171 return `Relay encountered an internal error (${error.reason}).`;
172 }
173}
174
175130function decodedRelayClientError(message: string) {
176131 return (cause: ManagedRelay.ManagedRelayClientError) => {
177132 const relayError =
docs/internals/environment-auth.md+3-1
......@@ -84,7 +84,9 @@ that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`;
8484the resulting JWK thumbprint is stored on the session, which is then issued with
8585method `dpop-access-token` and a one-hour TTL instead of the bearer default. An
8686invalid proof gets a DPoP challenge header and a credential error rather than a
87bearer token.
87bearer token. Newer servers include a safe `dpopFailureReason` category in that
88error. When an older server omits the category, clients mention clock skew as
89one possible cause rather than presenting it as confirmed.
8890
8991`dpop-access-token` is advertised alongside `browser-session-cookie` and
9092`bearer-access-token` in the descriptor's `sessionMethods`
docs/operations/observability.md+6
......@@ -49,6 +49,12 @@ records instead carry OTLP resource, scope, and optional status fields.
4949The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in
5050`packages/shared/src/observability.ts`.
5151
52DPoP proof failures include the safe `environment.dpop.failure_code` span
53attribute. A `time_window` failure means that a signed proof was too old or too
54far in the future for the environment server's allowed window. It can point to
55a date or time problem on either device, but it can also result from a delayed
56request.
57
5258### Metrics
5359
5460Metrics are not written to a local file.
docs/operations/relay-observability.md+6
......@@ -51,3 +51,9 @@ Agents should prefer the provisioned view or APL queries for completed incidents
5151tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who
5252need scripted query access use the authorized account-level `AXIOM_TOKEN` together with
5353`AXIOM_ORG_ID`; scoped ingest tokens remain write-only credentials for their producers.
54
55DPoP proof failures include the stable `relay.dpop.failure_code` span attribute. A `time_window`
56failure means that a signed proof was too old or too far in the future for the relay's allowed
57window. It can point to a date or time problem on either device, but it can also result from a
58delayed request. The client uses this category, and the absence of a category from an older relay,
59to decide whether clock skew is confirmed or only one possible cause.
docs/user/remote-access.md+2
......@@ -174,6 +174,8 @@ the conversation and in **Settings** → **Connections**. Follow the action show
174174be able to update and reconnect the server for you, or it may ask you to update the desktop app or
175175run a copied command on the server machine.
176176
177If T3 Connect cannot connect, check the date and time on both devices, then try again.
178
177179Finish active work before updating because the server restarts briefly. For step-by-step guidance,
178180see [Keeping T3 Code in Sync](./updating.md).
179181
infra/relay/src/auth/DpopProofs.ts+26-5
......@@ -3,10 +3,9 @@ import * as DateTime from "effect/DateTime";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as Schema from "effect/Schema";
6import * as HttpApiError from "effect/unstable/httpapi/HttpApiError";
76import { lt } from "drizzle-orm";
87
9import { verifyDpopProof } from "@t3tools/shared/dpop";
8import { DpopVerificationFailureCode, verifyDpopProof } from "@t3tools/shared/dpop";
109import * as RelayDb from "../db.ts";
1110import { relayDpopProofs } from "../persistence/schema.ts";
1211
......@@ -26,6 +25,23 @@ export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass<Dpo
2625 }
2726}
2827
28export const DpopProofFailureCode = Schema.Union([
29 DpopVerificationFailureCode,
30 Schema.Literal("replayed"),
31]);
32export type DpopProofFailureCode = typeof DpopProofFailureCode.Type;
33
34export class DpopProofRejected extends Schema.TaggedErrorClass<DpopProofRejected>()(
35 "DpopProofRejected",
36 {
37 code: DpopProofFailureCode,
38 },
39) {
40 override get message(): string {
41 return `DPoP proof rejected: ${this.code}`;
42 }
43}
44
2945export class DpopProofReplay extends Context.Service<
3046 DpopProofReplay,
3147 {
......@@ -36,7 +52,7 @@ export class DpopProofReplay extends Context.Service<
3652 readonly expectedThumbprint?: string;
3753 readonly expectedAccessToken?: string;
3854 readonly now: DateTime.DateTime;
39 }) => Effect.Effect<string, HttpApiError.Unauthorized | DpopProofReplayPersistenceError>;
55 }) => Effect.Effect<string, DpopProofRejected | DpopProofReplayPersistenceError>;
4056 readonly consume: (input: {
4157 readonly thumbprint: string;
4258 readonly jti: string;
......@@ -98,13 +114,16 @@ const make = Effect.gen(function* () {
98114 });
99115 if (!result.ok) {
100116 yield* Effect.logWarning("relay dpop proof rejected", {
117 code: result.code,
101118 reason: result.reason,
102119 method: input.method,
103120 url: input.url,
104121 expectedThumbprintPresent: input.expectedThumbprint !== undefined,
105122 expectedAccessTokenPresent: input.expectedAccessToken !== undefined,
106123 });
107 return yield* new HttpApiError.Unauthorized({});
124 return yield* new DpopProofRejected({
125 code: result.code,
126 });
108127 }
109128 const consumed = yield* consume({
110129 thumbprint: result.thumbprint,
......@@ -118,7 +137,9 @@ const make = Effect.gen(function* () {
118137 jti: result.jti,
119138 iat: result.iat,
120139 });
121 return yield* new HttpApiError.Unauthorized({});
140 return yield* new DpopProofRejected({
141 code: "replayed",
142 });
122143 }
123144 yield* Effect.annotateCurrentSpan({
124145 "relay.dpop.thumbprint": result.thumbprint,
infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts+35-4
......@@ -96,6 +96,34 @@ function consumeEachProofOnce() {
9696}
9797
9898describe("DpopProofReplay.verifyAndConsume", () => {
99 it.effect("reports a signed proof outside the time window", () => {
100 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
101 const proof = makeDpopProof({
102 method: "POST",
103 url: "https://relay.example.com/v1/environments/env/connect",
104 iat: Math.floor(now.epochMilliseconds / 1_000) - 301,
105 jti: "proof-old",
106 });
107
108 return Effect.gen(function* () {
109 const replay = yield* DpopProofs.DpopProofReplay;
110 const error = yield* Effect.flip(
111 replay.verifyAndConsume({
112 proof: proof.proof,
113 method: "POST",
114 url: "https://relay.example.com/v1/environments/env/connect",
115 expectedThumbprint: proof.thumbprint,
116 now,
117 }),
118 );
119
120 expect(error).toMatchObject({
121 _tag: "DpopProofRejected",
122 code: "time_window",
123 });
124 }).pipe(Effect.provide(layer(() => Effect.die("unexpected replay persistence"))));
125 });
126
99127 it.effect("rejects replayed proofs after persistence consumes the jti once", () => {
100128 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
101129 const proof = makeDpopProof({
......@@ -114,7 +142,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
114142 expectedThumbprint: proof.thumbprint,
115143 now,
116144 });
117 const second = yield* Effect.exit(
145 const second = yield* Effect.flip(
118146 replay.verifyAndConsume({
119147 proof: proof.proof,
120148 method: "POST",
......@@ -125,7 +153,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
125153 );
126154
127155 expect(first).toBe(proof.thumbprint);
128 expect(second._tag).toBe("Failure");
156 expect(second).toMatchObject({ _tag: "DpopProofRejected", code: "replayed" });
129157 }).pipe(Effect.provide(layer(consumeEachProofOnce())));
130158 });
131159
......@@ -140,7 +168,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
140168
141169 return Effect.gen(function* () {
142170 const replay = yield* DpopProofs.DpopProofReplay;
143 const result = yield* Effect.exit(
171 const error = yield* Effect.flip(
144172 replay.verifyAndConsume({
145173 proof: proof.proof,
146174 method: "POST",
......@@ -151,7 +179,10 @@ describe("DpopProofReplay.verifyAndConsume", () => {
151179 }),
152180 );
153181
154 expect(result._tag).toBe("Failure");
182 expect(error).toMatchObject({
183 _tag: "DpopProofRejected",
184 code: "access_token_hash_mismatch",
185 });
155186 }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence"))));
156187 });
157188
infra/relay/src/http/Api.test.ts+22
......@@ -23,6 +23,7 @@ import {
2323 relayDocsRedirectRoute,
2424 relayEnvironmentAuthLayer,
2525 relayNotFoundRoute,
26 relayDpopFailureReason,
2627 revokeEnvironmentLinkRecord,
2728 traceRelayHttpRequestWith,
2829 unlinkEnvironmentRecord,
......@@ -115,6 +116,27 @@ describe("relay client authentication", () => {
115116 );
116117});
117118
119describe("relay DPoP failure mapping", () => {
120 it("maps verifier failures to safe client-facing categories", () => {
121 const mappings = [
122 ["time_window", "time_window"],
123 ["key_mismatch", "key_mismatch"],
124 ["method_mismatch", "request_mismatch"],
125 ["url_mismatch", "request_mismatch"],
126 ["access_token_hash_mismatch", "token_mismatch"],
127 ["replayed", "replay"],
128 ["missing_proof", "invalid_proof"],
129 ["malformed_proof", "invalid_proof"],
130 ["invalid_signature", "invalid_proof"],
131 ["invalid_proof", "invalid_proof"],
132 ] as const;
133
134 for (const [code, expected] of mappings) {
135 expect(relayDpopFailureReason(code)).toBe(expected);
136 }
137 });
138});
139
118140describe("relay environment authentication", () => {
119141 it.effect("preserves credential lookup persistence failures as internal errors", () => {
120142 const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({
infra/relay/src/http/Api.ts+51-1
......@@ -35,6 +35,7 @@ import {
3535 RelayMobileRegistrationScope,
3636 RelayAuthInvalidError,
3737 type RelayAuthInvalidReason,
38 type RelayDpopFailureReason,
3839 RelayEnvironmentAuth,
3940 RelayEnvironmentConnectNotAuthorizedError,
4041 RelayEnvironmentEndpointTimedOutError,
......@@ -999,6 +1000,7 @@ class ClerkTokenVerificationFailed extends Schema.TaggedErrorClass<ClerkTokenVer
9991000}
10001001
10011002const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized);
1003const isDpopProofRejected = Schema.is(DpopProofs.DpopProofRejected);
10021004
10031005const currentTraceId = Effect.currentParentSpan.pipe(
10041006 Effect.map((span) => span.traceId),
......@@ -1030,10 +1032,37 @@ type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;
10301032const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);
10311033
10321034type MapRelayCommonApiError<E> =
1033 | Exclude<E, HttpApiError.Unauthorized | RelayCommonPersistenceError>
1035 | Exclude<
1036 E,
1037 HttpApiError.Unauthorized | DpopProofs.DpopProofRejected | RelayCommonPersistenceError
1038 >
10341039 | (Extract<E, HttpApiError.Unauthorized> extends never ? never : RelayAuthInvalidError)
1040 | (Extract<E, DpopProofs.DpopProofRejected> extends never ? never : RelayAuthInvalidError)
10351041 | (Extract<E, RelayCommonPersistenceError> extends never ? never : RelayInternalError);
10361042
1043export function relayDpopFailureReason(
1044 code: DpopProofs.DpopProofFailureCode,
1045): RelayDpopFailureReason {
1046 switch (code) {
1047 case "time_window":
1048 return "time_window";
1049 case "key_mismatch":
1050 return "key_mismatch";
1051 case "method_mismatch":
1052 case "url_mismatch":
1053 return "request_mismatch";
1054 case "access_token_hash_mismatch":
1055 return "token_mismatch";
1056 case "replayed":
1057 return "replay";
1058 case "missing_proof":
1059 case "malformed_proof":
1060 case "invalid_signature":
1061 case "invalid_proof":
1062 return "invalid_proof";
1063 }
1064}
1065
10371066function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {
10381067 return currentTraceId.pipe(
10391068 Effect.flatMap((traceId) =>
......@@ -1045,11 +1074,32 @@ function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {
10451074function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {
10461075 const mapError = Effect.fnUntraced(function* <E>(error: E) {
10471076 const traceId = yield* currentTraceId;
1077 if (isDpopProofRejected(error)) {
1078 yield* Effect.annotateCurrentSpan({
1079 "relay.dpop.failure_code": error.code,
1080 });
1081 return yield* Effect.fail(
1082 new RelayAuthInvalidError({
1083 code: "auth_invalid",
1084 reason: authReason,
1085 ...(authReason === "invalid_dpop"
1086 ? { dpopFailureReason: relayDpopFailureReason(error.code) }
1087 : {}),
1088 traceId,
1089 }) as MapRelayCommonApiError<E>,
1090 );
1091 }
10481092 if (isHttpUnauthorized(error)) {
1093 if (authReason === "invalid_dpop") {
1094 yield* Effect.annotateCurrentSpan({
1095 "relay.dpop.failure_code": "invalid_proof",
1096 });
1097 }
10491098 return yield* Effect.fail(
10501099 new RelayAuthInvalidError({
10511100 code: "auth_invalid",
10521101 reason: authReason,
1102 ...(authReason === "invalid_dpop" ? { dpopFailureReason: "invalid_proof" } : {}),
10531103 traceId,
10541104 }) as MapRelayCommonApiError<E>,
10551105 );
packages/client-runtime/src/authorization/layer.test.ts+24
......@@ -6,6 +6,7 @@ import * as Option from "effect/Option";
66import * as Ref from "effect/Ref";
77import * as TestClock from "effect/testing/TestClock";
88
9import { DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts";
910import * as ManagedRelay from "../relay/managedRelay.ts";
1011import { remoteHttpClientLayer } from "../rpc/http.ts";
1112import * as ClientCapabilities from "../platform/capabilities.ts";
......@@ -344,6 +345,29 @@ describe("RemoteEnvironmentAuthorization", () => {
344345 }),
345346 );
346347
348 it.effect("presents clock skew as one possible cause for a generic DPoP rejection", () =>
349 Effect.gen(function* () {
350 const harness = yield* makeHarness({
351 responses: [Response.json(DESCRIPTOR), authInvalid()],
352 });
353
354 const failure = yield* Effect.gen(function* () {
355 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
356 return yield* remote.authorizeDpop({
357 expectedEnvironmentId: ENVIRONMENT_ID,
358 obtainBootstrap: harness.obtainBootstrap,
359 });
360 }).pipe(Effect.provide(harness.layer), Effect.flip);
361
362 expect(failure).toMatchObject({
363 _tag: "ConnectionBlockedError",
364 reason: "authentication",
365 detail: `The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`,
366 traceId: "trace-auth-invalid",
367 });
368 }),
369 );
370
347371 it.effect("refreshes a cached endpoint after its first transient failure", () =>
348372 Effect.gen(function* () {
349373 const cached = new TokenStore.RemoteDpopAccessToken({
packages/client-runtime/src/authorization/service.ts+7-3
......@@ -10,7 +10,11 @@ import {
1010 resolveRemoteDpopWebSocketConnectionUrl,
1111 resolveRemoteWebSocketConnectionUrl,
1212} from "./remote.ts";
13import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts";
13import {
14 environmentMismatchError,
15 mapRemoteDpopEnvironmentError,
16 mapRemoteEnvironmentError,
17} from "../connection/errors.ts";
1418import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
1519import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
1620import { environmentEndpointUrl } from "../environment/endpoint.ts";
......@@ -69,7 +73,7 @@ const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
6973function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
7074 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
7175 ? error
72 : mapRemoteEnvironmentError(error);
76 : mapRemoteDpopEnvironmentError(error);
7377}
7478
7579const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (
......@@ -276,7 +280,7 @@ export const make = Effect.gen(function* () {
276280 scopes: presentation.scopes,
277281 clientMetadata: presentation.metadata,
278282 }).pipe(
279 Effect.mapError(mapRemoteEnvironmentError),
283 Effect.mapError(mapRemoteDpopEnvironmentError),
280284 Effect.provideService(HttpClient.HttpClient, httpClient),
281285 Effect.withSpan("environment.authorization.accessToken.exchange"),
282286 );
packages/client-runtime/src/connection/errors.test.ts created+75
......@@ -0,0 +1,75 @@
1import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
2import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
3import { describe, expect, it } from "@effect/vitest";
4
5import { mapManagedRelayError, mapRemoteDpopEnvironmentError } from "./errors.ts";
6import { DPOP_RETRY_HINT, DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts";
7import { ManagedRelayRequestFailedError } from "../relay/managedRelay.ts";
8
9describe("mapManagedRelayError", () => {
10 it("presents clock skew as one possible cause for a generic DPoP error", () => {
11 const mapped = mapManagedRelayError(
12 new ManagedRelayRequestFailedError({
13 action: "connect relay environment",
14 cause: new Error("request failed"),
15 relayError: new RelayAuthInvalidError({
16 code: "auth_invalid",
17 reason: "invalid_dpop",
18 traceId: "trace-1",
19 }),
20 traceId: "trace-1",
21 }),
22 );
23
24 expect(mapped).toMatchObject({
25 _tag: "ConnectionBlockedError",
26 reason: "authentication",
27 detail: `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
28 traceId: "trace-1",
29 });
30 });
31
32 it("uses a neutral hint when the relay identifies a non-clock DPoP error", () => {
33 const mapped = mapManagedRelayError(
34 new ManagedRelayRequestFailedError({
35 action: "connect relay environment",
36 cause: new Error("request failed"),
37 relayError: new RelayAuthInvalidError({
38 code: "auth_invalid",
39 reason: "invalid_dpop",
40 dpopFailureReason: "key_mismatch",
41 traceId: "trace-1",
42 }),
43 }),
44 );
45
46 expect(mapped.message).toBe(`Relay rejected the DPoP proof. ${DPOP_RETRY_HINT}`);
47 });
48});
49
50describe("mapRemoteDpopEnvironmentError", () => {
51 it("does not present a generic environment auth error as confirmed clock skew", () => {
52 const mapped = mapRemoteDpopEnvironmentError(
53 new EnvironmentAuthInvalidError({
54 code: "auth_invalid",
55 reason: "invalid_credential",
56 traceId: "trace-1",
57 }),
58 );
59
60 expect(mapped.message).toBe(`The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`);
61 });
62
63 it("uses a neutral hint for a non-clock DPoP error from a new server", () => {
64 const mapped = mapRemoteDpopEnvironmentError(
65 new EnvironmentAuthInvalidError({
66 code: "auth_invalid",
67 reason: "invalid_credential",
68 dpopFailureReason: "key_mismatch",
69 traceId: "trace-1",
70 }),
71 );
72
73 expect(mapped.message).toBe(`The environment credential is invalid. ${DPOP_RETRY_HINT}`);
74 });
75});
packages/client-runtime/src/connection/errors.ts+26-5
......@@ -1,6 +1,7 @@
11import type { EnvironmentId } from "@t3tools/contracts";
22import type { RelayProtectedError } from "@t3tools/contracts/relay";
33import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
4import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";
45import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
56import {
67 ConnectionBlockedError,
......@@ -40,7 +41,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
4041 case "RelayAgentActivityPublishProofInvalidError":
4142 return new ConnectionBlockedError({
4243 reason: "authentication",
43 detail: error.message,
44 detail: relayProtectedErrorMessage(error),
4445 traceId: error.traceId,
4546 });
4647 case "RelayEnvironmentConnectNotAuthorizedError":
......@@ -48,27 +49,27 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
4849 case "RelayEnvironmentLinkLimitExceededError":
4950 return new ConnectionBlockedError({
5051 reason: "permission",
51 detail: error.message,
52 detail: relayProtectedErrorMessage(error),
5253 traceId: error.traceId,
5354 });
5455 case "RelayEnvironmentEndpointTimedOutError":
5556 return new ConnectionTransientError({
5657 reason: "timeout",
57 detail: error.message,
58 detail: relayProtectedErrorMessage(error),
5859 traceId: error.traceId,
5960 });
6061 case "RelayEnvironmentEndpointUnavailableError":
6162 case "RelayEnvironmentLinkUnavailableError":
6263 return new ConnectionTransientError({
6364 reason: "endpoint-unavailable",
64 detail: error.message,
65 detail: relayProtectedErrorMessage(error),
6566 traceId: error.traceId,
6667 });
6768 case "RelayEnvironmentLinkFailedError":
6869 case "RelayInternalError":
6970 return new ConnectionTransientError({
7071 reason: "relay-unavailable",
71 detail: error.message,
72 detail: relayProtectedErrorMessage(error),
7273 traceId: error.traceId,
7374 });
7475 }
......@@ -166,3 +167,23 @@ export function mapRemoteEnvironmentError(
166167 });
167168 }
168169}
170
171/**
172 * Map an environment error from a request that used DPoP authentication. An
173 * older environment server reports a DPoP clock failure as the same generic
174 * invalid-credential response as other failures, so keep the compatibility
175 * hint cautious when the server omits the category. Newer servers can identify
176 * clock and non-clock proof failures precisely.
177 */
178export function mapRemoteDpopEnvironmentError(
179 error: RemoteEnvironmentAuthError,
180): ConnectionAttemptError {
181 if (error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential") {
182 return new ConnectionBlockedError({
183 reason: "authentication",
184 detail: dpopFailureMessage("The environment credential is invalid.", error.dpopFailureReason),
185 traceId: error.traceId,
186 });
187 }
188 return mapRemoteEnvironmentError(error);
189}
packages/client-runtime/src/relay/errorPresentation.test.ts created+57
......@@ -0,0 +1,57 @@
1import { RelayAuthInvalidError } from "@t3tools/contracts/relay";
2import { describe, expect, it } from "@effect/vitest";
3
4import {
5 DPOP_CLOCK_HINT,
6 DPOP_RETRY_HINT,
7 DPOP_UNKNOWN_HINT,
8 relayProtectedErrorMessage,
9} from "./errorPresentation.ts";
10
11describe("relayProtectedErrorMessage", () => {
12 it("presents clock skew as one possible cause when the relay omits the reason", () => {
13 const error = new RelayAuthInvalidError({
14 code: "auth_invalid",
15 reason: "invalid_dpop",
16 traceId: "trace-1",
17 });
18
19 expect(relayProtectedErrorMessage(error)).toBe(
20 `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
21 );
22 });
23
24 it("keeps the clock hint for a relay that confirms a time-window failure", () => {
25 const error = new RelayAuthInvalidError({
26 code: "auth_invalid",
27 reason: "invalid_dpop",
28 dpopFailureReason: "time_window",
29 traceId: "trace-1",
30 });
31
32 expect(relayProtectedErrorMessage(error)).toContain(DPOP_CLOCK_HINT);
33 });
34
35 it("does not blame the clock when the relay identifies another proof failure", () => {
36 const error = new RelayAuthInvalidError({
37 code: "auth_invalid",
38 reason: "invalid_dpop",
39 dpopFailureReason: "key_mismatch",
40 traceId: "trace-1",
41 });
42
43 expect(relayProtectedErrorMessage(error)).toBe(
44 `Relay rejected the DPoP proof. ${DPOP_RETRY_HINT}`,
45 );
46 });
47
48 it("preserves the existing message for other authentication failures", () => {
49 const error = new RelayAuthInvalidError({
50 code: "auth_invalid",
51 reason: "invalid_bearer",
52 traceId: "trace-1",
53 });
54
55 expect(relayProtectedErrorMessage(error)).toBe("Relay rejected the cloud session token.");
56 });
57});
packages/client-runtime/src/relay/errorPresentation.ts created+66
......@@ -0,0 +1,66 @@
1import type { DpopFailureReason } from "@t3tools/contracts";
2import type { RelayProtectedError } from "@t3tools/contracts/relay";
3
4export const DPOP_CLOCK_HINT =
5 "Hint: Check that automatic date and time is enabled on both devices, then try again.";
6
7/** Older servers omit the DPoP category, but newer servers can also omit it for
8 * a credential failure that happens after proof verification. */
9export const DPOP_UNKNOWN_HINT =
10 "Hint: Try again. If it still fails, clock skew may be the cause; check that automatic date and time is enabled on both devices.";
11
12export const DPOP_RETRY_HINT = "Hint: Try again. If the problem continues, copy the trace ID.";
13
14export function dpopFailureHint(reason: DpopFailureReason | undefined): string {
15 if (reason === "time_window") return DPOP_CLOCK_HINT;
16 if (reason === undefined) return DPOP_UNKNOWN_HINT;
17 return DPOP_RETRY_HINT;
18}
19
20export function dpopFailureMessage(message: string, reason: DpopFailureReason | undefined): string {
21 return `${message} ${dpopFailureHint(reason)}`;
22}
23
24export function relayProtectedErrorMessage(error: RelayProtectedError): string {
25 switch (error._tag) {
26 case "RelayAuthInvalidError":
27 switch (error.reason) {
28 case "missing_bearer":
29 case "invalid_bearer":
30 return "Relay rejected the cloud session token.";
31 case "invalid_dpop":
32 return dpopFailureMessage("Relay rejected the DPoP proof.", error.dpopFailureReason);
33 case "not_authorized":
34 return "Relay rejected the authenticated request.";
35 }
36 case "RelayEnvironmentLinkProofExpiredError":
37 return "Relay rejected an expired environment link proof.";
38 case "RelayEnvironmentLinkProofInvalidError":
39 return `Relay rejected the environment link proof (${error.reason}).`;
40 case "RelayEnvironmentConnectNotAuthorizedError":
41 // "Not authorized" covers non-auth causes too; surface the reason so a
42 // missing link does not read as a credential problem.
43 if (error.reason === "environment_link_not_found") {
44 return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
45 }
46 return error.reason
47 ? `Relay rejected the environment connection request (${error.reason}).`
48 : "Relay rejected the environment connection request.";
49 case "RelayEnvironmentEndpointUnavailableError":
50 return `Relay could not reach the environment endpoint (${error.reason}).`;
51 case "RelayEnvironmentEndpointTimedOutError":
52 return "Relay timed out while contacting the environment endpoint.";
53 case "RelayEnvironmentLinkFailedError":
54 return `Relay could not link the environment (${error.reason}).`;
55 case "RelayEnvironmentLinkUnavailableError":
56 return `Relay cannot provision the managed endpoint (${error.reason}).`;
57 case "RelayEnvironmentLinkLimitExceededError":
58 return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
59 case "RelayAgentActivityPublishProofExpiredError":
60 return "Relay rejected an expired agent activity publish proof.";
61 case "RelayAgentActivityPublishProofInvalidError":
62 return `Relay rejected the agent activity publish proof (${error.reason}).`;
63 case "RelayInternalError":
64 return `Relay encountered an internal error (${error.reason}).`;
65 }
66}
packages/client-runtime/src/relay/index.ts+1
......@@ -1,3 +1,4 @@
11export * as Discovery from "./discovery.ts";
2export * from "./errorPresentation.ts";
23export * as ManagedRelay from "./managedRelay.ts";
34export * from "./managedRelayState.ts";
packages/client-runtime/src/relay/managedRelay.test.ts+37
......@@ -462,6 +462,43 @@ describe("ManagedRelayClient", () => {
462462 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
463463 });
464464
465 it.effect("accepts generic DPoP errors from relays without the optional reason", () => {
466 const fetchFn = (() =>
467 Promise.resolve(
468 Response.json(
469 {
470 _tag: "RelayAuthInvalidError",
471 code: "auth_invalid",
472 reason: "invalid_dpop",
473 traceId: "trace-old-relay",
474 },
475 { status: 401 },
476 ),
477 )) satisfies typeof globalThis.fetch;
478
479 return Effect.gen(function* () {
480 const relayClient = yield* ManagedRelay.ManagedRelayClient;
481 const error = yield* relayClient
482 .listEnvironments({ clerkToken: "clerk-token" })
483 .pipe(Effect.flip);
484
485 expect(error).toMatchObject({
486 _tag: "ManagedRelayRequestFailedError",
487 traceId: "trace-old-relay",
488 relayError: {
489 _tag: "RelayAuthInvalidError",
490 reason: "invalid_dpop",
491 },
492 });
493 if (
494 error._tag === "ManagedRelayRequestFailedError" &&
495 error.relayError?._tag === "RelayAuthInvalidError"
496 ) {
497 expect(error.relayError.dpopFailureReason).toBeUndefined();
498 }
499 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
500 });
501
465502 it.effect("lists account devices through the Clerk bearer client endpoint", () => {
466503 const fetchFn = ((input, init) => {
467504 expect(String(input)).toBe("https://relay.example.test/v1/client/devices");
packages/client-runtime/src/relay/managedRelayState.test.ts+33-4
......@@ -1,8 +1,9 @@
11import { EnvironmentId } from "@t3tools/contracts";
2import type {
3 RelayClientDeviceRecord,
4 RelayClientEnvironmentRecord,
5 RelayEnvironmentStatusResponse,
2import {
3 RelayAuthInvalidError,
4 type RelayClientDeviceRecord,
5 type RelayClientEnvironmentRecord,
6 type RelayEnvironmentStatusResponse,
67} from "@t3tools/contracts/relay";
78import { describe, expect, it } from "@effect/vitest";
89import * as Effect from "effect/Effect";
......@@ -12,6 +13,7 @@ import * as Stream from "effect/Stream";
1213import { Atom, AtomRegistry } from "effect/unstable/reactivity";
1314import { afterEach, vi } from "vite-plus/test";
1415
16import { DPOP_UNKNOWN_HINT } from "./errorPresentation.ts";
1517import * as ManagedRelay from "./managedRelay.ts";
1618import {
1719 createManagedRelayQueryManager,
......@@ -421,4 +423,31 @@ describe("createManagedRelayQueryManager", () => {
421423 });
422424 });
423425 });
426
427 it("presents clock skew as one possible cause for snapshot requests from older relays", async () => {
428 const manager = createManager({
429 getEnvironmentStatus: () =>
430 Effect.fail(
431 new ManagedRelay.ManagedRelayRequestFailedError({
432 action: "get relay environment status",
433 cause: new Error("Relay request failed."),
434 relayError: new RelayAuthInvalidError({
435 code: "auth_invalid",
436 reason: "invalid_dpop",
437 traceId: "trace-status",
438 }),
439 traceId: "trace-status",
440 }),
441 ),
442 });
443 setSession();
444 const atom = manager.environmentStatusAtom({ accountId: "account-1", environment });
445
446 registry.get(atom);
447 await vi.waitFor(() => {
448 expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe(
449 `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
450 );
451 });
452 });
424453});
packages/client-runtime/src/relay/managedRelayState.ts+9-1
......@@ -13,15 +13,18 @@ import * as Clock from "effect/Clock";
1313import * as Data from "effect/Data";
1414import * as Effect from "effect/Effect";
1515import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
1617import * as Stream from "effect/Stream";
1718import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1819
1920import { findErrorTraceId } from "../errors/errorTrace.ts";
2021import * as ManagedRelay from "./managedRelay.ts";
22import { relayProtectedErrorMessage } from "./errorPresentation.ts";
2123
2224const DEFAULT_STALE_TIME_MS = 15_000;
2325const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
2426const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000;
27const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError);
2528
2629export interface ManagedRelaySession {
2730 readonly accountId: string;
......@@ -315,7 +318,12 @@ export function readManagedRelaySnapshotState<A>(
315318 let errorTraceId: string | null = null;
316319 if (result._tag === "Failure") {
317320 const cause = Cause.squash(result.cause);
318 error = cause instanceof Error ? cause.message : "Could not load T3 Connect data.";
321 error =
322 isManagedRelayRequestFailedError(cause) && cause.relayError
323 ? relayProtectedErrorMessage(cause.relayError)
324 : cause instanceof Error
325 ? cause.message
326 : "Could not load T3 Connect data.";
319327 errorTraceId = findErrorTraceId(cause);
320328 }
321329 return {
packages/contracts/src/baseSchemas.ts+14
......@@ -18,6 +18,20 @@ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
1818export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
1919export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
2020
21/**
22 * Safe categories for a failed DPoP proof. These describe the class of failure
23 * without exposing proof contents or server-side authentication details.
24 */
25export const DpopFailureReason = Schema.Literals([
26 "time_window",
27 "key_mismatch",
28 "request_mismatch",
29 "token_mismatch",
30 "replay",
31 "invalid_proof",
32]);
33export type DpopFailureReason = typeof DpopFailureReason.Type;
34
2135export const IsoDateTime = Schema.String;
2236export type IsoDateTime = typeof IsoDateTime.Type;
2337
packages/contracts/src/environmentHttp.ts+8-1
......@@ -24,7 +24,12 @@ import {
2424 AuthWebSocketTicketResult,
2525 ServerAuthSessionMethod,
2626} from "./auth.ts";
27import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
27import {
28 DpopFailureReason,
29 AuthSessionId,
30 ThreadId,
31 TrimmedNonEmptyString,
32} from "./baseSchemas.ts";
2833import { ExecutionEnvironmentDescriptor } from "./environment.ts";
2934import {
3035 ClientOrchestrationCommand,
......@@ -117,6 +122,8 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<Environ
117122 {
118123 code: Schema.Literal("auth_invalid"),
119124 reason: EnvironmentAuthInvalidReason,
125 // Older servers do not send a DPoP failure category.
126 dpopFailureReason: Schema.optionalKey(DpopFailureReason),
120127 traceId: TrimmedNonEmptyString,
121128 },
122129 { httpApiStatus: 401 },
packages/contracts/src/relay.ts+11-1
......@@ -8,7 +8,12 @@ import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
88import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity";
99import * as OpenApi from "effect/unstable/httpapi/OpenApi";
1010
11import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
11import {
12 DpopFailureReason,
13 EnvironmentId,
14 ThreadId,
15 TrimmedNonEmptyString,
16} from "./baseSchemas.ts";
1217import { ExecutionEnvironmentDescriptor } from "./environment.ts";
1318
1419export const RelayAgentAwarenessPlatform = Schema.Literal("ios");
......@@ -322,6 +327,9 @@ export const RelayAuthInvalidReason = Schema.Literals([
322327]);
323328export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type;
324329
330export const RelayDpopFailureReason = DpopFailureReason;
331export type RelayDpopFailureReason = typeof RelayDpopFailureReason.Type;
332
325333export const RelayInternalErrorReason = Schema.Literals([
326334 "database_unavailable",
327335 "persistence_failed",
......@@ -335,6 +343,8 @@ export class RelayAuthInvalidError extends Schema.TaggedErrorClass<RelayAuthInva
335343 {
336344 code: Schema.Literal("auth_invalid"),
337345 reason: RelayAuthInvalidReason,
346 // Older relays do not send a DPoP failure category.
347 dpopFailureReason: Schema.optionalKey(RelayDpopFailureReason),
338348 traceId: TrimmedNonEmptyString,
339349 },
340350 { httpApiStatus: 401 },
packages/shared/src/dpop.test.ts+37
......@@ -145,6 +145,43 @@ describe("verifyDpopProof", () => {
145145 );
146146 });
147147
148 it("reports a time-window failure only after the proof signature is valid", () => {
149 const thumbprint = computeDpopJwkThumbprint(publicJwk);
150 const outsideWindow = verifyDpopProof({
151 proof,
152 method: "POST",
153 url: "https://example.com/oauth/token",
154 nowEpochSeconds: 1_000,
155 expectedThumbprint: thumbprint,
156 });
157 if (outsideWindow.ok) {
158 assert.fail("Expected an old DPoP proof to fail.");
159 }
160 assert.equal(outsideWindow.code, "time_window");
161
162 const { privateKey: otherPrivateKey } = NodeCrypto.generateKeyPairSync("ec", {
163 namedCurve: "P-256",
164 });
165 const invalidSignatureProof = signDpopProof({
166 method: "POST",
167 url: "https://example.com/oauth/token",
168 iat: 100,
169 privateKey: otherPrivateKey,
170 publicJwk,
171 });
172 const invalidSignature = verifyDpopProof({
173 proof: invalidSignatureProof,
174 method: "POST",
175 url: "https://example.com/oauth/token",
176 nowEpochSeconds: 1_000,
177 expectedThumbprint: thumbprint,
178 });
179 if (invalidSignature.ok) {
180 assert.fail("Expected a proof signed by a different key to fail.");
181 }
182 assert.equal(invalidSignature.code, "invalid_signature");
183 });
184
148185 it("requires the RFC 9449 access token hash when an access token is expected", () => {
149186 const thumbprint = computeDpopJwkThumbprint(publicJwk);
150187 const accessTokenProof = signDpopProof({
packages/shared/src/dpop.ts+49-25
......@@ -17,6 +17,19 @@ export const DpopPublicJwk = DpopPublicJwkSchema;
1717export type DpopPublicJwk = DpopPublicJwkType;
1818export { normalizeDpopHtu };
1919
20export const DpopVerificationFailureCode = Schema.Literals([
21 "missing_proof",
22 "malformed_proof",
23 "key_mismatch",
24 "method_mismatch",
25 "url_mismatch",
26 "access_token_hash_mismatch",
27 "time_window",
28 "invalid_signature",
29 "invalid_proof",
30]);
31export type DpopVerificationFailureCode = typeof DpopVerificationFailureCode.Type;
32
2033const DpopJwtHeaderPublicJwk = Schema.Struct({
2134 ...DpopPublicJwkSchema.fields,
2235 d: Schema.optionalKey(Schema.Never),
......@@ -51,6 +64,7 @@ export type DpopVerificationResult =
5164 }
5265 | {
5366 readonly ok: false;
67 readonly code: DpopVerificationFailureCode;
5468 readonly reason: string;
5569 };
5670
......@@ -106,50 +120,46 @@ export function verifyDpopProof(input: {
106120 readonly maxAgeSeconds?: number;
107121}): DpopVerificationResult {
108122 if (!input.proof?.trim()) {
109 return { ok: false, reason: "Missing DPoP proof." };
123 return { ok: false, code: "missing_proof", reason: "Missing DPoP proof." };
110124 }
111125
112126 const parts = input.proof.split(".");
113127 if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
114 return { ok: false, reason: "Invalid DPoP compact JWT." };
128 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP compact JWT." };
115129 }
116130
117131 try {
118132 const header = decodeBase64UrlDpopJwtHeader(parts[0]);
119133 const payload = decodeBase64UrlDpopJwtPayload(parts[1]);
120134 if (Option.isNone(header)) {
121 return { ok: false, reason: "Invalid DPoP JWT header." };
135 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT header." };
122136 }
123137 if (Option.isNone(payload)) {
124 return { ok: false, reason: "Invalid DPoP JWT payload." };
138 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT payload." };
125139 }
126140
127141 const thumbprint = computeDpopJwkThumbprint(header.value.jwk);
128142 if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {
129 return { ok: false, reason: "DPoP key thumbprint mismatch." };
143 return { ok: false, code: "key_mismatch", reason: "DPoP key thumbprint mismatch." };
130144 }
131145 if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) {
132 return { ok: false, reason: "DPoP method mismatch." };
146 return { ok: false, code: "method_mismatch", reason: "DPoP method mismatch." };
133147 }
134148 const normalizedHtu = normalizeDpopHtu(input.url);
135149 if (normalizedHtu === null || payload.value.htu !== normalizedHtu) {
136 return { ok: false, reason: "DPoP URL mismatch." };
150 return { ok: false, code: "url_mismatch", reason: "DPoP URL mismatch." };
137151 }
138152 if (input.expectedAccessToken) {
139153 const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);
140154 if (payload.value.ath !== expectedAth) {
141 return { ok: false, reason: "DPoP access token hash mismatch." };
155 return {
156 ok: false,
157 code: "access_token_hash_mismatch",
158 reason: "DPoP access token hash mismatch.",
159 };
142160 }
143161 }
144162
145 const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
146 if (
147 payload.value.iat > input.nowEpochSeconds + 5 ||
148 input.nowEpochSeconds - payload.value.iat > maxAgeSeconds
149 ) {
150 return { ok: false, reason: "DPoP proof is outside the allowed time window." };
151 }
152
153163 const signature = base64UrlToBytes(parts[2]);
154164 const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
155165 const verified = p256.verify(
......@@ -161,15 +171,29 @@ export function verifyDpopProof(input: {
161171 format: "compact",
162172 },
163173 );
164 return verified
165 ? {
166 ok: true,
167 thumbprint,
168 jti: payload.value.jti,
169 iat: payload.value.iat,
170 }
171 : { ok: false, reason: "Invalid DPoP signature." };
174 if (!verified) {
175 return { ok: false, code: "invalid_signature", reason: "Invalid DPoP signature." };
176 }
177
178 const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
179 if (
180 payload.value.iat > input.nowEpochSeconds + 5 ||
181 input.nowEpochSeconds - payload.value.iat > maxAgeSeconds
182 ) {
183 return {
184 ok: false,
185 code: "time_window",
186 reason: "DPoP proof is outside the allowed time window.",
187 };
188 }
189
190 return {
191 ok: true,
192 thumbprint,
193 jti: payload.value.jti,
194 iat: payload.value.iat,
195 };
172196 } catch {
173 return { ok: false, reason: "Invalid DPoP proof." };
197 return { ok: false, code: "invalid_proof", reason: "Invalid DPoP proof." };
174198 }
175199}