| 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,7 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import { EnvironmentId } from "@t3tools/contracts"; | 5 | import { EnvironmentId } from "@t3tools/contracts"; |
| 6 | import { RelayMobileClientId } from "@t3tools/contracts/relay"; | 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 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; | 8 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; |
| 9 | import { HttpClient } from "effect/unstable/http"; | 9 | import { HttpClient } from "effect/unstable/http"; |
| 10 | import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; | 10 | import { 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 | ); |
| 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 | 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"; |
| 4 | import { HttpClient } from "effect/unstable/http"; | 4 | import { HttpClient } from "effect/unstable/http"; |
| 5 | import { | 5 | import { |
| 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 { |
| 25 | import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; | 25 | import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; |
| 26 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; | 26 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; |
| 27 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | 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 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; | 33 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; |
| 30 | 34 | ||
| 31 | import { authClientMetadata } from "../../lib/authClientMetadata"; | 35 | import { 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 | ); |
| 80 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); | ||
| 76 | 81 | ||
| 77 | const MANAGED_ENDPOINT_PROVIDER_KIND = | 82 | const MANAGED_ENDPOINT_PROVIDER_KIND = |
| 78 | "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; | 83 | "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; |
| 79 | 84 | ||
| 80 | function cloudEnvironmentLinkError(message: string) { | 85 | function 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: environmentError | 94 | 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 | } |
| 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 | function decodedRelayClientError(message: string) { | 130 | function 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 | } |
| 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 | function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> { | 164 | function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> { |
| 189 | const relayUrl = readRelayUrl(); | 165 | const relayUrl = readRelayUrl(); |
| 190 | return relayUrl | 166 | 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"; |
| 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 22 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 21 | import * as Context from "effect/Context"; | 23 | import * 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"; |
| 368 | 371 | ||
| 372 | export const serverAuthDpopFailureReason = ( | ||
| 373 | error: ServerAuthCredentialError, | ||
| 374 | ): DpopFailureReasonType | undefined => | ||
| 375 | error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined; | ||
| 376 | |||
| 369 | export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass<ServerAuthInvalidScopeError>()( | 377 | export 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"; |
| 2 | import * as PlatformError from "effect/PlatformError"; | 2 | import * as PlatformError from "effect/PlatformError"; |
| 3 | 3 | ||
| 4 | import { SecretStorePersistError } from "./ServerSecretStore.ts"; | 4 | import { SecretStorePersistError } from "./ServerSecretStore.ts"; |
| 5 | import { mapDpopReplayStoreError } from "./dpop.ts"; | 5 | import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts"; |
| 6 | 6 | ||
| 7 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | 7 | const 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 | }); |
| 28 | 29 | ||
| ... | @@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => { | ... | @@ -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,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 | import * as Crypto from "effect/Crypto"; | 6 | import * as Crypto from "effect/Crypto"; |
| 3 | import * as DateTime from "effect/DateTime"; | 7 | import * as DateTime from "effect/DateTime"; |
| 4 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| ... | @@ -14,12 +18,32 @@ import { | ... | @@ -14,12 +18,32 @@ import { |
| 14 | } from "./EnvironmentAuth.ts"; | 18 | } from "./EnvironmentAuth.ts"; |
| 15 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 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 | export const mapDpopReplayStoreError = ( | 40 | export 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"; |
| 25 | import type { AuthEnvironmentScope } from "@t3tools/contracts"; | 25 | import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; |
| 26 | import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; | 26 | import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; |
| 27 | import { causeErrorTag } from "@t3tools/shared/observability"; | 27 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 28 | import * as DateTime from "effect/DateTime"; | 28 | import * 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 | } |
| 97 | 97 | ||
| 98 | export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) { | 98 | export 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.dpop | 295 | 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 | ); |
| 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 | 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"; |
| 25 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; | 24 | import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; |
| 26 | import { request, runStream } from "@t3tools/client-runtime/rpc"; | 25 | import { request, runStream } from "@t3tools/client-runtime/rpc"; |
| 27 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; | 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 | import { | 29 | import { |
| 31 | readPrimaryEnvironmentDescriptor, | 30 | readPrimaryEnvironmentDescriptor, |
| ... | @@ -128,50 +127,6 @@ const isEnvironmentCloudApiError = Schema.is( | ... | @@ -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 | function decodedRelayClientError(message: string) { | 130 | function 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`; |
| 84 | the resulting JWK thumbprint is stored on the session, which is then issued with | 84 | the resulting JWK thumbprint is stored on the session, which is then issued with |
| 85 | method `dpop-access-token` and a one-hour TTL instead of the bearer default. An | 85 | method `dpop-access-token` and a one-hour TTL instead of the bearer default. An |
| 86 | invalid proof gets a DPoP challenge header and a credential error rather than a | 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 | `dpop-access-token` is advertised alongside `browser-session-cookie` and | 91 | `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. |
| 49 | The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in | 49 | The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in |
| 50 | `packages/shared/src/observability.ts`. | 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 | ### Metrics | 58 | ### Metrics |
| 53 | 59 | ||
| 54 | Metrics are not written to a local file. | 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,3 +51,9 @@ Agents should prefer the provisioned view or APL queries for completed incidents |
| 51 | tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who | 51 | tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who |
| 52 | need scripted query access use the authorized account-level `AXIOM_TOKEN` together with | 52 | need 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 | |||
| 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,6 +174,8 @@ the conversation and in **Settings** → **Connections**. Follow the action show |
| 174 | be able to update and reconnect the server for you, or it may ask you to update the desktop app or | 174 | be able to update and reconnect the server for you, or it may ask you to update the desktop app or |
| 175 | run a copied command on the server machine. | 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 | Finish active work before updating because the server restarts briefly. For step-by-step guidance, | 179 | Finish active work before updating because the server restarts briefly. For step-by-step guidance, |
| 178 | see [Keeping T3 Code in Sync](./updating.md). | 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,10 +3,9 @@ import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; | ||
| 7 | import { lt } from "drizzle-orm"; | 6 | import { lt } from "drizzle-orm"; |
| 8 | 7 | ||
| 9 | import { verifyDpopProof } from "@t3tools/shared/dpop"; | 8 | import { DpopVerificationFailureCode, verifyDpopProof } from "@t3tools/shared/dpop"; |
| 10 | import * as RelayDb from "../db.ts"; | 9 | import * as RelayDb from "../db.ts"; |
| 11 | import { relayDpopProofs } from "../persistence/schema.ts"; | 10 | import { relayDpopProofs } from "../persistence/schema.ts"; |
| 12 | 11 | ||
| ... | @@ -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 | } |
| 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 | export class DpopProofReplay extends Context.Service< | 45 | export 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 | } |
| 97 | 97 | ||
| 98 | describe("DpopProofReplay.verifyAndConsume", () => { | 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 | 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 | ); |
| 126 | 154 | ||
| 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 | }); |
| 131 | 159 | ||
| ... | @@ -140,7 +168,7 @@ describe("DpopProofReplay.verifyAndConsume", () => { | ... | @@ -140,7 +168,7 @@ describe("DpopProofReplay.verifyAndConsume", () => { |
| 140 | 168 | ||
| 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 | ); |
| 153 | 181 | ||
| 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 | }); |
| 157 | 188 |
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 | }); |
| 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 | describe("relay environment authentication", () => { | 140 | describe("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 | } |
| 1000 | 1001 | ||
| 1001 | const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized); | 1002 | const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized); |
| 1003 | const isDpopProofRejected = Schema.is(DpopProofs.DpopProofRejected); | ||
| 1002 | 1004 | ||
| 1003 | const currentTraceId = Effect.currentParentSpan.pipe( | 1005 | const 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; |
| 1030 | const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError); | 1032 | const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError); |
| 1031 | 1033 | ||
| 1032 | type MapRelayCommonApiError<E> = | 1034 | type 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); |
| 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 | function relayInternalErrorResponse(reason: RelayInternalError["reason"]) { | 1066 | function 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"]) { |
| 1045 | function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) { | 1074 | function 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"; |
| 6 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as TestClock from "effect/testing/TestClock"; | 7 | import * as TestClock from "effect/testing/TestClock"; |
| 8 | 8 | ||
| 9 | import { DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts"; | ||
| 9 | import * as ManagedRelay from "../relay/managedRelay.ts"; | 10 | import * as ManagedRelay from "../relay/managedRelay.ts"; |
| 10 | import { remoteHttpClientLayer } from "../rpc/http.ts"; | 11 | import { remoteHttpClientLayer } from "../rpc/http.ts"; |
| 11 | import * as ClientCapabilities from "../platform/capabilities.ts"; | 12 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| ... | @@ -344,6 +345,29 @@ describe("RemoteEnvironmentAuthorization", () => { | ... | @@ -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 | 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"; |
| 13 | import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; | 13 | import { |
| 14 | environmentMismatchError, | ||
| 15 | mapRemoteDpopEnvironmentError, | ||
| 16 | mapRemoteEnvironmentError, | ||
| 17 | } from "../connection/errors.ts"; | ||
| 14 | import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; | 18 | import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; |
| 15 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | 19 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; |
| 16 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | 20 | import { 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; |
| 69 | function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { | 73 | function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { |
| 70 | return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" | 74 | return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" |
| 71 | ? error | 75 | ? error |
| 72 | : mapRemoteEnvironmentError(error); | 76 | : mapRemoteDpopEnvironmentError(error); |
| 73 | } | 77 | } |
| 74 | 78 | ||
| 75 | const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( | 79 | const 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 @@ | ||
| 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,6 +1,7 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import type { RelayProtectedError } from "@t3tools/contracts/relay"; | 2 | import type { RelayProtectedError } from "@t3tools/contracts/relay"; |
| 3 | import type { ManagedRelayClientError } from "../relay/managedRelay.ts"; | 3 | import type { ManagedRelayClientError } from "../relay/managedRelay.ts"; |
| 4 | import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts"; | ||
| 4 | import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts"; | 5 | import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts"; |
| 5 | import { | 6 | import { |
| 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 | */ | ||
| 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,3 +1,4 @@ |
| 1 | export * as Discovery from "./discovery.ts"; | 1 | export * as Discovery from "./discovery.ts"; |
| 2 | export * from "./errorPresentation.ts"; | ||
| 2 | export * as ManagedRelay from "./managedRelay.ts"; | 3 | export * as ManagedRelay from "./managedRelay.ts"; |
| 3 | export * from "./managedRelayState.ts"; | 4 | export * 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 | }); |
| 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 | 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 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import type { | 2 | import { |
| 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"; |
| 7 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; | 9 | import * 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"; |
| 12 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | 13 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 13 | import { afterEach, vi } from "vite-plus/test"; | 14 | import { afterEach, vi } from "vite-plus/test"; |
| 14 | 15 | ||
| 16 | import { DPOP_UNKNOWN_HINT } from "./errorPresentation.ts"; | ||
| 15 | import * as ManagedRelay from "./managedRelay.ts"; | 17 | import * as ManagedRelay from "./managedRelay.ts"; |
| 16 | import { | 18 | import { |
| 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"; |
| 13 | import * as Data from "effect/Data"; | 13 | import * as Data from "effect/Data"; |
| 14 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Option from "effect/Option"; | 15 | import * as Option from "effect/Option"; |
| 16 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Stream from "effect/Stream"; | 17 | import * as Stream from "effect/Stream"; |
| 17 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | 18 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 18 | 19 | ||
| 19 | import { findErrorTraceId } from "../errors/errorTrace.ts"; | 20 | import { findErrorTraceId } from "../errors/errorTrace.ts"; |
| 20 | import * as ManagedRelay from "./managedRelay.ts"; | 21 | import * as ManagedRelay from "./managedRelay.ts"; |
| 22 | import { relayProtectedErrorMessage } from "./errorPresentation.ts"; | ||
| 21 | 23 | ||
| 22 | const DEFAULT_STALE_TIME_MS = 15_000; | 24 | const DEFAULT_STALE_TIME_MS = 15_000; |
| 23 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; | 25 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; |
| 24 | const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; | 26 | const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; |
| 27 | const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError); | ||
| 25 | 28 | ||
| 26 | export interface ManagedRelaySession { | 29 | export 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)) |
| 18 | export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); | 18 | export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); |
| 19 | export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | 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 | export const IsoDateTime = Schema.String; | 35 | export const IsoDateTime = Schema.String; |
| 22 | export type IsoDateTime = typeof IsoDateTime.Type; | 36 | export type IsoDateTime = typeof IsoDateTime.Type; |
| 23 | 37 |
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"; |
| 27 | import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 27 | import { |
| 28 | DpopFailureReason, | ||
| 29 | AuthSessionId, | ||
| 30 | ThreadId, | ||
| 31 | TrimmedNonEmptyString, | ||
| 32 | } from "./baseSchemas.ts"; | ||
| 28 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 33 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 29 | import { | 34 | import { |
| 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"; |
| 8 | import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity"; | 8 | import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity"; |
| 9 | import * as OpenApi from "effect/unstable/httpapi/OpenApi"; | 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 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 17 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 13 | 18 | ||
| 14 | export const RelayAgentAwarenessPlatform = Schema.Literal("ios"); | 19 | export 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 | ]); |
| 323 | export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type; | 328 | export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type; |
| 324 | 329 | ||
| 330 | export const RelayDpopFailureReason = DpopFailureReason; | ||
| 331 | export type RelayDpopFailureReason = typeof RelayDpopFailureReason.Type; | ||
| 332 | |||
| 325 | export const RelayInternalErrorReason = Schema.Literals([ | 333 | export 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 | }); |
| 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 | 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; |
| 17 | export type DpopPublicJwk = DpopPublicJwkType; | 17 | export type DpopPublicJwk = DpopPublicJwkType; |
| 18 | export { normalizeDpopHtu }; | 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 | const DpopJwtHeaderPublicJwk = Schema.Struct({ | 33 | const 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 | }; |
| 56 | 70 | ||
| ... | @@ -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 | } |
| 111 | 125 | ||
| 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 | } |
| 116 | 130 | ||
| 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 | } |
| 126 | 140 | ||
| 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 | } |
| 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 | 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 verified | 174 | 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 | } |