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