diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 54d7a75aa361b..8672eb36298df 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -3,7 +3,13 @@ import * as NodeCrypto from "node:crypto"; import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { authScopeResponse, AuthStandardClientScopes } from "@t3tools/contracts"; +import { + AuthAccessWriteScope, + AuthOrchestrationOperateScope, + AuthRelayWriteScope, + authScopeResponse, + AuthStandardClientScopes, +} from "@t3tools/contracts"; import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, @@ -90,7 +96,7 @@ const makeDpopKey = () => { sign: (input: { readonly method: string; readonly url: string; - readonly token: string; + readonly token?: string; readonly iat: number; }) => { const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); @@ -100,7 +106,7 @@ const makeDpopKey = () => { htu: input.url, jti: `proof-${++proofCount}`, iat: input.iat, - ath: computeDpopAccessTokenHash(input.token), + ...(input.token ? { ath: computeDpopAccessTokenHash(input.token) } : {}), }); const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { key: privateKey, @@ -808,6 +814,76 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer))( ), ); + it.effect("mints a key-bound LAN credential that cannot outlive or outrank its session", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const key = makeDpopKey(); + const host = "desk.tail1234.ts.net"; + const url = `https://${host}/api/auth/lan-credential`; + const iat = Math.floor((yield* DateTime.now).epochMilliseconds / 1_000); + const mint = (authorization: string, dpop?: string) => + serverAuth.issueLanCredential( + HttpServerRequest.fromWeb( + new Request(url, { + method: "POST", + headers: { + host, + "x-forwarded-proto": "https", + authorization, + ...(dpop === undefined ? {} : { dpop }), + }, + }), + ), + ); + const parent = yield* sessions.issue({ + method: "bearer-access-token", + subject: "paired-phone", + scopes: AuthStandardClientScopes, + client: { label: "Phone", deviceType: "mobile" }, + }); + + const unproven = yield* Effect.flip(mint(`Bearer ${parent.token}`)); + expect(unproven._tag).toBe("ServerAuthInvalidCredentialError"); + + const issued = yield* mint( + `Bearer ${parent.token}`, + key.sign({ method: "POST", url, iat }), + ); + expect(issued.token_type).toBe("DPoP"); + const lan = yield* sessions.verify(issued.access_token); + expect(lan.proofKeyThumbprint).toBe(key.thumbprint); + expect(lan.scopes).not.toContain(AuthAccessWriteScope); + expect(lan.scopes).not.toContain(AuthRelayWriteScope); + expect(lan.scopes).toContain(AuthOrchestrationOperateScope); + expect(lan.client.label).toBe("Phone (local network)"); + expect(DateTime.toEpochMillis(lan.expiresAt!)).toBeLessThanOrEqual( + DateTime.toEpochMillis(parent.expiresAt), + ); + + const asBearer = yield* Effect.flip( + serverAuth.authenticateHttpRequest(makeBearerRequest(issued.access_token)), + ); + expect(asBearer._tag).toBe("ServerAuthInvalidCredentialError"); + + const chained = yield* Effect.flip( + mint( + `DPoP ${issued.access_token}`, + key.sign({ method: "POST", url, token: issued.access_token, iat }), + ), + ); + expect(chained._tag).toBe("ServerAuthLanCredentialNotAllowedError"); + + yield* sessions.revoke(parent.sessionId); + const revoked = yield* Effect.flip(sessions.verify(issued.access_token)); + expect(revoked._tag).toBe("SessionTokenRevokedError"); + const remaining = yield* sessions.listActive(); + expect(remaining.some((session) => session.sessionId === lan.sessionId)).toBe(false); + }).pipe( + Effect.provide(layerEnvironmentAuth({ desktopBootstrapToken: "desktop-bootstrap-token" })), + ), + ); + it.effect("rejects a replayed proof on a bound RPC socket ticket", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 7af16657da7aa..accb0b882be2d 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -3,6 +3,7 @@ import { AuthAccessWriteScope, AuthFilesystemReadScope, AuthOrchestrationOperateScope, + AuthRelayWriteScope, AuthStandardClientScopes, type AuthAccessTokenResult, type AuthBrowserSessionResult, @@ -443,6 +444,15 @@ export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError()( + "ServerAuthLanCredentialNotAllowedError", + {}, +) { + override get message(): string { + return "Only a paired bearer session can mint a LAN credential."; + } +} + export class ServerAuthForbiddenOperationError extends Schema.TaggedError()( "ServerAuthForbiddenOperationError", {}, @@ -483,6 +493,12 @@ export class EnvironmentAuth extends Context.Service< AuthAccessTokenResult, ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError >; + readonly issueLanCredential: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect< + AuthAccessTokenResult, + ServerAuthLanCredentialNotAllowedError | ServerAuthCredentialError | ServerAuthInternalError + >; readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; readonly label?: string; @@ -1017,6 +1033,61 @@ export const make = Effect.gen(function* () { ); }; + const issueLanCredential: EnvironmentAuth["Service"]["issueLanCredential"] = Effect.fn( + "EnvironmentAuth.issueLanCredential", + )(function* (request) { + const authenticated = yield* authenticateRequest(request); + const token = resolveRequestCredential(request)?.token; + if (authenticated.method !== "bearer-access-token" || token === undefined) { + return yield* new ServerAuthLanCredentialNotAllowedError({}); + } + const parent = yield* sessions.verify(token).pipe(mapSessionVerificationErrors); + if (parent.peerLink === true) { + return yield* new ServerAuthLanCredentialNotAllowedError({}); + } + const scopes = parent.scopes.filter( + (scope) => + scope !== AuthAccessWriteScope && + scope !== AuthRelayWriteScope && + (AuthStandardClientScopes as ReadonlyArray).includes(scope), + ); + if (scopes.length === 0 || parent.expiresAt === undefined) { + return yield* new ServerAuthLanCredentialNotAllowedError({}); + } + const proofKeyThumbprint = yield* verifyRequestDpopProof({ request }).pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), + Effect.provideService(Digest.Digest, digest), + ); + const now = yield* DateTime.now; + const session = yield* sessions + .issue({ + method: "dpop-access-token", + subject: SessionStore.lanCredentialSubject(parent.sessionId), + scopes, + proofKeyThumbprint, + ttl: Duration.millis(parent.expiresAt.epochMilliseconds - now.epochMilliseconds), + replaceActiveForSubjectAndMethod: true, + client: { + ...parent.client, + label: `${parent.client.label ?? "Client"} (local network)`, + }, + ...(parent.runtimeModeCeiling ? { runtimeModeCeiling: parent.runtimeModeCeiling } : {}), + }) + .pipe( + Effect.mapError((cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause })), + ); + return { + access_token: session.token, + issued_token_type: AuthAccessTokenType, + token_type: "DPoP", + expires_in: Math.max( + 0, + Math.floor((session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000), + ), + scope: encodeOAuthScope(session.scopes), + } satisfies AuthAccessTokenResult; + }); + const toPairingCredential = (issued: IssuedPairingLink): AuthPairingCredentialResult => ({ id: issued.id, credential: issued.credential, @@ -1391,6 +1462,7 @@ export const make = Effect.gen(function* () { getSessionState, createBrowserSession, exchangeBootstrapCredentialForAccessToken, + issueLanCredential, createPairingLink, issuePairingCredential, issueStartupPairingCredential, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index a14650e76a37c..edbca9f7a83aa 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -287,6 +287,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer))( }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), ); + it.effect("closes a LAN credential's socket when its parent session is revoked", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const parent = yield* sessions.issue({ + subject: "paired-phone", + method: "bearer-access-token", + }); + const lan = yield* sessions.issue({ + ...relaySessionInput, + subject: SessionStore.lanCredentialSubject(parent.sessionId), + }); + const ticket = yield* sessions.issueWebSocketToken(lan.sessionId); + const watcher = yield* sessions + .awaitInvalidation(lan.sessionId, relaySessionInput.proofKeyThumbprint) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* sessions.revoke(parent.sessionId); + yield* Fiber.join(watcher); + const socket = yield* Effect.flip(sessions.verifyWebSocketToken(ticket.token)); + expect(socket._tag).toBe("WebSocketSessionRevokedError"); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), + ); + it.effect("closes a DPoP socket when an earlier renewal in its chain is revoked", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 5321675452d6f..222dbd6eaa7c7 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -426,6 +426,13 @@ export class SessionStore extends Context.Service< >()("t3/auth/SessionStore") {} export const SIGNING_SECRET_NAME = "server-signing-key"; +const LAN_CREDENTIAL_SUBJECT_PREFIX = "lan-credential:"; +export const lanCredentialSubject = (parentSessionId: AuthSessionId) => + `${LAN_CREDENTIAL_SUBJECT_PREFIX}${parentSessionId}`; +const lanCredentialParent = (subject: string) => + subject.startsWith(LAN_CREDENTIAL_SUBJECT_PREFIX) + ? AuthSessionId.make(subject.slice(LAN_CREDENTIAL_SUBJECT_PREFIX.length)) + : undefined; const DEFAULT_SESSION_TTL = Duration.days(30); const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); @@ -639,6 +646,36 @@ const make = Effect.gen(function* () { Effect.withSpan("SessionStore.markDisconnected"), ); + const revokeLanCredentials = Effect.fn("SessionStore.revokeLanCredentials")( + function* (parentSessionIds: ReadonlyArray) { + if (parentSessionIds.length === 0) return; + const subjects = new Set(parentSessionIds.map(lanCredentialSubject)); + const connected = yield* Ref.get(connectedSessionsRef); + const rows = yield* authSessions.listActive({ + now: yield* DateTime.now, + connectedSessionIds: Array.from(connected.keys()), + }); + yield* Effect.forEach( + rows.filter((row) => subjects.has(row.subject)), + (row) => revoke(row.sessionId), + { discard: true }, + ); + }, + Effect.catchCause((cause) => + Effect.logWarning("Failed to revoke LAN credentials of a revoked session.", { cause }), + ), + ); + + const lanCredentialParentRevokedAt = Effect.fnUntraced(function* (subject: string) { + const parentSessionId = lanCredentialParent(subject); + if (parentSessionId === undefined) return null; + const parent = yield* authSessions.getById({ sessionId: parentSessionId }); + if (Option.isSome(parent) && parent.value.revokedAt === null) return null; + return Option.isSome(parent) && parent.value.revokedAt !== null + ? parent.value.revokedAt + : yield* DateTime.now; + }); + const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( function* (input) { @@ -721,6 +758,7 @@ const make = Effect.gen(function* () { concurrency: "unbounded", discard: true, }); + yield* revokeLanCredentials(replacedSessionIds); } yield* emitUpsert( toAuthClientSession({ @@ -832,6 +870,17 @@ const make = Effect.gen(function* () { revokedAt: row.value.revokedAt, }); } + const parentRevokedAt = yield* lanCredentialParentRevokedAt(row.value.subject).pipe( + Effect.mapError( + (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }), + ), + ); + if (parentRevokedAt !== null) { + return yield* new SessionTokenRevokedError({ + sessionId: claims.sid, + revokedAt: parentRevokedAt, + }); + } return { sessionId: claims.sid, @@ -945,6 +994,17 @@ const make = Effect.gen(function* () { revokedAt: row.value.revokedAt, }); } + const parentRevokedAt = yield* lanCredentialParentRevokedAt(row.value.subject).pipe( + Effect.mapError( + (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }), + ), + ); + if (parentRevokedAt !== null) { + return yield* new WebSocketSessionRevokedError({ + sessionId: claims.sid, + revokedAt: parentRevokedAt, + }); + } return { sessionId: row.value.sessionId, @@ -1001,6 +1061,7 @@ const make = Effect.gen(function* () { return next; }); yield* emitRemoved(sessionId); + yield* revokeLanCredentials([sessionId]); } return revoked; }, @@ -1061,7 +1122,8 @@ const make = Effect.gen(function* () { // Revoking any session the socket has used closes it. Events only wake this loop // and the rows decide: revocations are written before they are published, and // clientRemoved also reports an expired session losing its last socket. - const chain = [sessionId]; + const parentSessionId = lanCredentialParent(original.value.subject); + const chain = parentSessionId === undefined ? [sessionId] : [sessionId, parentSessionId]; const nextRemoval = Effect.gen(function* () { while (true) { const change = yield* PubSub.take(subscription); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index c02da89d7d46d..16aba2ec10a28 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -10,6 +10,7 @@ import { EnvironmentInternalError, type EnvironmentInternalErrorReason, EnvironmentOperationForbiddenError, + type EnvironmentOperationForbiddenReason, EnvironmentRequestInvalidError, type EnvironmentRequestInvalidReason, EnvironmentResourceNotFoundError, @@ -134,7 +135,7 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope ); } -function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") { +function failEnvironmentOperationForbidden(reason: EnvironmentOperationForbiddenReason) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => Effect.fail( @@ -440,6 +441,30 @@ export const layer = HttpApiBuilder.group( ), ), ) + .handle( + "lanCredential", + Effect.fn("environment.auth.lanCredential")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const request = yield* HttpServerRequest.HttpServerRequest; + yield* appendCredentialResponseHeaders; + return yield* serverAuth.issueLanCredential(request); + }, + Effect.catchTags({ + ServerAuthLanCredentialNotAllowedError: () => + failEnvironmentOperationForbidden("lan_credential_not_allowed"), + }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), + ), + ) .handle( "pairingCredential", Effect.fn("environment.auth.pairingCredential")( diff --git a/apps/web/test/environmentHttpTest.ts b/apps/web/test/environmentHttpTest.ts index f7bd2a6025290..85c6b2f46d6ae 100644 --- a/apps/web/test/environmentHttpTest.ts +++ b/apps/web/test/environmentHttpTest.ts @@ -102,6 +102,7 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc .handle("token", () => unexpectedEndpoint("auth.token")) .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) .handle("webSocketTicketFor", () => unexpectedEndpoint("auth.webSocketTicketFor")) + .handle("lanCredential", () => unexpectedEndpoint("auth.lanCredential")) .handle( "pairingCredential", Effect.fn("test.environment.auth.pairingCredential")(function* ({ payload }) { diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 44cd45d127e11..18b3259ddfefe 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -424,6 +424,7 @@ export const fakeDriver = { }), preflight: () => Effect.succeed(true), checkHost: () => Effect.succeed("unchecked"), + mintLanCredential: () => Effect.die("Fake environments report no LAN routes."), }; export type FakeDriver = typeof fakeDriver; diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 80d2feb6a87a2..c7b831e42c0a4 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -131,10 +131,20 @@ machine must allow network access: **Network access** in the desktop app, or the You can reorder a learned route, but not remove it. It goes away with the route it was learned through, or when the machine stops reporting that address. +A device paired over HTTPS or Tailscale also learns the machine's LAN address. +Its paired token is never sent there. T3 Code uses a separate local network +credential tied to that device, which appears under connected clients as +"(local network)" and is removed when you revoke the device. Its credential cannot call access-management APIs directly; terminal and agent +access still allow commands on the machine. Because LAN traffic is unencrypted, +this route is placed after your Tailscale, +HTTPS, SSH, and T3 Connect routes, so it is used only when they do not answer. +A LAN address learned through T3 Connect is placed the same way. Drag it higher +to prefer it at home. + On desktop and mobile, a machine paired over an HTTP IPv4 LAN address can be found again on the same network if its address changes. Allow local network -access when prompted. HTTPS and Tailscale pairings keep their existing secure -routes. Browsers and devices on another network still need a reachable route, +access when prompted. A device paired over HTTPS or Tailscale does the same once +it has a local network credential. Browsers and devices on another network still need a reachable route, as do networks that block Bonjour or isolate guests. T3 Code connects over the first route that answers. Away from home, a LAN diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 135c443df3f49..f506bea18210c 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -167,6 +167,24 @@ export const bootstrapRemoteBearerSession = Effect.fn( ); }); +export const issueRemoteLanCredential = Effect.fn( + "clientRuntime.authorization.issueRemoteLanCredential", +)(function* (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/lan-credential"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.lanCredential({ + headers: { authorization: `Bearer ${input.bearerToken}`, dpop: input.dpopProof }, + }), + ); +}); + export const fetchRemoteSessionState = Effect.fn( "clientRuntime.authorization.fetchRemoteSessionState", )(function* (input: { diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 9b4d9c2f1f892..d66543713d08f 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -7,6 +7,7 @@ import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { exchangeRemoteDpopAccessToken, + issueRemoteLanCredential, type RemoteEnvironmentAuthError, resolveRemoteDpopWebSocketConnectionUrl, resolveRemoteWebSocketConnectionUrl, @@ -78,6 +79,16 @@ export class RemoteEnvironmentAuthorization extends Context.Service< */ readonly directEndpoint?: { readonly httpBaseUrl: string; readonly wsBaseUrl: string }; }) => Effect.Effect; + readonly authorizeLanDpop: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly accessToken: string; + }) => Effect.Effect; + readonly mintLanCredential: (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; readonly authorizeDpopHttp: (input: { readonly expectedEnvironmentId: EnvironmentId; readonly rejectedAccessToken?: string; @@ -96,10 +107,13 @@ export class RemoteEnvironmentAuthorization extends Context.Service< const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000; const DPOP_AUTHORIZATION_TIMEOUT_MS = 30_000; -function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { +function mapDpopSocketError( + error: RemoteEnvironmentAuthError | ConnectionAttemptError, + connectionMethod: ClientConnectionMethod = "relay", +) { return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" ? error - : mapRemoteDpopEnvironmentError(error); + : mapRemoteDpopEnvironmentError(error, connectionMethod); } const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( @@ -192,6 +206,72 @@ export const make = Effect.gen(function* () { }, ); + const createSocketProof = (proof: ManagedRelay.ManagedRelayDpopProofInput) => + signer.createProof(proof).pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the websocket authorization proof.", + }), + ), + ); + + const authorizeLanDpop = Effect.fn("clientRuntime.connection.remote.authorizeLanDpop")(function* ( + input: Parameters[0], + ) { + const descriptor = yield* verifyDirectEndpoint({ + expectedEnvironmentId: input.expectedEnvironmentId, + httpBaseUrl: input.httpBaseUrl, + connectionMethod: "direct", + }); + const socketUrl = yield* resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: input.wsBaseUrl, + httpBaseUrl: input.httpBaseUrl, + accessToken: input.accessToken, + createProof: createSocketProof, + clientMetadata: presentation.metadata, + connectionMethod: "direct", + }).pipe( + Effect.mapError((error) => mapDpopSocketError(error, "direct")), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.httpBaseUrl, + socketUrl, + descriptor, + httpAuthorization: { _tag: "LanDpop" as const, accessToken: input.accessToken }, + }; + }); + + const mintLanCredential = Effect.fn("clientRuntime.connection.remote.mintLanCredential")( + function* ( + input: Parameters[0], + ) { + const dpopProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(input.httpBaseUrl, "/api/auth/lan-credential"), + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the LAN credential proof.", + }), + ), + ); + const issued = yield* issueRemoteLanCredential({ ...input, dpopProof }).pipe( + Effect.mapError((error) => mapDpopSocketError(error, "direct")), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return issued.access_token; + }, + ); + const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( function* ( token: TokenStore.RemoteDpopAccessToken, @@ -202,16 +282,7 @@ export const make = Effect.gen(function* () { wsBaseUrl: endpoint.wsBaseUrl, httpBaseUrl: endpoint.httpBaseUrl, accessToken: token.accessToken, - createProof: (proof) => - signer.createProof(proof).pipe( - Effect.mapError( - () => - new ConnectionBlockedError({ - reason: "configuration", - detail: "Could not create the websocket authorization proof.", - }), - ), - ), + createProof: createSocketProof, clientMetadata: presentation.metadata, connectionMethod: "relay", ...(timeoutMs === undefined ? {} : { timeoutMs }), @@ -527,6 +598,8 @@ export const make = Effect.gen(function* () { return RemoteEnvironmentAuthorization.of({ authorizeBearer, + authorizeLanDpop, + mintLanCredential, authorizeDpop: Effect.fn("environment.authorization")(function (input) { return authorizeDpop(input); }), diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index a303594576898..69f7e8ebb350b 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -24,6 +24,11 @@ export class BearerConnectionProfile extends Schema.TaggedClass Effect.Effect; + readonly mintLanCredential: (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; /** Asks the host behind a live session to answer a fresh request on the session's own origin. */ readonly checkHost: (prepared: PreparedConnection) => Effect.Effect; } @@ -241,7 +245,12 @@ export const make = Effect.gen(function* () { Effect.withSpan("ConnectionDriver.preflight"), ); - return ConnectionDriver.of({ connect, preflight, checkHost }); + return ConnectionDriver.of({ + connect, + preflight, + checkHost, + mintLanCredential: remote.mintLanCredential, + }); }); export const layer = Layer.effect(ConnectionDriver, make); diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts index d270ed2c52473..049255b4a8604 100644 --- a/packages/client-runtime/src/connection/errors.test.ts +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -1,3 +1,4 @@ +import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; import { RelayAuthInvalidError, RelayEnvironmentConnectNotAuthorizedError, @@ -101,4 +102,40 @@ describe("mapRemoteDpopEnvironmentError", () => { detail: `${error.message} ${NETWORK_BLOCKING_HINT}`, }); }); + + it("names a direct LAN failure without the tunnel hint", () => { + const error = new RemoteEnvironmentAuthFetchError({ + message: "Failed to fetch remote environment endpoint.", + cause: new TypeError("Failed to fetch"), + }); + expect(mapRemoteDpopEnvironmentError(error, "direct")).toMatchObject({ + _tag: "ConnectionTransientError", + hop: "server", + via: "direct", + detail: error.message, + }); + expect(mapRemoteDpopEnvironmentError(error, "direct").message).not.toContain( + NETWORK_BLOCKING_HINT, + ); + }); + + it("keeps a direct DPoP clock failure classified", () => { + expect( + mapRemoteDpopEnvironmentError( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-clock", + dpopFailureReason: "time_window", + }), + "direct", + ), + ).toMatchObject({ + _tag: "ConnectionBlockedError", + hop: "server", + via: "direct", + failureCause: "clock_skew", + dpopFailureReason: "time_window", + }); + }); }); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index a5961df442f2c..74985a3d3b28f 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -261,6 +261,7 @@ export function mapRemoteEnvironmentError( */ export function mapRemoteDpopEnvironmentError( error: RemoteEnvironmentAuthError, + connectionMethod: ClientConnectionMethod = "relay", ): ConnectionAttemptError { if (error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential") { return new ConnectionBlockedError({ @@ -268,9 +269,12 @@ export function mapRemoteDpopEnvironmentError( detail: dpopFailureMessage("The environment credential is invalid.", error.dpopFailureReason), traceId: error.traceId, hop: "server", - via: "relay", + via: connectionMethod === "relay" ? "relay" : "direct", + ...(error.dpopFailureReason === undefined + ? {} + : { dpopFailureReason: error.dpopFailureReason }), ...(error.dpopFailureReason === "time_window" ? { failureCause: "clock_skew" } : {}), }); } - return mapRemoteEnvironmentError(error, "relay"); + return mapRemoteEnvironmentError(error, connectionMethod); } diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index 73675d431aa7a..43d69d4e64528 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -1,4 +1,4 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { DpopFailureReason, EnvironmentId } from "@t3tools/contracts"; import { RelayEnvironmentEndpointFailure } from "@t3tools/contracts/relay"; import * as Schema from "effect/Schema"; @@ -99,6 +99,7 @@ const ConnectionFailureFields = { hop: Schema.optionalKey(ConnectionHop), via: Schema.optionalKey(Schema.Literals(["relay", "direct"])), failureCause: Schema.optionalKey(ConnectionFailureCause), + dpopFailureReason: Schema.optionalKey(DpopFailureReason), httpStatus: Schema.optionalKey(Schema.Number), }; @@ -145,6 +146,10 @@ export type PreparedHttpAuthorization = readonly _tag: "Dpop"; readonly accessToken: string; readonly expiresAtEpochMs: number; + } + | { + readonly _tag: "LanDpop"; + readonly accessToken: string; }; export interface PreparedConnection { diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 3a03773da7af5..cedb2e67c602f 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -20,6 +20,7 @@ import * as Scheduler from "effect/Scheduler"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import * as ClientCapabilities from "../platform/capabilities.ts"; @@ -42,7 +43,7 @@ import * as Connectivity from "./connectivity.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; import * as ConnectionDriver from "./driver.ts"; import type { RouteCheck } from "./driver.ts"; -import { connectionRouteId, connectionRouteKind } from "./routes.ts"; +import { connectionRouteId, connectionRouteKind, connectionRoutes } from "./routes.ts"; import { ConnectionTransientError, ConnectionBlockedError, @@ -168,6 +169,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ) => Effect.Effect; readonly initialDisabled?: ReadonlyArray; readonly directEndpoints?: ServerConfig["directEndpoints"]; + readonly mintLanCredential?: ConnectionDriver.ConnectionDriver["Service"]["mintLanCredential"]; }, ) { const storedTargets = yield* Ref.make>(initialTargets); @@ -451,6 +453,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ), ), checkHost: () => Effect.succeed("unchecked"), + mintLanCredential: + options?.mintLanCredential ?? (() => Effect.die("Unexpected LAN credential mint.")), }); const layerCache = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore); @@ -476,6 +480,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ); return { + credentialStore, layer, storedTargets, shellCache, @@ -2350,6 +2355,281 @@ describe("EnvironmentRegistry routes", () => { }), ); + it.effect("mints one LAN credential for a tailnet pairing and saves the route below it", () => + Effect.gen(function* () { + const tailnet = new BearerConnectionProfile({ + ...BEARER_PROFILE, + httpBaseUrl: "https://desk.tail1234.ts.net/", + wsBaseUrl: "wss://desk.tail1234.ts.net/", + }); + const mints = yield* Ref.make>([]); + const harness = yield* makeHarness( + [BEARER_TARGET], + [tailnet], + [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]], + { + directEndpoints: [{ kind: "lan", httpBaseUrl: "http://192.168.1.22:3773/" }], + mintLanCredential: (input) => + Ref.update(mints, (values) => [...values, input.httpBaseUrl]).pipe( + Effect.as("lan-token"), + ), + }, + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const credentials = harness.credentialStore; + yield* registry.start; + const learnedId = `learned:${BEARER_TARGET.environmentId}:http://192.168.1.22:3773@${BEARER_TARGET.connectionId}`; + yield* SubscriptionRef.changes(registry.entries).pipe( + Stream.filter((entries) => + (entries.get(BEARER_TARGET.environmentId)?.alternateRoutes ?? []).some( + (route) => connectionRouteId(route.target) === learnedId, + ), + ), + Stream.runHead, + ); + expect(yield* Ref.get(mints)).toEqual(["https://desk.tail1234.ts.net/"]); + expect( + routesOf(yield* Ref.get(harness.storedTargets), BEARER_TARGET.environmentId).map( + connectionRouteId, + ), + ).toEqual([BEARER_TARGET.connectionId, learnedId]); + const stored = yield* credentials.get(`lan-credential:${BEARER_TARGET.connectionId}`); + expect(Option.map(stored, (credential) => credential.token)).toEqual( + Option.some("lan-token"), + ); + + yield* registry.remove(BEARER_TARGET.environmentId); + expect( + Option.isNone(yield* credentials.get(`lan-credential:${BEARER_TARGET.connectionId}`)), + ).toBe(true); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("saves the LAN route a discovery connection authenticated", () => + Effect.gen(function* () { + const environmentId = BEARER_TARGET.environmentId; + const oldUrl = "http://192.168.1.10:3773/"; + const newUrl = "http://192.168.1.22:3773/"; + const oldId = `learned:${environmentId}:http://192.168.1.10:3773@${BEARER_TARGET.connectionId}`; + const newId = `learned:${environmentId}:http://192.168.1.22:3773@${BEARER_TARGET.connectionId}`; + const tailnet = new BearerConnectionProfile({ + ...BEARER_PROFILE, + httpBaseUrl: "https://desk.tail1234.ts.net/", + wsBaseUrl: "wss://desk.tail1234.ts.net/", + }); + const oldTarget = new BearerConnectionTarget({ ...BEARER_TARGET, connectionId: oldId }); + const oldProfile = new BearerConnectionProfile({ + connectionId: oldId, + environmentId, + label: BEARER_TARGET.label, + httpBaseUrl: oldUrl, + wsBaseUrl: "ws://192.168.1.10:3773/", + learned: true, + lanDpop: true, + }); + const harness = yield* makeHarness( + [BEARER_TARGET, oldTarget], + [tailnet, oldProfile], + [ + [BEARER_TARGET.connectionId, BEARER_CREDENTIAL], + [ + `lan-credential:${BEARER_TARGET.connectionId}`, + new BearerConnectionCredential({ token: "lan-token" }), + ], + ], + { + directEndpoints: [{ kind: "lan", httpBaseUrl: newUrl }], + checkRoute: (route) => { + const profile = Option.getOrNull(route.profile); + const http = profile?._tag === "BearerConnectionProfile" ? profile.httpBaseUrl : ""; + return http.startsWith("http://192.168.1.22") ? "answered" : "silent"; + }, + }, + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.setDiscoveredCandidates(new Map([[environmentId, [newUrl]]])); + yield* registry.start; + yield* SubscriptionRef.changes(registry.entries).pipe( + Stream.filter((entries) => { + const entry = entries.get(environmentId); + if (entry === undefined) return false; + const ids = connectionRoutes(entry).map((route) => connectionRouteId(route.target)); + return ids.includes(newId) && !ids.includes(oldId); + }), + Stream.runHead, + ); + yield* registry.reorderRoutes(environmentId, [BEARER_TARGET.connectionId, newId]); + expect(yield* Ref.get(harness.connectedRoutes)).toContain(newId); + expect( + routesOf(yield* Ref.get(harness.storedTargets), environmentId).map(connectionRouteId), + ).toEqual([BEARER_TARGET.connectionId, newId]); + const controls = yield* Ref.get(harness.sessions); + const active = controls.at(-1); + expect(active).toBeDefined(); + yield* Deferred.fail( + active!.closed, + new ConnectionTransientError({ reason: "transport", detail: "Disconnected." }), + ); + yield* TestClock.adjust("10 seconds"); + const connected = (yield* Ref.get(harness.connectedRoutes)).filter((id) => id === newId); + expect(connected.length).toBeGreaterThan(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("replaces the LAN credential when the paired token changes", () => + Effect.gen(function* () { + const environmentId = BEARER_TARGET.environmentId; + const learnedId = `learned:${environmentId}:http://192.168.1.10:3773@${BEARER_TARGET.connectionId}`; + const tailnet = new BearerConnectionProfile({ + ...BEARER_PROFILE, + httpBaseUrl: "https://desk.tail1234.ts.net/", + wsBaseUrl: "wss://desk.tail1234.ts.net/", + }); + const learnedTarget = new BearerConnectionTarget({ + ...BEARER_TARGET, + connectionId: learnedId, + }); + const learnedProfile = new BearerConnectionProfile({ + connectionId: learnedId, + environmentId, + label: BEARER_TARGET.label, + httpBaseUrl: "http://192.168.1.10:3773/", + wsBaseUrl: "ws://192.168.1.10:3773/", + learned: true, + lanDpop: true, + }); + const arrived = yield* Ref.make(0); + const release = yield* Deferred.make(); + const harness = yield* makeHarness( + [BEARER_TARGET, learnedTarget], + [tailnet, learnedProfile], + [ + [BEARER_TARGET.connectionId, new BearerConnectionCredential({ token: "old-token" })], + [ + `lan-credential:${BEARER_TARGET.connectionId}`, + new BearerConnectionCredential({ token: "lan:old-token" }), + ], + ], + { + prepareRoute: () => + new ConnectionBlockedError({ reason: "configuration", detail: "offline" }), + mintLanCredential: (input) => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(arrived, (value) => value + 1); + if (count >= 2) yield* Deferred.succeed(release, undefined); + yield* Deferred.await(release); + return `lan:${input.bearerToken}`; + }), + }, + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const registration = (token: string) => + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: tailnet, + credential: new BearerConnectionCredential({ token }), + }); + yield* Effect.all( + [registry.register(registration("token-a")), registry.register(registration("token-b"))], + { concurrency: "unbounded" }, + ); + const parent = Option.getOrThrow( + yield* harness.credentialStore.get(BEARER_TARGET.connectionId), + ); + const lan = Option.getOrThrow( + yield* harness.credentialStore.get(`lan-credential:${BEARER_TARGET.connectionId}`), + ); + expect(parent.token === "token-a" || parent.token === "token-b").toBe(true); + expect(lan.token).toBe(`lan:${parent.token}`); + expect(lan.token).not.toBe("lan:old-token"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("drops a LAN credential that no saved route still uses", () => + Effect.gen(function* () { + const environmentId = BEARER_TARGET.environmentId; + const learnedId = `learned:${environmentId}:http://192.168.1.10:3773@${BEARER_TARGET.connectionId}`; + const tailnet = new BearerConnectionProfile({ + ...BEARER_PROFILE, + httpBaseUrl: "https://desk.tail1234.ts.net/", + wsBaseUrl: "wss://desk.tail1234.ts.net/", + }); + const learnedTarget = new BearerConnectionTarget({ + ...BEARER_TARGET, + connectionId: learnedId, + }); + const learnedProfile = new BearerConnectionProfile({ + connectionId: learnedId, + environmentId, + label: BEARER_TARGET.label, + httpBaseUrl: "http://192.168.1.10:3773/", + wsBaseUrl: "ws://192.168.1.10:3773/", + learned: true, + lanDpop: true, + }); + const harness = yield* makeHarness( + [BEARER_TARGET, learnedTarget], + [tailnet, learnedProfile], + [ + [BEARER_TARGET.connectionId, BEARER_CREDENTIAL], + [ + `lan-credential:${BEARER_TARGET.connectionId}`, + new BearerConnectionCredential({ token: "lan-token" }), + ], + ], + { + directEndpoints: [], + checkRoute: (route) => { + const profile = Option.getOrNull(route.profile); + return profile?._tag === "BearerConnectionProfile" && + profile.httpBaseUrl.startsWith("https:") + ? "answered" + : "silent"; + }, + }, + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* SubscriptionRef.changes(registry.entries).pipe( + Stream.filter((entries) => { + const entry = entries.get(environmentId); + const routeIds = [ + entry?.target, + ...(entry?.alternateRoutes ?? []).map((route) => route.target), + ] + .filter((target) => target !== undefined) + .map(connectionRouteId); + return routeIds.length > 0 && !routeIds.includes(learnedId); + }), + Stream.runHead, + ); + expect( + Option.isNone( + yield* harness.credentialStore.get(`lan-credential:${BEARER_TARGET.connectionId}`), + ), + ).toBe(true); + + yield* harness.credentialStore.put( + `lan-credential:${BEARER_TARGET.connectionId}`, + new BearerConnectionCredential({ token: "orphan" }), + ); + yield* registry.remove(environmentId); + expect( + Option.isNone( + yield* harness.credentialStore.get(`lan-credential:${BEARER_TARGET.connectionId}`), + ), + ).toBe(true); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("saves the Tailscale mark when the server reports a learned address as tailnet", () => Effect.gen(function* () { const httpBaseUrl = "http://100.101.102.103:3773/"; diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 104271deae569..39fdd199f0834 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -14,6 +14,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import * as ClientCapabilities from "../platform/capabilities.ts"; import { + BearerConnectionCredential, type ConnectionCatalogEntry, type ConnectionCredential, type ConnectionProfile, @@ -48,6 +49,10 @@ import { connectionRouteId, connectionRoutes, credentialConnectionId, + isLanDpopRoute, + lanCredentialConnectionId, + lanCredentialParentId, + routeHttpBaseUrl, entryWithRoutes, findRouteToSameAddress, hasSavedBearerRoute, @@ -59,6 +64,14 @@ import { const isSshConnectionProfile = Schema.is(SshConnectionProfile); +function httpOrigin(httpBaseUrl: string): string | null { + try { + return new URL(httpBaseUrl).origin; + } catch { + return null; + } +} + function unsupportedState( entry: ConnectionCatalogEntry, ): Pick { @@ -575,6 +588,27 @@ export const make = Effect.gen(function* () { } }); + const commitLanCredential = Effect.fn("EnvironmentRegistry.commitLanCredential")( + function* (input: { + readonly environmentId: EnvironmentId; + readonly parentId: string; + readonly parentToken: string; + readonly token: string; + }) { + yield* withLeaseLock( + input.environmentId, + Effect.gen(function* () { + const current = yield* credentials.get(input.parentId); + if (Option.isNone(current) || current.value.token !== input.parentToken) return; + yield* credentials.put( + lanCredentialConnectionId(input.parentId), + new BearerConnectionCredential({ token: input.token }), + ); + }), + ); + }, + ); + const register = Effect.fn("EnvironmentRegistry.register")(function* ( registration: ConnectionRegistration, options?: { readonly knownAddressOnly?: boolean }, @@ -582,7 +616,14 @@ export const make = Effect.gen(function* () { const registered = connectionRegistrationCatalogEntry(registration); const environmentId = registered.target.environmentId; const route: ConnectionRoute = { target: registered.target, profile: registered.profile }; - return yield* withLeaseLock( + let remint: + | { + readonly parentId: string; + readonly parentToken: string; + readonly httpBaseUrl: string; + } + | undefined; + const accepted = yield* withLeaseLock( environmentId, Effect.gen(function* () { if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { @@ -607,7 +648,37 @@ export const make = Effect.gen(function* () { } entry = next.entry; } + const previousToken = yield* Effect.gen(function* () { + if (registration._tag !== "BearerConnectionRegistration") return undefined; + const stored = yield* Effect.exit(credentials.get(registration.target.connectionId)); + if (Exit.isFailure(stored) || Option.isNone(stored.value)) return undefined; + return stored.value.value.token; + }); yield* registrations.register(registration, persistedRoutes(entry)); + if ( + registration._tag === "BearerConnectionRegistration" && + previousToken !== undefined && + previousToken !== registration.credential.token + ) { + yield* credentials + .remove(lanCredentialConnectionId(registration.target.connectionId)) + .pipe(Effect.ignore); + const parentId = lanCredentialParentId({ + entry, + activeRoute: route, + reported: connectionRoutes(entry).flatMap((candidate) => { + const httpBaseUrl = routeHttpBaseUrl(candidate); + return httpBaseUrl === null ? [] : [{ httpBaseUrl }]; + }), + }); + if (parentId !== null) { + remint = { + parentId, + parentToken: registration.credential.token, + httpBaseUrl: registration.profile.httpBaseUrl, + }; + } + } yield* Ref.update(persistedEnvironmentIds, (current) => new Set(current).add(environmentId), ); @@ -615,6 +686,30 @@ export const make = Effect.gen(function* () { return true; }), ); + if (remint !== undefined) { + const pending = remint; + const minted = yield* Effect.exit( + driver + .mintLanCredential({ + httpBaseUrl: pending.httpBaseUrl, + bearerToken: pending.parentToken, + }) + .pipe( + Effect.flatMap((token) => + commitLanCredential({ + environmentId, + parentId: pending.parentId, + parentToken: pending.parentToken, + token, + }), + ), + ), + ); + if (Exit.isFailure(minted)) { + yield* Effect.logDebug("Could not mint a LAN credential.", { cause: minted.cause }); + } + } + return accepted; }); const replaceRoutesLocked = Effect.fn("EnvironmentRegistry.replaceRoutesLocked")(function* ( @@ -738,6 +833,9 @@ export const make = Effect.gen(function* () { yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); if (bearerReplaced && registration._tag === "BearerConnectionRegistration") { + yield* credentials + .remove(lanCredentialConnectionId(registration.target.connectionId)) + .pipe(Effect.ignore); const scope = (yield* SubscriptionRef.get(serviceScopes)).get(target.environmentId); if (scope !== undefined) { const state = yield* SubscriptionRef.get(scope.supervisor.state); @@ -877,6 +975,7 @@ export const make = Effect.gen(function* () { } const entry = yield* getEntry(environmentId); + yield* syncLanCredentials(connectionRoutes(entry), []); yield* pullRequestRoutingPermissions.forget(environmentId); yield* registrations.remove(environmentId); yield* Ref.update(persistedEnvironmentIds, (current) => { @@ -968,6 +1067,81 @@ export const make = Effect.gen(function* () { const allowInsecureRoutes = typeof globalThis.location === "undefined" || globalThis.location.protocol !== "https:"; + const ensureLanCredential = Effect.fnUntraced( + function* (input: { + readonly environmentId: EnvironmentId; + readonly activeRoute: ConnectionRoute; + readonly reported: ReadonlyArray; + }) { + const entry = (yield* SubscriptionRef.get(entries)).get(input.environmentId); + const httpBaseUrl = routeHttpBaseUrl(input.activeRoute); + if ( + entry === undefined || + httpBaseUrl === null || + !allowInsecureRoutes || + (yield* Ref.get(platformEnvironmentIds)).has(input.environmentId) + ) { + return false; + } + const parentId = lanCredentialParentId({ ...input, entry }); + if (parentId === null) return false; + const credentialId = lanCredentialConnectionId(parentId); + if (Option.isSome(yield* credentials.get(credentialId))) return true; + const parent = yield* credentials.get(parentId); + if (Option.isNone(parent)) return false; + const parentToken = parent.value.token; + const token = yield* driver.mintLanCredential({ + httpBaseUrl, + bearerToken: parentToken, + }); + yield* commitLanCredential({ + environmentId: input.environmentId, + parentId, + parentToken, + token, + }); + return true; + }, + Effect.catch((error) => + Effect.logDebug("Could not mint a LAN credential.", { error }).pipe(Effect.as(false)), + ), + ); + + const syncLanCredentials = Effect.fnUntraced(function* ( + before: ReadonlyArray, + after: ReadonlyArray, + ) { + const live = new Set( + after + .filter(isLanDpopRoute) + .map((route) => credentialConnectionId(connectionRouteId(route.target))), + ); + const known = new Set(); + for (const route of [...before, ...after]) { + const profile = Option.getOrNull(route.profile); + if (profile?._tag !== "BearerConnectionProfile" || profile.authorization === "t3-connect") { + continue; + } + if (isLanDpopRoute(route) || profile.learned !== true) { + known.add(credentialConnectionId(connectionRouteId(route.target))); + } + } + yield* Effect.forEach( + known, + (parentId) => + live.has(parentId) + ? Effect.void + : credentials + .remove(lanCredentialConnectionId(parentId)) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear a LAN credential.", { error }), + ), + ), + { discard: true }, + ); + }); + /** * Learned routes reuse the active route's credential, so they do not revoke pull request routing * trust the way a newly paired address does. @@ -978,6 +1152,7 @@ export const make = Effect.gen(function* () { readonly reported: ReadonlyArray; readonly discoveryOrigins: ReadonlyArray; }) { + const lanCredential = yield* ensureLanCredential(input); return yield* withLeaseLock( input.environmentId, Effect.gen(function* () { @@ -992,21 +1167,33 @@ export const make = Effect.gen(function* () { (yield* Ref.get(discoveryCandidates)).get(input.environmentId) ?? [], )) { const profile = Option.getOrNull(route.profile); - if (profile?._tag === "BearerConnectionProfile") - discoveryOrigins.add(new URL(profile.httpBaseUrl).origin); + if (profile?._tag === "BearerConnectionProfile") { + const origin = httpOrigin(profile.httpBaseUrl); + if (origin !== null) discoveryOrigins.add(origin); + } } + const activeHttpBaseUrl = routeHttpBaseUrl(input.activeRoute); + const activeDiscoveredOrigin = + input.activeRoute.discovery === true && + isLanDpopRoute(input.activeRoute) && + activeHttpBaseUrl !== null + ? httpOrigin(activeHttpBaseUrl) + : null; + const beforeRoutes = connectionRoutes(entry); const routes = mergeLearnedRoutes({ entry, + lanCredential, activeRoute: input.activeRoute, reported: input.reported.filter((endpoint) => { - try { - return !discoveryOrigins.has(new URL(endpoint.httpBaseUrl).origin); - } catch { - return false; - } + const origin = httpOrigin(endpoint.httpBaseUrl); + return ( + origin !== null && + (origin === activeDiscoveredOrigin || !discoveryOrigins.has(origin)) + ); }), allowInsecure: allowInsecureRoutes, }); + yield* syncLanCredentials(beforeRoutes, routes ?? beforeRoutes); if (routes === null) return Option.none(); const next = entryWithRoutes(entry, routes); const previousProfiles = new Map( @@ -1063,6 +1250,7 @@ export const make = Effect.gen(function* () { const remaining = routesAfterRemoving(routes, routeId); if (remaining.length === 0) return yield* removeLocked(environmentId); yield* replaceRoutesLocked(entry, remaining); + yield* syncLanCredentials(routes, remaining); const profile = Option.getOrNull(route.profile); if (profile !== null && isSshConnectionProfile(profile)) { yield* disconnectSsh(environmentId, profile); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 1010bca048021..76bad8f583cbe 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -22,6 +22,7 @@ import { } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; import { + ConnectionBlockedError, ConnectionTransientError, BearerConnectionTarget, PrimaryConnectionTarget, @@ -66,6 +67,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o readonly credentials?: ReadonlyArray; readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly authorizeLanDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeLanDpop"]; readonly primaryBearerToken?: string; readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; readonly descriptorProtocolVersion?: number | null | undefined; @@ -114,6 +116,17 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o expiresAtEpochMs: Number.MAX_SAFE_INTEGER, }, })), + authorizeLanDpop: + options?.authorizeLanDpop ?? + ((input) => + Effect.succeed({ + environmentId: input.expectedEnvironmentId, + label: "Authorized LAN environment", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "ws://authorized.example.test/ws?wsTicket=lan", + httpAuthorization: { _tag: "LanDpop" as const, accessToken: input.accessToken }, + })), + mintLanCredential: () => Effect.die("unused"), authorizeDpopHttp: () => Effect.die("unused"), renewDpopBeforeExpiry: () => Effect.die("unused"), }); @@ -208,6 +221,177 @@ describe("ConnectionResolver", () => { }), ); + it.effect("connects a LAN credential route without offering the paired token", () => + Effect.gen(function* () { + const target = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "LAN", + connectionId: "learned:environment-1:http://192.168.1.23:3000@paired", + }); + const profile = new BearerConnectionProfile({ + connectionId: target.connectionId, + environmentId: ENVIRONMENT_ID, + label: "LAN", + httpBaseUrl: "http://192.168.1.23:3000/", + wsBaseUrl: "ws://192.168.1.23:3000/", + learned: true, + lanDpop: true, + }); + const entry = catalogEntry(target, Option.some(profile)); + const authorizeBearer = () => Effect.die("The paired token must stay off plain HTTP."); + const paired = new BearerConnectionCredential({ token: "paired-token" }); + const lan = new BearerConnectionCredential({ token: "lan-token" }); + + const usable = yield* ConnectionResolver.ConnectionResolver.pipe( + Effect.provide( + yield* makeDependencies({ + credentials: [ + ["paired", paired], + ["lan-credential:paired", lan], + ], + authorizeBearer, + verifyLan: () => Effect.succeed(true), + }), + ), + ); + expect((yield* usable.prepare(entry)).httpAuthorization).toEqual({ + _tag: "LanDpop", + accessToken: "lan-token", + }); + + const missing = yield* ConnectionResolver.ConnectionResolver.pipe( + Effect.provide( + yield* makeDependencies({ credentials: [["paired", paired]], authorizeBearer }), + ), + ); + expect((yield* missing.prepare(entry).pipe(Effect.flip)).reason).toBe("endpoint-unavailable"); + + const rejections = yield* Ref.make(0); + const rejected = yield* ConnectionResolver.ConnectionResolver.pipe( + Effect.provide( + yield* makeDependencies({ + credentials: [ + ["paired", paired], + ["lan-credential:paired", lan], + ], + authorizeBearer, + verifyLan: () => Effect.succeed(true), + authorizeLanDpop: () => + Ref.update(rejections, (count) => count + 1).pipe( + Effect.andThen( + Effect.fail( + new ConnectionBlockedError({ reason: "authentication", detail: "Revoked." }), + ), + ), + ), + }), + ), + ); + expect((yield* rejected.prepare(entry).pipe(Effect.flip)).reason).toBe( + "endpoint-unavailable", + ); + yield* rejected.prepare(entry).pipe(Effect.flip); + expect(yield* Ref.get(rejections)).toBe(1); + }), + ); + + it.effect( + "keeps a LAN credential when the endpoint cannot prove itself or the proof clock is wrong", + () => + Effect.gen(function* () { + const target = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "LAN", + connectionId: "learned:environment-1:http://192.168.1.23:3000@paired", + }); + const profile = new BearerConnectionProfile({ + connectionId: target.connectionId, + environmentId: ENVIRONMENT_ID, + label: "LAN", + httpBaseUrl: "http://192.168.1.23:3000/", + wsBaseUrl: "ws://192.168.1.23:3000/", + learned: true, + lanDpop: true, + }); + const entry = catalogEntry(target, Option.some(profile)); + const paired = new BearerConnectionCredential({ token: "paired-token" }); + const lan = new BearerConnectionCredential({ token: "lan-token" }); + const proved = yield* Ref.make(false); + const authorizations = yield* Ref.make(0); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe( + Effect.provide( + yield* makeDependencies({ + credentials: [ + ["paired", paired], + ["lan-credential:paired", lan], + ], + verifyLan: () => Ref.get(proved), + authorizeLanDpop: () => + Ref.update(authorizations, (count) => count + 1).pipe( + Effect.andThen( + Effect.fail( + new ConnectionBlockedError({ + reason: "authentication", + detail: "The environment credential is invalid.", + failureCause: "clock_skew", + dpopFailureReason: "time_window", + hop: "server", + via: "direct", + }), + ), + ), + ), + }), + ), + ); + + const unproven = yield* broker.prepare(entry).pipe(Effect.flip); + expect(unproven.reason).toBe("endpoint-unavailable"); + expect(unproven.message).toBe("The endpoint could not prove its identity."); + expect(yield* Ref.get(authorizations)).toBe(0); + + yield* Ref.set(proved, true); + const skewed = yield* broker.prepare(entry).pipe(Effect.flip); + expect(skewed).toMatchObject({ + _tag: "ConnectionTransientError", + reason: "endpoint-unavailable", + failureCause: "clock_skew", + dpopFailureReason: "time_window", + via: "direct", + }); + expect(skewed.message).not.toContain("renew"); + + const device = yield* ConnectionResolver.ConnectionResolver.pipe( + Effect.provide( + yield* makeDependencies({ + credentials: [ + ["paired", paired], + ["lan-credential:paired", lan], + ], + verifyLan: () => Effect.succeed(true), + authorizeLanDpop: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "authentication", + detail: "Could not create the websocket authorization proof.", + hop: "device", + }), + ), + }), + ), + ); + const local = yield* device.prepare(entry).pipe(Effect.flip); + expect(local._tag).toBe("ConnectionTransientError"); + expect(local.hop).toBe("device"); + expect(local.message).not.toContain("renew"); + + yield* Ref.set(proved, true); + const retry = yield* broker.prepare(entry).pipe(Effect.flip); + expect(retry.failureCause).toBe("clock_skew"); + expect(yield* Ref.get(authorizations)).toBe(2); + }), + ); + it.effect("does not authorize a discovered endpoint when its proof fails", () => Effect.gen(function* () { const offered = yield* Ref.make>([]); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index 9d30998bc0923..ca9f96140dca0 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -52,7 +52,7 @@ import { appendOrchestrationProtocol, orchestrationProtocolCompatibilityError, } from "./compatibility.ts"; -import { credentialConnectionId } from "./routes.ts"; +import { credentialConnectionId, lanCredentialConnectionId } from "./routes.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; export class ConnectionResolver extends Context.Service< @@ -163,6 +163,60 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") }); return { ...authorized, target } satisfies PreparedConnection; } + if (profile.lanDpop === true) { + const unavailable = (detail: string) => + new ConnectionTransientError({ reason: "endpoint-unavailable", detail }); + const credentialId = lanCredentialConnectionId(credentialConnectionId(target.connectionId)); + const lanCredential = yield* credentials.get(credentialId); + if (Option.isNone(lanCredential) || !isBearerCredential(lanCredential.value)) { + return yield* unavailable("Connect over another route once to enable the local network."); + } + const accessToken = lanCredential.value.token; + if ( + !(yield* discovery.verify({ + token: accessToken, + environmentId: target.environmentId, + candidate: profile.httpBaseUrl, + })) + ) { + return yield* unavailable("The endpoint could not prove its identity."); + } + const authorized = yield* remote + .authorizeLanDpop({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: profile.httpBaseUrl, + wsBaseUrl: profile.wsBaseUrl, + accessToken, + }) + .pipe( + Effect.catchIf( + (error) => + error._tag === "ConnectionBlockedError" && + error.reason === "authentication" && + error.failureCause !== "clock_skew" && + error.dpopFailureReason === undefined && + error.hop !== "device", + () => + credentials + .remove(credentialId) + .pipe( + Effect.andThen( + Effect.fail( + unavailable("Connect over another route to renew the local network access."), + ), + ), + ), + ), + Effect.mapError((error) => { + if (error._tag !== "ConnectionBlockedError" || error.reason !== "authentication") { + return error; + } + const { _tag, ...failure } = error; + return new ConnectionTransientError({ ...failure, reason: "endpoint-unavailable" }); + }), + ); + return { ...authorized, target } satisfies PreparedConnection; + } const credential = yield* credentials.get(credentialConnectionId(target.connectionId)).pipe( Effect.flatMap( Option.match({ diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index b5a54d454dddb..a4a52c51aa361 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -1,6 +1,10 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { connectOverRoutes, type EnvironmentConnectionLease, type RouteCheck } from "./driver.ts"; import { BearerConnectionProfile, @@ -10,7 +14,9 @@ import { import { pullRequestRoutingConnectionKey } from "./pullRequestRoutingPermissions.ts"; import { BearerConnectionTarget, RelayConnectionTarget } from "./model.ts"; import { + connectionRoutes, discoveredRoutes, + lanCredentialParentId, connectionRouteId, connectionRouteKind, connectionRouteLabel, @@ -126,6 +132,59 @@ describe("discovered routes", () => { ); expect(discoveredRoutes(restricted, ["http://192.168.1.22:3773/"])).toEqual([]); }); + + it("follows a moved machine with its LAN credential, not the paired token", () => { + const lanDpop = new BearerConnectionProfile({ + connectionId: `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@tailnet`, + environmentId: ENVIRONMENT_ID, + label: "Desk", + httpBaseUrl: "http://192.168.1.10:3773/", + wsBaseUrl: "ws://192.168.1.10:3773/", + learned: true, + lanDpop: true, + }); + const entry = entryWithRoutes( + { target: TAILNET.target, profile: TAILNET.profile, enabled: true }, + [ + TAILNET, + { + target: new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Desk", + connectionId: lanDpop.connectionId, + }), + profile: Option.some(lanDpop), + }, + ], + ); + const [route] = discoveredRoutes(entry, ["http://192.168.1.22:3773/"]); + expect(connectionRouteId(route!.target)).toBe( + `learned:${ENVIRONMENT_ID}:http://192.168.1.22:3773@tailnet`, + ); + expect(Option.getOrThrow(route!.profile)).toMatchObject({ lanDpop: true }); + expect(sendsTokenInClear(connectionRoutes(entry), route!)).toBe(false); + }); + + it("discovers a moved address from a T3 Connect LAN route once a paired token needs a LAN credential", () => { + const learnedId = `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`; + const connect = learned(learnedId, "http://192.168.1.10:3773/"); + const profile = Option.getOrThrow(connect.profile) as BearerConnectionProfile; + const overConnect = { + ...connect, + profile: Option.some( + new BearerConnectionProfile({ ...profile, authorization: "t3-connect" }), + ), + }; + const entry = entryWithRoutes( + { target: TAILNET.target, profile: TAILNET.profile, enabled: true }, + [TAILNET, RELAY, overConnect], + ); + const [route] = discoveredRoutes(entry, ["http://192.168.1.22:3773/"]); + expect(connectionRouteId(route!.target)).toBe( + `learned:${ENVIRONMENT_ID}:http://192.168.1.22:3773@tailnet`, + ); + expect(Option.getOrThrow(route!.profile)).toMatchObject({ lanDpop: true }); + }); }); describe("learned routes", () => { @@ -138,15 +197,78 @@ describe("learned routes", () => { routes?.map((route) => connectionRouteId(route.target)) ?? null; const profileOf = (route: ConnectionRoute) => Option.getOrThrow(route.profile); - it("learns a LAN address over T3 Connect, ahead of it, using the T3 Connect credential", () => { + it("learns a LAN address over the tailnet only with a LAN credential, ranked below encrypted routes", () => { + const entry = entryWithRoutes( + { target: TAILNET.target, profile: TAILNET.profile, enabled: true }, + [TAILNET, RELAY], + ); + const reported = [{ httpBaseUrl: "http://192.168.1.10:3773/", kind: "lan" }]; + expect( + mergeLearnedRoutes({ entry, activeRoute: TAILNET, reported, allowInsecure: true }), + ).toBeNull(); + const routes = mergeLearnedRoutes({ + entry, + activeRoute: TAILNET, + reported, + allowInsecure: true, + lanCredential: true, + }); + expect(ids(routes)).toEqual([ + "tailnet", + "relay", + `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@tailnet`, + ]); + expect(profileOf(routes![2]!)).toMatchObject({ learned: true, lanDpop: true }); + expect(sendsTokenInClear(routes!, routes![2]!)).toBe(false); + }); + + it.each([ + [ + "a tailnet pairing with a LAN address", + TAILNET, + "http://192.168.1.10:3773/", + "lan", + "tailnet", + ], + [ + "a tailnet pairing with only tailnet addresses", + TAILNET, + "http://100.64.0.1:3773/", + "tailnet", + null, + ], + [ + "a LAN pairing, whose token may use plain HTTP", + LAN, + "http://192.168.1.10:3773/", + "lan", + null, + ], + [ + "a tailnet pairing with only a loopback address", + TAILNET, + "http://127.0.0.1:3773/", + "lan", + null, + ], + ])("decides whether %s needs a LAN credential", (_name, route, httpBaseUrl, kind, expected) => { + const entry = entryWithRoutes({ target: route.target, profile: route.profile, enabled: true }, [ + route, + ]); + expect( + lanCredentialParentId({ entry, activeRoute: route, reported: [{ httpBaseUrl, kind }] }), + ).toBe(expected); + }); + + it("learns a LAN address over T3 Connect behind it, using the T3 Connect credential", () => { const routes = mergeLearnedRoutes({ entry: relayOnly, activeRoute: RELAY, reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], allowInsecure: true, }); - expect(ids(routes)).toEqual([`learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`, "relay"]); - expect(profileOf(routes![0]!)).toMatchObject({ + expect(ids(routes)).toEqual(["relay", `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`]); + expect(profileOf(routes![1]!)).toMatchObject({ learned: true, authorization: "t3-connect", wsBaseUrl: "ws://192.168.1.10:3773/", @@ -160,19 +282,14 @@ describe("learned routes", () => { reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], allowInsecure: true, })!; - const entry: ConnectionCatalogEntry = { - ...relayOnly, - target: first[0]!.target, - profile: first[0]!.profile, - alternateRoutes: first.slice(1), - }; + const entry = entryWithRoutes(relayOnly, first); const moved = mergeLearnedRoutes({ entry, activeRoute: RELAY, reported: [{ httpBaseUrl: "http://10.0.0.42:3773/" }], allowInsecure: true, }); - expect(ids(moved)).toEqual([`learned:${ENVIRONMENT_ID}:http://10.0.0.42:3773`, "relay"]); + expect(ids(moved)).toEqual(["relay", `learned:${ENVIRONMENT_ID}:http://10.0.0.42:3773`]); }); it("keeps a learned route where the user moved it while the server reports it", () => { @@ -200,8 +317,8 @@ describe("learned routes", () => { }); expect(ids(next)).toEqual([ `learned:${ENVIRONMENT_ID}:http://100.101.102.103:3773`, - "relay", `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`, + "relay", ]); }); @@ -304,8 +421,8 @@ describe("learned routes", () => { allowInsecure: true, }); // The stale route is gone; the address is learned again with T3 Connect's credential. - expect(ids(routes)).toEqual([`learned:${ENVIRONMENT_ID}:${lanOrigin}`, "public", "relay"]); - expect(profileOf(routes![0]!)).toMatchObject({ authorization: "t3-connect" }); + expect(ids(routes)).toEqual(["public", "relay", `learned:${ENVIRONMENT_ID}:${lanOrigin}`]); + expect(profileOf(routes![2]!)).toMatchObject({ authorization: "t3-connect" }); }); it("keeps plain HTTP routes that don't borrow the active HTTPS-paired token", () => { @@ -393,12 +510,7 @@ describe("learned routes", () => { reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], allowInsecure: true, })!; - const withLearned: ConnectionCatalogEntry = { - ...relayOnly, - target: learned[0]!.target, - profile: learned[0]!.profile, - alternateRoutes: learned.slice(1), - }; + const withLearned = entryWithRoutes(relayOnly, learned); expect(pullRequestRoutingConnectionKey(withLearned)).toBe( pullRequestRoutingConnectionKey(relayOnly), ); @@ -411,13 +523,8 @@ describe("learned routes", () => { reported: [{ httpBaseUrl: "http://192.168.1.10:3773/" }], allowInsecure: true, })!; - const learnedLan = first[0]!; - const entry: ConnectionCatalogEntry = { - ...relayOnly, - target: learnedLan.target, - profile: learnedLan.profile, - alternateRoutes: first.slice(1), - }; + const learnedLan = first.find((route) => connectionRouteId(route.target) !== "relay")!; + const entry = entryWithRoutes(relayOnly, first); const next = mergeLearnedRoutes({ entry, activeRoute: learnedLan, @@ -455,4 +562,151 @@ describe("learned routes", () => { expect(moved).not.toBeNull(); expect(profileOf(moved![0]!)).toMatchObject({ httpBaseUrl: "https://desk.local:3773/" }); }); + + it("does not mint a LAN credential for a loopback address or a LAN route that is already saved", () => { + const savedLan = entryWithRoutes( + { target: TAILNET.target, profile: TAILNET.profile, enabled: true }, + [TAILNET, LAN], + ); + expect( + lanCredentialParentId({ + entry: savedLan, + activeRoute: TAILNET, + reported: [{ kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }], + }), + ).toBeNull(); + expect( + mergeLearnedRoutes({ + entry: savedLan, + activeRoute: TAILNET, + reported: [{ kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + lanCredential: true, + }), + ).toBeNull(); + }); + + it("upgrades a cleartext learned route to the LAN credential instead of dropping it", () => { + const stale = learned( + `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@tailnet`, + "http://192.168.1.10:3773/", + ); + const entry = entryWithRoutes( + { target: TAILNET.target, profile: TAILNET.profile, enabled: true }, + [TAILNET, stale, RELAY], + ); + expect(sendsTokenInClear(connectionRoutes(entry), stale)).toBe(true); + const routes = mergeLearnedRoutes({ + entry, + activeRoute: TAILNET, + reported: [{ kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + lanCredential: true, + }); + const learnedId = `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@tailnet`; + expect(ids(routes)).toEqual(["tailnet", "relay", learnedId]); + const upgraded = routes!.find((route) => connectionRouteId(route.target) === learnedId)!; + expect(profileOf(upgraded)).toMatchObject({ lanDpop: true }); + expect(profileOf(upgraded)).not.toHaveProperty("authorization"); + const saved = entryWithRoutes(entry, routes!); + expect(usableRoutes(saved).map((route) => connectionRouteId(route.target))).toContain( + learnedId, + ); + expect(sendsTokenInClear(connectionRoutes(saved), upgraded)).toBe(false); + }); + + it("upgrades a T3 Connect LAN route when the tailnet connection has a LAN credential", () => { + const saved = entryWithRoutes( + { target: TAILNET.target, profile: TAILNET.profile, enabled: true }, + [TAILNET, RELAY], + ); + const reported = [{ httpBaseUrl: "http://192.168.1.10:3773/", kind: "lan" as const }]; + const fromRelay = mergeLearnedRoutes({ + entry: saved, + activeRoute: RELAY, + reported, + allowInsecure: true, + }); + expect(ids(fromRelay)).toEqual([ + "tailnet", + "relay", + `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773`, + ]); + expect(profileOf(fromRelay![2]!)).toMatchObject({ authorization: "t3-connect" }); + const relayEntry = entryWithRoutes(saved, fromRelay!); + const [discovered] = discoveredRoutes(relayEntry, ["http://192.168.1.22:3773/"]); + expect(connectionRouteId(discovered!.target)).toBe( + `learned:${ENVIRONMENT_ID}:http://192.168.1.22:3773@tailnet`, + ); + expect(profileOf(discovered!)).toMatchObject({ lanDpop: true }); + const fromTailnet = mergeLearnedRoutes({ + entry: relayEntry, + activeRoute: TAILNET, + reported, + allowInsecure: true, + lanCredential: true, + }); + const learnedId = `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@tailnet`; + expect(ids(fromTailnet)).toEqual(["tailnet", "relay", learnedId]); + expect(profileOf(fromTailnet![2]!)).toMatchObject({ lanDpop: true }); + expect(profileOf(fromTailnet![2]!)).not.toHaveProperty("authorization"); + const again = mergeLearnedRoutes({ + entry: entryWithRoutes(relayEntry, fromTailnet!), + activeRoute: TAILNET, + reported, + allowInsecure: true, + lanCredential: true, + }); + expect(again).toBeNull(); + }); + + it.effect("keeps a user relay-first order ahead of a new LAN credential route", () => + Effect.gen(function* () { + const saved = entryWithRoutes( + { target: RELAY.target, profile: RELAY.profile, enabled: true }, + [RELAY, TAILNET], + ); + const learned = mergeLearnedRoutes({ + entry: saved, + activeRoute: TAILNET, + reported: [{ httpBaseUrl: "http://192.168.1.10:3773/", kind: "lan" }], + allowInsecure: true, + lanCredential: true, + }); + const learnedId = `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@tailnet`; + expect(ids(learned)).toEqual(["relay", "tailnet", learnedId]); + const connected = yield* firstAnswered(learned!); + expect(connected).toEqual(["relay"]); + const tailnetSilent: RouteCheck = "silent"; + const answered: RouteCheck = "answered"; + const fallback = yield* firstAnswered(learned!, (route) => + connectionRouteId(route.target) === "tailnet" ? tailnetSilent : answered, + ); + expect(fallback).toEqual(["relay"]); + }), + ); }); + +function firstAnswered( + routes: ReadonlyArray, + check: (route: ConnectionRoute) => RouteCheck = () => "answered", +) { + const entry = entryWithRoutes( + { target: routes[0]!.target, profile: routes[0]!.profile, enabled: true }, + routes, + ); + return Effect.scoped( + Effect.gen(function* () { + const tried = yield* Ref.make>([]); + yield* connectOverRoutes( + entry, + (route) => Effect.succeed(check(route)), + (route) => + Ref.update(tried, (current) => [...current, connectionRouteId(route.target)]).pipe( + Effect.as({} as EnvironmentConnectionLease), + ), + ); + return yield* Ref.get(tried); + }), + ); +} diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 74ed30e4f1a7e..2671af9d90263 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -121,16 +121,41 @@ const ROUTE_KIND_RANK: Record = { relay: 5, }; +export function isLanDpopRoute(route: ConnectionRoute): boolean { + const profile = Option.getOrNull(route.profile); + return profile?._tag === "BearerConnectionProfile" && profile.lanDpop === true; +} + +const CLEARTEXT_LAN_FALLBACK_RANK = ROUTE_KIND_RANK.relay + 1; + +function isCleartextLanFallback(route: ConnectionRoute): boolean { + const profile = Option.getOrNull(route.profile); + if ( + profile?._tag !== "BearerConnectionProfile" || + !profile.httpBaseUrl.startsWith("http:") || + connectionRouteKind(route) !== "lan" + ) { + return false; + } + return profile.lanDpop === true || profile.authorization === "t3-connect"; +} + +const routeRank = (route: ConnectionRoute) => + isCleartextLanFallback(route) + ? CLEARTEXT_LAN_FALLBACK_RANK + : ROUTE_KIND_RANK[connectionRouteKind(route)]; + export function insertRoute( routes: ReadonlyArray, route: ConnectionRoute, { aheadOfSameKind = false }: { readonly aheadOfSameKind?: boolean } = {}, ): ReadonlyArray { const kind = connectionRouteKind(route); - const rank = ROUTE_KIND_RANK[kind]; + const rank = routeRank(route); const index = routes.findIndex((existing) => { - const existingKind = connectionRouteKind(existing); - return ROUTE_KIND_RANK[existingKind] > rank || (aheadOfSameKind && existingKind === kind); + return ( + routeRank(existing) > rank || (aheadOfSameKind && connectionRouteKind(existing) === kind) + ); }); return index === -1 ? [...routes, route] @@ -218,6 +243,7 @@ export function mergeLearnedRoutes(input: { readonly reported: ReadonlyArray; /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */ readonly allowInsecure: boolean; + readonly lanCredential?: boolean; }): ReadonlyArray | null { const { entry } = input; const active = input.activeRoute.target; @@ -236,6 +262,14 @@ export function mergeLearnedRoutes(input: { authorization === undefined && active._tag === "BearerConnectionTarget" ? credentialConnectionId(active.connectionId) : undefined; + const savedParentId = savedLanCredentialParentId(saved); + const activeParentId = + sharedCredential !== undefined && !tokenAllowsPlainHttp(saved, sharedCredential) + ? sharedCredential + : null; + const lanParentId = activeParentId ?? savedParentId; + const canUseLanCredential = + lanParentId !== null && (input.lanCredential === true || isLanDpopRoute(input.activeRoute)); const reported = new Map(); const tailscaleOrigins = new Set(); for (const endpoint of input.reported) { @@ -269,6 +303,13 @@ export function mergeLearnedRoutes(input: { ]; } if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return []; + if ( + canUseLanCredential && + lanParentId !== null && + replacesWithLanCredential(route, url, lanParentId, tailscaleOrigins) + ) { + return []; + } if (sendsTokenInClear(saved, route, tailscaleOrigins.has(normalized(url)))) return []; known.add(normalized(url)); return [withNetwork(route, tailscaleOrigins.has(normalized(url)))]; @@ -276,19 +317,18 @@ export function mergeLearnedRoutes(input: { let next: ReadonlyArray = kept; for (const url of reported.values()) { if (known.has(url.origin)) continue; - if ( + const needsLanCredential = url.protocol === "http:" && sharedCredential !== undefined && !tailscaleOrigins.has(url.origin) && - !tokenAllowsPlainHttp(saved, sharedCredential) - ) { - continue; - } + !tokenAllowsPlainHttp(saved, sharedCredential); + if (needsLanCredential && !canUseLanCredential) continue; + const lanDpop = needsLanCredential && canUseLanCredential && lanParentId !== null; const httpBaseUrl = `${url.origin}/`; const connectionId = learnedConnectionId( entry.target.environmentId, url.origin, - sharedCredential, + lanDpop ? lanParentId : sharedCredential, ); next = insertRoute(next, { target: new BearerConnectionTarget({ @@ -304,7 +344,8 @@ export function mergeLearnedRoutes(input: { httpBaseUrl, wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`, learned: true, - ...(authorization === undefined ? {} : { authorization }), + ...(lanDpop || authorization === undefined ? {} : { authorization }), + ...(lanDpop ? { lanDpop: true as const } : {}), ...(tailscaleOrigins.has(url.origin) ? { network: "tailscale" as const } : {}), }), ), @@ -312,14 +353,56 @@ export function mergeLearnedRoutes(input: { } const signature = (routes: ReadonlyArray) => routes - .map( - (route) => - `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""} ${connectionRouteKind(route)}`, - ) + .map((route) => { + const profile = Option.getOrNull(route.profile); + const bearer = profile?._tag === "BearerConnectionProfile" ? profile : null; + return `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""} ${connectionRouteKind(route)} ${bearer?.lanDpop === true ? "lanDpop" : ""} ${bearer?.authorization ?? ""}`; + }) .join("\n"); return signature(saved) === signature(next) ? null : next; } +function replacesWithLanCredential( + route: ConnectionRoute, + httpBaseUrl: string, + parentId: string, + tailscaleOrigins: ReadonlySet, +): boolean { + let url: URL; + try { + url = new URL(httpBaseUrl); + } catch { + return false; + } + if (url.protocol !== "http:" || tailscaleOrigins.has(url.origin)) return false; + const profile = Option.getOrNull(route.profile); + return !( + connectionRouteId(route.target) === + learnedConnectionId(route.target.environmentId, url.origin, parentId) && + profile?._tag === "BearerConnectionProfile" && + profile.lanDpop === true && + profile.authorization === undefined + ); +} + +function savedLanCredentialParentId(routes: ReadonlyArray): string | null { + for (const route of routes) { + const profile = Option.getOrNull(route.profile); + if ( + route.target._tag !== "BearerConnectionTarget" || + profile?._tag !== "BearerConnectionProfile" || + profile.learned === true || + profile.authorization === "t3-connect" || + profile.lanDpop === true + ) { + continue; + } + const parentId = credentialConnectionId(route.target.connectionId); + if (!tokenAllowsPlainHttp(routes, parentId)) return parentId; + } + return null; +} + // A paired token goes to a plain HTTP address outside the tailnet only when // it was paired that way, so a token paired over HTTPS, loopback, or the // tailnet keeps to the transports it was issued for. @@ -347,7 +430,11 @@ export function sendsTokenInClear( tailscale?: boolean, ): boolean { const profile = Option.getOrNull(route.profile); - if (profile?._tag !== "BearerConnectionProfile" || profile.authorization === "t3-connect") { + if ( + profile?._tag !== "BearerConnectionProfile" || + profile.authorization === "t3-connect" || + profile.lanDpop === true + ) { return false; } if ((tailscale ?? profile.network === "tailscale") || !profile.httpBaseUrl.startsWith("http:")) { @@ -365,12 +452,65 @@ export function usableRoutes(entry: ConnectionCatalogEntry): ReadonlyArray !sendsTokenInClear(routes, route)); } +export function lanCredentialParentId(input: { + readonly entry: ConnectionCatalogEntry; + readonly activeRoute: ConnectionRoute; + readonly reported: ReadonlyArray; +}): string | null { + const { target } = input.activeRoute; + const profile = Option.getOrNull(input.activeRoute.profile); + if ( + target._tag !== "BearerConnectionTarget" || + profile?._tag !== "BearerConnectionProfile" || + profile.authorization === "t3-connect" || + profile.lanDpop === true + ) { + return null; + } + const saved = connectionRoutes(input.entry); + const parentId = credentialConnectionId(target.connectionId); + if (tokenAllowsPlainHttp(saved, parentId)) return null; + const known = new Set( + saved.flatMap((route) => { + if (isLearned(route)) return []; + const httpBaseUrl = routeHttpBaseUrl(route); + if (httpBaseUrl === null) return []; + try { + return [new URL(httpBaseUrl).origin]; + } catch { + return []; + } + }), + ); + return input.reported.some((endpoint) => acceptedLanOrigin(endpoint, known) !== null) + ? parentId + : null; +} + +function acceptedLanOrigin(endpoint: ReportedEndpoint, known: ReadonlySet): string | null { + if (endpoint.kind === "tailnet") return null; + let url: URL; + try { + url = new URL(endpoint.httpBaseUrl); + } catch { + return null; + } + if (url.protocol !== "http:" || isLocalLoopbackHost(url.hostname) || known.has(url.origin)) { + return null; + } + return url.origin; +} + +export function lanCredentialConnectionId(parentId: string): string { + return `lan-credential:${parentId}`; +} + export function discoveredRoutes( entry: ConnectionCatalogEntry, candidates: ReadonlyArray, ): ReadonlyArray { const saved = connectionRoutes(entry); - const source = saved.find((route) => { + const paired = saved.find((route) => { const profile = Option.getOrNull(route.profile); return ( route.target._tag === "BearerConnectionTarget" && @@ -381,6 +521,16 @@ export function discoveredRoutes( ["lan", "public"].includes(connectionRouteKind(route)) ); }); + const parentId = savedLanCredentialParentId(saved); + const parentRoute = + parentId === null + ? undefined + : saved.find((route) => connectionRouteId(route.target) === parentId); + const learnedOverConnect = saved.some( + (route) => isCleartextLanFallback(route) && !isLanDpopRoute(route), + ); + const source = + paired ?? saved.find(isLanDpopRoute) ?? (learnedOverConnect ? parentRoute : undefined); if (!entry.enabled || !source || source.target._tag !== "BearerConnectionTarget") return []; const known = new Set( saved @@ -402,7 +552,7 @@ export function discoveredRoutes( const connectionId = learnedConnectionId( entry.target.environmentId, url.origin, - source.target.connectionId, + credentialConnectionId(source.target.connectionId), ); const route: ConnectionRoute = { discovery: true, @@ -419,6 +569,7 @@ export function discoveredRoutes( httpBaseUrl: `${url.origin}/`, wsBaseUrl: `ws://${url.host}/`, learned: true, + ...(paired === undefined ? { lanDpop: true as const } : {}), }), ), }; @@ -447,6 +598,7 @@ function withNetwork(route: ConnectionRoute, tailscale: boolean): ConnectionRout wsBaseUrl: profile.wsBaseUrl, ...(profile.learned === undefined ? {} : { learned: profile.learned }), ...(profile.authorization === undefined ? {} : { authorization: profile.authorization }), + ...(profile.lanDpop === undefined ? {} : { lanDpop: profile.lanDpop }), ...(tailscale ? { network: "tailscale" as const } : {}), }), ), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 0b238bce2fa35..c5cb352a098e2 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -261,6 +261,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: Layer.succeed( ConnectionDriver.ConnectionDriver, ConnectionDriver.ConnectionDriver.of({ + mintLanCredential: () => Effect.die("Unexpected LAN credential mint."), connect, preflight: (_entry, route) => options?.preflight?.(route) ?? @@ -2121,6 +2122,14 @@ describe("EnvironmentSupervisor routes", () => { expect((yield* SubscriptionRef.get(supervisor.state)).generation).toBe(2); return; } + if (discovery === "initial") { + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect((yield* SubscriptionRef.get(supervisor.state)).generation).toBe(1); + expect( + Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag, + ).toBe("RelayConnectionTarget"); + return; + } if (discovery === "withdrawn" || discovery === "omitted") { yield* Deferred.await(discoveryProcessed); expect(yield* Ref.get(learned)).toContain( @@ -2137,8 +2146,62 @@ describe("EnvironmentSupervisor routes", () => { "BearerConnectionTarget", ); expect(yield* Ref.get(learned)).toEqual([ - `learned:${TARGET.environmentId}:${discovery !== "initial" ? "https://fixture.tail1234.ts.net" : "http://192.168.1.10:3773"}`, + `learned:${TARGET.environmentId}:https://fixture.tail1234.ts.net`, ]); }), ); + it.effect("prefers an answering T3 Connect route over a plain HTTP LAN credential", () => + Effect.gen(function* () { + const tailnetTarget = new BearerConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, + connectionId: "tailnet", + }); + const tailnetRoute: ConnectionRoute = { + target: tailnetTarget, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "tailnet", + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "https://desk.tail1234.ts.net/", + wsBaseUrl: "wss://desk.tail1234.ts.net/", + }), + ), + }; + const relayRoute: ConnectionRoute = { target: RELAY_TARGET, profile: Option.none() }; + const saved = entryWithRoutes( + { target: tailnetTarget, profile: tailnetRoute.profile, enabled: true }, + [tailnetRoute, relayRoute], + ); + const merged = mergeLearnedRoutes({ + entry: saved, + activeRoute: tailnetRoute, + reported: [{ kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }], + allowInsecure: true, + lanCredential: true, + }); + const entry = entryWithRoutes(saved, merged!); + const harness = yield* makeHarness({ + checkRoute: (route) => { + if (route.target._tag === "RelayConnectionTarget") return Effect.succeed("answered"); + const profile = Option.getOrNull(route.profile); + const http = profile?._tag === "BearerConnectionProfile" ? profile.httpBaseUrl : ""; + return Effect.succeed(http.startsWith("https:") ? "silent" : "answered"); + }, + prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), + }); + const supervisor = yield* EnvironmentSupervisor.make(entry, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( + "RelayConnectionTarget", + ); + }), + ); }); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 62bfc4f229ad9..0ea8274f85827 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -131,6 +131,8 @@ function makeHarness(reply: (requestNumber: number) => Response | Promise Effect.die("Unexpected bearer connection preparation."), authorizeDpop: () => Effect.die("HTTP requests must not prepare a WebSocket connection."), + authorizeLanDpop: () => Effect.die("HTTP requests must not prepare a WebSocket connection."), + mintLanCredential: () => Effect.die("HTTP requests must not mint a LAN credential."), authorizeDpopHttp: (input) => Effect.sync(() => { authorizations.push(input); @@ -359,6 +361,28 @@ describe("authenticated environment HTTP requests", () => { }), ); + it.effect("signs a LAN credential request without going through T3 Connect", () => + Effect.gen(function* () { + const harness = makeHarness(() => credentialRejectedResponse()); + const exit = yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({ + ...harness.input, + prepared: { + ...PREPARED, + httpBaseUrl: "http://192.168.1.22:3773", + httpAuthorization: { _tag: "LanDpop", accessToken: "lan-token" }, + }, + diff: DIFF, + }).pipe(Effect.provide(harness.httpLayer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(harness.authorizations).toEqual([]); + expect(harness.calls).toHaveLength(1); + const headers = new Headers(harness.calls[0]!.init.headers); + expect(headers.get("authorization")).toBe("DPoP lan-token"); + expect(headers.get("dpop")).toBe("proof-1"); + }), + ); + it.effect("retries a rejected diff once with a new token, endpoint, and proof", () => Effect.gen(function* () { const harness = makeHarness((requestNumber) => diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 160b82664885d..32eecd16c9920 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -104,6 +104,7 @@ export type EnvironmentAuthInvalidReason = typeof EnvironmentAuthInvalidReason.T export const EnvironmentOperationForbiddenReason = Schema.Literals([ "current_session_revoke_not_allowed", + "lan_credential_not_allowed", ]); export type EnvironmentOperationForbiddenReason = typeof EnvironmentOperationForbiddenReason.Type; @@ -349,6 +350,11 @@ const EnvironmentPairingCredentialErrors = [ EnvironmentHttpForbiddenError, ...EnvironmentScopedOperationErrors, ] as const; +const EnvironmentLanCredentialErrors = [ + EnvironmentOperationForbiddenError, + EnvironmentAuthInvalidError, + EnvironmentInternalError, +] as const; const EnvironmentSessionRevokeErrors = [ EnvironmentScopeRequiredError, EnvironmentOperationForbiddenError, @@ -571,6 +577,13 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") error: [EnvironmentInternalError], }).middleware(EnvironmentAuthenticatedAuth), ) + .add( + HttpApiEndpoint.post("lanCredential", "/api/auth/lan-credential", { + headers: OptionalBearerHeaders, + success: AuthAccessTokenResult, + error: EnvironmentLanCredentialErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) .add( HttpApiEndpoint.post("pairingCredential", "/api/auth/pairing-token", { headers: OptionalBearerHeaders,