diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 4d7b0864184b..f8218e3ffd56 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_UNKNOWN_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"; @@ -1081,13 +1081,88 @@ describe("mobile cloud link environment client", () => { ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, traceId: "trace-connect", }); }), ); + it.effect( + "presents clock skew as one possible cause 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. ${DPOP_UNKNOWN_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..c2033117f69d 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 { + dpopFailureMessage, + ManagedRelay, + relayProtectedErrorMessage, +} from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; @@ -73,18 +77,24 @@ const isEnvironmentCloudApiError = Schema.is( EnvironmentCloudEndpointUnavailableError, ]), ); +const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); 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" + ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason) + : detail, cause, ...(traceId === null ? {} : { traceId }), }); @@ -117,50 +127,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 +151,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; } +function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null { + if (isEnvironmentAuthInvalidError(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 +536,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/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..43f90e440915 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,32 @@ 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 +73,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 +108,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..a392d686106c 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 { @@ -1797,6 +1799,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reports clock skew for a future-dated DPoP token exchange proof", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const now = yield* DateTime.now; + const dpop = makeDpopProof({ + method: "POST", + url: tokenUrl, + iat: Math.floor(now.epochMilliseconds / 1_000) + 25, + }); + + const exchange = yield* exchangeAccessToken(credential.credential, { + headers: { dpop: dpop.proof }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + + assert.equal(exchange.response.status, 401); + assert.equal(exchange.body._tag, "EnvironmentAuthInvalidError"); + assert.equal(exchange.body.code, "auth_invalid"); + assert.equal(exchange.body.reason, "invalid_credential"); + assert.equal(exchange.body.dpopFailureReason, "time_window"); + assert.equal(typeof exchange.body.traceId, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects replayed DPoP proofs across token exchanges", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1846,6 +1880,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 +1956,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/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 5f4f5b6e9607..5518d8e8c585 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -84,7 +84,9 @@ that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`; the resulting JWK thumbprint is stored on the session, which is then issued with method `dpop-access-token` and a one-hour TTL instead of the bearer default. An invalid proof gets a DPoP challenge header and a credential error rather than a -bearer token. +bearer token. Newer servers include a safe `dpopFailureReason` category in that +error. When an older server omits the category, clients mention clock skew as +one possible cause rather than presenting it as confirmed. `dpop-access-token` is advertised alongside `browser-session-cookie` and `bearer-access-token` in the descriptor's `sessionMethods` diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 7341bfb5edac..966eab112c6b 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -49,6 +49,12 @@ records instead carry OTLP resource, scope, and optional status fields. The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in `packages/shared/src/observability.ts`. +DPoP proof failures include the safe `environment.dpop.failure_code` span +attribute. A `time_window` failure means that a signed proof was too old or too +far in the future for the environment server's allowed window. It can point to +a date or time problem on either device, but it can also result from a delayed +request. + ### Metrics Metrics are not written to a local file. diff --git a/docs/operations/relay-observability.md b/docs/operations/relay-observability.md index 2bc697b2ef1f..c7f84d4ef825 100644 --- a/docs/operations/relay-observability.md +++ b/docs/operations/relay-observability.md @@ -51,3 +51,9 @@ Agents should prefer the provisioned view or APL queries for completed incidents tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who need scripted query access use the authorized account-level `AXIOM_TOKEN` together with `AXIOM_ORG_ID`; scoped ingest tokens remain write-only credentials for their producers. + +DPoP proof failures include the stable `relay.dpop.failure_code` span attribute. A `time_window` +failure means that a signed proof was too old or too far in the future for the relay's allowed +window. It can point to a date or time problem on either device, but it can also result from a +delayed request. The client uses this category, and the absence of a category from an older relay, +to decide whether clock skew is confirmed or only one possible cause. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5993fca5b352..40473930c601 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -174,6 +174,8 @@ the conversation and in **Settings** → **Connections**. Follow the action show be able to update and reconnect the server for you, or it may ask you to update the desktop app or run a copied command on the server machine. +If T3 Connect cannot connect, check the date and time on both devices, then try again. + Finish active work before updating because the server restarts briefly. For step-by-step guidance, see [Keeping T3 Code in Sync](./updating.md). diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts index fa784eb639b6..d83d413c3734 100644 --- a/infra/relay/src/auth/DpopProofs.ts +++ b/infra/relay/src/auth/DpopProofs.ts @@ -3,10 +3,9 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; import { lt } from "drizzle-orm"; -import { verifyDpopProof } from "@t3tools/shared/dpop"; +import { DpopVerificationFailureCode, verifyDpopProof } from "@t3tools/shared/dpop"; import * as RelayDb from "../db.ts"; import { relayDpopProofs } from "../persistence/schema.ts"; @@ -26,6 +25,23 @@ export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass()( + "DpopProofRejected", + { + code: DpopProofFailureCode, + }, +) { + override get message(): string { + return `DPoP proof rejected: ${this.code}`; + } +} + export class DpopProofReplay extends Context.Service< DpopProofReplay, { @@ -36,7 +52,7 @@ export class DpopProofReplay extends Context.Service< readonly expectedThumbprint?: string; readonly expectedAccessToken?: string; readonly now: DateTime.DateTime; - }) => Effect.Effect; + }) => Effect.Effect; readonly consume: (input: { readonly thumbprint: string; readonly jti: string; @@ -98,13 +114,16 @@ const make = Effect.gen(function* () { }); if (!result.ok) { yield* Effect.logWarning("relay dpop proof rejected", { + code: result.code, reason: result.reason, method: input.method, url: input.url, expectedThumbprintPresent: input.expectedThumbprint !== undefined, expectedAccessTokenPresent: input.expectedAccessToken !== undefined, }); - return yield* new HttpApiError.Unauthorized({}); + return yield* new DpopProofRejected({ + code: result.code, + }); } const consumed = yield* consume({ thumbprint: result.thumbprint, @@ -118,7 +137,9 @@ const make = Effect.gen(function* () { jti: result.jti, iat: result.iat, }); - return yield* new HttpApiError.Unauthorized({}); + return yield* new DpopProofRejected({ + code: "replayed", + }); } yield* Effect.annotateCurrentSpan({ "relay.dpop.thumbprint": result.thumbprint, diff --git a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts index 7663e874879b..fc83df40691e 100644 --- a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts +++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts @@ -96,6 +96,34 @@ function consumeEachProofOnce() { } describe("DpopProofReplay.verifyAndConsume", () => { + it.effect("reports a signed proof outside the time window", () => { + const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); + const proof = makeDpopProof({ + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + iat: Math.floor(now.epochMilliseconds / 1_000) - 301, + jti: "proof-old", + }); + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const error = yield* Effect.flip( + replay.verifyAndConsume({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + expectedThumbprint: proof.thumbprint, + now, + }), + ); + + expect(error).toMatchObject({ + _tag: "DpopProofRejected", + code: "time_window", + }); + }).pipe(Effect.provide(layer(() => Effect.die("unexpected replay persistence")))); + }); + it.effect("rejects replayed proofs after persistence consumes the jti once", () => { const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); const proof = makeDpopProof({ @@ -114,7 +142,7 @@ describe("DpopProofReplay.verifyAndConsume", () => { expectedThumbprint: proof.thumbprint, now, }); - const second = yield* Effect.exit( + const second = yield* Effect.flip( replay.verifyAndConsume({ proof: proof.proof, method: "POST", @@ -125,7 +153,7 @@ describe("DpopProofReplay.verifyAndConsume", () => { ); expect(first).toBe(proof.thumbprint); - expect(second._tag).toBe("Failure"); + expect(second).toMatchObject({ _tag: "DpopProofRejected", code: "replayed" }); }).pipe(Effect.provide(layer(consumeEachProofOnce()))); }); @@ -140,7 +168,7 @@ describe("DpopProofReplay.verifyAndConsume", () => { return Effect.gen(function* () { const replay = yield* DpopProofs.DpopProofReplay; - const result = yield* Effect.exit( + const error = yield* Effect.flip( replay.verifyAndConsume({ proof: proof.proof, method: "POST", @@ -151,7 +179,10 @@ describe("DpopProofReplay.verifyAndConsume", () => { }), ); - expect(result._tag).toBe("Failure"); + expect(error).toMatchObject({ + _tag: "DpopProofRejected", + code: "access_token_hash_mismatch", + }); }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence")))); }); diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index daf756a2b7cc..222deafd621b 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -23,6 +23,7 @@ import { relayDocsRedirectRoute, relayEnvironmentAuthLayer, relayNotFoundRoute, + relayDpopFailureReason, revokeEnvironmentLinkRecord, traceRelayHttpRequestWith, unlinkEnvironmentRecord, @@ -115,6 +116,27 @@ describe("relay client authentication", () => { ); }); +describe("relay DPoP failure mapping", () => { + 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"], + ["replayed", "replay"], + ["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(relayDpopFailureReason(code)).toBe(expected); + } + }); +}); + describe("relay environment authentication", () => { it.effect("preserves credential lookup persistence failures as internal errors", () => { const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 50bcff665a9b..05197666c80d 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -35,6 +35,7 @@ import { RelayMobileRegistrationScope, RelayAuthInvalidError, type RelayAuthInvalidReason, + type RelayDpopFailureReason, RelayEnvironmentAuth, RelayEnvironmentConnectNotAuthorizedError, RelayEnvironmentEndpointTimedOutError, @@ -999,6 +1000,7 @@ class ClerkTokenVerificationFailed extends Schema.TaggedErrorClass span.traceId), @@ -1030,10 +1032,37 @@ type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type; const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError); type MapRelayCommonApiError = - | Exclude + | Exclude< + E, + HttpApiError.Unauthorized | DpopProofs.DpopProofRejected | RelayCommonPersistenceError + > | (Extract extends never ? never : RelayAuthInvalidError) + | (Extract extends never ? never : RelayAuthInvalidError) | (Extract extends never ? never : RelayInternalError); +export function relayDpopFailureReason( + code: DpopProofs.DpopProofFailureCode, +): RelayDpopFailureReason { + 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 "replayed": + return "replay"; + case "missing_proof": + case "malformed_proof": + case "invalid_signature": + case "invalid_proof": + return "invalid_proof"; + } +} + function relayInternalErrorResponse(reason: RelayInternalError["reason"]) { return currentTraceId.pipe( Effect.flatMap((traceId) => @@ -1045,11 +1074,32 @@ function relayInternalErrorResponse(reason: RelayInternalError["reason"]) { function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) { const mapError = Effect.fnUntraced(function* (error: E) { const traceId = yield* currentTraceId; + if (isDpopProofRejected(error)) { + yield* Effect.annotateCurrentSpan({ + "relay.dpop.failure_code": error.code, + }); + return yield* Effect.fail( + new RelayAuthInvalidError({ + code: "auth_invalid", + reason: authReason, + ...(authReason === "invalid_dpop" + ? { dpopFailureReason: relayDpopFailureReason(error.code) } + : {}), + traceId, + }) as MapRelayCommonApiError, + ); + } if (isHttpUnauthorized(error)) { + if (authReason === "invalid_dpop") { + yield* Effect.annotateCurrentSpan({ + "relay.dpop.failure_code": "invalid_proof", + }); + } return yield* Effect.fail( new RelayAuthInvalidError({ code: "auth_invalid", reason: authReason, + ...(authReason === "invalid_dpop" ? { dpopFailureReason: "invalid_proof" } : {}), traceId, }) as MapRelayCommonApiError, ); diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index 466a5c2dd4ea..6a7eed25c56e 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; +import { DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import { remoteHttpClientLayer } from "../rpc/http.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; @@ -341,6 +342,29 @@ describe("RemoteEnvironmentAuthorization", () => { }), ); + it.effect("presents clock skew as one possible cause for a generic DPoP rejection", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + responses: [Response.json(DESCRIPTOR), authInvalid()], + }); + + const failure = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer), Effect.flip); + + expect(failure).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "authentication", + detail: `The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`, + traceId: "trace-auth-invalid", + }); + }), + ); + it.effect("refreshes a cached endpoint after its first transient failure", () => Effect.gen(function* () { const cached = new TokenStore.RemoteDpopAccessToken({ diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index fef8db274b3a..0d3849878860 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -6,7 +6,11 @@ import { resolveRemoteDpopWebSocketConnectionUrl, resolveRemoteWebSocketConnectionUrl, } from "./remote.ts"; -import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; +import { + environmentMismatchError, + mapRemoteDpopEnvironmentError, + mapRemoteEnvironmentError, +} from "../connection/errors.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; @@ -64,7 +68,7 @@ const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000; function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" ? error - : mapRemoteEnvironmentError(error); + : mapRemoteDpopEnvironmentError(error); } const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( @@ -268,7 +272,7 @@ export const make = Effect.gen(function* () { scopes: presentation.scopes, clientMetadata: presentation.metadata, }).pipe( - Effect.mapError(mapRemoteEnvironmentError), + Effect.mapError(mapRemoteDpopEnvironmentError), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.withSpan("environment.authorization.accessToken.exchange"), ); diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts new file mode 100644 index 000000000000..771c9490ca3e --- /dev/null +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -0,0 +1,75 @@ +import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; +import { RelayAuthInvalidError } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; + +import { mapManagedRelayError, mapRemoteDpopEnvironmentError } from "./errors.ts"; +import { DPOP_RETRY_HINT, DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts"; +import { ManagedRelayRequestFailedError } from "../relay/managedRelay.ts"; + +describe("mapManagedRelayError", () => { + it("presents clock skew as one possible cause for a generic DPoP error", () => { + const mapped = mapManagedRelayError( + new ManagedRelayRequestFailedError({ + action: "connect relay environment", + cause: new Error("request failed"), + relayError: new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-1", + }), + traceId: "trace-1", + }), + ); + + expect(mapped).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "authentication", + detail: `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, + traceId: "trace-1", + }); + }); + + it("uses a neutral hint when the relay identifies a non-clock DPoP error", () => { + const mapped = mapManagedRelayError( + new ManagedRelayRequestFailedError({ + action: "connect relay environment", + cause: new Error("request failed"), + relayError: new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + dpopFailureReason: "key_mismatch", + traceId: "trace-1", + }), + }), + ); + + expect(mapped.message).toBe(`Relay rejected the DPoP proof. ${DPOP_RETRY_HINT}`); + }); +}); + +describe("mapRemoteDpopEnvironmentError", () => { + it("does not present a generic environment auth error as confirmed clock skew", () => { + const mapped = mapRemoteDpopEnvironmentError( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-1", + }), + ); + + expect(mapped.message).toBe(`The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`); + }); + + it("uses a neutral hint for a non-clock DPoP error from a new server", () => { + const mapped = mapRemoteDpopEnvironmentError( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + dpopFailureReason: "key_mismatch", + traceId: "trace-1", + }), + ); + + expect(mapped.message).toBe(`The environment credential is invalid. ${DPOP_RETRY_HINT}`); + }); +}); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index 66c10333d6a9..ed8a117a6f5c 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -1,6 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayProtectedError } from "@t3tools/contracts/relay"; import type { ManagedRelayClientError } from "../relay/managedRelay.ts"; +import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts"; import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts"; import { ConnectionBlockedError, @@ -40,7 +41,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError case "RelayAgentActivityPublishProofInvalidError": return new ConnectionBlockedError({ reason: "authentication", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); case "RelayEnvironmentConnectNotAuthorizedError": @@ -48,27 +49,27 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError case "RelayEnvironmentLinkLimitExceededError": return new ConnectionBlockedError({ reason: "permission", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); case "RelayEnvironmentEndpointTimedOutError": return new ConnectionTransientError({ reason: "timeout", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); case "RelayEnvironmentEndpointUnavailableError": case "RelayEnvironmentLinkUnavailableError": return new ConnectionTransientError({ reason: "endpoint-unavailable", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); case "RelayEnvironmentLinkFailedError": case "RelayInternalError": return new ConnectionTransientError({ reason: "relay-unavailable", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); } @@ -166,3 +167,23 @@ export function mapRemoteEnvironmentError( }); } } + +/** + * Map an environment error from a request that used DPoP authentication. An + * older environment server reports a DPoP clock failure as the same generic + * invalid-credential response as other failures, so keep the compatibility + * hint cautious when the server omits the category. Newer servers can identify + * clock and non-clock proof failures precisely. + */ +export function mapRemoteDpopEnvironmentError( + error: RemoteEnvironmentAuthError, +): ConnectionAttemptError { + if (error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential") { + return new ConnectionBlockedError({ + reason: "authentication", + detail: dpopFailureMessage("The environment credential is invalid.", error.dpopFailureReason), + traceId: error.traceId, + }); + } + return mapRemoteEnvironmentError(error); +} diff --git a/packages/client-runtime/src/relay/errorPresentation.test.ts b/packages/client-runtime/src/relay/errorPresentation.test.ts new file mode 100644 index 000000000000..a27810c4366e --- /dev/null +++ b/packages/client-runtime/src/relay/errorPresentation.test.ts @@ -0,0 +1,57 @@ +import { RelayAuthInvalidError } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; + +import { + DPOP_CLOCK_HINT, + DPOP_RETRY_HINT, + DPOP_UNKNOWN_HINT, + relayProtectedErrorMessage, +} from "./errorPresentation.ts"; + +describe("relayProtectedErrorMessage", () => { + it("presents clock skew as one possible cause when the relay omits the reason", () => { + const error = new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toBe( + `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, + ); + }); + + it("keeps the clock hint for a relay that confirms a time-window failure", () => { + const error = new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + dpopFailureReason: "time_window", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toContain(DPOP_CLOCK_HINT); + }); + + it("does not blame the clock when the relay identifies another proof failure", () => { + const error = new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + dpopFailureReason: "key_mismatch", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toBe( + `Relay rejected the DPoP proof. ${DPOP_RETRY_HINT}`, + ); + }); + + it("preserves the existing message for other authentication failures", () => { + const error = new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toBe("Relay rejected the cloud session token."); + }); +}); diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts new file mode 100644 index 000000000000..a9364752103d --- /dev/null +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -0,0 +1,66 @@ +import type { DpopFailureReason } from "@t3tools/contracts"; +import type { RelayProtectedError } from "@t3tools/contracts/relay"; + +export const DPOP_CLOCK_HINT = + "Hint: Check that automatic date and time is enabled on both devices, then try again."; + +/** Older servers omit the DPoP category, but newer servers can also omit it for + * a credential failure that happens after proof verification. */ +export const DPOP_UNKNOWN_HINT = + "Hint: Try again. If it still fails, clock skew may be the cause; check that automatic date and time is enabled on both devices."; + +export const DPOP_RETRY_HINT = "Hint: Try again. If the problem continues, copy the trace ID."; + +export function dpopFailureHint(reason: DpopFailureReason | undefined): string { + if (reason === "time_window") return DPOP_CLOCK_HINT; + if (reason === undefined) return DPOP_UNKNOWN_HINT; + return DPOP_RETRY_HINT; +} + +export function dpopFailureMessage(message: string, reason: DpopFailureReason | undefined): string { + return `${message} ${dpopFailureHint(reason)}`; +} + +export function relayProtectedErrorMessage(error: RelayProtectedError): 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 dpopFailureMessage("Relay rejected the DPoP proof.", error.dpopFailureReason); + 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 does not 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}).`; + } +} diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts index 76f755353044..4c8104eb44bc 100644 --- a/packages/client-runtime/src/relay/index.ts +++ b/packages/client-runtime/src/relay/index.ts @@ -1,3 +1,4 @@ export * as Discovery from "./discovery.ts"; +export * from "./errorPresentation.ts"; export * as ManagedRelay from "./managedRelay.ts"; export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index 278c205883f5..fb2feaa61772 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -462,6 +462,43 @@ describe("ManagedRelayClient", () => { }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); }); + it.effect("accepts generic DPoP errors from relays without the optional reason", () => { + const fetchFn = (() => + Promise.resolve( + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-old-relay", + }, + { status: 401 }, + ), + )) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayRequestFailedError", + traceId: "trace-old-relay", + relayError: { + _tag: "RelayAuthInvalidError", + reason: "invalid_dpop", + }, + }); + if ( + error._tag === "ManagedRelayRequestFailedError" && + error.relayError?._tag === "RelayAuthInvalidError" + ) { + expect(error.relayError.dpopFailureReason).toBeUndefined(); + } + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + it.effect("lists account devices through the Clerk bearer client endpoint", () => { const fetchFn = ((input, init) => { expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 00ac733762fe..0588da342066 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -1,8 +1,9 @@ import { EnvironmentId } from "@t3tools/contracts"; -import type { - RelayClientDeviceRecord, - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, +import { + RelayAuthInvalidError, + type RelayClientDeviceRecord, + type RelayClientEnvironmentRecord, + type RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -12,6 +13,7 @@ import * as Stream from "effect/Stream"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { afterEach, vi } from "vite-plus/test"; +import { DPOP_UNKNOWN_HINT } from "./errorPresentation.ts"; import * as ManagedRelay from "./managedRelay.ts"; import { createManagedRelayQueryManager, @@ -421,4 +423,31 @@ describe("createManagedRelayQueryManager", () => { }); }); }); + + it("presents clock skew as one possible cause for snapshot requests from older relays", async () => { + const manager = createManager({ + getEnvironmentStatus: () => + Effect.fail( + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "get relay environment status", + cause: new Error("Relay request failed."), + relayError: new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-status", + }), + traceId: "trace-status", + }), + ), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( + `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, + ); + }); + }); }); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 1a3a22efb204..6eb1fb6c6760 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -13,15 +13,18 @@ import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { findErrorTraceId } from "../errors/errorTrace.ts"; import * as ManagedRelay from "./managedRelay.ts"; +import { relayProtectedErrorMessage } from "./errorPresentation.ts"; const DEFAULT_STALE_TIME_MS = 15_000; const DEFAULT_IDLE_TTL_MS = 5 * 60_000; const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; +const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError); export interface ManagedRelaySession { readonly accountId: string; @@ -315,7 +318,12 @@ export function readManagedRelaySnapshotState( let errorTraceId: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; + error = + isManagedRelayRequestFailedError(cause) && cause.relayError + ? relayProtectedErrorMessage(cause.relayError) + : cause instanceof Error + ? cause.message + : "Could not load T3 Connect data."; errorTraceId = findErrorTraceId(cause); } return { diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index e12bf3e975de..088463fae49e 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -18,6 +18,20 @@ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); +/** + * Safe categories for a failed DPoP proof. These describe the class of failure + * without exposing proof contents or server-side authentication details. + */ +export const DpopFailureReason = Schema.Literals([ + "time_window", + "key_mismatch", + "request_mismatch", + "token_mismatch", + "replay", + "invalid_proof", +]); +export type DpopFailureReason = typeof DpopFailureReason.Type; + export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e7494862251e..a895697e36b0 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -24,7 +24,12 @@ import { AuthWebSocketTicketResult, ServerAuthSessionMethod, } from "./auth.ts"; -import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + DpopFailureReason, + AuthSessionId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { ClientOrchestrationCommand, @@ -117,6 +122,8 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass { ); }); + it("reports a time-window failure only after the proof signature is valid", () => { + const thumbprint = computeDpopJwkThumbprint(publicJwk); + const outsideWindow = verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 1_000, + expectedThumbprint: thumbprint, + }); + if (outsideWindow.ok) { + assert.fail("Expected an old DPoP proof to fail."); + } + assert.equal(outsideWindow.code, "time_window"); + + const { privateKey: otherPrivateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + const invalidSignatureProof = signDpopProof({ + method: "POST", + url: "https://example.com/oauth/token", + iat: 100, + privateKey: otherPrivateKey, + publicJwk, + }); + const invalidSignature = verifyDpopProof({ + proof: invalidSignatureProof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 1_000, + expectedThumbprint: thumbprint, + }); + if (invalidSignature.ok) { + assert.fail("Expected a proof signed by a different key to fail."); + } + assert.equal(invalidSignature.code, "invalid_signature"); + }); + it("requires the RFC 9449 access token hash when an access token is expected", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); const accessTokenProof = signDpopProof({ diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index dabfaffa4cdd..46f2e8f8fa1d 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -17,6 +17,19 @@ export const DpopPublicJwk = DpopPublicJwkSchema; export type DpopPublicJwk = DpopPublicJwkType; export { normalizeDpopHtu }; +export const DpopVerificationFailureCode = Schema.Literals([ + "missing_proof", + "malformed_proof", + "key_mismatch", + "method_mismatch", + "url_mismatch", + "access_token_hash_mismatch", + "time_window", + "invalid_signature", + "invalid_proof", +]); +export type DpopVerificationFailureCode = typeof DpopVerificationFailureCode.Type; + const DpopJwtHeaderPublicJwk = Schema.Struct({ ...DpopPublicJwkSchema.fields, d: Schema.optionalKey(Schema.Never), @@ -51,6 +64,7 @@ export type DpopVerificationResult = } | { readonly ok: false; + readonly code: DpopVerificationFailureCode; readonly reason: string; }; @@ -106,50 +120,46 @@ export function verifyDpopProof(input: { readonly maxAgeSeconds?: number; }): DpopVerificationResult { if (!input.proof?.trim()) { - return { ok: false, reason: "Missing DPoP proof." }; + return { ok: false, code: "missing_proof", reason: "Missing DPoP proof." }; } const parts = input.proof.split("."); if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { - return { ok: false, reason: "Invalid DPoP compact JWT." }; + return { ok: false, code: "malformed_proof", reason: "Invalid DPoP compact JWT." }; } try { const header = decodeBase64UrlDpopJwtHeader(parts[0]); const payload = decodeBase64UrlDpopJwtPayload(parts[1]); if (Option.isNone(header)) { - return { ok: false, reason: "Invalid DPoP JWT header." }; + return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT header." }; } if (Option.isNone(payload)) { - return { ok: false, reason: "Invalid DPoP JWT payload." }; + return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT payload." }; } const thumbprint = computeDpopJwkThumbprint(header.value.jwk); if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) { - return { ok: false, reason: "DPoP key thumbprint mismatch." }; + return { ok: false, code: "key_mismatch", reason: "DPoP key thumbprint mismatch." }; } if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) { - return { ok: false, reason: "DPoP method mismatch." }; + return { ok: false, code: "method_mismatch", reason: "DPoP method mismatch." }; } const normalizedHtu = normalizeDpopHtu(input.url); if (normalizedHtu === null || payload.value.htu !== normalizedHtu) { - return { ok: false, reason: "DPoP URL mismatch." }; + return { ok: false, code: "url_mismatch", reason: "DPoP URL mismatch." }; } if (input.expectedAccessToken) { const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken); if (payload.value.ath !== expectedAth) { - return { ok: false, reason: "DPoP access token hash mismatch." }; + return { + ok: false, + code: "access_token_hash_mismatch", + reason: "DPoP access token hash mismatch.", + }; } } - const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; - if ( - payload.value.iat > input.nowEpochSeconds + 5 || - input.nowEpochSeconds - payload.value.iat > maxAgeSeconds - ) { - return { ok: false, reason: "DPoP proof is outside the allowed time window." }; - } - const signature = base64UrlToBytes(parts[2]); const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); const verified = p256.verify( @@ -161,15 +171,29 @@ export function verifyDpopProof(input: { format: "compact", }, ); - return verified - ? { - ok: true, - thumbprint, - jti: payload.value.jti, - iat: payload.value.iat, - } - : { ok: false, reason: "Invalid DPoP signature." }; + if (!verified) { + return { ok: false, code: "invalid_signature", reason: "Invalid DPoP signature." }; + } + + const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; + if ( + payload.value.iat > input.nowEpochSeconds + 5 || + input.nowEpochSeconds - payload.value.iat > maxAgeSeconds + ) { + return { + ok: false, + code: "time_window", + reason: "DPoP proof is outside the allowed time window.", + }; + } + + return { + ok: true, + thumbprint, + jti: payload.value.jti, + iat: payload.value.iat, + }; } catch { - return { ok: false, reason: "Invalid DPoP proof." }; + return { ok: false, code: "invalid_proof", reason: "Invalid DPoP proof." }; } }