From 553ba54f24c72e3626c8fe9a556fd0728eb0a44f Mon Sep 17 00:00:00 2001 From: "Lucille L. Blumire" Date: Wed, 27 May 2026 16:12:57 +0200 Subject: [PATCH] fix: decode ssh auth date strings --- .../src/ssh/DesktopSshRemoteApi.test.ts | 77 +++++++++++++++++++ apps/desktop/src/ssh/DesktopSshRemoteApi.ts | 12 +-- packages/contracts/src/auth.ts | 24 ++++++ 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts index 8b6798d38cb..e373e7769c8 100644 --- a/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts @@ -1,5 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; @@ -31,6 +32,13 @@ function makeLayer( ); } +const authDescriptor = { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-session-token"], + sessionCookieName: "t3_session", +} as const; + describe("DesktopSshRemoteApi", () => { it.effect("fetches and decodes the remote environment descriptor", () => { const requestUrls: string[] = []; @@ -58,6 +66,75 @@ describe("DesktopSshRemoteApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("decodes auth timestamp strings returned by remote JSON endpoints", () => { + const expiresAt = "2026-06-26T13:15:36.023Z"; + const requestUrls: string[] = []; + const layer = makeLayer((request) => + Effect.sync(() => { + requestUrls.push(request.url); + + switch (new URL(request.url).pathname) { + case "/api/auth/bootstrap/bearer": + return jsonResponse(request, { + authenticated: true, + role: "client", + sessionMethod: "bearer-session-token", + expiresAt, + sessionToken: "session-token", + }); + case "/api/auth/session": + return jsonResponse(request, { + authenticated: true, + auth: authDescriptor, + role: "client", + sessionMethod: "bearer-session-token", + expiresAt, + }); + case "/api/auth/ws-token": + return jsonResponse(request, { + token: "websocket-token", + expiresAt, + }); + default: + return jsonResponse(request, { error: "unexpected request" }, 404); + } + }), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + + const bootstrap = yield* remoteApi.bootstrapBearerSession({ + httpBaseUrl: "http://127.0.0.1:41773/", + credential: "pairing-token", + }); + assert.equal(bootstrap.sessionToken, "session-token"); + assert.equal(DateTime.formatIso(bootstrap.expiresAt), expiresAt); + + const session = yield* remoteApi.fetchSessionState({ + httpBaseUrl: "http://127.0.0.1:41773/", + bearerToken: bootstrap.sessionToken, + }); + if (!session.expiresAt) { + throw new Error("expected authenticated session expiry"); + } + assert.equal(DateTime.formatIso(session.expiresAt), expiresAt); + + const websocket = yield* remoteApi.issueWebSocketToken({ + httpBaseUrl: "http://127.0.0.1:41773/", + bearerToken: bootstrap.sessionToken, + }); + assert.equal(websocket.token, "websocket-token"); + assert.equal(DateTime.formatIso(websocket.expiresAt), expiresAt); + + assert.deepEqual(requestUrls, [ + "http://127.0.0.1:41773/api/auth/bootstrap/bearer", + "http://127.0.0.1:41773/api/auth/session", + "http://127.0.0.1:41773/api/auth/ws-token", + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect("wraps schema decode failures in a typed remote api error", () => { const layer = makeLayer((request) => Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts index 60184d098a6..8c0e3961b55 100644 --- a/apps/desktop/src/ssh/DesktopSshRemoteApi.ts +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts @@ -1,7 +1,7 @@ import { - AuthBearerBootstrapResult, - AuthSessionState, - AuthWebSocketTokenResult, + AuthBearerBootstrapJsonResult, + AuthSessionJsonState, + AuthWebSocketTokenJsonResult, type AuthBearerBootstrapResult as AuthBearerBootstrapResultType, type AuthSessionState as AuthSessionStateType, type AuthWebSocketTokenResult as AuthWebSocketTokenResultType, @@ -58,9 +58,9 @@ export class DesktopSshRemoteApi extends Context.Service< const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect( ExecutionEnvironmentDescriptor, ); -const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult); -const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState); -const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult); +const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapJsonResult); +const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionJsonState); +const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenJsonResult); const mapError = (operation: DesktopSshRemoteApiOperation) => diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 8439d12b069..0c7afdf562e 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -122,12 +122,27 @@ export const AuthBearerBootstrapResult = Schema.Struct({ }); export type AuthBearerBootstrapResult = typeof AuthBearerBootstrapResult.Type; +export const AuthBearerBootstrapJsonResult = Schema.Struct({ + authenticated: Schema.Literal(true), + role: AuthSessionRole, + sessionMethod: Schema.Literal("bearer-session-token"), + expiresAt: Schema.DateTimeUtcFromString, + sessionToken: TrimmedNonEmptyString, +}); +export type AuthBearerBootstrapJsonResult = typeof AuthBearerBootstrapJsonResult.Type; + export const AuthWebSocketTokenResult = Schema.Struct({ token: TrimmedNonEmptyString, expiresAt: Schema.DateTimeUtc, }); export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type; +export const AuthWebSocketTokenJsonResult = Schema.Struct({ + token: TrimmedNonEmptyString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type AuthWebSocketTokenJsonResult = typeof AuthWebSocketTokenJsonResult.Type; + export const AuthPairingCredentialResult = Schema.Struct({ id: TrimmedNonEmptyString, credential: TrimmedNonEmptyString, @@ -264,3 +279,12 @@ export const AuthSessionState = Schema.Struct({ expiresAt: Schema.optionalKey(Schema.DateTimeUtc), }); export type AuthSessionState = typeof AuthSessionState.Type; + +export const AuthSessionJsonState = Schema.Struct({ + authenticated: Schema.Boolean, + auth: ServerAuthDescriptor, + role: Schema.optionalKey(AuthSessionRole), + sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), + expiresAt: Schema.optionalKey(Schema.DateTimeUtcFromString), +}); +export type AuthSessionJsonState = typeof AuthSessionJsonState.Type;