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";...@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import { EnvironmentId } from "@t3tools/contracts";5import { EnvironmentId } from "@t3tools/contracts";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import { ManagedRelay } from "@t3tools/client-runtime/relay";7import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";
8import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";8import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
9import { HttpClient } from "effect/unstable/http";9import { HttpClient } from "effect/unstable/http";
10import { MobilePreferencesStore } from "../../persistence/mobile-preferences";10import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
...@@ -1089,13 +1089,88 @@ describe("mobile cloud link environment client", () => {...@@ -1089,13 +1089,88 @@ describe("mobile cloud link environment client", () => {
1089 ).pipe(Effect.flip);1089 ).pipe(Effect.flip);
1090 expect(error).toMatchObject({1090 expect(error).toMatchObject({
1091 _tag: "CloudEnvironmentLinkError",1091 _tag: "CloudEnvironmentLinkError",
1092 message:1092 message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
1093 "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.",
1094 traceId: "trace-connect",1093 traceId: "trace-connect",
1095 });1094 });
1096 }),1095 }),
1097 );1096 );
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
1099 it.effect("rejects relay connect responses for a different endpoint", () =>1174 it.effect("rejects relay connect responses for a different endpoint", () =>
1100 Effect.gen(function* () {1175 Effect.gen(function* () {
1101 vi.stubGlobal(1176 vi.stubGlobal(
apps/mobile/src/features/cloud/linkEnvironment.ts+29-51
...@@ -4,6 +4,7 @@ import * as Schema from "effect/Schema";...@@ -4,6 +4,7 @@ import * as Schema from "effect/Schema";
4import { HttpClient } from "effect/unstable/http";4import { HttpClient } from "effect/unstable/http";
5import {5import {
6 EnvironmentCloudEndpointUnavailableError,6 EnvironmentCloudEndpointUnavailableError,
7 EnvironmentAuthInvalidError,
7 EnvironmentHttpBadRequestError,8 EnvironmentHttpBadRequestError,
8 EnvironmentHttpConflictError,9 EnvironmentHttpConflictError,
9 EnvironmentHttpForbiddenError,10 EnvironmentHttpForbiddenError,
...@@ -17,7 +18,6 @@ import {...@@ -17,7 +18,6 @@ import {
17 RelayEnvironmentConnectScope,18 RelayEnvironmentConnectScope,
18 RelayEnvironmentStatusScope,19 RelayEnvironmentStatusScope,
19 type RelayDpopAccessTokenScope,20 type RelayDpopAccessTokenScope,
20 type RelayProtectedError as RelayProtectedErrorType,
21 type RelayClientEnvironmentRecord,21 type RelayClientEnvironmentRecord,
22 type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,22 type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
23 type RelayManagedEndpointProviderKind,23 type RelayManagedEndpointProviderKind,
...@@ -25,7 +25,11 @@ import {...@@ -25,7 +25,11 @@ import {
25import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";25import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
26import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";26import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
27import { findErrorTraceId } from "@t3tools/client-runtime/errors";27import { 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";
29import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";33import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3034
31import { authClientMetadata } from "../../lib/authClientMetadata";35import { authClientMetadata } from "../../lib/authClientMetadata";
...@@ -73,18 +77,24 @@ const isEnvironmentCloudApiError = Schema.is(...@@ -73,18 +77,24 @@ const isEnvironmentCloudApiError = Schema.is(
73 EnvironmentCloudEndpointUnavailableError,77 EnvironmentCloudEndpointUnavailableError,
74 ]),78 ]),
75);79);
80const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
7681
77const MANAGED_ENDPOINT_PROVIDER_KIND =82const MANAGED_ENDPOINT_PROVIDER_KIND =
78 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;83 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
7984
80function cloudEnvironmentLinkError(message: string) {85function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) {
81 return (cause: unknown) => {86 return (cause: unknown) => {
82 const environmentError = findEnvironmentCloudApiError(cause);87 const environmentError = findEnvironmentCloudApiError(cause);
83 const traceId = findErrorTraceId(cause);88 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);
84 return new CloudEnvironmentLinkError({93 return new CloudEnvironmentLinkError({
85 message: environmentError94 message:
86 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`95 dpopAuthError?.reason === "invalid_credential"
87 : withDevCause(message, cause),96 ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason)
97 : detail,
88 cause,98 cause,
89 ...(traceId === null ? {} : { traceId }),99 ...(traceId === null ? {} : { traceId }),
90 });100 });
...@@ -117,50 +127,6 @@ function withDevCause(message: string, cause: unknown): string {...@@ -117,50 +127,6 @@ function withDevCause(message: string, cause: unknown): string {
117 return detail ? `${message} (${detail})` : message;127 return detail ? `${message} (${detail})` : message;
118}128}
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
164function decodedRelayClientError(message: string) {130function decodedRelayClientError(message: string) {
165 return (cause: ManagedRelay.ManagedRelayClientError) => {131 return (cause: ManagedRelay.ManagedRelayClientError) => {
166 const relayError =132 const relayError =
...@@ -185,6 +151,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin...@@ -185,6 +151,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
185 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;151 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
186}152}
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
188function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {164function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {
189 const relayUrl = readRelayUrl();165 const relayUrl = readRelayUrl();
190 return relayUrl166 return relayUrl
...@@ -560,7 +536,9 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag...@@ -560,7 +536,9 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
560 clientMetadata: authClientMetadata(),536 clientMetadata: authClientMetadata(),
561 }).pipe(537 }).pipe(
562 Effect.mapError(538 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 }),
564 ),542 ),
565 );543 );
566 const pairingUrl = new URL(connect.endpoint.httpBaseUrl);544 const pairingUrl = new URL(connect.endpoint.httpBaseUrl);
apps/server/src/auth/EnvironmentAuth.ts+10
...@@ -16,6 +16,8 @@ import {...@@ -16,6 +16,8 @@ import {
16 type ServerAuthDescriptor,16 type ServerAuthDescriptor,
17 type ServerAuthSessionMethod,17 type ServerAuthSessionMethod,
18 type AuthWebSocketTicketResult,18 type AuthWebSocketTicketResult,
19 DpopFailureReason,
20 type DpopFailureReason as DpopFailureReasonType,
19} from "@t3tools/contracts";21} from "@t3tools/contracts";
20import { encodeOAuthScope } from "@t3tools/shared/oauthScope";22import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
21import * as Context from "effect/Context";23import * as Context from "effect/Context";
...@@ -347,6 +349,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass<Se...@@ -347,6 +349,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass<Se
347 "ServerAuthInvalidCredentialError",349 "ServerAuthInvalidCredentialError",
348 {350 {
349 diagnostic: Schema.optional(Schema.String),351 diagnostic: Schema.optional(Schema.String),
352 dpopFailureReason: Schema.optionalKey(DpopFailureReason),
350 cause: Schema.optional(Schema.Defect()),353 cause: Schema.optional(Schema.Defect()),
351 },354 },
352) {355) {
...@@ -366,6 +369,11 @@ export const serverAuthCredentialReason = (...@@ -366,6 +369,11 @@ export const serverAuthCredentialReason = (
366): "missing_credential" | "invalid_credential" =>369): "missing_credential" | "invalid_credential" =>
367 error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential";370 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
369export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass<ServerAuthInvalidScopeError>()(377export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass<ServerAuthInvalidScopeError>()(
370 "ServerAuthInvalidScopeError",378 "ServerAuthInvalidScopeError",
371 {},379 {},
...@@ -606,6 +614,7 @@ export const make = Effect.gen(function* () {...@@ -606,6 +614,7 @@ export const make = Effect.gen(function* () {
606 return Effect.fail(614 return Effect.fail(
607 new ServerAuthInvalidCredentialError({615 new ServerAuthInvalidCredentialError({
608 diagnostic: "DPoP-bound access token requires DPoP authorization.",616 diagnostic: "DPoP-bound access token requires DPoP authorization.",
617 dpopFailureReason: "invalid_proof",
609 }),618 }),
610 );619 );
611 }620 }
...@@ -623,6 +632,7 @@ export const make = Effect.gen(function* () {...@@ -623,6 +632,7 @@ export const make = Effect.gen(function* () {
623 return Effect.fail(632 return Effect.fail(
624 new ServerAuthInvalidCredentialError({633 new ServerAuthInvalidCredentialError({
625 diagnostic: "DPoP authorization requires a proof-bound access token.",634 diagnostic: "DPoP authorization requires a proof-bound access token.",
635 dpopFailureReason: "invalid_proof",
626 }),636 }),
627 );637 );
628 }638 }
apps/server/src/auth/dpop.test.ts+22-1
...@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
2import * as PlatformError from "effect/PlatformError";2import * as PlatformError from "effect/PlatformError";
33
4import { SecretStorePersistError } from "./ServerSecretStore.ts";4import { SecretStorePersistError } from "./ServerSecretStore.ts";
5import { mapDpopReplayStoreError } from "./dpop.ts";5import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts";
66
7const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>7const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
8 new SecretStorePersistError({8 new SecretStorePersistError({
...@@ -23,6 +23,7 @@ describe("mapDpopReplayStoreError", () => {...@@ -23,6 +23,7 @@ describe("mapDpopReplayStoreError", () => {
23 expect(error._tag).toBe("ServerAuthInvalidCredentialError");23 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
24 if (error._tag === "ServerAuthInvalidCredentialError") {24 if (error._tag === "ServerAuthInvalidCredentialError") {
25 expect(error.cause).toBe(cause);25 expect(error.cause).toBe(cause);
26 expect(error.dpopFailureReason).toBe("replay");
26 }27 }
27 });28 });
2829
...@@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => {...@@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => {
35 }36 }
36 });37 });
37});38});
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 @@...@@ -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";
2import * as Crypto from "effect/Crypto";6import * as Crypto from "effect/Crypto";
3import * as DateTime from "effect/DateTime";7import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -14,12 +18,32 @@ import {...@@ -14,12 +18,32 @@ import {
14} from "./EnvironmentAuth.ts";18} from "./EnvironmentAuth.ts";
15import * as ServerSecretStore from "./ServerSecretStore.ts";19import * 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
17export const mapDpopReplayStoreError = (40export const mapDpopReplayStoreError = (
18 error: ServerSecretStore.SecretStoreError,41 error: ServerSecretStore.SecretStoreError,
19): ServerAuthInvalidCredentialError | ServerAuthInternalError =>42): ServerAuthInvalidCredentialError | ServerAuthInternalError =>
20 ServerSecretStore.isSecretAlreadyExistsError(error)43 ServerSecretStore.isSecretAlreadyExistsError(error)
21 ? new ServerAuthInvalidCredentialError({44 ? new ServerAuthInvalidCredentialError({
22 diagnostic: "DPoP proof replayed.",45 diagnostic: "DPoP proof replayed.",
46 dpopFailureReason: "replay",
23 cause: error,47 cause: error,
24 })48 })
25 : new ServerAuthDpopReplayStateRecordError({49 : new ServerAuthDpopReplayStateRecordError({
...@@ -49,8 +73,12 @@ export const verifyRequestDpopProof = (input: {...@@ -49,8 +73,12 @@ export const verifyRequestDpopProof = (input: {
49 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),73 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
50 });74 });
51 if (!result.ok) {75 if (!result.ok) {
76 yield* Effect.annotateCurrentSpan({
77 "environment.dpop.failure_code": result.code,
78 });
52 return yield* new ServerAuthInvalidCredentialError({79 return yield* new ServerAuthInvalidCredentialError({
53 diagnostic: result.reason,80 diagnostic: result.reason,
81 dpopFailureReason: mapDpopFailureReason(result.code),
54 });82 });
55 }83 }
56 const secretStore = yield* ServerSecretStore.ServerSecretStore;84 const secretStore = yield* ServerSecretStore.ServerSecretStore;
...@@ -80,7 +108,15 @@ export const verifyRequestDpopProof = (input: {...@@ -80,7 +108,15 @@ export const verifyRequestDpopProof = (input: {
80 )108 )
81 .pipe(109 .pipe(
82 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>110 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 }),
84 ),120 ),
85 );121 );
86 return result.thumbprint;122 return result.thumbprint;
apps/server/src/auth/http.ts+32-8
...@@ -22,7 +22,7 @@ import {...@@ -22,7 +22,7 @@ import {
22 EnvironmentAuthenticatedAuth,22 EnvironmentAuthenticatedAuth,
23 EnvironmentAuthenticatedPrincipal,23 EnvironmentAuthenticatedPrincipal,
24} from "@t3tools/contracts";24} from "@t3tools/contracts";
25import type { AuthEnvironmentScope } from "@t3tools/contracts";25import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
26import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";26import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
27import { causeErrorTag } from "@t3tools/shared/observability";27import { causeErrorTag } from "@t3tools/shared/observability";
28import * as DateTime from "effect/DateTime";28import * as DateTime from "effect/DateTime";
...@@ -95,10 +95,20 @@ export function annotateEnvironmentRequest(endpoint: string) {...@@ -95,10 +95,20 @@ export function annotateEnvironmentRequest(endpoint: string) {
95 });95 });
96}96}
9797
98export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) {98export function failEnvironmentAuthInvalid(
99 reason: EnvironmentAuthInvalidReason,
100 dpopFailureReason?: DpopFailureReason,
101) {
99 return currentEnvironmentTraceId.pipe(102 return currentEnvironmentTraceId.pipe(
100 Effect.flatMap((traceId) =>103 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 ),
102 ),112 ),
103 );113 );
104}114}
...@@ -180,7 +190,10 @@ export const environmentAuthenticatedAuthLayer = Layer.effect(...@@ -180,7 +190,10 @@ export const environmentAuthenticatedAuthLayer = Layer.effect(
180 const request = yield* HttpServerRequest.HttpServerRequest;190 const request = yield* HttpServerRequest.HttpServerRequest;
181 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(191 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
182 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>192 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
183 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),193 failEnvironmentAuthInvalid(
194 EnvironmentAuth.serverAuthCredentialReason(error),
195 EnvironmentAuth.serverAuthDpopFailureReason(error),
196 ),
184 ),197 ),
185 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>198 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
186 failEnvironmentInternal("internal_error", error),199 failEnvironmentInternal("internal_error", error),
...@@ -244,7 +257,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(...@@ -244,7 +257,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
244 return result.response;257 return result.response;
245 },258 },
246 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>259 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
247 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),260 failEnvironmentAuthInvalid(
261 EnvironmentAuth.serverAuthCredentialReason(error),
262 EnvironmentAuth.serverAuthDpopFailureReason(error),
263 ),
248 ),264 ),
249 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>265 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
250 failEnvironmentInternal("browser_session_issuance_failed", error),266 failEnvironmentInternal("browser_session_issuance_failed", error),
...@@ -278,9 +294,14 @@ export const authHttpApiLayer = HttpApiBuilder.group(...@@ -278,9 +294,14 @@ export const authHttpApiLayer = HttpApiBuilder.group(
278 }294 }
279 const proofKeyThumbprint = args.headers.dpop295 const proofKeyThumbprint = args.headers.dpop
280 ? yield* verifyRequestDpopProof({ request }).pipe(296 ? yield* verifyRequestDpopProof({ request }).pipe(
281 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () =>297 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
282 appendDpopChallengeHeader.pipe(298 appendDpopChallengeHeader.pipe(
283 Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),299 Effect.andThen(
300 failEnvironmentAuthInvalid(
301 "invalid_credential",
302 EnvironmentAuth.serverAuthDpopFailureReason(error),
303 ),
304 ),
284 ),305 ),
285 ),306 ),
286 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>307 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
...@@ -307,7 +328,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(...@@ -307,7 +328,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
307 },328 },
308 traceRelayRequest,329 traceRelayRequest,
309 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>330 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
310 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),331 failEnvironmentAuthInvalid(
332 EnvironmentAuth.serverAuthCredentialReason(error),
333 EnvironmentAuth.serverAuthDpopFailureReason(error),
334 ),
311 ),335 ),
312 Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) =>336 Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) =>
313 failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)),337 failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)),
apps/server/src/http.ts+4-1
...@@ -160,7 +160,10 @@ const authenticateRawRouteWithScope = (...@@ -160,7 +160,10 @@ const authenticateRawRouteWithScope = (
160 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;160 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
161 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(161 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
162 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>162 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
163 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),163 failEnvironmentAuthInvalid(
164 EnvironmentAuth.serverAuthCredentialReason(error),
165 EnvironmentAuth.serverAuthDpopFailureReason(error),
166 ),
164 ),167 ),
165 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>168 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
166 failEnvironmentInternal("internal_error", error),169 failEnvironmentInternal("internal_error", error),
apps/server/src/server.test.ts+36
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 AuthTokenExchangeGrantType,10 AuthTokenExchangeGrantType,
11 CommandId,11 CommandId,
12 DEFAULT_SERVER_SETTINGS,12 DEFAULT_SERVER_SETTINGS,
13 type DpopFailureReason,
13 EnvironmentId,14 EnvironmentId,
14 EventId,15 EventId,
15 GitCommandError,16 GitCommandError,
...@@ -1115,6 +1116,7 @@ const exchangeAccessToken = (...@@ -1115,6 +1116,7 @@ const exchangeAccessToken = (
1115 readonly _tag?: string;1116 readonly _tag?: string;
1116 readonly code?: string;1117 readonly code?: string;
1117 readonly reason?: string;1118 readonly reason?: string;
1119 readonly dpopFailureReason?: DpopFailureReason;
1118 readonly traceId?: string;1120 readonly traceId?: string;
1119 }>(response);1121 }>(response);
1120 return {1122 return {
...@@ -1797,6 +1799,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1797,6 +1799,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1797 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1799 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1798 );1800 );
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
1800 it.effect("rejects replayed DPoP proofs across token exchanges", () =>1834 it.effect("rejects replayed DPoP proofs across token exchanges", () =>
1801 Effect.gen(function* () {1835 Effect.gen(function* () {
1802 yield* buildAppUnderTest();1836 yield* buildAppUnderTest();
...@@ -1846,6 +1880,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1846,6 +1880,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1846 assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError");1880 assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError");
1847 assert.equal(replayBootstrap.body.code, "auth_invalid");1881 assert.equal(replayBootstrap.body.code, "auth_invalid");
1848 assert.equal(replayBootstrap.body.reason, "invalid_credential");1882 assert.equal(replayBootstrap.body.reason, "invalid_credential");
1883 assert.equal(replayBootstrap.body.dpopFailureReason, "replay");
1849 assert.equal(typeof replayBootstrap.body.traceId, "string");1884 assert.equal(typeof replayBootstrap.body.traceId, "string");
1850 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1885 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1851 );1886 );
...@@ -1921,6 +1956,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1921,6 +1956,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1921 assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError");1956 assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError");
1922 assert.equal(bootstrap.body.code, "auth_invalid");1957 assert.equal(bootstrap.body.code, "auth_invalid");
1923 assert.equal(bootstrap.body.reason, "invalid_credential");1958 assert.equal(bootstrap.body.reason, "invalid_credential");
1959 assert.equal(bootstrap.body.dpopFailureReason, "request_mismatch");
1924 assert.equal(typeof bootstrap.body.traceId, "string");1960 assert.equal(typeof bootstrap.body.traceId, "string");
1925 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1961 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1926 );1962 );
apps/server/src/ws.ts+4-1
...@@ -2500,7 +2500,10 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -2500,7 +2500,10 @@ export const websocketRpcRouteLayer = Layer.unwrap(
2500 const analytics = yield* AnalyticsService.AnalyticsService;2500 const analytics = yield* AnalyticsService.AnalyticsService;
2501 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(2501 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
2502 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>2502 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
2503 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),2503 failEnvironmentAuthInvalid(
2504 EnvironmentAuth.serverAuthCredentialReason(error),
2505 EnvironmentAuth.serverAuthDpopFailureReason(error),
2506 ),
2504 ),2507 ),
2505 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>2508 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
2506 failEnvironmentInternal("internal_error", error),2509 failEnvironmentInternal("internal_error", error),
apps/web/src/cloud/linkEnvironment.ts+1-46
...@@ -19,13 +19,12 @@ import {...@@ -19,13 +19,12 @@ import {
19 type RelayClientDeviceRecord,19 type RelayClientDeviceRecord,
20 type RelayClientEnvironmentRecord,20 type RelayClientEnvironmentRecord,
21 type RelayEnvironmentLinkResponse,21 type RelayEnvironmentLinkResponse,
22 type RelayProtectedError as RelayProtectedErrorType,
23 type RelayManagedEndpointProviderKind,22 type RelayManagedEndpointProviderKind,
24} from "@t3tools/contracts/relay";23} from "@t3tools/contracts/relay";
25import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";24import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
26import { request, runStream } from "@t3tools/client-runtime/rpc";25import { request, runStream } from "@t3tools/client-runtime/rpc";
27import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";26import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
28import { ManagedRelay } from "@t3tools/client-runtime/relay";27import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";
2928
30import {29import {
31 readPrimaryEnvironmentDescriptor,30 readPrimaryEnvironmentDescriptor,
...@@ -128,50 +127,6 @@ const isEnvironmentCloudApiError = Schema.is(...@@ -128,50 +127,6 @@ const isEnvironmentCloudApiError = Schema.is(
128 ]),127 ]),
129);128);
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
175function decodedRelayClientError(message: string) {130function decodedRelayClientError(message: string) {
176 return (cause: ManagedRelay.ManagedRelayClientError) => {131 return (cause: ManagedRelay.ManagedRelayClientError) => {
177 const relayError =132 const relayError =
docs/internals/environment-auth.md+3-1
...@@ -84,7 +84,9 @@ that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`;...@@ -84,7 +84,9 @@ that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`;
84the resulting JWK thumbprint is stored on the session, which is then issued with84the resulting JWK thumbprint is stored on the session, which is then issued with
85method `dpop-access-token` and a one-hour TTL instead of the bearer default. An85method `dpop-access-token` and a one-hour TTL instead of the bearer default. An
86invalid proof gets a DPoP challenge header and a credential error rather than a86invalid 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
89`dpop-access-token` is advertised alongside `browser-session-cookie` and91`dpop-access-token` is advertised alongside `browser-session-cookie` and
90`bearer-access-token` in the descriptor's `sessionMethods`92`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....@@ -49,6 +49,12 @@ records instead carry OTLP resource, scope, and optional status fields.
49The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in49The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in
50`packages/shared/src/observability.ts`.50`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
52### Metrics58### Metrics
5359
54Metrics are not written to a local file.60Metrics 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...@@ -51,3 +51,9 @@ Agents should prefer the provisioned view or APL queries for completed incidents
51tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who51tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who
52need scripted query access use the authorized account-level `AXIOM_TOKEN` together with52need scripted query access use the authorized account-level `AXIOM_TOKEN` together with
53`AXIOM_ORG_ID`; scoped ingest tokens remain write-only credentials for their producers.53`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...@@ -174,6 +174,8 @@ the conversation and in **Settings** → **Connections**. Follow the action show
174be able to update and reconnect the server for you, or it may ask you to update the desktop app or174be able to update and reconnect the server for you, or it may ask you to update the desktop app or
175run a copied command on the server machine.175run 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
177Finish active work before updating because the server restarts briefly. For step-by-step guidance,179Finish active work before updating because the server restarts briefly. For step-by-step guidance,
178see [Keeping T3 Code in Sync](./updating.md).180see [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";...@@ -3,10 +3,9 @@ import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
6import * as HttpApiError from "effect/unstable/httpapi/HttpApiError";
7import { lt } from "drizzle-orm";6import { lt } from "drizzle-orm";
87
9import { verifyDpopProof } from "@t3tools/shared/dpop";8import { DpopVerificationFailureCode, verifyDpopProof } from "@t3tools/shared/dpop";
10import * as RelayDb from "../db.ts";9import * as RelayDb from "../db.ts";
11import { relayDpopProofs } from "../persistence/schema.ts";10import { relayDpopProofs } from "../persistence/schema.ts";
1211
...@@ -26,6 +25,23 @@ export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass<Dpo...@@ -26,6 +25,23 @@ export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass<Dpo
26 }25 }
27}26}
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
29export class DpopProofReplay extends Context.Service<45export class DpopProofReplay extends Context.Service<
30 DpopProofReplay,46 DpopProofReplay,
31 {47 {
...@@ -36,7 +52,7 @@ export class DpopProofReplay extends Context.Service<...@@ -36,7 +52,7 @@ export class DpopProofReplay extends Context.Service<
36 readonly expectedThumbprint?: string;52 readonly expectedThumbprint?: string;
37 readonly expectedAccessToken?: string;53 readonly expectedAccessToken?: string;
38 readonly now: DateTime.DateTime;54 readonly now: DateTime.DateTime;
39 }) => Effect.Effect<string, HttpApiError.Unauthorized | DpopProofReplayPersistenceError>;55 }) => Effect.Effect<string, DpopProofRejected | DpopProofReplayPersistenceError>;
40 readonly consume: (input: {56 readonly consume: (input: {
41 readonly thumbprint: string;57 readonly thumbprint: string;
42 readonly jti: string;58 readonly jti: string;
...@@ -98,13 +114,16 @@ const make = Effect.gen(function* () {...@@ -98,13 +114,16 @@ const make = Effect.gen(function* () {
98 });114 });
99 if (!result.ok) {115 if (!result.ok) {
100 yield* Effect.logWarning("relay dpop proof rejected", {116 yield* Effect.logWarning("relay dpop proof rejected", {
117 code: result.code,
101 reason: result.reason,118 reason: result.reason,
102 method: input.method,119 method: input.method,
103 url: input.url,120 url: input.url,
104 expectedThumbprintPresent: input.expectedThumbprint !== undefined,121 expectedThumbprintPresent: input.expectedThumbprint !== undefined,
105 expectedAccessTokenPresent: input.expectedAccessToken !== undefined,122 expectedAccessTokenPresent: input.expectedAccessToken !== undefined,
106 });123 });
107 return yield* new HttpApiError.Unauthorized({});124 return yield* new DpopProofRejected({
125 code: result.code,
126 });
108 }127 }
109 const consumed = yield* consume({128 const consumed = yield* consume({
110 thumbprint: result.thumbprint,129 thumbprint: result.thumbprint,
...@@ -118,7 +137,9 @@ const make = Effect.gen(function* () {...@@ -118,7 +137,9 @@ const make = Effect.gen(function* () {
118 jti: result.jti,137 jti: result.jti,
119 iat: result.iat,138 iat: result.iat,
120 });139 });
121 return yield* new HttpApiError.Unauthorized({});140 return yield* new DpopProofRejected({
141 code: "replayed",
142 });
122 }143 }
123 yield* Effect.annotateCurrentSpan({144 yield* Effect.annotateCurrentSpan({
124 "relay.dpop.thumbprint": result.thumbprint,145 "relay.dpop.thumbprint": result.thumbprint,
infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts+35-4
...@@ -96,6 +96,34 @@ function consumeEachProofOnce() {...@@ -96,6 +96,34 @@ function consumeEachProofOnce() {
96}96}
9797
98describe("DpopProofReplay.verifyAndConsume", () => {98describe("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
99 it.effect("rejects replayed proofs after persistence consumes the jti once", () => {127 it.effect("rejects replayed proofs after persistence consumes the jti once", () => {
100 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");128 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
101 const proof = makeDpopProof({129 const proof = makeDpopProof({
...@@ -114,7 +142,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {...@@ -114,7 +142,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
114 expectedThumbprint: proof.thumbprint,142 expectedThumbprint: proof.thumbprint,
115 now,143 now,
116 });144 });
117 const second = yield* Effect.exit(145 const second = yield* Effect.flip(
118 replay.verifyAndConsume({146 replay.verifyAndConsume({
119 proof: proof.proof,147 proof: proof.proof,
120 method: "POST",148 method: "POST",
...@@ -125,7 +153,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {...@@ -125,7 +153,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
125 );153 );
126154
127 expect(first).toBe(proof.thumbprint);155 expect(first).toBe(proof.thumbprint);
128 expect(second._tag).toBe("Failure");156 expect(second).toMatchObject({ _tag: "DpopProofRejected", code: "replayed" });
129 }).pipe(Effect.provide(layer(consumeEachProofOnce())));157 }).pipe(Effect.provide(layer(consumeEachProofOnce())));
130 });158 });
131159
...@@ -140,7 +168,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {...@@ -140,7 +168,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
140168
141 return Effect.gen(function* () {169 return Effect.gen(function* () {
142 const replay = yield* DpopProofs.DpopProofReplay;170 const replay = yield* DpopProofs.DpopProofReplay;
143 const result = yield* Effect.exit(171 const error = yield* Effect.flip(
144 replay.verifyAndConsume({172 replay.verifyAndConsume({
145 proof: proof.proof,173 proof: proof.proof,
146 method: "POST",174 method: "POST",
...@@ -151,7 +179,10 @@ describe("DpopProofReplay.verifyAndConsume", () => {...@@ -151,7 +179,10 @@ describe("DpopProofReplay.verifyAndConsume", () => {
151 }),179 }),
152 );180 );
153181
154 expect(result._tag).toBe("Failure");182 expect(error).toMatchObject({
183 _tag: "DpopProofRejected",
184 code: "access_token_hash_mismatch",
185 });
155 }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence"))));186 }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence"))));
156 });187 });
157188
infra/relay/src/http/Api.test.ts+22
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23 relayDocsRedirectRoute,23 relayDocsRedirectRoute,
24 relayEnvironmentAuthLayer,24 relayEnvironmentAuthLayer,
25 relayNotFoundRoute,25 relayNotFoundRoute,
26 relayDpopFailureReason,
26 revokeEnvironmentLinkRecord,27 revokeEnvironmentLinkRecord,
27 traceRelayHttpRequestWith,28 traceRelayHttpRequestWith,
28 unlinkEnvironmentRecord,29 unlinkEnvironmentRecord,
...@@ -115,6 +116,27 @@ describe("relay client authentication", () => {...@@ -115,6 +116,27 @@ describe("relay client authentication", () => {
115 );116 );
116});117});
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
118describe("relay environment authentication", () => {140describe("relay environment authentication", () => {
119 it.effect("preserves credential lookup persistence failures as internal errors", () => {141 it.effect("preserves credential lookup persistence failures as internal errors", () => {
120 const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({142 const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({
infra/relay/src/http/Api.ts+51-1
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 RelayMobileRegistrationScope,35 RelayMobileRegistrationScope,
36 RelayAuthInvalidError,36 RelayAuthInvalidError,
37 type RelayAuthInvalidReason,37 type RelayAuthInvalidReason,
38 type RelayDpopFailureReason,
38 RelayEnvironmentAuth,39 RelayEnvironmentAuth,
39 RelayEnvironmentConnectNotAuthorizedError,40 RelayEnvironmentConnectNotAuthorizedError,
40 RelayEnvironmentEndpointTimedOutError,41 RelayEnvironmentEndpointTimedOutError,
...@@ -999,6 +1000,7 @@ class ClerkTokenVerificationFailed extends Schema.TaggedErrorClass<ClerkTokenVer...@@ -999,6 +1000,7 @@ class ClerkTokenVerificationFailed extends Schema.TaggedErrorClass<ClerkTokenVer
999}1000}
10001001
1001const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized);1002const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized);
1003const isDpopProofRejected = Schema.is(DpopProofs.DpopProofRejected);
10021004
1003const currentTraceId = Effect.currentParentSpan.pipe(1005const currentTraceId = Effect.currentParentSpan.pipe(
1004 Effect.map((span) => span.traceId),1006 Effect.map((span) => span.traceId),
...@@ -1030,10 +1032,37 @@ type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;...@@ -1030,10 +1032,37 @@ type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;
1030const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);1032const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);
10311033
1032type MapRelayCommonApiError<E> =1034type MapRelayCommonApiError<E> =
1033 | Exclude<E, HttpApiError.Unauthorized | RelayCommonPersistenceError>1035 | Exclude<
1036 E,
1037 HttpApiError.Unauthorized | DpopProofs.DpopProofRejected | RelayCommonPersistenceError
1038 >
1034 | (Extract<E, HttpApiError.Unauthorized> extends never ? never : RelayAuthInvalidError)1039 | (Extract<E, HttpApiError.Unauthorized> extends never ? never : RelayAuthInvalidError)
1040 | (Extract<E, DpopProofs.DpopProofRejected> extends never ? never : RelayAuthInvalidError)
1035 | (Extract<E, RelayCommonPersistenceError> extends never ? never : RelayInternalError);1041 | (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
1037function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {1066function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {
1038 return currentTraceId.pipe(1067 return currentTraceId.pipe(
1039 Effect.flatMap((traceId) =>1068 Effect.flatMap((traceId) =>
...@@ -1045,11 +1074,32 @@ function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {...@@ -1045,11 +1074,32 @@ function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {
1045function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {1074function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {
1046 const mapError = Effect.fnUntraced(function* <E>(error: E) {1075 const mapError = Effect.fnUntraced(function* <E>(error: E) {
1047 const traceId = yield* currentTraceId;1076 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 }
1048 if (isHttpUnauthorized(error)) {1092 if (isHttpUnauthorized(error)) {
1093 if (authReason === "invalid_dpop") {
1094 yield* Effect.annotateCurrentSpan({
1095 "relay.dpop.failure_code": "invalid_proof",
1096 });
1097 }
1049 return yield* Effect.fail(1098 return yield* Effect.fail(
1050 new RelayAuthInvalidError({1099 new RelayAuthInvalidError({
1051 code: "auth_invalid",1100 code: "auth_invalid",
1052 reason: authReason,1101 reason: authReason,
1102 ...(authReason === "invalid_dpop" ? { dpopFailureReason: "invalid_proof" } : {}),
1053 traceId,1103 traceId,
1054 }) as MapRelayCommonApiError<E>,1104 }) as MapRelayCommonApiError<E>,
1055 );1105 );
packages/client-runtime/src/authorization/layer.test.ts+24
...@@ -6,6 +6,7 @@ import * as Option from "effect/Option";...@@ -6,6 +6,7 @@ import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";7import * as TestClock from "effect/testing/TestClock";
88
9import { DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts";
9import * as ManagedRelay from "../relay/managedRelay.ts";10import * as ManagedRelay from "../relay/managedRelay.ts";
10import { remoteHttpClientLayer } from "../rpc/http.ts";11import { remoteHttpClientLayer } from "../rpc/http.ts";
11import * as ClientCapabilities from "../platform/capabilities.ts";12import * as ClientCapabilities from "../platform/capabilities.ts";
...@@ -344,6 +345,29 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -344,6 +345,29 @@ describe("RemoteEnvironmentAuthorization", () => {
344 }),345 }),
345 );346 );
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
347 it.effect("refreshes a cached endpoint after its first transient failure", () =>371 it.effect("refreshes a cached endpoint after its first transient failure", () =>
348 Effect.gen(function* () {372 Effect.gen(function* () {
349 const cached = new TokenStore.RemoteDpopAccessToken({373 const cached = new TokenStore.RemoteDpopAccessToken({
packages/client-runtime/src/authorization/service.ts+7-3
...@@ -10,7 +10,11 @@ import {...@@ -10,7 +10,11 @@ import {
10 resolveRemoteDpopWebSocketConnectionUrl,10 resolveRemoteDpopWebSocketConnectionUrl,
11 resolveRemoteWebSocketConnectionUrl,11 resolveRemoteWebSocketConnectionUrl,
12} from "./remote.ts";12} from "./remote.ts";
13import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts";13import {
14 environmentMismatchError,
15 mapRemoteDpopEnvironmentError,
16 mapRemoteEnvironmentError,
17} from "../connection/errors.ts";
14import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";18import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
15import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";19import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
16import { environmentEndpointUrl } from "../environment/endpoint.ts";20import { environmentEndpointUrl } from "../environment/endpoint.ts";
...@@ -69,7 +73,7 @@ const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;...@@ -69,7 +73,7 @@ const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
69function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {73function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
70 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"74 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
71 ? error75 ? error
72 : mapRemoteEnvironmentError(error);76 : mapRemoteDpopEnvironmentError(error);
73}77}
7478
75const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (79const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (
...@@ -276,7 +280,7 @@ export const make = Effect.gen(function* () {...@@ -276,7 +280,7 @@ export const make = Effect.gen(function* () {
276 scopes: presentation.scopes,280 scopes: presentation.scopes,
277 clientMetadata: presentation.metadata,281 clientMetadata: presentation.metadata,
278 }).pipe(282 }).pipe(
279 Effect.mapError(mapRemoteEnvironmentError),283 Effect.mapError(mapRemoteDpopEnvironmentError),
280 Effect.provideService(HttpClient.HttpClient, httpClient),284 Effect.provideService(HttpClient.HttpClient, httpClient),
281 Effect.withSpan("environment.authorization.accessToken.exchange"),285 Effect.withSpan("environment.authorization.accessToken.exchange"),
282 );286 );
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 @@...@@ -1,6 +1,7 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import type { RelayProtectedError } from "@t3tools/contracts/relay";2import type { RelayProtectedError } from "@t3tools/contracts/relay";
3import type { ManagedRelayClientError } from "../relay/managedRelay.ts";3import type { ManagedRelayClientError } from "../relay/managedRelay.ts";
4import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts";
4import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";5import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts";
5import {6import {
6 ConnectionBlockedError,7 ConnectionBlockedError,
...@@ -40,7 +41,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError...@@ -40,7 +41,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
40 case "RelayAgentActivityPublishProofInvalidError":41 case "RelayAgentActivityPublishProofInvalidError":
41 return new ConnectionBlockedError({42 return new ConnectionBlockedError({
42 reason: "authentication",43 reason: "authentication",
43 detail: error.message,44 detail: relayProtectedErrorMessage(error),
44 traceId: error.traceId,45 traceId: error.traceId,
45 });46 });
46 case "RelayEnvironmentConnectNotAuthorizedError":47 case "RelayEnvironmentConnectNotAuthorizedError":
...@@ -48,27 +49,27 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError...@@ -48,27 +49,27 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
48 case "RelayEnvironmentLinkLimitExceededError":49 case "RelayEnvironmentLinkLimitExceededError":
49 return new ConnectionBlockedError({50 return new ConnectionBlockedError({
50 reason: "permission",51 reason: "permission",
51 detail: error.message,52 detail: relayProtectedErrorMessage(error),
52 traceId: error.traceId,53 traceId: error.traceId,
53 });54 });
54 case "RelayEnvironmentEndpointTimedOutError":55 case "RelayEnvironmentEndpointTimedOutError":
55 return new ConnectionTransientError({56 return new ConnectionTransientError({
56 reason: "timeout",57 reason: "timeout",
57 detail: error.message,58 detail: relayProtectedErrorMessage(error),
58 traceId: error.traceId,59 traceId: error.traceId,
59 });60 });
60 case "RelayEnvironmentEndpointUnavailableError":61 case "RelayEnvironmentEndpointUnavailableError":
61 case "RelayEnvironmentLinkUnavailableError":62 case "RelayEnvironmentLinkUnavailableError":
62 return new ConnectionTransientError({63 return new ConnectionTransientError({
63 reason: "endpoint-unavailable",64 reason: "endpoint-unavailable",
64 detail: error.message,65 detail: relayProtectedErrorMessage(error),
65 traceId: error.traceId,66 traceId: error.traceId,
66 });67 });
67 case "RelayEnvironmentLinkFailedError":68 case "RelayEnvironmentLinkFailedError":
68 case "RelayInternalError":69 case "RelayInternalError":
69 return new ConnectionTransientError({70 return new ConnectionTransientError({
70 reason: "relay-unavailable",71 reason: "relay-unavailable",
71 detail: error.message,72 detail: relayProtectedErrorMessage(error),
72 traceId: error.traceId,73 traceId: error.traceId,
73 });74 });
74 }75 }
...@@ -166,3 +167,23 @@ export function mapRemoteEnvironmentError(...@@ -166,3 +167,23 @@ export function mapRemoteEnvironmentError(
166 });167 });
167 }168 }
168}169}
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 @@...@@ -1,3 +1,4 @@
1export * as Discovery from "./discovery.ts";1export * as Discovery from "./discovery.ts";
2export * from "./errorPresentation.ts";
2export * as ManagedRelay from "./managedRelay.ts";3export * as ManagedRelay from "./managedRelay.ts";
3export * from "./managedRelayState.ts";4export * from "./managedRelayState.ts";
packages/client-runtime/src/relay/managedRelay.test.ts+37
...@@ -462,6 +462,43 @@ describe("ManagedRelayClient", () => {...@@ -462,6 +462,43 @@ describe("ManagedRelayClient", () => {
462 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));462 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
463 });463 });
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
465 it.effect("lists account devices through the Clerk bearer client endpoint", () => {502 it.effect("lists account devices through the Clerk bearer client endpoint", () => {
466 const fetchFn = ((input, init) => {503 const fetchFn = ((input, init) => {
467 expect(String(input)).toBe("https://relay.example.test/v1/client/devices");504 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 @@...@@ -1,8 +1,9 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import type {2import {
3 RelayClientDeviceRecord,3 RelayAuthInvalidError,
4 RelayClientEnvironmentRecord,4 type RelayClientDeviceRecord,
5 RelayEnvironmentStatusResponse,5 type RelayClientEnvironmentRecord,
6 type RelayEnvironmentStatusResponse,
6} from "@t3tools/contracts/relay";7} from "@t3tools/contracts/relay";
7import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
...@@ -12,6 +13,7 @@ import * as Stream from "effect/Stream";...@@ -12,6 +13,7 @@ import * as Stream from "effect/Stream";
12import { Atom, AtomRegistry } from "effect/unstable/reactivity";13import { Atom, AtomRegistry } from "effect/unstable/reactivity";
13import { afterEach, vi } from "vite-plus/test";14import { afterEach, vi } from "vite-plus/test";
1415
16import { DPOP_UNKNOWN_HINT } from "./errorPresentation.ts";
15import * as ManagedRelay from "./managedRelay.ts";17import * as ManagedRelay from "./managedRelay.ts";
16import {18import {
17 createManagedRelayQueryManager,19 createManagedRelayQueryManager,
...@@ -421,4 +423,31 @@ describe("createManagedRelayQueryManager", () => {...@@ -421,4 +423,31 @@ describe("createManagedRelayQueryManager", () => {
421 });423 });
422 });424 });
423 });425 });
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 });
424});453});
packages/client-runtime/src/relay/managedRelayState.ts+9-1
...@@ -13,15 +13,18 @@ import * as Clock from "effect/Clock";...@@ -13,15 +13,18 @@ import * as Clock from "effect/Clock";
13import * as Data from "effect/Data";13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
16import * as Stream from "effect/Stream";17import * as Stream from "effect/Stream";
17import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";18import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1819
19import { findErrorTraceId } from "../errors/errorTrace.ts";20import { findErrorTraceId } from "../errors/errorTrace.ts";
20import * as ManagedRelay from "./managedRelay.ts";21import * as ManagedRelay from "./managedRelay.ts";
22import { relayProtectedErrorMessage } from "./errorPresentation.ts";
2123
22const DEFAULT_STALE_TIME_MS = 15_000;24const DEFAULT_STALE_TIME_MS = 15_000;
23const DEFAULT_IDLE_TTL_MS = 5 * 60_000;25const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
24const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000;26const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000;
27const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError);
2528
26export interface ManagedRelaySession {29export interface ManagedRelaySession {
27 readonly accountId: string;30 readonly accountId: string;
...@@ -315,7 +318,12 @@ export function readManagedRelaySnapshotState<A>(...@@ -315,7 +318,12 @@ export function readManagedRelaySnapshotState<A>(
315 let errorTraceId: string | null = null;318 let errorTraceId: string | null = null;
316 if (result._tag === "Failure") {319 if (result._tag === "Failure") {
317 const cause = Cause.squash(result.cause);320 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.";
319 errorTraceId = findErrorTraceId(cause);327 errorTraceId = findErrorTraceId(cause);
320 }328 }
321 return {329 return {
packages/contracts/src/baseSchemas.ts+14
...@@ -18,6 +18,20 @@ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))...@@ -18,6 +18,20 @@ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
18export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));18export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
19export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));19export 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
21export const IsoDateTime = Schema.String;35export const IsoDateTime = Schema.String;
22export type IsoDateTime = typeof IsoDateTime.Type;36export type IsoDateTime = typeof IsoDateTime.Type;
2337
packages/contracts/src/environmentHttp.ts+8-1
...@@ -24,7 +24,12 @@ import {...@@ -24,7 +24,12 @@ import {
24 AuthWebSocketTicketResult,24 AuthWebSocketTicketResult,
25 ServerAuthSessionMethod,25 ServerAuthSessionMethod,
26} from "./auth.ts";26} from "./auth.ts";
27import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";27import {
28 DpopFailureReason,
29 AuthSessionId,
30 ThreadId,
31 TrimmedNonEmptyString,
32} from "./baseSchemas.ts";
28import { ExecutionEnvironmentDescriptor } from "./environment.ts";33import { ExecutionEnvironmentDescriptor } from "./environment.ts";
29import {34import {
30 ClientOrchestrationCommand,35 ClientOrchestrationCommand,
...@@ -117,6 +122,8 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<Environ...@@ -117,6 +122,8 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<Environ
117 {122 {
118 code: Schema.Literal("auth_invalid"),123 code: Schema.Literal("auth_invalid"),
119 reason: EnvironmentAuthInvalidReason,124 reason: EnvironmentAuthInvalidReason,
125 // Older servers do not send a DPoP failure category.
126 dpopFailureReason: Schema.optionalKey(DpopFailureReason),
120 traceId: TrimmedNonEmptyString,127 traceId: TrimmedNonEmptyString,
121 },128 },
122 { httpApiStatus: 401 },129 { httpApiStatus: 401 },
packages/contracts/src/relay.ts+11-1
...@@ -8,7 +8,12 @@ import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";...@@ -8,7 +8,12 @@ import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
8import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity";8import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity";
9import * as OpenApi from "effect/unstable/httpapi/OpenApi";9import * 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";
12import { ExecutionEnvironmentDescriptor } from "./environment.ts";17import { ExecutionEnvironmentDescriptor } from "./environment.ts";
1318
14export const RelayAgentAwarenessPlatform = Schema.Literal("ios");19export const RelayAgentAwarenessPlatform = Schema.Literal("ios");
...@@ -322,6 +327,9 @@ export const RelayAuthInvalidReason = Schema.Literals([...@@ -322,6 +327,9 @@ export const RelayAuthInvalidReason = Schema.Literals([
322]);327]);
323export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type;328export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type;
324329
330export const RelayDpopFailureReason = DpopFailureReason;
331export type RelayDpopFailureReason = typeof RelayDpopFailureReason.Type;
332
325export const RelayInternalErrorReason = Schema.Literals([333export const RelayInternalErrorReason = Schema.Literals([
326 "database_unavailable",334 "database_unavailable",
327 "persistence_failed",335 "persistence_failed",
...@@ -335,6 +343,8 @@ export class RelayAuthInvalidError extends Schema.TaggedErrorClass<RelayAuthInva...@@ -335,6 +343,8 @@ export class RelayAuthInvalidError extends Schema.TaggedErrorClass<RelayAuthInva
335 {343 {
336 code: Schema.Literal("auth_invalid"),344 code: Schema.Literal("auth_invalid"),
337 reason: RelayAuthInvalidReason,345 reason: RelayAuthInvalidReason,
346 // Older relays do not send a DPoP failure category.
347 dpopFailureReason: Schema.optionalKey(RelayDpopFailureReason),
338 traceId: TrimmedNonEmptyString,348 traceId: TrimmedNonEmptyString,
339 },349 },
340 { httpApiStatus: 401 },350 { httpApiStatus: 401 },
packages/shared/src/dpop.test.ts+37
...@@ -145,6 +145,43 @@ describe("verifyDpopProof", () => {...@@ -145,6 +145,43 @@ describe("verifyDpopProof", () => {
145 );145 );
146 });146 });
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
148 it("requires the RFC 9449 access token hash when an access token is expected", () => {185 it("requires the RFC 9449 access token hash when an access token is expected", () => {
149 const thumbprint = computeDpopJwkThumbprint(publicJwk);186 const thumbprint = computeDpopJwkThumbprint(publicJwk);
150 const accessTokenProof = signDpopProof({187 const accessTokenProof = signDpopProof({
packages/shared/src/dpop.ts+49-25
...@@ -17,6 +17,19 @@ export const DpopPublicJwk = DpopPublicJwkSchema;...@@ -17,6 +17,19 @@ export const DpopPublicJwk = DpopPublicJwkSchema;
17export type DpopPublicJwk = DpopPublicJwkType;17export type DpopPublicJwk = DpopPublicJwkType;
18export { normalizeDpopHtu };18export { 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
20const DpopJwtHeaderPublicJwk = Schema.Struct({33const DpopJwtHeaderPublicJwk = Schema.Struct({
21 ...DpopPublicJwkSchema.fields,34 ...DpopPublicJwkSchema.fields,
22 d: Schema.optionalKey(Schema.Never),35 d: Schema.optionalKey(Schema.Never),
...@@ -51,6 +64,7 @@ export type DpopVerificationResult =...@@ -51,6 +64,7 @@ export type DpopVerificationResult =
51 }64 }
52 | {65 | {
53 readonly ok: false;66 readonly ok: false;
67 readonly code: DpopVerificationFailureCode;
54 readonly reason: string;68 readonly reason: string;
55 };69 };
5670
...@@ -106,50 +120,46 @@ export function verifyDpopProof(input: {...@@ -106,50 +120,46 @@ export function verifyDpopProof(input: {
106 readonly maxAgeSeconds?: number;120 readonly maxAgeSeconds?: number;
107}): DpopVerificationResult {121}): DpopVerificationResult {
108 if (!input.proof?.trim()) {122 if (!input.proof?.trim()) {
109 return { ok: false, reason: "Missing DPoP proof." };123 return { ok: false, code: "missing_proof", reason: "Missing DPoP proof." };
110 }124 }
111125
112 const parts = input.proof.split(".");126 const parts = input.proof.split(".");
113 if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {127 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." };
115 }129 }
116130
117 try {131 try {
118 const header = decodeBase64UrlDpopJwtHeader(parts[0]);132 const header = decodeBase64UrlDpopJwtHeader(parts[0]);
119 const payload = decodeBase64UrlDpopJwtPayload(parts[1]);133 const payload = decodeBase64UrlDpopJwtPayload(parts[1]);
120 if (Option.isNone(header)) {134 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." };
122 }136 }
123 if (Option.isNone(payload)) {137 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." };
125 }139 }
126140
127 const thumbprint = computeDpopJwkThumbprint(header.value.jwk);141 const thumbprint = computeDpopJwkThumbprint(header.value.jwk);
128 if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {142 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." };
130 }144 }
131 if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) {145 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." };
133 }147 }
134 const normalizedHtu = normalizeDpopHtu(input.url);148 const normalizedHtu = normalizeDpopHtu(input.url);
135 if (normalizedHtu === null || payload.value.htu !== normalizedHtu) {149 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." };
137 }151 }
138 if (input.expectedAccessToken) {152 if (input.expectedAccessToken) {
139 const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);153 const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);
140 if (payload.value.ath !== expectedAth) {154 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 };
142 }160 }
143 }161 }
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
153 const signature = base64UrlToBytes(parts[2]);163 const signature = base64UrlToBytes(parts[2]);
154 const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));164 const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
155 const verified = p256.verify(165 const verified = p256.verify(
...@@ -161,15 +171,29 @@ export function verifyDpopProof(input: {...@@ -161,15 +171,29 @@ export function verifyDpopProof(input: {
161 format: "compact",171 format: "compact",
162 },172 },
163 );173 );
164 return verified174 if (!verified) {
165 ? {175 return { ok: false, code: "invalid_signature", reason: "Invalid DPoP signature." };
166 ok: true,176 }
167 thumbprint,177
168 jti: payload.value.jti,178 const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
169 iat: payload.value.iat,179 if (
170 }180 payload.value.iat > input.nowEpochSeconds + 5 ||
171 : { ok: false, reason: "Invalid DPoP signature." };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 };
172 } catch {196 } catch {
173 return { ok: false, reason: "Invalid DPoP proof." };197 return { ok: false, code: "invalid_proof", reason: "Invalid DPoP proof." };
174 }198 }
175}199}