From 4f4335b7f7ddc823213f5a4dc700972ce23659e3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 27 Jun 2026 07:27:55 -0700 Subject: [PATCH 1/7] Add Connect client approval mode --- .../features/cloud/linkEnvironment.test.ts | 2 +- .../src/features/cloud/linkEnvironment.ts | 32 +- apps/server/src/auth/ConnectClientStore.ts | 414 +++++++++++++++++ apps/server/src/auth/EnvironmentAuth.ts | 179 +++++++ apps/server/src/auth/http.ts | 76 +++ apps/server/src/cli/connect.ts | 264 ++++++++++- apps/server/src/cloud/http.ts | 75 ++- .../src/persistence/AuthConnectClients.ts | 435 ++++++++++++++++++ apps/server/src/persistence/Errors.ts | 2 + apps/server/src/persistence/Migrations.ts | 2 + .../033_AuthConnectClientApprovals.ts | 37 ++ apps/server/src/ws.ts | 38 +- apps/web/src/cloud/linkEnvironment.test.ts | 4 + .../settings/ConnectionsSettings.tsx | 270 ++++++++++- apps/web/src/environments/primary/auth.ts | 120 +++++ apps/web/src/environments/primary/index.ts | 6 + apps/web/test/environmentHttpTest.ts | 7 +- .../environments/EnvironmentConnector.test.ts | 66 ++- .../src/environments/EnvironmentConnector.ts | 54 ++- infra/relay/src/http/Api.ts | 1 + .../src/connection/resolver.test.ts | 53 +++ .../client-runtime/src/connection/resolver.ts | 11 + .../client-runtime/src/relay/managedRelay.ts | 5 +- .../client-runtime/src/state/auth.test.ts | 52 ++- packages/client-runtime/src/state/auth.ts | 23 + packages/contracts/src/auth.ts | 66 +++ packages/contracts/src/environmentHttp.ts | 65 +++ packages/contracts/src/relay.ts | 66 ++- 28 files changed, 2370 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/auth/ConnectClientStore.ts create mode 100644 apps/server/src/persistence/AuthConnectClients.ts create mode 100644 apps/server/src/persistence/Migrations/033_AuthConnectClientApprovals.ts diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b9ab3aeab05..5f2f6338272 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -835,7 +835,7 @@ describe("mobile cloud link environment client", () => { // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(connectRequestBody)).toMatchObject({ deviceId: "device-1", - clientKeyThumbprint: "client-proof-key-thumbprint", + clientProofKeyThumbprint: "client-proof-key-thumbprint", }); expect(createProofMock).toHaveBeenCalledWith({ method: "POST", diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a77ca628978..02193a361a5 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -484,6 +484,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag scopes: [RelayEnvironmentConnectScope], environmentId: input.environmentId, deviceId, + client: authClientMetadata(), }) .pipe( Effect.mapError( @@ -497,21 +498,30 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag message: "Relay returned credentials for a different environment.", }); } + if (!("credential" in connect)) { + return yield* new CloudEnvironmentLinkError({ + message: + connect.approvalStatus === "rejected" + ? "This device was rejected by the environment. Approve it in the environment settings to connect." + : "Waiting for this device to be approved in the environment settings.", + }); + } + const authorizedConnect = connect; if (input.expectedEnvironment) { yield* ensureConnectEndpointMatchesEnvironment({ environment: input.expectedEnvironment, - connect, + connect: authorizedConnect, }); } const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: connect.endpoint.httpBaseUrl, + httpBaseUrl: authorizedConnect.endpoint.httpBaseUrl, }).pipe( Effect.mapError( cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."), ), ); - if (descriptor.environmentId !== connect.environmentId) { + if (descriptor.environmentId !== authorizedConnect.environmentId) { return yield* new CloudEnvironmentLinkError({ message: "Connected endpoint descriptor does not match the selected environment.", }); @@ -520,12 +530,12 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag const bootstrapDpop = yield* signer .createProof({ method: "POST", - url: new URL("/oauth/token", connect.endpoint.httpBaseUrl).toString(), + url: new URL("/oauth/token", authorizedConnect.endpoint.httpBaseUrl).toString(), }) .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof."))); const bootstrap = yield* exchangeRemoteDpopAccessToken({ - httpBaseUrl: connect.endpoint.httpBaseUrl, - credential: connect.credential, + httpBaseUrl: authorizedConnect.endpoint.httpBaseUrl, + credential: authorizedConnect.credential, dpopProof: bootstrapDpop, clientMetadata: authClientMetadata(), }).pipe( @@ -533,16 +543,16 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), ), ); - const pairingUrl = new URL(connect.endpoint.httpBaseUrl); - pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString(); + const pairingUrl = new URL(authorizedConnect.endpoint.httpBaseUrl); + pairingUrl.hash = new URLSearchParams([["token", authorizedConnect.credential]]).toString(); return { environmentId: descriptor.environmentId, environmentLabel: descriptor.label, pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(), - displayUrl: connect.endpoint.httpBaseUrl, - httpBaseUrl: connect.endpoint.httpBaseUrl, - wsBaseUrl: connect.endpoint.wsBaseUrl, + displayUrl: authorizedConnect.endpoint.httpBaseUrl, + httpBaseUrl: authorizedConnect.endpoint.httpBaseUrl, + wsBaseUrl: authorizedConnect.endpoint.wsBaseUrl, bearerToken: null, authenticationMethod: "dpop", dpopAccessToken: bootstrap.access_token, diff --git a/apps/server/src/auth/ConnectClientStore.ts b/apps/server/src/auth/ConnectClientStore.ts new file mode 100644 index 00000000000..5c93f36c898 --- /dev/null +++ b/apps/server/src/auth/ConnectClientStore.ts @@ -0,0 +1,414 @@ +import { + AuthConnectSecurityMode, + type AuthClientMetadata, + type AuthClientPresentationMetadata, + type AuthConnectClient, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as AuthConnectClients from "../persistence/AuthConnectClients.ts"; +import type { AuthConnectClientRepositoryError } from "../persistence/Errors.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; + +const CONNECT_SECURITY_MODE_SECRET = "connect-security-mode"; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const connectClientInternalErrorContext = { + cause: Schema.Defect(), +}; + +export class ConnectSecurityModeLoadError extends Schema.TaggedErrorClass()( + "ConnectSecurityModeLoadError", + { + ...connectClientInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to load Connect security mode."; + } +} + +export class ConnectSecurityModeUpdateError extends Schema.TaggedErrorClass()( + "ConnectSecurityModeUpdateError", + { + ...connectClientInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to update Connect security mode."; + } +} + +export class ConnectClientsLoadError extends Schema.TaggedErrorClass()( + "ConnectClientsLoadError", + { + ...connectClientInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to load Connect clients."; + } +} + +export class ConnectClientRequestError extends Schema.TaggedErrorClass()( + "ConnectClientRequestError", + { + clientProofKeyThumbprint: Schema.String, + ...connectClientInternalErrorContext, + }, +) { + override get message(): string { + return `Failed to record Connect client '${this.clientProofKeyThumbprint}'.`; + } +} + +export class ConnectClientApprovalError extends Schema.TaggedErrorClass()( + "ConnectClientApprovalError", + { + clientProofKeyThumbprint: Schema.String, + ...connectClientInternalErrorContext, + }, +) { + override get message(): string { + return `Failed to approve Connect client '${this.clientProofKeyThumbprint}'.`; + } +} + +export class ConnectClientRejectionError extends Schema.TaggedErrorClass()( + "ConnectClientRejectionError", + { + clientProofKeyThumbprint: Schema.String, + ...connectClientInternalErrorContext, + }, +) { + override get message(): string { + return `Failed to reject Connect client '${this.clientProofKeyThumbprint}'.`; + } +} + +export class ConnectClientRevocationError extends Schema.TaggedErrorClass()( + "ConnectClientRevocationError", + { + clientProofKeyThumbprint: Schema.String, + ...connectClientInternalErrorContext, + }, +) { + override get message(): string { + return `Failed to revoke Connect client '${this.clientProofKeyThumbprint}'.`; + } +} + +export const ConnectClientStoreError = Schema.Union([ + ConnectSecurityModeLoadError, + ConnectSecurityModeUpdateError, + ConnectClientsLoadError, + ConnectClientRequestError, + ConnectClientApprovalError, + ConnectClientRejectionError, + ConnectClientRevocationError, +]); +export type ConnectClientStoreError = typeof ConnectClientStoreError.Type; +export const isConnectClientStoreError = Schema.is(ConnectClientStoreError); + +export type ConnectClientChange = + | { + readonly type: "connectSecurityModeUpdated"; + readonly mode: AuthConnectSecurityMode; + } + | { + readonly type: "connectClientUpserted"; + readonly client: AuthConnectClient; + } + | { + readonly type: "connectClientRemoved"; + readonly clientProofKeyThumbprint: string; + }; + +export type ConnectClientAuthorization = + | { + readonly mode: "account"; + readonly status: "approved"; + } + | { + readonly mode: "client-approval"; + readonly status: "pending" | "approved" | "rejected"; + readonly client: AuthConnectClient; + }; + +export class ConnectClientStore extends Context.Service< + ConnectClientStore, + { + readonly getSecurityMode: () => Effect.Effect< + AuthConnectSecurityMode, + ConnectSecurityModeLoadError + >; + readonly setSecurityMode: ( + mode: AuthConnectSecurityMode, + ) => Effect.Effect; + readonly listClients: () => Effect.Effect< + ReadonlyArray, + ConnectClientsLoadError + >; + readonly requestClient: (input: { + readonly cloudUserId: string; + readonly clientProofKeyThumbprint: string; + readonly deviceId?: string; + readonly client?: AuthClientPresentationMetadata; + }) => Effect.Effect< + ConnectClientAuthorization, + ConnectClientRequestError | ConnectSecurityModeLoadError + >; + readonly approve: ( + clientProofKeyThumbprint: string, + ) => Effect.Effect, ConnectClientApprovalError>; + readonly reject: ( + clientProofKeyThumbprint: string, + ) => Effect.Effect, ConnectClientRejectionError>; + readonly revoke: ( + clientProofKeyThumbprint: string, + ) => Effect.Effect; + readonly streamChanges: Stream.Stream; + } +>()("t3/auth/ConnectClientStore") {} + +function toAuthClientMetadata( + record: AuthConnectClients.AuthConnectClientMetadataRecord, +): AuthClientMetadata { + return { + ...(record.label ? { label: record.label } : {}), + ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), + ...(record.userAgent ? { userAgent: record.userAgent } : {}), + deviceType: record.deviceType, + ...(record.os ? { os: record.os } : {}), + ...(record.browser ? { browser: record.browser } : {}), + }; +} + +function toAuthConnectClient( + record: AuthConnectClients.AuthConnectClientRecord, +): AuthConnectClient { + return { + clientProofKeyThumbprint: record.clientProofKeyThumbprint, + cloudUserId: record.cloudUserId, + ...(record.deviceId ? { deviceId: record.deviceId } : {}), + status: record.status, + client: toAuthClientMetadata(record.client), + requestedAt: DateTime.toUtc(record.requestedAt), + updatedAt: DateTime.toUtc(record.updatedAt), + approvedAt: record.approvedAt === null ? null : DateTime.toUtc(record.approvedAt), + rejectedAt: record.rejectedAt === null ? null : DateTime.toUtc(record.rejectedAt), + lastSeenAt: record.lastSeenAt === null ? null : DateTime.toUtc(record.lastSeenAt), + }; +} + +function fromPresentationMetadata( + client: AuthClientPresentationMetadata | undefined, +): AuthConnectClients.AuthConnectClientMetadataRecord { + return { + label: client?.label ?? null, + ipAddress: null, + userAgent: null, + deviceType: client?.deviceType ?? "unknown", + os: client?.os ?? null, + browser: null, + }; +} + +function decodeSecurityMode(bytes: Uint8Array): AuthConnectSecurityMode { + const value = textDecoder.decode(bytes).trim(); + return value === "client-approval" ? "client-approval" : "account"; +} + +function encodeSecurityMode(mode: AuthConnectSecurityMode): Uint8Array { + return textEncoder.encode(mode); +} + +export const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const repository = yield* AuthConnectClients.AuthConnectClientRepository; + const changesPubSub = yield* PubSub.unbounded(); + + const emitMode = (mode: AuthConnectSecurityMode) => + PubSub.publish(changesPubSub, { + type: "connectSecurityModeUpdated", + mode, + }).pipe(Effect.asVoid); + + const emitUpsert = (client: AuthConnectClient) => + PubSub.publish(changesPubSub, { + type: "connectClientUpserted", + client, + }).pipe(Effect.asVoid); + + const emitRemoved = (clientProofKeyThumbprint: string) => + PubSub.publish(changesPubSub, { + type: "connectClientRemoved", + clientProofKeyThumbprint, + }).pipe(Effect.asVoid); + + const getSecurityMode: ConnectClientStore["Service"]["getSecurityMode"] = () => + secrets.get(CONNECT_SECURITY_MODE_SECRET).pipe( + Effect.map((mode) => + Option.isSome(mode) ? decodeSecurityMode(mode.value) : ("account" as const), + ), + Effect.mapError((cause) => new ConnectSecurityModeLoadError({ cause })), + Effect.withSpan("ConnectClientStore.getSecurityMode"), + ); + + const setSecurityMode: ConnectClientStore["Service"]["setSecurityMode"] = (mode) => + secrets.set(CONNECT_SECURITY_MODE_SECRET, encodeSecurityMode(mode)).pipe( + Effect.as(mode), + Effect.tap(emitMode), + Effect.mapError((cause) => new ConnectSecurityModeUpdateError({ cause })), + Effect.withSpan("ConnectClientStore.setSecurityMode"), + ); + + const listClients: ConnectClientStore["Service"]["listClients"] = () => + repository.listActive().pipe( + Effect.map((clients) => clients.map(toAuthConnectClient)), + Effect.mapError((cause) => new ConnectClientsLoadError({ cause })), + Effect.withSpan("ConnectClientStore.listClients"), + ); + + const requestClient: ConnectClientStore["Service"]["requestClient"] = Effect.fn( + "ConnectClientStore.requestClient", + )(function* (input) { + const mode = yield* getSecurityMode(); + if (mode === "account") { + return { mode, status: "approved" as const }; + } + + const requestedAt = yield* DateTime.now; + const record = yield* repository + .upsertRequest({ + clientProofKeyThumbprint: input.clientProofKeyThumbprint, + cloudUserId: input.cloudUserId, + deviceId: input.deviceId ?? null, + client: fromPresentationMetadata(input.client), + requestedAt, + }) + .pipe( + Effect.mapError( + (cause) => + new ConnectClientRequestError({ + clientProofKeyThumbprint: input.clientProofKeyThumbprint, + cause, + }), + ), + ); + + const visibleClient = toAuthConnectClient(record); + yield* emitUpsert(visibleClient); + + if (record.status !== "approved") { + return { mode, status: record.status, client: visibleClient }; + } + + const seenAt = yield* DateTime.now; + const seen = yield* repository + .markSeen({ + clientProofKeyThumbprint: input.clientProofKeyThumbprint, + seenAt, + }) + .pipe( + Effect.mapError( + (cause) => + new ConnectClientRequestError({ + clientProofKeyThumbprint: input.clientProofKeyThumbprint, + cause, + }), + ), + ); + if (Option.isSome(seen)) { + const seenClient = toAuthConnectClient(seen.value); + yield* emitUpsert(seenClient); + return { mode, status: "approved" as const, client: seenClient }; + } + + return { mode, status: "approved" as const, client: visibleClient }; + }); + + const updateDecision = ( + clientProofKeyThumbprint: string, + status: "approved" | "rejected", + toError: (cause: AuthConnectClientRepositoryError) => E, + ): Effect.Effect, E> => + DateTime.now.pipe( + Effect.flatMap((decidedAt) => + repository.updateStatus({ + clientProofKeyThumbprint, + status, + decidedAt, + }), + ), + Effect.mapError(toError), + Effect.map((updated) => Option.map(updated, toAuthConnectClient)), + Effect.tap((updated) => (Option.isSome(updated) ? emitUpsert(updated.value) : Effect.void)), + ); + + const approve: ConnectClientStore["Service"]["approve"] = (clientProofKeyThumbprint) => + updateDecision( + clientProofKeyThumbprint, + "approved", + (cause) => + new ConnectClientApprovalError({ + clientProofKeyThumbprint, + cause, + }), + ).pipe(Effect.withSpan("ConnectClientStore.approve")); + + const reject: ConnectClientStore["Service"]["reject"] = (clientProofKeyThumbprint) => + updateDecision( + clientProofKeyThumbprint, + "rejected", + (cause) => + new ConnectClientRejectionError({ + clientProofKeyThumbprint, + cause, + }), + ).pipe(Effect.withSpan("ConnectClientStore.reject")); + + const revoke: ConnectClientStore["Service"]["revoke"] = (clientProofKeyThumbprint) => + DateTime.now.pipe( + Effect.flatMap((revokedAt) => + repository.revoke({ + clientProofKeyThumbprint, + revokedAt, + }), + ), + Effect.tap((revoked) => (revoked ? emitRemoved(clientProofKeyThumbprint) : Effect.void)), + Effect.mapError( + (cause) => + new ConnectClientRevocationError({ + clientProofKeyThumbprint, + cause, + }), + ), + Effect.withSpan("ConnectClientStore.revoke"), + ); + + return ConnectClientStore.of({ + getSecurityMode, + setSecurityMode, + listClients, + requestClient, + approve, + reject, + revoke, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + }); +}); + +export const layer = Layer.effect(ConnectClientStore, make).pipe( + Layer.provideMerge(AuthConnectClients.layer), +); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..a6516408e56 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -3,6 +3,9 @@ import { AuthAccessWriteScope, AuthAdministrativeScopes, AuthStandardClientScopes, + type AuthClientPresentationMetadata, + type AuthConnectClient, + type AuthConnectSecurityMode, type AuthAccessTokenResult, type AuthBrowserSessionResult, type AuthClientMetadata, @@ -26,9 +29,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; +import * as ConnectClientStore from "./ConnectClientStore.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; @@ -307,6 +312,83 @@ export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass< } } +export class ServerAuthConnectSecurityModeReadError extends Schema.TaggedErrorClass()( + "ServerAuthConnectSecurityModeReadError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to read Connect security mode."; + } +} + +export class ServerAuthConnectSecurityModeUpdateError extends Schema.TaggedErrorClass()( + "ServerAuthConnectSecurityModeUpdateError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to update Connect security mode."; + } +} + +export class ServerAuthConnectClientsListError extends Schema.TaggedErrorClass()( + "ServerAuthConnectClientsListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list Connect clients."; + } +} + +export class ServerAuthConnectClientRequestError extends Schema.TaggedErrorClass()( + "ServerAuthConnectClientRequestError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to authorize Connect client request."; + } +} + +export class ServerAuthConnectClientApprovalError extends Schema.TaggedErrorClass()( + "ServerAuthConnectClientApprovalError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to approve Connect client."; + } +} + +export class ServerAuthConnectClientRejectionError extends Schema.TaggedErrorClass()( + "ServerAuthConnectClientRejectionError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to reject Connect client."; + } +} + +export class ServerAuthConnectClientRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthConnectClientRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke Connect client."; + } +} + export const ServerAuthInternalError = Schema.Union([ ServerAuthBootstrapCredentialValidationError, ServerAuthSessionCredentialValidationError, @@ -330,6 +412,13 @@ export const ServerAuthInternalError = Schema.Union([ ServerAuthCloudRelayIssuerMissingError, ServerAuthCloudHealthJwtSigningError, ServerAuthCloudMintJwtSigningError, + ServerAuthConnectSecurityModeReadError, + ServerAuthConnectSecurityModeUpdateError, + ServerAuthConnectClientsListError, + ServerAuthConnectClientRequestError, + ServerAuthConnectClientApprovalError, + ServerAuthConnectClientRejectionError, + ServerAuthConnectClientRevocationError, ]); export type ServerAuthInternalError = typeof ServerAuthInternalError.Type; export const isServerAuthInternalError = Schema.is(ServerAuthInternalError); @@ -476,6 +565,33 @@ export class EnvironmentAuth extends Context.Service< readonly revokeOtherClientSessions: ( currentSessionId: AuthSessionId, ) => Effect.Effect; + readonly getConnectSecurityMode: () => Effect.Effect< + AuthConnectSecurityMode, + ServerAuthInternalError + >; + readonly setConnectSecurityMode: ( + mode: AuthConnectSecurityMode, + ) => Effect.Effect; + readonly listConnectClients: () => Effect.Effect< + ReadonlyArray, + ServerAuthInternalError + >; + readonly authorizeConnectClientRequest: (input: { + readonly cloudUserId: string; + readonly clientProofKeyThumbprint: string; + readonly deviceId?: string; + readonly client?: AuthClientPresentationMetadata; + }) => Effect.Effect; + readonly approveConnectClient: ( + clientProofKeyThumbprint: string, + ) => Effect.Effect, ServerAuthInternalError>; + readonly rejectConnectClient: ( + clientProofKeyThumbprint: string, + ) => Effect.Effect, ServerAuthInternalError>; + readonly revokeConnectClient: ( + clientProofKeyThumbprint: string, + ) => Effect.Effect; + readonly streamConnectClientChanges: Stream.Stream; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; @@ -557,6 +673,7 @@ export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; + const connectClients = yield* ConnectClientStore.ConnectClientStore; const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); @@ -903,6 +1020,59 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), ); + const getConnectSecurityMode: EnvironmentAuth["Service"]["getConnectSecurityMode"] = () => + connectClients.getSecurityMode().pipe( + Effect.mapError((cause) => new ServerAuthConnectSecurityModeReadError({ cause })), + Effect.withSpan("EnvironmentAuth.getConnectSecurityMode"), + ); + + const setConnectSecurityMode: EnvironmentAuth["Service"]["setConnectSecurityMode"] = (mode) => + connectClients.setSecurityMode(mode).pipe( + Effect.mapError((cause) => new ServerAuthConnectSecurityModeUpdateError({ cause })), + Effect.withSpan("EnvironmentAuth.setConnectSecurityMode"), + ); + + const listConnectClients: EnvironmentAuth["Service"]["listConnectClients"] = () => + connectClients.listClients().pipe( + Effect.mapError((cause) => new ServerAuthConnectClientsListError({ cause })), + Effect.withSpan("EnvironmentAuth.listConnectClients"), + ); + + const authorizeConnectClientRequest: EnvironmentAuth["Service"]["authorizeConnectClientRequest"] = + (input) => + connectClients.requestClient(input).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectSecurityModeLoadError" + ? new ServerAuthConnectSecurityModeReadError({ cause }) + : new ServerAuthConnectClientRequestError({ cause }), + ), + Effect.withSpan("EnvironmentAuth.authorizeConnectClientRequest"), + ); + + const approveConnectClient: EnvironmentAuth["Service"]["approveConnectClient"] = ( + clientProofKeyThumbprint, + ) => + connectClients.approve(clientProofKeyThumbprint).pipe( + Effect.mapError((cause) => new ServerAuthConnectClientApprovalError({ cause })), + Effect.withSpan("EnvironmentAuth.approveConnectClient"), + ); + + const rejectConnectClient: EnvironmentAuth["Service"]["rejectConnectClient"] = ( + clientProofKeyThumbprint, + ) => + connectClients.reject(clientProofKeyThumbprint).pipe( + Effect.mapError((cause) => new ServerAuthConnectClientRejectionError({ cause })), + Effect.withSpan("EnvironmentAuth.rejectConnectClient"), + ); + + const revokeConnectClient: EnvironmentAuth["Service"]["revokeConnectClient"] = ( + clientProofKeyThumbprint, + ) => + connectClients.revoke(clientProofKeyThumbprint).pipe( + Effect.mapError((cause) => new ServerAuthConnectClientRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeConnectClient"), + ); + const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) => issueStartupPairingCredential().pipe( Effect.map((issued) => { @@ -973,6 +1143,14 @@ export const make = Effect.gen(function* () { listClientSessions, revokeClientSession, revokeOtherClientSessions, + getConnectSecurityMode, + setConnectSecurityMode, + listConnectClients, + authorizeConnectClientRequest, + approveConnectClient, + rejectConnectClient, + revokeConnectClient, + streamConnectClientChanges: connectClients.streamChanges, authenticateHttpRequest, authenticateWebSocketUpgrade, issueWebSocketTicket, @@ -983,6 +1161,7 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(PairingGrantStore.layer), Layer.provideMerge(SessionStore.layer), + Layer.provideMerge(ConnectClientStore.layer), Layer.provideMerge(EnvironmentAuthPolicy.layer), ); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 71fb00b970a..297287f5f44 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Cookies from "effect/unstable/http/Cookies"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; @@ -419,6 +420,81 @@ export const authHttpApiLayer = HttpApiBuilder.group( failEnvironmentInternal("client_session_revoke_failed", error), ), ), + ) + .handle( + "connectSecurityMode", + Effect.fn("environment.auth.connectSecurityMode")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + const mode = yield* serverAuth.setConnectSecurityMode(args.payload.mode); + return { mode }; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("connect_security_mode_update_failed", error), + ), + ), + ) + .handle( + "connectClients", + Effect.fn("environment.auth.connectClients")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessReadScope); + return yield* serverAuth.listConnectClients(); + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("connect_clients_load_failed", error), + ), + ), + ) + .handle( + "approveConnectClient", + Effect.fn("environment.auth.approveConnectClient")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + const client = yield* serverAuth.approveConnectClient( + args.payload.clientProofKeyThumbprint, + ); + return { client: Option.getOrNull(client) }; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("connect_client_approval_failed", error), + ), + ), + ) + .handle( + "rejectConnectClient", + Effect.fn("environment.auth.rejectConnectClient")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + const client = yield* serverAuth.rejectConnectClient( + args.payload.clientProofKeyThumbprint, + ); + return { client: Option.getOrNull(client) }; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("connect_client_rejection_failed", error), + ), + ), + ) + .handle( + "revokeConnectClient", + Effect.fn("environment.auth.revokeConnectClient")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + const revoked = yield* serverAuth.revokeConnectClient( + args.payload.clientProofKeyThumbprint, + ); + return { revoked }; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("connect_client_revoke_failed", error), + ), + ), ); }), ); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 3ce53391fa6..7e567f82435 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -1,14 +1,19 @@ import { + AuthConnectClient, + AuthConnectSecurityMode, AuthRelayWriteScope, EnvironmentHttpApi, + type AuthConnectClient as AuthConnectClientType, type RelayClientInstallProgressEvent, type RelayClientInstallProgressStage, } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -16,7 +21,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; -import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, HttpClient, @@ -52,9 +58,69 @@ interface CloudCliStatus { readonly linked: boolean; readonly cloudUserId: string | null; readonly relayUrl: string | null; + readonly connectSecurityMode: AuthConnectSecurityMode; readonly relayClient: RelayClient.RelayClientStatus; } +const ConnectSecurityModeOutput = Schema.Struct({ + mode: AuthConnectSecurityMode, +}); +const encodeConnectSecurityModeOutputJson = Schema.encodeUnknownEffect( + fromJsonStringPretty(ConnectSecurityModeOutput), +); +const ConnectSecurityClientsOutput = Schema.Struct({ + clients: Schema.Array(AuthConnectClient), +}); +const ConnectSecurityClientDecisionOutput = Schema.Struct({ + client: Schema.NullOr(AuthConnectClient), +}); +const ConnectSecurityClientRevokeOutput = Schema.Struct({ + revoked: Schema.Boolean, +}); +const encodeConnectSecurityClientsOutputJson = Schema.encodeUnknownEffect( + fromJsonStringPretty(ConnectSecurityClientsOutput), +); +const encodeConnectSecurityClientDecisionOutputJson = Schema.encodeUnknownEffect( + fromJsonStringPretty(ConnectSecurityClientDecisionOutput), +); +const encodeConnectSecurityClientRevokeOutputJson = Schema.encodeUnknownEffect( + fromJsonStringPretty(ConnectSecurityClientRevokeOutput), +); + +function formatConnectClientLabel(client: AuthConnectClientType): string { + return ( + client.client.label ?? + client.client.os ?? + `client ${client.clientProofKeyThumbprint.slice(0, 12)}` + ); +} + +function formatConnectClientDetails(client: AuthConnectClientType): string { + const details = [ + client.client.deviceType !== "unknown" ? client.client.deviceType : null, + client.client.os ?? null, + client.deviceId ? `device ${client.deviceId}` : null, + client.lastSeenAt ? `last seen ${DateTime.formatIso(client.lastSeenAt)}` : null, + `requested ${DateTime.formatIso(client.requestedAt)}`, + ].filter((value): value is string => value !== null); + return details.join(", "); +} + +function formatConnectClientList(clients: ReadonlyArray): string { + if (clients.length === 0) { + return "No T3 Connect clients are registered."; + } + return [ + "T3 Connect clients", + ...clients.map( + (client) => + ` ${client.clientProofKeyThumbprint} ${client.status} ${formatConnectClientLabel( + client, + )} (${formatConnectClientDetails(client)})`, + ), + ].join("\n"); +} + function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray { switch (executable.status) { case "available": { @@ -103,6 +169,9 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b ` Exposure: ${status.desired ? "enabled" : "disabled"}`, ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, ` Environment link: ${provisioned}`, + ` Client approval: ${ + status.connectSecurityMode === "client-approval" ? "required" : "account-wide" + }`, ` Relay: ${status.relayUrl ?? "not provisioned"}`, ...formatRelayClientStatus(status.relayClient), ...(nextStep ? ["", `Next: ${nextStep}`] : []), @@ -411,22 +480,26 @@ const connectStatusCommand = Command.make("status", { const secrets = yield* ServerSecretStore.ServerSecretStore; const relayClient = yield* RelayClient.RelayClient; const tokens = yield* CliTokenManager.CloudCliTokenManager; - const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all( - [ - CliState.readCliDesiredCloudLink, - tokens.hasCredential, - secrets.get(CLOUD_LINKED_USER_ID), - secrets.get(RELAY_URL_SECRET), - relayClient.resolve, - ], - { concurrency: "unbounded" }, - ); + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const [desired, authenticated, cloudUserId, relayUrl, connectSecurityMode, executable] = + yield* Effect.all( + [ + CliState.readCliDesiredCloudLink, + tokens.hasCredential, + secrets.get(CLOUD_LINKED_USER_ID), + secrets.get(RELAY_URL_SECRET), + environmentAuth.getConnectSecurityMode(), + relayClient.resolve, + ], + { concurrency: "unbounded" }, + ); const status: CloudCliStatus = { desired, authenticated, linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, + connectSecurityMode, relayClient: executable, }; yield* Console.log(formatCloudStatus(status, { json: flags.json })); @@ -438,6 +511,174 @@ const connectStatusCommand = Command.make("status", { ), ); +const connectSecurityModeFlag = Flag.choice("mode", ["account", "client-approval"] as const).pipe( + Flag.withDescription("Connect security mode."), + Flag.optional, +); + +const connectClientThumbprintArgument = Argument.string("client-proof-key-thumbprint").pipe( + Argument.withDescription("T3 Connect client proof key thumbprint."), +); + +const connectSecurityClientsCommand = Command.make("clients", { + ...projectLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List T3 Connect clients registered for approval."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const clients = yield* environmentAuth.listConnectClients(); + if (flags.json) { + const output = yield* encodeConnectSecurityClientsOutputJson({ clients }); + yield* Console.log(output); + return; + } + yield* Console.log(formatConnectClientList(clients)); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const connectSecurityApproveCommand = Command.make("approve", { + ...projectLocationFlags, + clientProofKeyThumbprint: connectClientThumbprintArgument, + json: jsonFlag, +}).pipe( + Command.withDescription("Approve a T3 Connect client."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const client = yield* environmentAuth.approveConnectClient(flags.clientProofKeyThumbprint); + const clientOrNull = Option.getOrNull(client); + if (flags.json) { + const output = yield* encodeConnectSecurityClientDecisionOutputJson({ + client: clientOrNull, + }); + yield* Console.log(output); + return; + } + yield* Console.log( + clientOrNull + ? `Approved T3 Connect client ${flags.clientProofKeyThumbprint}.` + : `No active T3 Connect client found for ${flags.clientProofKeyThumbprint}.`, + ); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const connectSecurityRejectCommand = Command.make("reject", { + ...projectLocationFlags, + clientProofKeyThumbprint: connectClientThumbprintArgument, + json: jsonFlag, +}).pipe( + Command.withDescription("Reject a T3 Connect client."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const client = yield* environmentAuth.rejectConnectClient(flags.clientProofKeyThumbprint); + const clientOrNull = Option.getOrNull(client); + if (flags.json) { + const output = yield* encodeConnectSecurityClientDecisionOutputJson({ + client: clientOrNull, + }); + yield* Console.log(output); + return; + } + yield* Console.log( + clientOrNull + ? `Rejected T3 Connect client ${flags.clientProofKeyThumbprint}.` + : `No active T3 Connect client found for ${flags.clientProofKeyThumbprint}.`, + ); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const connectSecurityRevokeCommand = Command.make("revoke", { + ...projectLocationFlags, + clientProofKeyThumbprint: connectClientThumbprintArgument, + json: jsonFlag, +}).pipe( + Command.withDescription("Revoke a T3 Connect client approval record."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const revoked = yield* environmentAuth.revokeConnectClient(flags.clientProofKeyThumbprint); + if (flags.json) { + const output = yield* encodeConnectSecurityClientRevokeOutputJson({ revoked }); + yield* Console.log(output); + return; + } + yield* Console.log( + revoked + ? `Revoked T3 Connect client ${flags.clientProofKeyThumbprint}.` + : `No active T3 Connect client found for ${flags.clientProofKeyThumbprint}.`, + ); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const connectSecurityCommand = Command.make("security", { + ...projectLocationFlags, + mode: connectSecurityModeFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Read or update T3 Connect client-approval mode."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const mode = Option.isSome(flags.mode) + ? yield* environmentAuth.setConnectSecurityMode(flags.mode.value) + : yield* environmentAuth.getConnectSecurityMode(); + if (flags.json) { + const output = yield* encodeConnectSecurityModeOutputJson({ mode }); + yield* Console.log(output); + return; + } + yield* Console.log( + mode === "client-approval" + ? "T3 Connect client approval is required." + : "T3 Connect uses account-wide access.", + ); + }), + { + quietLogs: flags.json, + }, + ), + ), + Command.withSubcommands([ + connectSecurityClientsCommand, + connectSecurityApproveCommand, + connectSecurityRejectCommand, + connectSecurityRevokeCommand, + ]), +); + const connectUnlinkCommand = Command.make("unlink", { ...projectLocationFlags, }).pipe( @@ -462,6 +703,7 @@ export const connectCommand = Command.make("connect").pipe( connectLoginCommand, connectLinkCommand, connectStatusCommand, + connectSecurityCommand, connectUnlinkCommand, connectLogoutCommand, ]), diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index fc2adca9fbc..d29f88985d1 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -608,15 +608,17 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( dependencies: CloudHttpDependencies, ) { - const [cloudUserId, relayUrl, relayIssuer, publishAgentActivity] = yield* Effect.all( - [ - dependencies.secrets.get(CLOUD_LINKED_USER_ID), - dependencies.secrets.get(RELAY_URL_SECRET), - dependencies.secrets.get(RELAY_ISSUER_SECRET), - dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), - ], - { concurrency: 4 }, - ); + const [cloudUserId, relayUrl, relayIssuer, publishAgentActivity, connectSecurityMode] = + yield* Effect.all( + [ + dependencies.secrets.get(CLOUD_LINKED_USER_ID), + dependencies.secrets.get(RELAY_URL_SECRET), + dependencies.secrets.get(RELAY_ISSUER_SECRET), + dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), + dependencies.environmentAuth.getConnectSecurityMode(), + ], + { concurrency: 5 }, + ); return { linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, @@ -625,6 +627,7 @@ const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function publishAgentActivity: Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) === "true" : false, + connectSecurityMode, } satisfies EnvironmentCloudLinkStateResult; }); @@ -637,6 +640,9 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not read environment relay configuration."), ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), ); const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( @@ -680,6 +686,9 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), ); const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( @@ -868,6 +877,54 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") } const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + const connectAuthorization = yield* dependencies.environmentAuth.authorizeConnectClientRequest({ + cloudUserId: linkedCloudUserId, + clientProofKeyThumbprint: proof.clientProofKeyThumbprint, + ...(proof.deviceId ? { deviceId: proof.deviceId } : {}), + ...(proof.client ? { client: proof.client } : {}), + }); + if ( + connectAuthorization.mode === "client-approval" && + connectAuthorization.status !== "approved" + ) { + const responseExpiresAt = DateTime.add(now, { minutes: 5 }); + const responsePayload = { + iss: `t3-env:${environmentId}`, + aud: normalizeRelayIssuer(relayIssuer), + sub: environmentId, + jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), + iat: nowSeconds, + exp: Math.floor(responseExpiresAt.epochMilliseconds / 1_000), + environmentId, + clientProofKeyThumbprint: proof.clientProofKeyThumbprint, + requestNonce: proof.nonce, + status: "pending_approval", + approvalStatus: connectAuthorization.status, + } satisfies RelayEnvironmentMintResponseProofPayload; + const responseProof = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_MINT_RESPONSE_TYP, + payload: responsePayload, + }).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ + cause, + }), + ), + ); + const response = { + status: "pending_approval", + clientProofKeyThumbprint: proof.clientProofKeyThumbprint, + approvalStatus: connectAuthorization.status, + requestedAt: DateTime.formatIso(connectAuthorization.client.requestedAt), + proof: responseProof, + } satisfies RelayEnvironmentMintResponseShape; + + yield* appendCloudCredentialResponseHeaders; + return response; + } + const issued = yield* dependencies.environmentAuth.createPairingLink({ scopes: AuthStandardClientScopes, subject: "cloud-connect", diff --git a/apps/server/src/persistence/AuthConnectClients.ts b/apps/server/src/persistence/AuthConnectClients.ts new file mode 100644 index 00000000000..57cfac9cb50 --- /dev/null +++ b/apps/server/src/persistence/AuthConnectClients.ts @@ -0,0 +1,435 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { AuthClientMetadataDeviceType, AuthConnectClientStatus } from "@t3tools/contracts"; + +import { + type AuthConnectClientRepositoryError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, +} from "./Errors.ts"; + +export const AuthConnectClientMetadataRecord = Schema.Struct({ + label: Schema.NullOr(Schema.String), + ipAddress: Schema.NullOr(Schema.String), + userAgent: Schema.NullOr(Schema.String), + deviceType: AuthClientMetadataDeviceType, + os: Schema.NullOr(Schema.String), + browser: Schema.NullOr(Schema.String), +}); +export type AuthConnectClientMetadataRecord = typeof AuthConnectClientMetadataRecord.Type; + +export const AuthConnectClientRecord = Schema.Struct({ + clientProofKeyThumbprint: Schema.String, + cloudUserId: Schema.String, + deviceId: Schema.NullOr(Schema.String), + status: AuthConnectClientStatus, + client: AuthConnectClientMetadataRecord, + requestedAt: Schema.DateTimeUtcFromString, + updatedAt: Schema.DateTimeUtcFromString, + approvedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + rejectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + lastSeenAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthConnectClientRecord = typeof AuthConnectClientRecord.Type; + +export const UpsertAuthConnectClientRequestInput = Schema.Struct({ + clientProofKeyThumbprint: Schema.String, + cloudUserId: Schema.String, + deviceId: Schema.NullOr(Schema.String), + client: AuthConnectClientMetadataRecord, + requestedAt: Schema.DateTimeUtcFromString, +}); +export type UpsertAuthConnectClientRequestInput = typeof UpsertAuthConnectClientRequestInput.Type; + +export const UpdateAuthConnectClientStatusInput = Schema.Struct({ + clientProofKeyThumbprint: Schema.String, + status: Schema.Literals(["approved", "rejected"]), + decidedAt: Schema.DateTimeUtcFromString, +}); +export type UpdateAuthConnectClientStatusInput = typeof UpdateAuthConnectClientStatusInput.Type; + +export const RevokeAuthConnectClientInput = Schema.Struct({ + clientProofKeyThumbprint: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthConnectClientInput = typeof RevokeAuthConnectClientInput.Type; + +export const MarkAuthConnectClientSeenInput = Schema.Struct({ + clientProofKeyThumbprint: Schema.String, + seenAt: Schema.DateTimeUtcFromString, +}); +export type MarkAuthConnectClientSeenInput = typeof MarkAuthConnectClientSeenInput.Type; + +export class AuthConnectClientRepository extends Context.Service< + AuthConnectClientRepository, + { + readonly upsertRequest: ( + input: UpsertAuthConnectClientRequestInput, + ) => Effect.Effect; + readonly updateStatus: ( + input: UpdateAuthConnectClientStatusInput, + ) => Effect.Effect, AuthConnectClientRepositoryError>; + readonly revoke: ( + input: RevokeAuthConnectClientInput, + ) => Effect.Effect; + readonly markSeen: ( + input: MarkAuthConnectClientSeenInput, + ) => Effect.Effect, AuthConnectClientRepositoryError>; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + AuthConnectClientRepositoryError + >; + } +>()("t3/persistence/AuthConnectClients/AuthConnectClientRepository") {} + +const AuthConnectClientDbRow = Schema.Struct({ + clientProofKeyThumbprint: Schema.String, + cloudUserId: Schema.String, + deviceId: Schema.NullOr(Schema.String), + status: AuthConnectClientStatus, + clientLabel: Schema.NullOr(Schema.String), + clientIpAddress: Schema.NullOr(Schema.String), + clientUserAgent: Schema.NullOr(Schema.String), + clientDeviceType: Schema.Literals(["desktop", "mobile", "tablet", "bot", "unknown"]), + clientOs: Schema.NullOr(Schema.String), + clientBrowser: Schema.NullOr(Schema.String), + requestedAt: Schema.DateTimeUtcFromString, + updatedAt: Schema.DateTimeUtcFromString, + approvedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + rejectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + lastSeenAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); + +const AuthConnectClientRawDbRow = Schema.Struct({ + clientProofKeyThumbprint: Schema.String, + cloudUserId: Schema.Unknown, + deviceId: Schema.Unknown, + status: Schema.Unknown, + clientLabel: Schema.Unknown, + clientIpAddress: Schema.Unknown, + clientUserAgent: Schema.Unknown, + clientDeviceType: Schema.Unknown, + clientOs: Schema.Unknown, + clientBrowser: Schema.Unknown, + requestedAt: Schema.Unknown, + updatedAt: Schema.Unknown, + approvedAt: Schema.Unknown, + rejectedAt: Schema.Unknown, + revokedAt: Schema.Unknown, + lastSeenAt: Schema.Unknown, +}); + +const decodeAuthConnectClientDbRow = Schema.decodeUnknownEffect(AuthConnectClientDbRow); + +function toAuthConnectClientRecord( + row: typeof AuthConnectClientDbRow.Type, +): AuthConnectClientRecord { + return { + clientProofKeyThumbprint: row.clientProofKeyThumbprint, + cloudUserId: row.cloudUserId, + deviceId: row.deviceId, + status: row.status, + client: { + label: row.clientLabel, + ipAddress: row.clientIpAddress, + userAgent: row.clientUserAgent, + deviceType: row.clientDeviceType, + os: row.clientOs, + browser: row.clientBrowser, + }, + requestedAt: row.requestedAt, + updatedAt: row.updatedAt, + approvedAt: row.approvedAt, + rejectedAt: row.rejectedAt, + revokedAt: row.revokedAt, + lastSeenAt: row.lastSeenAt, + }; +} + +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { + return (cause: unknown): AuthConnectClientRepositoryError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); +} + +const rowSelection = ` + client_proof_key_thumbprint AS "clientProofKeyThumbprint", + cloud_user_id AS "cloudUserId", + device_id AS "deviceId", + status AS "status", + client_label AS "clientLabel", + client_ip_address AS "clientIpAddress", + client_user_agent AS "clientUserAgent", + client_device_type AS "clientDeviceType", + client_os AS "clientOs", + client_browser AS "clientBrowser", + requested_at AS "requestedAt", + updated_at AS "updatedAt", + approved_at AS "approvedAt", + rejected_at AS "rejectedAt", + revoked_at AS "revokedAt", + last_seen_at AS "lastSeenAt" +`; + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRequestRow = SqlSchema.findOne({ + Request: UpsertAuthConnectClientRequestInput, + Result: AuthConnectClientRawDbRow, + execute: (input) => + sql` + INSERT INTO auth_connect_clients ( + client_proof_key_thumbprint, + cloud_user_id, + device_id, + status, + client_label, + client_ip_address, + client_user_agent, + client_device_type, + client_os, + client_browser, + requested_at, + updated_at, + approved_at, + rejected_at, + revoked_at, + last_seen_at + ) + VALUES ( + ${input.clientProofKeyThumbprint}, + ${input.cloudUserId}, + ${input.deviceId}, + 'pending', + ${input.client.label}, + ${input.client.ipAddress}, + ${input.client.userAgent}, + ${input.client.deviceType}, + ${input.client.os}, + ${input.client.browser}, + ${input.requestedAt}, + ${input.requestedAt}, + NULL, + NULL, + NULL, + NULL + ) + ON CONFLICT(client_proof_key_thumbprint) DO UPDATE SET + cloud_user_id = excluded.cloud_user_id, + device_id = excluded.device_id, + status = CASE + WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.status + ELSE 'pending' + END, + client_label = excluded.client_label, + client_ip_address = excluded.client_ip_address, + client_user_agent = excluded.client_user_agent, + client_device_type = excluded.client_device_type, + client_os = excluded.client_os, + client_browser = excluded.client_browser, + requested_at = excluded.requested_at, + updated_at = excluded.updated_at, + approved_at = CASE + WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.approved_at + ELSE NULL + END, + rejected_at = CASE + WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.rejected_at + ELSE NULL + END, + revoked_at = NULL + RETURNING ${sql.unsafe(rowSelection)} + `, + }); + + const updateStatusRow = SqlSchema.findOneOption({ + Request: UpdateAuthConnectClientStatusInput, + Result: AuthConnectClientRawDbRow, + execute: ({ clientProofKeyThumbprint, status, decidedAt }) => + sql` + UPDATE auth_connect_clients + SET + status = ${status}, + updated_at = ${decidedAt}, + approved_at = CASE WHEN ${status} = 'approved' THEN ${decidedAt} ELSE approved_at END, + rejected_at = CASE WHEN ${status} = 'rejected' THEN ${decidedAt} ELSE rejected_at END, + revoked_at = NULL + WHERE client_proof_key_thumbprint = ${clientProofKeyThumbprint} + AND revoked_at IS NULL + RETURNING ${sql.unsafe(rowSelection)} + `, + }); + + const revokeRow = SqlSchema.findAll({ + Request: RevokeAuthConnectClientInput, + Result: Schema.Struct({ clientProofKeyThumbprint: Schema.String }), + execute: ({ clientProofKeyThumbprint, revokedAt }) => + sql` + UPDATE auth_connect_clients + SET + revoked_at = ${revokedAt}, + updated_at = ${revokedAt} + WHERE client_proof_key_thumbprint = ${clientProofKeyThumbprint} + AND revoked_at IS NULL + RETURNING client_proof_key_thumbprint AS "clientProofKeyThumbprint" + `, + }); + + const markSeenRow = SqlSchema.findOneOption({ + Request: MarkAuthConnectClientSeenInput, + Result: AuthConnectClientRawDbRow, + execute: ({ clientProofKeyThumbprint, seenAt }) => + sql` + UPDATE auth_connect_clients + SET + last_seen_at = ${seenAt}, + updated_at = ${seenAt} + WHERE client_proof_key_thumbprint = ${clientProofKeyThumbprint} + AND status = 'approved' + AND revoked_at IS NULL + RETURNING ${sql.unsafe(rowSelection)} + `, + }); + + const listRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: AuthConnectClientRawDbRow, + execute: () => + sql` + SELECT ${sql.unsafe(rowSelection)} + FROM auth_connect_clients + WHERE revoked_at IS NULL + ORDER BY + CASE status + WHEN 'pending' THEN 0 + WHEN 'approved' THEN 1 + ELSE 2 + END, + updated_at DESC, + client_proof_key_thumbprint DESC + `, + }); + + const decodeRow = ( + row: typeof AuthConnectClientRawDbRow.Type, + operation: string, + ): Effect.Effect => + decodeAuthConnectClientDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError(operation, cause, { + clientProofKeyThumbprint: row.clientProofKeyThumbprint, + }), + ), + Effect.map(toAuthConnectClientRecord), + ); + + const upsertRequest: AuthConnectClientRepository["Service"]["upsertRequest"] = (input) => + upsertRequestRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthConnectClientRepository.upsertRequest:query", + "AuthConnectClientRepository.upsertRequest:decodeRow", + { clientProofKeyThumbprint: input.clientProofKeyThumbprint }, + ), + ), + Effect.flatMap((row) => + decodeRow(row, "AuthConnectClientRepository.upsertRequest:decodeRow"), + ), + ); + + const updateStatus: AuthConnectClientRepository["Service"]["updateStatus"] = (input) => + updateStatusRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthConnectClientRepository.updateStatus:query", + "AuthConnectClientRepository.updateStatus:decodeRow", + { clientProofKeyThumbprint: input.clientProofKeyThumbprint }, + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeRow(row, "AuthConnectClientRepository.updateStatus:decodeRow").pipe( + Effect.map(Option.some), + ), + }), + ), + ); + + const revoke: AuthConnectClientRepository["Service"]["revoke"] = (input) => + revokeRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthConnectClientRepository.revoke:query", + "AuthConnectClientRepository.revoke:decodeRows", + { clientProofKeyThumbprint: input.clientProofKeyThumbprint }, + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const markSeen: AuthConnectClientRepository["Service"]["markSeen"] = (input) => + markSeenRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthConnectClientRepository.markSeen:query", + "AuthConnectClientRepository.markSeen:decodeRow", + { clientProofKeyThumbprint: input.clientProofKeyThumbprint }, + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeRow(row, "AuthConnectClientRepository.markSeen:decodeRow").pipe( + Effect.map(Option.some), + ), + }), + ), + ); + + const listActive: AuthConnectClientRepository["Service"]["listActive"] = () => + listRows().pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthConnectClientRepository.listActive:query", + "AuthConnectClientRepository.listActive:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeRow(row, "AuthConnectClientRepository.listActive:decodeRows"), + ), + ), + ); + + return { + upsertRequest, + updateStatus, + revoke, + markSeen, + listActive, + } satisfies AuthConnectClientRepository["Service"]; +}); + +export const layer = Layer.effect(AuthConnectClientRepository, make); diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 03edaec77d6..1669f693d39 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -23,6 +23,7 @@ export const PersistenceErrorCorrelation = Schema.Union([ Schema.Struct({ sessionId: Schema.String }), Schema.Struct({ currentSessionId: Schema.String }), Schema.Struct({ pairingLinkId: Schema.String }), + Schema.Struct({ clientProofKeyThumbprint: Schema.String }), Schema.Struct({ threadId: Schema.String }), ]); export type PersistenceErrorCorrelation = typeof PersistenceErrorCorrelation.Type; @@ -134,5 +135,6 @@ export type OrchestrationCommandReceiptRepositoryError = export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type AuthConnectClientRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..b876db3e8e1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_AuthConnectClientApprovals.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "AuthConnectClientApprovals", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_AuthConnectClientApprovals.ts b/apps/server/src/persistence/Migrations/033_AuthConnectClientApprovals.ts new file mode 100644 index 00000000000..11c8695e495 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_AuthConnectClientApprovals.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_connect_clients ( + client_proof_key_thumbprint TEXT PRIMARY KEY, + cloud_user_id TEXT NOT NULL, + device_id TEXT, + status TEXT NOT NULL, + client_label TEXT, + client_ip_address TEXT, + client_user_agent TEXT, + client_device_type TEXT NOT NULL DEFAULT 'unknown', + client_os TEXT, + client_browser TEXT, + requested_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + approved_at TEXT, + rejected_at TEXT, + revoked_at TEXT, + last_seen_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_connect_clients_active + ON auth_connect_clients(revoked_at, status, updated_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_connect_clients_cloud_user + ON auth_connect_clients(cloud_user_id, revoked_at) + `; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9020e99f670..dd04b83e606 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -110,6 +110,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; +import * as ConnectClientStore from "./auth/ConnectClientStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -346,7 +347,10 @@ const RPC_REQUIRED_SCOPE = new Map([ ]); function toAuthAccessStreamEvent( - change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, + change: + | PairingGrantStore.BootstrapCredentialChange + | SessionStore.SessionCredentialChange + | ConnectClientStore.ConnectClientChange, revision: number, currentSessionId: AuthSessionId, ): AuthAccessStreamEvent { @@ -382,6 +386,27 @@ function toAuthAccessStreamEvent( type: "clientRemoved", payload: { sessionId: change.sessionId }, }; + case "connectSecurityModeUpdated": + return { + version: 1, + revision, + type: "connectSecurityModeUpdated", + payload: { mode: change.mode }, + }; + case "connectClientUpserted": + return { + version: 1, + revision, + type: "connectClientUpserted", + payload: change.client, + }; + case "connectClientRemoved": + return { + version: 1, + revision, + type: "connectClientRemoved", + payload: { clientProofKeyThumbprint: change.clientProofKeyThumbprint }, + }; } } @@ -512,6 +537,8 @@ const makeWsRpcLayer = ( const loadAuthAccessSnapshot = () => Effect.all({ + connectSecurityMode: serverAuth.getConnectSecurityMode(), + connectClients: serverAuth.listConnectClients(), pairingLinks: serverAuth.listPairingLinks(), clientSessions: serverAuth.listClientSessions(currentSessionId), }).pipe( @@ -1761,8 +1788,13 @@ const makeWsRpcLayer = ( const initialSnapshot = yield* loadAuthAccessSnapshot(); const revisionRef = yield* Ref.make(1); const accessChanges: Stream.Stream< - PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange - > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); + | PairingGrantStore.BootstrapCredentialChange + | SessionStore.SessionCredentialChange + | ConnectClientStore.ConnectClientChange + > = Stream.merge( + Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges), + serverAuth.streamConnectClientChanges, + ); const liveEvents: Stream.Stream = accessChanges.pipe( Stream.mapEffect((change) => diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 7e6f2365e50..49da313e405 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -205,6 +205,7 @@ describe("web cloud link environment client", () => { relayUrl: "https://relay.example.test", relayIssuer: "https://relay.example.test", publishAgentActivity: false, + connectSecurityMode: "account", }), ); vi.stubGlobal("fetch", fetchMock); @@ -218,6 +219,7 @@ describe("web cloud link environment client", () => { relayUrl: "https://relay.example.test", relayIssuer: "https://relay.example.test", publishAgentActivity: false, + connectSecurityMode: "account", }), ); expect(String(fetchMock.mock.calls[0]?.[0])).toBe( @@ -235,6 +237,7 @@ describe("web cloud link environment client", () => { relayUrl: "https://relay.example.test", relayIssuer: "https://relay.example.test", publishAgentActivity: false, + connectSecurityMode: "account", }), ); vi.stubGlobal("fetch", fetchMock); @@ -262,6 +265,7 @@ describe("web cloud link environment client", () => { relayUrl: "https://relay.example.test", relayIssuer: "https://relay.example.test", publishAgentActivity: true, + connectSecurityMode: "account", }), ); vi.stubGlobal("fetch", fetchMock); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5012986ff45..e4df355d42e 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -20,6 +20,8 @@ import { AuthReviewWriteScope, AuthStandardClientScopes, AuthTerminalOperateScope, + type AuthConnectClient, + type AuthConnectSecurityMode, type AuthClientSession, type AuthEnvironmentScope, type AuthPairingLink, @@ -102,13 +104,19 @@ import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; import { readHostedPairingRequest } from "../../hostedPairing"; import { + approveServerConnectClient, createServerPairingCredential, + rejectServerConnectClient, + revokeServerConnectClient, revokeOtherServerClientSessions, revokeServerClientSession, revokeServerPairingLink, isLoopbackHostname, + toServerConnectClientRecord, + updateServerConnectSecurityMode, usePrimarySessionState, type ServerClientSessionRecord, + type ServerConnectClientRecord, type ServerPairingLinkRecord, } from "~/environments/primary"; import { useUiStateStore } from "~/uiStateStore"; @@ -476,6 +484,21 @@ function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairing }; } +function sortDesktopConnectClients( + clients: ReadonlyArray, +): ReadonlyArray { + const statusRank: Record = { + pending: 0, + approved: 1, + rejected: 2, + }; + return [...clients].sort((left, right) => { + const rankDelta = statusRank[left.status] - statusRank[right.status]; + if (rankDelta !== 0) return rankDelta; + return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(); + }); +} + function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord { return { ...clientSession, @@ -1029,6 +1052,125 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ ); }); +type ConnectClientListRowProps = { + connectClient: ServerConnectClientRecord; + presentation?: AccessSectionPresentation; + pendingActionKey: string | null; + onApprove: (clientProofKeyThumbprint: string) => void; + onReject: (clientProofKeyThumbprint: string) => void; + onRevoke: (clientProofKeyThumbprint: string) => void; +}; + +const ConnectClientListRow = memo(function ConnectClientListRow({ + connectClient, + presentation = "current", + pendingActionKey, + onApprove, + onReject, + onRevoke, +}: ConnectClientListRowProps) { + const nowMs = useRelativeTimeTick(1_000); + const ago = (isoDate: string) => { + const elapsed = formatElapsedDurationLabel(isoDate, nowMs); + return elapsed === "just now" ? elapsed : `${elapsed} ago`; + }; + const pendingApproveKey = `approve:${connectClient.clientProofKeyThumbprint}`; + const pendingRejectKey = `reject:${connectClient.clientProofKeyThumbprint}`; + const pendingRevokeKey = `revoke:${connectClient.clientProofKeyThumbprint}`; + const statusConfig = + connectClient.status === "approved" + ? { + label: connectClient.lastSeenAt + ? `Last connected ${ago(connectClient.lastSeenAt)}` + : connectClient.approvedAt + ? `Approved ${ago(connectClient.approvedAt)}` + : "Approved", + dotClassName: "bg-success", + } + : connectClient.status === "pending" + ? { + label: `Requested ${ago(connectClient.requestedAt)}`, + dotClassName: "bg-warning", + } + : { + label: connectClient.rejectedAt + ? `Rejected ${ago(connectClient.rejectedAt)}` + : "Rejected", + dotClassName: "bg-destructive", + }; + const deviceInfoBits = [ + connectClient.client.deviceType !== "unknown" + ? connectClient.client.deviceType[0]?.toUpperCase() + connectClient.client.deviceType.slice(1) + : null, + connectClient.client.os ?? null, + connectClient.deviceId ? `Device ${connectClient.deviceId}` : null, + ].filter((value): value is string => value !== null); + const primaryLabel = + connectClient.client.label ?? + connectClient.client.os ?? + `Client ${connectClient.clientProofKeyThumbprint.slice(0, 8)}`; + + return ( +
+
+
+
+ +

{primaryLabel}

+ + T3 Connect + +
+

+ {deviceInfoBits.length > 0 ? ( + <> + {deviceInfoBits.join(" · ")} + · + + ) : null} + {statusConfig.label} +

+
+
+ {connectClient.status !== "approved" ? ( + + ) : null} + {connectClient.status === "pending" ? ( + + ) : null} + {connectClient.status !== "pending" ? ( + + ) : null} +
+
+
+ ); +}); + type AuthorizedClientsHeaderActionProps = { clientSessions: ReadonlyArray; isRevokingOtherClients: boolean; @@ -1209,10 +1351,15 @@ type PairingClientsListProps = { defaultEndpointKey: string | null; presentation?: AccessSectionPresentation; isLoading: boolean; + connectClients: ReadonlyArray; pairingLinks: ReadonlyArray; clientSessions: ReadonlyArray; + pendingConnectClientActionKey: string | null; revokingPairingLinkId: string | null; revokingClientSessionId: string | null; + onApproveConnectClient: (clientProofKeyThumbprint: string) => void; + onRejectConnectClient: (clientProofKeyThumbprint: string) => void; + onRevokeConnectClient: (clientProofKeyThumbprint: string) => void; onRevokePairingLink: (id: string) => void; onRevokeClientSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; }; @@ -1223,15 +1370,32 @@ const PairingClientsList = memo(function PairingClientsList({ defaultEndpointKey, presentation = "current", isLoading, + connectClients, pairingLinks, clientSessions, + pendingConnectClientActionKey, revokingPairingLinkId, revokingClientSessionId, + onApproveConnectClient, + onRejectConnectClient, + onRevokeConnectClient, onRevokePairingLink, onRevokeClientSession, }: PairingClientsListProps) { return ( <> + {connectClients.map((connectClient) => ( + + ))} + {pairingLinks.map((pairingLink) => ( ))} - {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? ( + {connectClients.length === 0 && + pairingLinks.length === 0 && + clientSessions.length === 0 && + !isLoading ? (

No pairing links or client sessions.

@@ -2052,6 +2219,10 @@ export function ConnectionsSettings() { string | null >(null); const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); + const [isUpdatingConnectSecurityMode, setIsUpdatingConnectSecurityMode] = useState(false); + const [pendingConnectClientActionKey, setPendingConnectClientActionKey] = useState( + null, + ); const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote"); const [savedBackendHost, setSavedBackendHost] = useState(""); @@ -2145,6 +2316,19 @@ export function ConnectionsSettings() { ), ); }, [authAccessChanges.data]); + const desktopConnectSecurityMode: AuthConnectSecurityMode = useMemo(() => { + const event = authAccessChanges.data; + return event?.type === "snapshot" ? event.payload.connectSecurityMode : "account"; + }, [authAccessChanges.data]); + const desktopConnectClients = useMemo(() => { + const event = authAccessChanges.data; + if (event?.type !== "snapshot") return []; + return sortDesktopConnectClients( + event.payload.connectClients.map((client: AuthConnectClient) => + toServerConnectClientRecord(client), + ), + ); + }, [authAccessChanges.data]); const isLocalBackendNetworkAccessible = desktopBridge ? desktopServerExposureState?.mode === "network-accessible" : currentAuthPolicy === "remote-reachable"; @@ -2341,6 +2525,58 @@ export function ConnectionsSettings() { } }, []); + const handleUpdateConnectSecurityMode = useCallback(async (requiresApproval: boolean) => { + setIsUpdatingConnectSecurityMode(true); + setDesktopAccessManagementMutationError(null); + try { + await updateServerConnectSecurityMode(requiresApproval ? "client-approval" : "account"); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update T3 Connect security."; + setDesktopAccessManagementMutationError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not update T3 Connect security", + description: message, + }), + ); + } finally { + setIsUpdatingConnectSecurityMode(false); + } + }, []); + + const handleConnectClientAction = useCallback( + async (action: "approve" | "reject" | "revoke", clientProofKeyThumbprint: string) => { + const actionKey = `${action}:${clientProofKeyThumbprint}`; + setPendingConnectClientActionKey(actionKey); + setDesktopAccessManagementMutationError(null); + try { + if (action === "approve") { + await approveServerConnectClient(clientProofKeyThumbprint); + } else if (action === "reject") { + await rejectServerConnectClient(clientProofKeyThumbprint); + } else { + await revokeServerConnectClient(clientProofKeyThumbprint); + } + } catch (error) { + const message = + error instanceof Error ? error.message : `Failed to ${action} T3 Connect client.`; + setDesktopAccessManagementMutationError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not ${action} T3 Connect client`, + description: message, + }), + ); + } finally { + setPendingConnectClientActionKey(null); + } + }, + [], + ); + const handleAddSavedBackend = useCallback(async () => { if (savedBackendMode === "ssh") { setIsAddingSavedBackend(true); @@ -2834,15 +3070,45 @@ export function ConnectionsSettings() { defaultEndpointKey={defaultDesktopAdvertisedEndpointKey} presentation={presentation} isLoading={isLoadingDesktopAccessManagement} + connectClients={desktopConnectClients} pairingLinks={visibleDesktopPairingLinks} clientSessions={desktopClientSessions} + pendingConnectClientActionKey={pendingConnectClientActionKey} revokingPairingLinkId={revokingDesktopPairingLinkId} revokingClientSessionId={revokingDesktopClientSessionId} + onApproveConnectClient={(clientProofKeyThumbprint) => + void handleConnectClientAction("approve", clientProofKeyThumbprint) + } + onRejectConnectClient={(clientProofKeyThumbprint) => + void handleConnectClientAction("reject", clientProofKeyThumbprint) + } + onRevokeConnectClient={(clientProofKeyThumbprint) => + void handleConnectClientAction("revoke", clientProofKeyThumbprint) + } onRevokePairingLink={handleRevokeDesktopPairingLink} onRevokeClientSession={handleRevokeDesktopClientSession} /> ); + const renderConnectSecurityModeRow = () => + hasCloudPublicConfig() ? ( + void handleUpdateConnectSecurityMode(checked)} + aria-label="Require T3 Connect client approval" + /> + } + /> + ) : null; const renderNetworkAccessRow = () => ( + {renderConnectSecurityModeRow()} ) : ( <> {renderDisabledNetworkAccessRow()} + {renderConnectSecurityModeRow()} )} diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 96814b92b79..b3583ded0fd 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -1,5 +1,7 @@ import type { AuthBrowserSessionResult, + AuthConnectClient, + AuthConnectSecurityMode, AuthClientMetadata, AuthEnvironmentScope, AuthPairingCredentialResult, @@ -32,6 +34,10 @@ const PrimaryEnvironmentRequestOperation = Schema.Literals([ "list-client-sessions", "revoke-client-session", "revoke-other-client-sessions", + "update-connect-security-mode", + "approve-connect-client", + "reject-connect-client", + "revoke-connect-client", ]); type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; @@ -140,6 +146,34 @@ export interface ServerClientSessionRecord { readonly current: boolean; } +export interface ServerConnectClientRecord { + readonly clientProofKeyThumbprint: string; + readonly cloudUserId: string; + readonly deviceId?: string; + readonly status: AuthConnectClient["status"]; + readonly client: AuthConnectClient["client"]; + readonly requestedAt: string; + readonly updatedAt: string; + readonly approvedAt: string | null; + readonly rejectedAt: string | null; + readonly lastSeenAt: string | null; +} + +export function toServerConnectClientRecord(client: AuthConnectClient): ServerConnectClientRecord { + return { + clientProofKeyThumbprint: client.clientProofKeyThumbprint, + cloudUserId: client.cloudUserId, + ...(client.deviceId ? { deviceId: client.deviceId } : {}), + status: client.status, + client: client.client, + requestedAt: DateTime.formatIso(client.requestedAt), + updatedAt: DateTime.formatIso(client.updatedAt), + approvedAt: client.approvedAt === null ? null : DateTime.formatIso(client.approvedAt), + rejectedAt: client.rejectedAt === null ? null : DateTime.formatIso(client.rejectedAt), + lastSeenAt: client.lastSeenAt === null ? null : DateTime.formatIso(client.lastSeenAt), + }; +} + type ServerAuthGateState = | { status: "authenticated" } | { @@ -501,6 +535,92 @@ export async function revokeOtherServerClientSessions(): Promise { } } +export async function updateServerConnectSecurityMode( + mode: AuthConnectSecurityMode, +): Promise { + try { + const result = await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => + client.auth.connectSecurityMode({ headers: {}, payload: { mode } }), + ), + ), + ); + return result.mode; + } catch (error) { + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "update-connect-security-mode", + cause: error, + }); + } +} + +export async function approveServerConnectClient( + clientProofKeyThumbprint: string, +): Promise { + try { + const result = await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => + client.auth.approveConnectClient({ + headers: {}, + payload: { clientProofKeyThumbprint }, + }), + ), + ), + ); + return result.client === null ? null : toServerConnectClientRecord(result.client); + } catch (error) { + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "approve-connect-client", + cause: error, + }); + } +} + +export async function rejectServerConnectClient( + clientProofKeyThumbprint: string, +): Promise { + try { + const result = await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => + client.auth.rejectConnectClient({ + headers: {}, + payload: { clientProofKeyThumbprint }, + }), + ), + ), + ); + return result.client === null ? null : toServerConnectClientRecord(result.client); + } catch (error) { + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "reject-connect-client", + cause: error, + }); + } +} + +export async function revokeServerConnectClient(clientProofKeyThumbprint: string): Promise { + try { + await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => + client.auth.revokeConnectClient({ + headers: {}, + payload: { clientProofKeyThumbprint }, + }), + ), + ), + ); + } catch (error) { + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-connect-client", + cause: error, + }); + } +} + export async function resolveInitialServerAuthGateState(): Promise { if (resolvedAuthenticatedGateState?.status === "authenticated") { return resolvedAuthenticatedGateState; diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 58342d53054..33311145f04 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -15,6 +15,7 @@ export { export { createServerPairingCredential, + approveServerConnectClient, fetchSessionState, isPrimaryEnvironmentPairingCredentialRejectedError, isPrimaryEnvironmentRequestError, @@ -25,11 +26,16 @@ export { PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, + rejectServerConnectClient, + revokeServerConnectClient, revokeServerClientSession, revokeServerPairingLink, + toServerConnectClientRecord, stripPairingTokenFromUrl, submitServerAuthCredential, + updateServerConnectSecurityMode, takePairingTokenFromUrl, + type ServerConnectClientRecord, type ServerClientSessionRecord, type ServerPairingLinkRecord, __resetServerAuthBootstrapForTests, diff --git a/apps/web/test/environmentHttpTest.ts b/apps/web/test/environmentHttpTest.ts index ce43faacc40..e7537587ec3 100644 --- a/apps/web/test/environmentHttpTest.ts +++ b/apps/web/test/environmentHttpTest.ts @@ -115,7 +115,12 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink")) .handle("clients", () => unexpectedEndpoint("auth.clients")) .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient")) - .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")), + .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")) + .handle("connectSecurityMode", () => unexpectedEndpoint("auth.connectSecurityMode")) + .handle("connectClients", () => unexpectedEndpoint("auth.connectClients")) + .handle("approveConnectClient", () => unexpectedEndpoint("auth.approveConnectClient")) + .handle("rejectConnectClient", () => unexpectedEndpoint("auth.rejectConnectClient")) + .handle("revokeConnectClient", () => unexpectedEndpoint("auth.revokeConnectClient")), ), ]), Effect.provideService(EnvironmentAuthenticatedAuth, authenticatedAuth), diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 63f12379870..5862775fefd 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -8,8 +8,9 @@ import { RelayCloudMintCredentialProofPayload, RelayEnvironmentHealthResponse, RelayEnvironmentHealthResponseProofPayload, + RelayEnvironmentMintAuthorizedResponseProofPayload, + RelayEnvironmentMintPendingApprovalResponseProofPayload, RelayEnvironmentMintResponse, - RelayEnvironmentMintResponseProofPayload, } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; @@ -95,7 +96,7 @@ function decodeRequestProof(proof: string): T { function signMintResponse( request: RelayCloudMintCredentialRequest, - overrides: Partial = {}, + overrides: Partial = {}, privateKey = environmentKeyPair.privateKey, ): RelayEnvironmentMintResponse { const requestProof = decodeRequestProof(request.proof); @@ -111,7 +112,7 @@ function signMintResponse( requestNonce: requestProof.nonce, credential: "pairing_credential", ...overrides, - } satisfies RelayEnvironmentMintResponseProofPayload; + } satisfies RelayEnvironmentMintAuthorizedResponseProofPayload; return { credential: payload.credential, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(payload.exp * 1_000)), @@ -119,6 +120,35 @@ function signMintResponse( }; } +function signPendingMintResponse( + request: RelayCloudMintCredentialRequest, + overrides: Partial = {}, + privateKey = environmentKeyPair.privateKey, +): RelayEnvironmentMintResponse { + const requestProof = decodeRequestProof(request.proof); + const payload = { + iss: `t3-env:${requestProof.environmentId}`, + aud: "https://relay.example.test", + sub: requestProof.environmentId, + jti: "mint-pending-response-jti", + iat: requestProof.iat, + exp: requestProof.exp, + environmentId: requestProof.environmentId, + clientProofKeyThumbprint: requestProof.clientProofKeyThumbprint, + requestNonce: requestProof.nonce, + status: "pending_approval", + approvalStatus: "pending", + ...overrides, + } satisfies RelayEnvironmentMintPendingApprovalResponseProofPayload; + return { + status: "pending_approval", + clientProofKeyThumbprint: payload.clientProofKeyThumbprint, + approvalStatus: payload.approvalStatus, + requestedAt: "2026-06-06T00:00:00.000Z", + proof: signTestJwt(payload, RELAY_MINT_RESPONSE_TYP, privateKey), + }; +} + function signHealthResponse( request: RelayCloudEnvironmentHealthRequest, privateKey = environmentKeyPair.privateKey, @@ -662,6 +692,7 @@ describe("EnvironmentConnector", () => { environmentId: "env-connector-test", clientProofKeyThumbprint: "client-proof-key-thumbprint", deviceId: "device-123", + client: { label: "Mobile", deviceType: "mobile", os: "iOS" }, }); expect(seenUrls).toEqual(["https://env.example.test/api/t3-connect/mint-credential"]); @@ -673,6 +704,7 @@ describe("EnvironmentConnector", () => { clientProofKeyThumbprint: "client-proof-key-thumbprint", cnf: { jkt: "client-proof-key-thumbprint" }, deviceId: "device-123", + client: { label: "Mobile", deviceType: "mobile", os: "iOS" }, scope: ["environment:connect"], }); expect(result).toMatchObject({ @@ -686,6 +718,34 @@ describe("EnvironmentConnector", () => { }).pipe(Effect.provide(connectorTestLayer(execute))); }); + it.effect("returns signed pending approval responses from the linked endpoint", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const mintRequest = decodeMintRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json(signPendingMintResponse(mintRequest), { status: 200 }), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* connector.connect({ + userId: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + }); + + expect(result).toMatchObject({ + status: "pending_approval", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + approvalStatus: "pending", + requestedAt: "2026-06-06T00:00:00.000Z", + }); + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + it.effect("only accepts mint responses signed by the user's linked environment key", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index db662aee94d..60d14baa6a5 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -1,4 +1,5 @@ import { + type AuthClientPresentationMetadata, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, EnvironmentHttpForbiddenError, @@ -12,6 +13,7 @@ import { RelayEnvironmentHealthResponseProofPayload, RelayEnvironmentMintResponse, RelayEnvironmentMintResponseProofPayload, + type RelayEnvironmentMintPendingApprovalResponse, RelayCloudMintCredentialProofPayload, type RelayEnvironmentConnectResponse, type RelayEnvironmentStatusResponse, @@ -149,6 +151,7 @@ export class EnvironmentConnector extends Context.Service< readonly environmentId: string; readonly clientProofKeyThumbprint: string; readonly deviceId?: string; + readonly client?: AuthClientPresentationMetadata; }) => Effect.Effect; readonly status: (input: { readonly userId: string; @@ -241,18 +244,37 @@ function verifyEnvironmentResponse(input: { environmentPublicKeys: input.environmentPublicKeys, decodePayload: decodeMintResponseProof, }).pipe( - Effect.map( - (proof) => - proof !== null && - proof.environmentId === input.environmentId && - proof.requestNonce === input.requestNonce && - proof.clientProofKeyThumbprint === input.clientProofKeyThumbprint && - proof.credential === input.response.credential && - Option.match(DateTime.make(input.response.expiresAt), { + Effect.map((proof) => { + const response = input.response; + if ( + proof === null || + proof.environmentId !== input.environmentId || + proof.requestNonce !== input.requestNonce || + proof.clientProofKeyThumbprint !== input.clientProofKeyThumbprint + ) { + return false; + } + + if (!("credential" in response)) { + return ( + "status" in proof && + proof.status === "pending_approval" && + proof.approvalStatus === response.approvalStatus && + proof.clientProofKeyThumbprint === response.clientProofKeyThumbprint + ); + } + + if ("status" in proof) { + return false; + } + return ( + proof.credential === response.credential && + Option.match(DateTime.make(response.expiresAt), { onNone: () => false, onSome: (expiresAt) => Math.floor(expiresAt.epochMilliseconds / 1_000) === proof.exp, - }), - ), + }) + ); + }), ); } @@ -615,6 +637,7 @@ const make = Effect.gen(function* () { clientProofKeyThumbprint: input.clientProofKeyThumbprint, cnf: { jkt: input.clientProofKeyThumbprint }, ...(input.deviceId ? { deviceId: input.deviceId } : {}), + ...(input.client ? { client: input.client } : {}), nonce, scope: ["environment:connect"], } satisfies RelayCloudMintCredentialProofPayload; @@ -674,6 +697,17 @@ const make = Effect.gen(function* () { operation: "connect", }); } + if (!("credential" in decoded)) { + const pending: RelayEnvironmentMintPendingApprovalResponse = decoded; + return { + status: "pending_approval", + environmentId: link.environmentId, + endpoint, + clientProofKeyThumbprint: pending.clientProofKeyThumbprint, + approvalStatus: pending.approvalStatus, + requestedAt: pending.requestedAt, + }; + } return { environmentId: link.environmentId, endpoint, diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 29e2026de3c..bf075424457 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -650,6 +650,7 @@ export const dpopClientApi = HttpApiBuilder.group( environmentId: params.environmentId, clientProofKeyThumbprint, ...(payload.deviceId ? { deviceId: payload.deviceId } : {}), + ...(payload.client ? { client: payload.client } : {}), }); }, mapRelayCommonApiErrors("invalid_dpop"), diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 0469e459d16..8ca1bd41110 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -179,6 +179,17 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o deviceId: Effect.succeed(Option.some("device-1")), }), ), + Layer.succeed( + ClientCapabilities.ClientPresentation, + ClientCapabilities.ClientPresentation.of({ + metadata: { + label: "Test Client", + deviceType: "desktop", + os: "Test OS", + }, + scopes: [], + }), + ), Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh), Layer.succeed( @@ -305,6 +316,11 @@ describe("ConnectionResolver", () => { readonly clerkToken: string; readonly scopes: ReadonlyArray; readonly deviceId?: string; + readonly client?: { + readonly label?: string; + readonly deviceType?: string; + readonly os?: string; + }; }> >([]); const bootstrapCredentials = yield* Ref.make>([]); @@ -320,6 +336,7 @@ describe("ConnectionResolver", () => { clerkToken: input.clerkToken, scopes: input.scopes, ...(input.deviceId ? { deviceId: input.deviceId } : {}), + ...(input.client ? { client: input.client } : {}), }, ]).pipe( Effect.as({ @@ -354,12 +371,48 @@ describe("ConnectionResolver", () => { clerkToken: "clerk-session", scopes: [RelayEnvironmentConnectScope], deviceId: "device-1", + client: { + label: "Test Client", + deviceType: "desktop", + os: "Test OS", + }, }, ]); expect(yield* Ref.get(bootstrapCredentials)).toEqual(["relay-bootstrap"]); }), ); + it.effect("blocks relay connection attempts that are waiting for environment approval", () => + Effect.gen(function* () { + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + connectEnvironment: (input) => + Effect.succeed({ + status: "pending_approval" as const, + environmentId: input.environmentId, + endpoint: ENDPOINT, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + approvalStatus: "pending" as const, + requestedAt: "2026-06-06T00:00:00.000Z", + }), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + const result = yield* Effect.result(broker.prepare(catalogEntry(target))); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "permission", + detail: "Waiting for this client to be approved in the environment settings.", + }); + } + }), + ); + it.effect("exports the complete relay authorization flow through the product tracer", () => Effect.gen(function* () { const userSpans: Array = []; diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index c219bde092c..3bab38da92a 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -142,6 +142,7 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f const relay = yield* ManagedRelay.ManagedRelayClient; const session = yield* ClientCapabilities.CloudSession; const identity = yield* ClientCapabilities.RelayDeviceIdentity; + const presentation = yield* ClientCapabilities.ClientPresentation; const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fnUntraced( @@ -161,6 +162,7 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f scopes: [RelayEnvironmentConnectScope], environmentId: target.environmentId, ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}), + client: presentation.metadata, }) .pipe(Effect.mapError(mapManagedRelayError)); if (connected.environmentId !== target.environmentId) { @@ -169,6 +171,15 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f actual: connected.environmentId, }); } + if ("status" in connected) { + return yield* new ConnectionBlockedError({ + reason: "permission", + detail: + connected.approvalStatus === "rejected" + ? "This client was rejected by the environment. Approve it in the environment settings to connect." + : "Waiting for this client to be approved in the environment settings.", + }); + } return connected; }).pipe(Effect.withSpan("relay.connection.bootstrap.obtain")), }); diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 08b720b46a3..4055de2b794 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -1,3 +1,4 @@ +import { type AuthClientPresentationMetadata } from "@t3tools/contracts"; import { RelayAccessTokenType, RelayApi, @@ -273,6 +274,7 @@ export class ManagedRelayClient extends Context.Service< readonly scopes: ReadonlyArray; readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; readonly deviceId?: string; + readonly client?: AuthClientPresentationMetadata; }) => Effect.Effect; readonly registerDevice: (input: { readonly clerkToken: string; @@ -783,7 +785,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( (authorization) => { const payload: RelayEnvironmentConnectRequest = { ...(input.deviceId ? { deviceId: input.deviceId } : {}), - clientKeyThumbprint: authorization.thumbprint, + clientProofKeyThumbprint: authorization.thumbprint, + ...(input.client ? { client: input.client } : {}), }; return client.dpopClient .connectEnvironment({ diff --git a/packages/client-runtime/src/state/auth.test.ts b/packages/client-runtime/src/state/auth.test.ts index b31fe617912..f0fa7e26cbb 100644 --- a/packages/client-runtime/src/state/auth.test.ts +++ b/packages/client-runtime/src/state/auth.test.ts @@ -45,6 +45,8 @@ describe("applyAuthAccessStreamEvent", () => { }); expect(withClient).toEqual({ + connectSecurityMode: "account", + connectClients: [], pairingLinks: [pairingLink], clientSessions: [clientSession], }); @@ -53,6 +55,8 @@ describe("applyAuthAccessStreamEvent", () => { it("applies removals without disturbing unrelated access state", () => { const snapshot = applyAuthAccessStreamEvent( { + connectSecurityMode: "client-approval", + connectClients: [], pairingLinks: [ { id: "pairing-link", @@ -74,6 +78,52 @@ describe("applyAuthAccessStreamEvent", () => { }, ); - expect(snapshot).toEqual(EMPTY_AUTH_ACCESS_SNAPSHOT); + expect(snapshot).toEqual({ + ...EMPTY_AUTH_ACCESS_SNAPSHOT, + connectSecurityMode: "client-approval", + }); + }); + + it("applies Connect security mode and client updates", () => { + const connectClient = { + clientProofKeyThumbprint: "thumbprint", + cloudUserId: "user_123", + status: "pending", + client: { + label: "Phone", + deviceType: "mobile", + }, + requestedAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + updatedAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + approvedAt: null, + rejectedAt: null, + lastSeenAt: null, + } as const; + + const withMode = applyAuthAccessStreamEvent(EMPTY_AUTH_ACCESS_SNAPSHOT, { + version: 1, + revision: 1, + type: "connectSecurityModeUpdated", + payload: { mode: "client-approval" }, + }); + const withClient = applyAuthAccessStreamEvent(withMode, { + version: 1, + revision: 2, + type: "connectClientUpserted", + payload: connectClient, + }); + const withoutClient = applyAuthAccessStreamEvent(withClient, { + version: 1, + revision: 3, + type: "connectClientRemoved", + payload: { clientProofKeyThumbprint: "thumbprint" }, + }); + + expect(withClient.connectSecurityMode).toBe("client-approval"); + expect(withClient.connectClients).toEqual([connectClient]); + expect(withoutClient).toEqual({ + ...EMPTY_AUTH_ACCESS_SNAPSHOT, + connectSecurityMode: "client-approval", + }); }); }); diff --git a/packages/client-runtime/src/state/auth.ts b/packages/client-runtime/src/state/auth.ts index 074b89627af..e33f2d2ed70 100644 --- a/packages/client-runtime/src/state/auth.ts +++ b/packages/client-runtime/src/state/auth.ts @@ -12,6 +12,8 @@ import { subscribe } from "../rpc/client.ts"; import { createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; export const EMPTY_AUTH_ACCESS_SNAPSHOT: AuthAccessSnapshot = { + connectSecurityMode: "account", + connectClients: [], pairingLinks: [], clientSessions: [], }; @@ -58,6 +60,27 @@ export function applyAuthAccessStreamEvent( (value) => value.sessionId !== event.payload.sessionId, ), }; + case "connectSecurityModeUpdated": + return { + ...current, + connectSecurityMode: event.payload.mode, + }; + case "connectClientUpserted": + return { + ...current, + connectClients: upsertByKey( + current.connectClients, + event.payload, + (value) => value.clientProofKeyThumbprint, + ), + }; + case "connectClientRemoved": + return { + ...current, + connectClients: current.connectClients.filter( + (value) => value.clientProofKeyThumbprint !== event.payload.clientProofKeyThumbprint, + ), + }; } } diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 70b2899757d..3dc302ec2ff 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -228,6 +228,26 @@ export const AuthClientMetadata = Schema.Struct({ }); export type AuthClientMetadata = typeof AuthClientMetadata.Type; +export const AuthConnectSecurityMode = Schema.Literals(["account", "client-approval"]); +export type AuthConnectSecurityMode = typeof AuthConnectSecurityMode.Type; + +export const AuthConnectClientStatus = Schema.Literals(["pending", "approved", "rejected"]); +export type AuthConnectClientStatus = typeof AuthConnectClientStatus.Type; + +export const AuthConnectClient = Schema.Struct({ + clientProofKeyThumbprint: TrimmedNonEmptyString, + cloudUserId: TrimmedNonEmptyString, + deviceId: Schema.optionalKey(TrimmedNonEmptyString), + status: AuthConnectClientStatus, + client: AuthClientMetadata, + requestedAt: Schema.DateTimeUtc, + updatedAt: Schema.DateTimeUtc, + approvedAt: Schema.NullOr(Schema.DateTimeUtc), + rejectedAt: Schema.NullOr(Schema.DateTimeUtc), + lastSeenAt: Schema.NullOr(Schema.DateTimeUtc), +}); +export type AuthConnectClient = typeof AuthConnectClient.Type; + export const AuthClientSession = Schema.Struct({ sessionId: AuthSessionId, subject: TrimmedNonEmptyString, @@ -243,6 +263,8 @@ export const AuthClientSession = Schema.Struct({ export type AuthClientSession = typeof AuthClientSession.Type; export const AuthAccessSnapshot = Schema.Struct({ + connectSecurityMode: AuthConnectSecurityMode, + connectClients: Schema.Array(AuthConnectClient), pairingLinks: Schema.Array(AuthPairingLink), clientSessions: Schema.Array(AuthClientSession), }); @@ -309,12 +331,46 @@ export const AuthAccessStreamClientRemovedEvent = Schema.Struct({ }); export type AuthAccessStreamClientRemovedEvent = typeof AuthAccessStreamClientRemovedEvent.Type; +export const AuthAccessStreamConnectSecurityModeUpdatedEvent = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + type: Schema.Literal("connectSecurityModeUpdated"), + payload: Schema.Struct({ + mode: AuthConnectSecurityMode, + }), +}); +export type AuthAccessStreamConnectSecurityModeUpdatedEvent = + typeof AuthAccessStreamConnectSecurityModeUpdatedEvent.Type; + +export const AuthAccessStreamConnectClientUpsertedEvent = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + type: Schema.Literal("connectClientUpserted"), + payload: AuthConnectClient, +}); +export type AuthAccessStreamConnectClientUpsertedEvent = + typeof AuthAccessStreamConnectClientUpsertedEvent.Type; + +export const AuthAccessStreamConnectClientRemovedEvent = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + type: Schema.Literal("connectClientRemoved"), + payload: Schema.Struct({ + clientProofKeyThumbprint: TrimmedNonEmptyString, + }), +}); +export type AuthAccessStreamConnectClientRemovedEvent = + typeof AuthAccessStreamConnectClientRemovedEvent.Type; + export const AuthAccessStreamEvent = Schema.Union([ AuthAccessStreamSnapshotEvent, AuthAccessStreamPairingLinkUpsertedEvent, AuthAccessStreamPairingLinkRemovedEvent, AuthAccessStreamClientUpsertedEvent, AuthAccessStreamClientRemovedEvent, + AuthAccessStreamConnectSecurityModeUpdatedEvent, + AuthAccessStreamConnectClientUpsertedEvent, + AuthAccessStreamConnectClientRemovedEvent, ]); export type AuthAccessStreamEvent = typeof AuthAccessStreamEvent.Type; @@ -323,6 +379,16 @@ export const AuthRevokePairingLinkInput = Schema.Struct({ }); export type AuthRevokePairingLinkInput = typeof AuthRevokePairingLinkInput.Type; +export const AuthUpdateConnectSecurityModeInput = Schema.Struct({ + mode: AuthConnectSecurityMode, +}); +export type AuthUpdateConnectSecurityModeInput = typeof AuthUpdateConnectSecurityModeInput.Type; + +export const AuthConnectClientDecisionInput = Schema.Struct({ + clientProofKeyThumbprint: TrimmedNonEmptyString, +}); +export type AuthConnectClientDecisionInput = typeof AuthConnectClientDecisionInput.Type; + export const AuthRevokeClientSessionInput = Schema.Struct({ sessionId: AuthSessionId, }); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index adc5f149cba..3c148d78c9f 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -12,6 +12,9 @@ import { AuthAccessTokenResult, AuthBrowserSessionRequest, AuthBrowserSessionResult, + AuthConnectClient, + AuthConnectClientDecisionInput, + AuthConnectSecurityMode, AuthClientSession, AuthCreatePairingCredentialInput, AuthPairingCredentialResult, @@ -21,6 +24,7 @@ import { AuthEnvironmentScope, AuthTokenExchangeRequest, AuthSessionState, + AuthUpdateConnectSecurityModeInput, AuthWebSocketTicketResult, ServerAuthSessionMethod, } from "./auth.ts"; @@ -79,6 +83,12 @@ export const EnvironmentInternalErrorReason = Schema.Literals([ "pairing_link_revoke_failed", "client_sessions_load_failed", "client_session_revoke_failed", + "connect_security_mode_load_failed", + "connect_security_mode_update_failed", + "connect_clients_load_failed", + "connect_client_approval_failed", + "connect_client_rejection_failed", + "connect_client_revoke_failed", "orchestration_snapshot_failed", "orchestration_dispatch_failed", "internal_error", @@ -317,6 +327,7 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ relayUrl: Schema.NullOr(Schema.String), relayIssuer: Schema.NullOr(Schema.String), publishAgentActivity: Schema.Boolean, + connectSecurityMode: AuthConnectSecurityMode, }); export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; @@ -340,6 +351,21 @@ export const AuthOtherClientSessionsRevokeResult = Schema.Struct({ }); export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type; +export const AuthConnectSecurityModeUpdateResult = Schema.Struct({ + mode: AuthUpdateConnectSecurityModeInput.fields.mode, +}); +export type AuthConnectSecurityModeUpdateResult = typeof AuthConnectSecurityModeUpdateResult.Type; + +export const AuthConnectClientDecisionResult = Schema.Struct({ + client: Schema.NullOr(AuthConnectClient), +}); +export type AuthConnectClientDecisionResult = typeof AuthConnectClientDecisionResult.Type; + +export const AuthConnectClientRevokeResult = Schema.Struct({ + revoked: Schema.Boolean, +}); +export type AuthConnectClientRevokeResult = typeof AuthConnectClientRevokeResult.Type; + export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", { success: ExecutionEnvironmentDescriptor, @@ -420,6 +446,45 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") success: AuthOtherClientSessionsRevokeResult, error: EnvironmentScopedOperationErrors, }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("connectSecurityMode", "/api/auth/connect-security-mode", { + headers: OptionalBearerHeaders, + payload: AuthUpdateConnectSecurityModeInput, + success: AuthConnectSecurityModeUpdateResult, + error: EnvironmentScopedOperationErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("connectClients", "/api/auth/connect-clients", { + headers: OptionalBearerHeaders, + success: Schema.Array(AuthConnectClient), + error: EnvironmentScopedOperationErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("approveConnectClient", "/api/auth/connect-clients/approve", { + headers: OptionalBearerHeaders, + payload: AuthConnectClientDecisionInput, + success: AuthConnectClientDecisionResult, + error: EnvironmentScopedOperationErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("rejectConnectClient", "/api/auth/connect-clients/reject", { + headers: OptionalBearerHeaders, + payload: AuthConnectClientDecisionInput, + success: AuthConnectClientDecisionResult, + error: EnvironmentScopedOperationErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("revokeConnectClient", "/api/auth/connect-clients/revoke", { + headers: OptionalBearerHeaders, + payload: AuthConnectClientDecisionInput, + success: AuthConnectClientRevokeResult, + error: EnvironmentScopedOperationErrors, + }).middleware(EnvironmentAuthenticatedAuth), ) {} export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index dea3709f488..ca17d95e00f 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -10,6 +10,7 @@ import * as OpenApi from "effect/unstable/httpapi/OpenApi"; import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { AuthClientPresentationMetadata, AuthConnectClientStatus } from "./auth.ts"; export const RelayAgentAwarenessPlatform = Schema.Literal("ios"); export type RelayAgentAwarenessPlatform = typeof RelayAgentAwarenessPlatform.Type; @@ -604,6 +605,11 @@ export const RelayEnvironmentConnectRequest = Schema.Struct({ description: "JWK thumbprint that the minted environment credential must be bound to.", }), ), + client: Schema.optional( + AuthClientPresentationMetadata.annotate({ + description: "Optional user-facing client metadata for environment approval prompts.", + }), + ), }).annotate({ description: "Requests a short-lived credential for connecting to an environment." }); export type RelayEnvironmentConnectRequest = typeof RelayEnvironmentConnectRequest.Type; @@ -689,12 +695,30 @@ export const RelayEnvironmentUnlinkParams = Schema.Struct({ }); export type RelayEnvironmentUnlinkParams = typeof RelayEnvironmentUnlinkParams.Type; -export const RelayEnvironmentConnectResponse = Schema.Struct({ +export const RelayEnvironmentConnectAuthorizedResponse = Schema.Struct({ environmentId: EnvironmentId, endpoint: RelayManagedEndpoint, credential: TrimmedNonEmptyString, expiresAt: TrimmedNonEmptyString, }); +export type RelayEnvironmentConnectAuthorizedResponse = + typeof RelayEnvironmentConnectAuthorizedResponse.Type; + +export const RelayEnvironmentConnectPendingApprovalResponse = Schema.Struct({ + status: Schema.Literal("pending_approval"), + environmentId: EnvironmentId, + endpoint: RelayManagedEndpoint, + clientProofKeyThumbprint: TrimmedNonEmptyString, + approvalStatus: AuthConnectClientStatus, + requestedAt: TrimmedNonEmptyString, +}); +export type RelayEnvironmentConnectPendingApprovalResponse = + typeof RelayEnvironmentConnectPendingApprovalResponse.Type; + +export const RelayEnvironmentConnectResponse = Schema.Union([ + RelayEnvironmentConnectAuthorizedResponse, + RelayEnvironmentConnectPendingApprovalResponse, +]); export type RelayEnvironmentConnectResponse = typeof RelayEnvironmentConnectResponse.Type; export const RelayEnvironmentStatusValue = Schema.Literals(["online", "offline"]); @@ -719,6 +743,7 @@ export const RelayCloudMintCredentialProofPayload = Schema.Struct({ jkt: TrimmedNonEmptyString, }), deviceId: Schema.optional(TrimmedNonEmptyString), + client: Schema.optional(AuthClientPresentationMetadata), nonce: TrimmedNonEmptyString, scope: Schema.Array(Schema.Literal("environment:connect")), }); @@ -769,21 +794,56 @@ export const RelayEnvironmentHealthResponse = Schema.Struct({ }); export type RelayEnvironmentHealthResponse = typeof RelayEnvironmentHealthResponse.Type; -export const RelayEnvironmentMintResponseProofPayload = Schema.Struct({ +export const RelayEnvironmentMintAuthorizedResponseProofPayload = Schema.Struct({ ...RelaySignedJwtRegisteredClaims, environmentId: EnvironmentId, clientProofKeyThumbprint: TrimmedNonEmptyString, requestNonce: TrimmedNonEmptyString, credential: TrimmedNonEmptyString, }); +export type RelayEnvironmentMintAuthorizedResponseProofPayload = + typeof RelayEnvironmentMintAuthorizedResponseProofPayload.Type; + +export const RelayEnvironmentMintPendingApprovalResponseProofPayload = Schema.Struct({ + ...RelaySignedJwtRegisteredClaims, + environmentId: EnvironmentId, + clientProofKeyThumbprint: TrimmedNonEmptyString, + requestNonce: TrimmedNonEmptyString, + status: Schema.Literal("pending_approval"), + approvalStatus: AuthConnectClientStatus, +}); +export type RelayEnvironmentMintPendingApprovalResponseProofPayload = + typeof RelayEnvironmentMintPendingApprovalResponseProofPayload.Type; + +export const RelayEnvironmentMintResponseProofPayload = Schema.Union([ + RelayEnvironmentMintAuthorizedResponseProofPayload, + RelayEnvironmentMintPendingApprovalResponseProofPayload, +]); export type RelayEnvironmentMintResponseProofPayload = typeof RelayEnvironmentMintResponseProofPayload.Type; -export const RelayEnvironmentMintResponse = Schema.Struct({ +export const RelayEnvironmentMintAuthorizedResponse = Schema.Struct({ credential: TrimmedNonEmptyString, expiresAt: TrimmedNonEmptyString, proof: TrimmedNonEmptyString, }); +export type RelayEnvironmentMintAuthorizedResponse = + typeof RelayEnvironmentMintAuthorizedResponse.Type; + +export const RelayEnvironmentMintPendingApprovalResponse = Schema.Struct({ + status: Schema.Literal("pending_approval"), + clientProofKeyThumbprint: TrimmedNonEmptyString, + approvalStatus: AuthConnectClientStatus, + requestedAt: TrimmedNonEmptyString, + proof: TrimmedNonEmptyString, +}); +export type RelayEnvironmentMintPendingApprovalResponse = + typeof RelayEnvironmentMintPendingApprovalResponse.Type; + +export const RelayEnvironmentMintResponse = Schema.Union([ + RelayEnvironmentMintAuthorizedResponse, + RelayEnvironmentMintPendingApprovalResponse, +]); export type RelayEnvironmentMintResponse = typeof RelayEnvironmentMintResponse.Type; export const RelayDeliveryKind = Schema.Literals([ From f09ee2dd7c125c0e1f94a1fab9e88827ff75a138 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 27 Jun 2026 07:36:59 -0700 Subject: [PATCH 2/7] Fix Connect approval race handling --- .../src/auth/ConnectClientStore.test.ts | 117 ++++++++++++++++++ apps/server/src/auth/ConnectClientStore.ts | 14 ++- .../persistence/AuthConnectClients.test.ts | 59 +++++++++ .../src/persistence/AuthConnectClients.ts | 13 +- 4 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/auth/ConnectClientStore.test.ts create mode 100644 apps/server/src/persistence/AuthConnectClients.test.ts diff --git a/apps/server/src/auth/ConnectClientStore.test.ts b/apps/server/src/auth/ConnectClientStore.test.ts new file mode 100644 index 00000000000..6cb66ef7970 --- /dev/null +++ b/apps/server/src/auth/ConnectClientStore.test.ts @@ -0,0 +1,117 @@ +import { expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as AuthConnectClients from "../persistence/AuthConnectClients.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import * as ConnectClientStore from "./ConnectClientStore.ts"; + +const textEncoder = new TextEncoder(); +const requestedAt = DateTime.makeUnsafe("2026-06-27T12:00:00.000Z"); +const approvedAt = DateTime.makeUnsafe("2026-06-27T12:05:00.000Z"); +const rejectedAt = DateTime.makeUnsafe("2026-06-27T12:06:00.000Z"); + +const approvedRecord: AuthConnectClients.AuthConnectClientRecord = { + clientProofKeyThumbprint: "client-thumbprint", + cloudUserId: "cloud-user", + deviceId: "device-1", + status: "approved", + client: { + label: "Client", + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: "macOS", + browser: null, + }, + requestedAt, + updatedAt: approvedAt, + approvedAt, + rejectedAt: null, + revokedAt: null, + lastSeenAt: null, +}; + +const secretStoreLayer = Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeed(Option.some(textEncoder.encode("client-approval"))), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: () => Effect.void, + }), +); + +const makeStoreLayer = ( + overrides: Partial, +) => + Layer.effect(ConnectClientStore.ConnectClientStore, ConnectClientStore.make).pipe( + Layer.provide(secretStoreLayer), + Layer.provide( + Layer.succeed( + AuthConnectClients.AuthConnectClientRepository, + AuthConnectClients.AuthConnectClientRepository.of({ + upsertRequest: () => Effect.succeed(approvedRecord), + updateStatus: () => Effect.succeed(Option.none()), + revoke: () => Effect.succeed(false), + markSeen: () => Effect.succeed(Option.some(approvedRecord)), + listActive: () => Effect.succeed([]), + ...overrides, + }), + ), + ), + ); + +it.effect("returns rejected when an approved client is rejected before last-seen update", () => + Effect.gen(function* () { + const store = yield* ConnectClientStore.ConnectClientStore; + const authorization = yield* store.requestClient({ + cloudUserId: "cloud-user", + clientProofKeyThumbprint: "client-thumbprint", + }); + + expect(authorization.mode).toBe("client-approval"); + expect(authorization.status).toBe("rejected"); + }).pipe( + Effect.provide( + makeStoreLayer({ + markSeen: () => + Effect.succeed( + Option.some({ + ...approvedRecord, + status: "rejected", + updatedAt: rejectedAt, + rejectedAt, + }), + ), + }), + ), + ), +); + +it.effect("returns pending when an approved client is revoked before last-seen update", () => + Effect.gen(function* () { + const store = yield* ConnectClientStore.ConnectClientStore; + const authorization = yield* store.requestClient({ + cloudUserId: "cloud-user", + clientProofKeyThumbprint: "client-thumbprint", + }); + + expect(authorization.mode).toBe("client-approval"); + expect(authorization.status).toBe("pending"); + if (authorization.mode === "client-approval") { + expect(authorization.client.status).toBe("pending"); + expect(authorization.client.approvedAt).toBeNull(); + expect(authorization.client.lastSeenAt).toBeNull(); + } + }).pipe( + Effect.provide( + makeStoreLayer({ + markSeen: () => Effect.succeed(Option.none()), + }), + ), + ), +); diff --git a/apps/server/src/auth/ConnectClientStore.ts b/apps/server/src/auth/ConnectClientStore.ts index 5c93f36c898..c14b075c371 100644 --- a/apps/server/src/auth/ConnectClientStore.ts +++ b/apps/server/src/auth/ConnectClientStore.ts @@ -330,10 +330,20 @@ export const make = Effect.gen(function* () { if (Option.isSome(seen)) { const seenClient = toAuthConnectClient(seen.value); yield* emitUpsert(seenClient); - return { mode, status: "approved" as const, client: seenClient }; + return { mode, status: seen.value.status, client: seenClient }; } - return { mode, status: "approved" as const, client: visibleClient }; + return { + mode, + status: "pending" as const, + client: { + ...visibleClient, + status: "pending" as const, + updatedAt: DateTime.toUtc(seenAt), + approvedAt: null, + lastSeenAt: null, + }, + }; }); const updateDecision = ( diff --git a/apps/server/src/persistence/AuthConnectClients.test.ts b/apps/server/src/persistence/AuthConnectClients.test.ts new file mode 100644 index 00000000000..62924333055 --- /dev/null +++ b/apps/server/src/persistence/AuthConnectClients.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as AuthConnectClients from "./AuthConnectClients.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; + +const layer = AuthConnectClients.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)); + +const client = { + label: "Client", + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: "macOS", + browser: null, +} satisfies AuthConnectClients.AuthConnectClientMetadataRecord; + +it.effect("clears stale last-seen timestamps when a revoked client re-registers", () => + Effect.gen(function* () { + const clients = yield* AuthConnectClients.AuthConnectClientRepository; + const clientProofKeyThumbprint = "client-thumbprint"; + + yield* clients.upsertRequest({ + clientProofKeyThumbprint, + cloudUserId: "cloud-user", + deviceId: "device-1", + client, + requestedAt: DateTime.makeUnsafe("2026-06-27T12:00:00.000Z"), + }); + yield* clients.updateStatus({ + clientProofKeyThumbprint, + status: "approved", + decidedAt: DateTime.makeUnsafe("2026-06-27T12:01:00.000Z"), + }); + const seen = yield* clients.markSeen({ + clientProofKeyThumbprint, + seenAt: DateTime.makeUnsafe("2026-06-27T12:02:00.000Z"), + }); + expect(Option.isSome(seen) ? seen.value.lastSeenAt : null).not.toBeNull(); + + yield* clients.revoke({ + clientProofKeyThumbprint, + revokedAt: DateTime.makeUnsafe("2026-06-27T12:03:00.000Z"), + }); + const reregistered = yield* clients.upsertRequest({ + clientProofKeyThumbprint, + cloudUserId: "cloud-user", + deviceId: "device-1", + client, + requestedAt: DateTime.makeUnsafe("2026-06-27T12:04:00.000Z"), + }); + + expect(reregistered.status).toBe("pending"); + expect(reregistered.lastSeenAt).toBeNull(); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/persistence/AuthConnectClients.ts b/apps/server/src/persistence/AuthConnectClients.ts index 57cfac9cb50..2c75c354cf1 100644 --- a/apps/server/src/persistence/AuthConnectClients.ts +++ b/apps/server/src/persistence/AuthConnectClients.ts @@ -256,6 +256,10 @@ export const make = Effect.gen(function* () { WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.rejected_at ELSE NULL END, + last_seen_at = CASE + WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.last_seen_at + ELSE NULL + END, revoked_at = NULL RETURNING ${sql.unsafe(rowSelection)} `, @@ -272,6 +276,10 @@ export const make = Effect.gen(function* () { updated_at = ${decidedAt}, approved_at = CASE WHEN ${status} = 'approved' THEN ${decidedAt} ELSE approved_at END, rejected_at = CASE WHEN ${status} = 'rejected' THEN ${decidedAt} ELSE rejected_at END, + last_seen_at = CASE + WHEN ${status} = 'approved' AND status = 'approved' THEN last_seen_at + ELSE NULL + END, revoked_at = NULL WHERE client_proof_key_thumbprint = ${clientProofKeyThumbprint} AND revoked_at IS NULL @@ -301,10 +309,9 @@ export const make = Effect.gen(function* () { sql` UPDATE auth_connect_clients SET - last_seen_at = ${seenAt}, - updated_at = ${seenAt} + last_seen_at = CASE WHEN status = 'approved' THEN ${seenAt} ELSE last_seen_at END, + updated_at = CASE WHEN status = 'approved' THEN ${seenAt} ELSE updated_at END WHERE client_proof_key_thumbprint = ${clientProofKeyThumbprint} - AND status = 'approved' AND revoked_at IS NULL RETURNING ${sql.unsafe(rowSelection)} `, From 136150d32fadbca37c9a0360f750d7a974bcb06a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 27 Jun 2026 07:48:27 -0700 Subject: [PATCH 3/7] Address Connect approval review findings --- .../src/auth/ConnectClientStore.test.ts | 49 ++++++++++--- apps/server/src/auth/ConnectClientStore.ts | 19 ++++-- .../persistence/AuthConnectClients.test.ts | 68 +++++++++++++++++++ .../src/persistence/AuthConnectClients.ts | 20 ++++-- .../settings/ConnectionsSettings.tsx | 37 +++++++--- 5 files changed, 161 insertions(+), 32 deletions(-) diff --git a/apps/server/src/auth/ConnectClientStore.test.ts b/apps/server/src/auth/ConnectClientStore.test.ts index 6cb66ef7970..581f542bf65 100644 --- a/apps/server/src/auth/ConnectClientStore.test.ts +++ b/apps/server/src/auth/ConnectClientStore.test.ts @@ -34,16 +34,36 @@ const approvedRecord: AuthConnectClients.AuthConnectClientRecord = { lastSeenAt: null, }; -const secretStoreLayer = Layer.succeed( - ServerSecretStore.ServerSecretStore, - ServerSecretStore.ServerSecretStore.of({ - get: () => Effect.succeed(Option.some(textEncoder.encode("client-approval"))), - set: () => Effect.void, - create: () => Effect.void, - getOrCreateRandom: () => Effect.succeed(new Uint8Array()), - remove: () => Effect.void, - }), -); +const makeSecretStoreLayer = (mode: string) => + Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeed(Option.some(textEncoder.encode(mode))), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: () => Effect.void, + }), + ); + +const secretStoreLayer = makeSecretStoreLayer("client-approval"); + +const makeStoreOnlyLayer = (mode: string) => + Layer.effect(ConnectClientStore.ConnectClientStore, ConnectClientStore.make).pipe( + Layer.provide(makeSecretStoreLayer(mode)), + Layer.provide( + Layer.succeed( + AuthConnectClients.AuthConnectClientRepository, + AuthConnectClients.AuthConnectClientRepository.of({ + upsertRequest: () => Effect.succeed(approvedRecord), + updateStatus: () => Effect.succeed(Option.none()), + revoke: () => Effect.succeed(false), + markSeen: () => Effect.succeed(Option.some(approvedRecord)), + listActive: () => Effect.succeed([]), + }), + ), + ), + ); const makeStoreLayer = ( overrides: Partial, @@ -65,6 +85,15 @@ const makeStoreLayer = ( ), ); +it.effect("fails closed when persisted security mode is invalid", () => + Effect.gen(function* () { + const store = yield* ConnectClientStore.ConnectClientStore; + const error = yield* Effect.flip(store.getSecurityMode()); + + expect(error._tag).toBe("ConnectSecurityModeLoadError"); + }).pipe(Effect.provide(makeStoreOnlyLayer("invalid-mode"))), +); + it.effect("returns rejected when an approved client is rejected before last-seen update", () => Effect.gen(function* () { const store = yield* ConnectClientStore.ConnectClientStore; diff --git a/apps/server/src/auth/ConnectClientStore.ts b/apps/server/src/auth/ConnectClientStore.ts index c14b075c371..9c9debe823b 100644 --- a/apps/server/src/auth/ConnectClientStore.ts +++ b/apps/server/src/auth/ConnectClientStore.ts @@ -222,9 +222,18 @@ function fromPresentationMetadata( }; } -function decodeSecurityMode(bytes: Uint8Array): AuthConnectSecurityMode { +function decodeSecurityMode( + bytes: Uint8Array, +): Effect.Effect { const value = textDecoder.decode(bytes).trim(); - return value === "client-approval" ? "client-approval" : "account"; + if (value === "account" || value === "client-approval") { + return Effect.succeed(value); + } + return Effect.fail( + new ConnectSecurityModeLoadError({ + cause: new Error(`Invalid Connect security mode: ${value}`), + }), + ); } function encodeSecurityMode(mode: AuthConnectSecurityMode): Uint8Array { @@ -256,10 +265,10 @@ export const make = Effect.gen(function* () { const getSecurityMode: ConnectClientStore["Service"]["getSecurityMode"] = () => secrets.get(CONNECT_SECURITY_MODE_SECRET).pipe( - Effect.map((mode) => - Option.isSome(mode) ? decodeSecurityMode(mode.value) : ("account" as const), - ), Effect.mapError((cause) => new ConnectSecurityModeLoadError({ cause })), + Effect.flatMap((mode) => + Option.isSome(mode) ? decodeSecurityMode(mode.value) : Effect.succeed("account" as const), + ), Effect.withSpan("ConnectClientStore.getSecurityMode"), ); diff --git a/apps/server/src/persistence/AuthConnectClients.test.ts b/apps/server/src/persistence/AuthConnectClients.test.ts index 62924333055..45612894a46 100644 --- a/apps/server/src/persistence/AuthConnectClients.test.ts +++ b/apps/server/src/persistence/AuthConnectClients.test.ts @@ -57,3 +57,71 @@ it.effect("clears stale last-seen timestamps when a revoked client re-registers" expect(reregistered.lastSeenAt).toBeNull(); }).pipe(Effect.provide(layer)), ); + +it.effect("resets approval when the same proof key is requested by a different cloud user", () => + Effect.gen(function* () { + const clients = yield* AuthConnectClients.AuthConnectClientRepository; + const clientProofKeyThumbprint = "client-thumbprint-cloud-user-change"; + + yield* clients.upsertRequest({ + clientProofKeyThumbprint, + cloudUserId: "cloud-user-a", + deviceId: "device-1", + client, + requestedAt: DateTime.makeUnsafe("2026-06-27T13:00:00.000Z"), + }); + yield* clients.updateStatus({ + clientProofKeyThumbprint, + status: "approved", + decidedAt: DateTime.makeUnsafe("2026-06-27T13:01:00.000Z"), + }); + yield* clients.markSeen({ + clientProofKeyThumbprint, + seenAt: DateTime.makeUnsafe("2026-06-27T13:02:00.000Z"), + }); + + const reregistered = yield* clients.upsertRequest({ + clientProofKeyThumbprint, + cloudUserId: "cloud-user-b", + deviceId: "device-1", + client, + requestedAt: DateTime.makeUnsafe("2026-06-27T13:03:00.000Z"), + }); + + expect(reregistered.cloudUserId).toBe("cloud-user-b"); + expect(reregistered.status).toBe("pending"); + expect(reregistered.approvedAt).toBeNull(); + expect(reregistered.rejectedAt).toBeNull(); + expect(reregistered.lastSeenAt).toBeNull(); + }).pipe(Effect.provide(layer)), +); + +it.effect("clears the opposite decision timestamp when approval status changes", () => + Effect.gen(function* () { + const clients = yield* AuthConnectClients.AuthConnectClientRepository; + const clientProofKeyThumbprint = "client-thumbprint-status-flip"; + + yield* clients.upsertRequest({ + clientProofKeyThumbprint, + cloudUserId: "cloud-user", + deviceId: "device-1", + client, + requestedAt: DateTime.makeUnsafe("2026-06-27T14:00:00.000Z"), + }); + const rejected = yield* clients.updateStatus({ + clientProofKeyThumbprint, + status: "rejected", + decidedAt: DateTime.makeUnsafe("2026-06-27T14:01:00.000Z"), + }); + expect(Option.isSome(rejected) ? rejected.value.rejectedAt : null).not.toBeNull(); + expect(Option.isSome(rejected) ? rejected.value.approvedAt : null).toBeNull(); + + const approved = yield* clients.updateStatus({ + clientProofKeyThumbprint, + status: "approved", + decidedAt: DateTime.makeUnsafe("2026-06-27T14:02:00.000Z"), + }); + expect(Option.isSome(approved) ? approved.value.approvedAt : null).not.toBeNull(); + expect(Option.isSome(approved) ? approved.value.rejectedAt : null).toBeNull(); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/persistence/AuthConnectClients.ts b/apps/server/src/persistence/AuthConnectClients.ts index 2c75c354cf1..15645a675f8 100644 --- a/apps/server/src/persistence/AuthConnectClients.ts +++ b/apps/server/src/persistence/AuthConnectClients.ts @@ -237,7 +237,9 @@ export const make = Effect.gen(function* () { cloud_user_id = excluded.cloud_user_id, device_id = excluded.device_id, status = CASE - WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.status + WHEN auth_connect_clients.revoked_at IS NULL + AND auth_connect_clients.cloud_user_id = excluded.cloud_user_id + THEN auth_connect_clients.status ELSE 'pending' END, client_label = excluded.client_label, @@ -249,15 +251,21 @@ export const make = Effect.gen(function* () { requested_at = excluded.requested_at, updated_at = excluded.updated_at, approved_at = CASE - WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.approved_at + WHEN auth_connect_clients.revoked_at IS NULL + AND auth_connect_clients.cloud_user_id = excluded.cloud_user_id + THEN auth_connect_clients.approved_at ELSE NULL END, rejected_at = CASE - WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.rejected_at + WHEN auth_connect_clients.revoked_at IS NULL + AND auth_connect_clients.cloud_user_id = excluded.cloud_user_id + THEN auth_connect_clients.rejected_at ELSE NULL END, last_seen_at = CASE - WHEN auth_connect_clients.revoked_at IS NULL THEN auth_connect_clients.last_seen_at + WHEN auth_connect_clients.revoked_at IS NULL + AND auth_connect_clients.cloud_user_id = excluded.cloud_user_id + THEN auth_connect_clients.last_seen_at ELSE NULL END, revoked_at = NULL @@ -274,8 +282,8 @@ export const make = Effect.gen(function* () { SET status = ${status}, updated_at = ${decidedAt}, - approved_at = CASE WHEN ${status} = 'approved' THEN ${decidedAt} ELSE approved_at END, - rejected_at = CASE WHEN ${status} = 'rejected' THEN ${decidedAt} ELSE rejected_at END, + approved_at = CASE WHEN ${status} = 'approved' THEN ${decidedAt} ELSE NULL END, + rejected_at = CASE WHEN ${status} = 'rejected' THEN ${decidedAt} ELSE NULL END, last_seen_at = CASE WHEN ${status} = 'approved' AND status = 'approved' THEN last_seen_at ELSE NULL diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e4df355d42e..c8983361b7a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1077,6 +1077,7 @@ const ConnectClientListRow = memo(function ConnectClientListRow({ const pendingApproveKey = `approve:${connectClient.clientProofKeyThumbprint}`; const pendingRejectKey = `reject:${connectClient.clientProofKeyThumbprint}`; const pendingRevokeKey = `revoke:${connectClient.clientProofKeyThumbprint}`; + const hasPendingAction = pendingActionKey !== null; const statusConfig = connectClient.status === "approved" ? { @@ -1139,7 +1140,7 @@ const ConnectClientListRow = memo(function ConnectClientListRow({ + + + ) : ( + void handleUpdateConnectSecurityMode(checked)} + aria-label="Require T3 Connect client approval" + /> + ) } /> ) : null; From a81b18d6e36f30b17edb9d3c5d6f99633f8f958a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 27 Jun 2026 08:21:02 -0700 Subject: [PATCH 6/7] Authenticate Connect pending approval timestamp --- apps/server/src/cloud/http.ts | 3 +- .../environments/EnvironmentConnector.test.ts | 37 ++++++++++++++++++- .../src/environments/EnvironmentConnector.ts | 3 +- packages/contracts/src/relay.ts | 1 + 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 1461a3751fe..3a1f5eea5e3 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -902,6 +902,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") requestNonce: proof.nonce, status: "pending_approval", approvalStatus: connectAuthorization.status, + requestedAt: DateTime.formatIso(connectAuthorization.client.requestedAt), } satisfies RelayEnvironmentMintResponseProofPayload; const responseProof = yield* signRelayJwt({ privateKey: keyPair.privateKey, @@ -919,7 +920,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") status: "pending_approval", clientProofKeyThumbprint: proof.clientProofKeyThumbprint, approvalStatus: connectAuthorization.status, - requestedAt: DateTime.formatIso(connectAuthorization.client.requestedAt), + requestedAt: responsePayload.requestedAt, proof: responseProof, } satisfies RelayEnvironmentMintResponseShape; diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 5862775fefd..f05b1dec055 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -124,6 +124,7 @@ function signPendingMintResponse( request: RelayCloudMintCredentialRequest, overrides: Partial = {}, privateKey = environmentKeyPair.privateKey, + responseOverrides: Partial = {}, ): RelayEnvironmentMintResponse { const requestProof = decodeRequestProof(request.proof); const payload = { @@ -138,14 +139,16 @@ function signPendingMintResponse( requestNonce: requestProof.nonce, status: "pending_approval", approvalStatus: "pending", + requestedAt: "2026-06-06T00:00:00.000Z", ...overrides, } satisfies RelayEnvironmentMintPendingApprovalResponseProofPayload; return { status: "pending_approval", clientProofKeyThumbprint: payload.clientProofKeyThumbprint, approvalStatus: payload.approvalStatus, - requestedAt: "2026-06-06T00:00:00.000Z", + requestedAt: payload.requestedAt, proof: signTestJwt(payload, RELAY_MINT_RESPONSE_TYP, privateKey), + ...responseOverrides, }; } @@ -746,6 +749,38 @@ describe("EnvironmentConnector", () => { }).pipe(Effect.provide(connectorTestLayer(execute))); }); + it.effect("rejects pending approval responses with a tampered requestedAt", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const mintRequest = decodeMintRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json( + signPendingMintResponse(mintRequest, {}, environmentKeyPair.privateKey, { + requestedAt: "2026-06-06T00:01:00.000Z", + }), + { status: 200 }, + ), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.exit( + connector.connect({ + userId: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); + } + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + it.effect("only accepts mint responses signed by the user's linked environment key", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index 60d14baa6a5..2589f8e6754 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -260,7 +260,8 @@ function verifyEnvironmentResponse(input: { "status" in proof && proof.status === "pending_approval" && proof.approvalStatus === response.approvalStatus && - proof.clientProofKeyThumbprint === response.clientProofKeyThumbprint + proof.clientProofKeyThumbprint === response.clientProofKeyThumbprint && + proof.requestedAt === response.requestedAt ); } diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index ca17d95e00f..9faf5c21d6b 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -811,6 +811,7 @@ export const RelayEnvironmentMintPendingApprovalResponseProofPayload = Schema.St requestNonce: TrimmedNonEmptyString, status: Schema.Literal("pending_approval"), approvalStatus: AuthConnectClientStatus, + requestedAt: TrimmedNonEmptyString, }); export type RelayEnvironmentMintPendingApprovalResponseProofPayload = typeof RelayEnvironmentMintPendingApprovalResponseProofPayload.Type; From 00ead3f6520f9b20638feea201efdbe6fbe858c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 27 Jun 2026 08:25:11 -0700 Subject: [PATCH 7/7] Tighten Connect client error causes --- apps/server/src/auth/ConnectClientStore.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/auth/ConnectClientStore.ts b/apps/server/src/auth/ConnectClientStore.ts index 17361b7c14c..09ad8f34f7c 100644 --- a/apps/server/src/auth/ConnectClientStore.ts +++ b/apps/server/src/auth/ConnectClientStore.ts @@ -22,13 +22,14 @@ const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const connectClientInternalErrorContext = { - cause: Schema.optional(Schema.Defect()), + cause: Schema.Defect(), }; export class ConnectSecurityModeLoadError extends Schema.TaggedErrorClass()( "ConnectSecurityModeLoadError", { ...connectClientInternalErrorContext, + cause: Schema.optional(Schema.Defect()), invalidValue: Schema.optional(Schema.String), }, ) {