From 6204d8f9486082e70b2cdb00f920757e8a8b69e6 Mon Sep 17 00:00:00 2001 From: extoci Date: Thu, 27 Aug 2026 03:21:03 +0000 Subject: [PATCH 1/8] fix(connect): explain DPoP connection failures --- .../features/cloud/linkEnvironment.test.ts | 79 ++++++++++++++++++- .../src/features/cloud/linkEnvironment.ts | 79 +++++++------------ .../connection/CloudEnvironmentRows.tsx | 3 +- .../connection/ConnectionEnvironmentRow.tsx | 3 +- .../features/projects/AddProjectScreen.tsx | 6 +- apps/server/src/auth/EnvironmentAuth.ts | 10 +++ apps/server/src/auth/dpop.test.ts | 23 +++++- apps/server/src/auth/dpop.ts | 42 +++++++++- apps/server/src/auth/http.ts | 40 ++++++++-- apps/server/src/http.ts | 5 +- apps/server/src/server.test.ts | 4 + apps/server/src/ws.ts | 5 +- apps/web/src/cloud/linkEnvironment.ts | 47 +---------- .../src/components/CommandPaletteResults.tsx | 4 +- .../clerk/T3ConnectUserProfilePage.tsx | 4 +- .../cloud/CloudEnvironmentConnectList.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 11 ++- .../settings/ProviderSettingsPanel.tsx | 8 +- docs/internals/environment-auth.md | 4 +- docs/operations/observability.md | 6 ++ docs/operations/relay-observability.md | 6 ++ docs/user/remote-access.md | 2 + infra/relay/src/auth/DpopProofs.ts | 35 ++++++-- .../auth/DpopProofs.verifyAndConsume.test.ts | 39 ++++++++- infra/relay/src/http/Api.test.ts | 22 ++++++ infra/relay/src/http/Api.ts | 51 +++++++++++- .../src/authorization/layer.test.ts | 24 ++++++ .../src/authorization/service.ts | 10 ++- .../src/connection/errors.test.ts | 75 ++++++++++++++++++ .../client-runtime/src/connection/errors.ts | 34 ++++++-- .../src/relay/errorPresentation.test.ts | 52 ++++++++++++ .../src/relay/errorPresentation.ts | 61 ++++++++++++++ packages/client-runtime/src/relay/index.ts | 1 + .../src/relay/managedRelay.test.ts | 37 +++++++++ .../src/relay/managedRelayState.test.ts | 36 ++++++++- .../src/relay/managedRelayState.ts | 8 +- packages/contracts/src/baseSchemas.ts | 14 ++++ packages/contracts/src/environmentHttp.ts | 9 ++- packages/contracts/src/relay.ts | 12 ++- packages/shared/src/dpop.test.ts | 37 +++++++++ packages/shared/src/dpop.ts | 74 +++++++++++------ 41 files changed, 857 insertions(+), 173 deletions(-) create mode 100644 packages/client-runtime/src/connection/errors.test.ts create mode 100644 packages/client-runtime/src/relay/errorPresentation.test.ts create mode 100644 packages/client-runtime/src/relay/errorPresentation.ts diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 4d7b0864184b..d812b71cef92 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { EnvironmentId } from "@t3tools/contracts"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { DPOP_CLOCK_HINT, ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; @@ -1082,12 +1082,87 @@ describe("mobile cloud link environment client", () => { expect(error).toMatchObject({ _tag: "CloudEnvironmentLinkError", message: - "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.\n\n` + + DPOP_CLOCK_HINT, traceId: "trace-connect", }); }), ); + it.effect("shows the clock hint when an older environment rejects DPoP", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => { + const value = String(url); + if (value.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json(validDpopAccessTokenResponse("environment:connect")), + ); + } + if (value.endsWith("/v1/environments/env-1/connect")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + credential: "one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ); + } + if (value.endsWith("/.well-known/t3/environment")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }), + ); + } + return Promise.resolve( + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-environment", + }, + { status: 401 }, + ), + ); + }), + ); + + const error = yield* withCloudServices( + connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + }), + ).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: `Could not exchange a managed endpoint DPoP access token.\n\n${DPOP_CLOCK_HINT}`, + traceId: "trace-environment", + }); + }), + ); + it.effect("rejects relay connect responses for a different endpoint", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 958827ee492b..c91bbe17c20a 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, + EnvironmentAuthInvalidError, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, EnvironmentHttpForbiddenError, @@ -17,7 +18,6 @@ import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, type RelayDpopAccessTokenScope, - type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, type RelayManagedEndpointProviderKind, @@ -25,7 +25,11 @@ import { import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { + dpopFailureHint, + ManagedRelay, + relayProtectedErrorMessage, +} from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; @@ -77,14 +81,19 @@ const isEnvironmentCloudApiError = Schema.is( const MANAGED_ENDPOINT_PROVIDER_KIND = "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; -function cloudEnvironmentLinkError(message: string) { +function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); const traceId = findErrorTraceId(cause); + const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null; + const detail = environmentError + ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` + : withDevCause(message, cause); return new CloudEnvironmentLinkError({ - message: environmentError - ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` - : withDevCause(message, cause), + message: + dpopAuthError?.reason === "invalid_credential" + ? `${detail}\n\n${dpopFailureHint(dpopAuthError.dpopFailureReason)}` + : detail, cause, ...(traceId === null ? {} : { traceId }), }); @@ -117,50 +126,6 @@ function withDevCause(message: string, cause: unknown): string { return detail ? `${message} (${detail})` : message; } -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayEnvironmentLinkLimitExceededError": - return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; - } -} - function decodedRelayClientError(message: string) { return (cause: ManagedRelay.ManagedRelayClientError) => { const relayError = @@ -185,6 +150,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; } +function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null { + if (Schema.is(EnvironmentAuthInvalidError)(cause)) { + return cause; + } + if (typeof cause !== "object" || cause === null) { + return null; + } + return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null; +} + function requireRelayUrl(): Effect.Effect { const relayUrl = readRelayUrl(); return relayUrl @@ -560,7 +535,9 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag clientMetadata: authClientMetadata(), }).pipe( Effect.mapError( - cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), + cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", { + dpop: true, + }), ), ); const pairingUrl = new URL(connect.endpoint.httpBaseUrl); diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 6da73eaeb1fa..8b0e506bb090 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -286,6 +286,7 @@ function CloudEnvironmentRowShell(props: { error: props.connectionError, traceId: props.connectionErrorTraceId, }); + const statusHasHint = statusText.includes("\n"); const statusClassName = props.connectionError ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted"; @@ -350,7 +351,7 @@ function CloudEnvironmentRowShell(props: { > {statusText} {errorTraceId ? ( diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 03a0eb5025f6..64f444ac9f25 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -43,6 +43,7 @@ export function ConnectionEnvironmentRow(props: { const statusLabel = connectionStatusLabel(props.environment); const statusTraceId = props.environment.connectionErrorTraceId; const hasConnectionFailure = props.environment.connectionError !== null; + const statusHasHint = statusLabel?.includes("\n") ?? false; const isRetrying = props.environment.connectionState === "connecting" || props.environment.connectionState === "reconnecting"; @@ -87,7 +88,7 @@ export function ConnectionEnvironmentRow(props: { "text-xs", hasConnectionFailure ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted", )} - numberOfLines={props.expanded ? undefined : 1} + numberOfLines={props.expanded || statusHasHint ? undefined : 1} selectable={props.expanded} > {statusLabel} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b48c7a0bdd94..80f423b57a43 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -160,6 +160,7 @@ function ListRow(props: { readonly onPress?: () => void; }) { const chevronColor = useThemeColor("--color-chevron"); + const subtitleHasHint = props.subtitle?.includes("\n") ?? false; return ( {props.title} {props.subtitle ? ( - + {props.subtitle} ) : null} diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index eb0563421408..f5d244dd9667 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -16,6 +16,8 @@ import { type ServerAuthDescriptor, type ServerAuthSessionMethod, type AuthWebSocketTicketResult, + DpopFailureReason, + type DpopFailureReason as DpopFailureReasonType, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; @@ -347,6 +349,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; +export const serverAuthDpopFailureReason = ( + error: ServerAuthCredentialError, +): DpopFailureReasonType | undefined => + error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined; + export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( "ServerAuthInvalidScopeError", {}, @@ -606,6 +614,7 @@ export const make = Effect.gen(function* () { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", + dpopFailureReason: "invalid_proof", }), ); } @@ -623,6 +632,7 @@ export const make = Effect.gen(function* () { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP authorization requires a proof-bound access token.", + dpopFailureReason: "invalid_proof", }), ); } diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index fa75c407b0c6..ea8d1cd99db7 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; import { SecretStorePersistError } from "./ServerSecretStore.ts"; -import { mapDpopReplayStoreError } from "./dpop.ts"; +import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ @@ -23,6 +23,7 @@ describe("mapDpopReplayStoreError", () => { expect(error._tag).toBe("ServerAuthInvalidCredentialError"); if (error._tag === "ServerAuthInvalidCredentialError") { expect(error.cause).toBe(cause); + expect(error.dpopFailureReason).toBe("replay"); } }); @@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => { } }); }); + +describe("mapDpopFailureReason", () => { + it("maps verifier failures to safe client-facing categories", () => { + const mappings = [ + ["time_window", "time_window"], + ["key_mismatch", "key_mismatch"], + ["method_mismatch", "request_mismatch"], + ["url_mismatch", "request_mismatch"], + ["access_token_hash_mismatch", "token_mismatch"], + ["missing_proof", "invalid_proof"], + ["malformed_proof", "invalid_proof"], + ["invalid_signature", "invalid_proof"], + ["invalid_proof", "invalid_proof"], + ] as const; + + for (const [code, expected] of mappings) { + expect(mapDpopFailureReason(code)).toBe(expected); + } + }); +}); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index f19984eb3690..28e9864a3619 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -1,4 +1,8 @@ -import { verifyDpopProof } from "@t3tools/shared/dpop"; +import { + type DpopVerificationFailureCode as DpopVerificationFailureCodeType, + verifyDpopProof, +} from "@t3tools/shared/dpop"; +import type { DpopFailureReason } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -14,12 +18,34 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +export const mapDpopFailureReason = ( + code: DpopVerificationFailureCodeType, +): DpopFailureReason => { + switch (code) { + case "time_window": + return "time_window"; + case "key_mismatch": + return "key_mismatch"; + case "method_mismatch": + case "url_mismatch": + return "request_mismatch"; + case "access_token_hash_mismatch": + return "token_mismatch"; + case "missing_proof": + case "malformed_proof": + case "invalid_signature": + case "invalid_proof": + return "invalid_proof"; + } +}; + export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError => ServerSecretStore.isSecretAlreadyExistsError(error) ? new ServerAuthInvalidCredentialError({ diagnostic: "DPoP proof replayed.", + dpopFailureReason: "replay", cause: error, }) : new ServerAuthDpopReplayStateRecordError({ @@ -49,8 +75,12 @@ export const verifyRequestDpopProof = (input: { ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), }); if (!result.ok) { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": result.code, + }); return yield* new ServerAuthInvalidCredentialError({ diagnostic: result.reason, + dpopFailureReason: mapDpopFailureReason(result.code), }); } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -80,7 +110,15 @@ export const verifyRequestDpopProof = (input: { ) .pipe( Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - Effect.fail(mapDpopReplayStoreError(error)), + Effect.gen(function* () { + const mapped = mapDpopReplayStoreError(error); + if (mapped._tag === "ServerAuthInvalidCredentialError") { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": mapped.dpopFailureReason, + }); + } + return yield* Effect.fail(mapped); + }), ), ); return result.thumbprint; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde251..58277141a946 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -22,7 +22,7 @@ import { EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, } from "@t3tools/contracts"; -import type { AuthEnvironmentScope } from "@t3tools/contracts"; +import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; @@ -95,10 +95,20 @@ export function annotateEnvironmentRequest(endpoint: string) { }); } -export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) { +export function failEnvironmentAuthInvalid( + reason: EnvironmentAuthInvalidReason, + dpopFailureReason?: DpopFailureReason, +) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => - Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })), + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason, + ...(dpopFailureReason === undefined ? {} : { dpopFailureReason }), + traceId, + }), + ), ), ); } @@ -180,7 +190,10 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( const request = yield* HttpServerRequest.HttpServerRequest; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -244,7 +257,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( return result.response; }, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("browser_session_issuance_failed", error), @@ -278,9 +294,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + Effect.andThen( + failEnvironmentAuthInvalid( + "invalid_credential", + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), + ), ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => @@ -307,7 +328,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( }, traceRelayRequest, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c3104e7bc420..33b1698acebb 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -117,7 +117,10 @@ const authenticateRawRouteWithScope = ( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a9a2c3fa10d6..7f3552626980 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10,6 +10,7 @@ import { AuthTokenExchangeGrantType, CommandId, DEFAULT_SERVER_SETTINGS, + type DpopFailureReason, EnvironmentId, EventId, GitCommandError, @@ -1115,6 +1116,7 @@ const exchangeAccessToken = ( readonly _tag?: string; readonly code?: string; readonly reason?: string; + readonly dpopFailureReason?: DpopFailureReason; readonly traceId?: string; }>(response); return { @@ -1846,6 +1848,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError"); assert.equal(replayBootstrap.body.code, "auth_invalid"); assert.equal(replayBootstrap.body.reason, "invalid_credential"); + assert.equal(replayBootstrap.body.dpopFailureReason, "replay"); assert.equal(typeof replayBootstrap.body.traceId, "string"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1921,6 +1924,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError"); assert.equal(bootstrap.body.code, "auth_invalid"); assert.equal(bootstrap.body.reason, "invalid_credential"); + assert.equal(bootstrap.body.dpopFailureReason, "request_mismatch"); assert.equal(typeof bootstrap.body.traceId, "string"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 226c82cdb1ac..dd69fe7ec287 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2472,7 +2472,10 @@ export const websocketRpcRouteLayer = Layer.unwrap( const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index a245cbc54db2..a267a6e07572 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -19,13 +19,12 @@ import { type RelayClientDeviceRecord, type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, - type RelayProtectedError as RelayProtectedErrorType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; import { readPrimaryEnvironmentDescriptor, @@ -128,50 +127,6 @@ const isEnvironmentCloudApiError = Schema.is( ]), ); -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayEnvironmentLinkLimitExceededError": - return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; - } -} - function decodedRelayClientError(message: string) { return (cause: ManagedRelay.ManagedRelayClientError) => { const relayError = diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index bbdbc28b0609..151ec1be50c9 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: { ) : null} {props.item.description ? ( - + {props.item.description} ) : null} @@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: { ) : null} {props.item.description ? ( - + {props.item.description} ) : null} diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx index 15ed569052be..0e56dd65c405 100644 --- a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx @@ -216,7 +216,9 @@ export function T3ConnectUserProfilePage() {

Could not load T3 Connect environments

-

{environmentsState.error}

+

+ {environmentsState.error} +

) : null} diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 460a253812a0..1fb67db1bc60 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -156,7 +156,9 @@ export function CloudEnvironmentConnectRows({

Could not load T3 Connect environments

-

{discoveryProblem}

+

+ {discoveryProblem} +