| author | |
| committer | |
| log | a04c09a190ac78e23ca63971067ff0fc2417bcca |
| tree | 46986704d13b6ab819d3aea5426049a2eaf8c15f |
| parent | e3f14058847dbd36695277aab941fefa94e21e5f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>102 files changed, 6822 insertions(+), 4876 deletions(-)
apps/desktop/package.json+1| ... | ... | @@ -16,6 +16,7 @@ |
| 16 | 16 | }, |
| 17 | 17 | "dependencies": { |
| 18 | 18 | "@effect/platform-node": "catalog:", |
| 19 | "@t3tools/client-runtime": "workspace:*", | |
| 19 | 20 | "@t3tools/contracts": "workspace:*", |
| 20 | 21 | "@t3tools/shared": "workspace:*", |
| 21 | 22 | "@t3tools/ssh": "workspace:*", |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2-2| ... | ... | @@ -22,7 +22,7 @@ import { |
| 22 | 22 | ensureSshEnvironment, |
| 23 | 23 | fetchSshEnvironmentDescriptor, |
| 24 | 24 | fetchSshSessionState, |
| 25 | issueSshWebSocketToken, | |
| 25 | issueSshWebSocketTicket, | |
| 26 | 26 | resolveSshPasswordPrompt, |
| 27 | 27 | } from "./methods/sshEnvironment.ts"; |
| 28 | 28 | import { |
| ... | ... | @@ -62,7 +62,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 62 | 62 | yield* ipc.handle(fetchSshEnvironmentDescriptor); |
| 63 | 63 | yield* ipc.handle(bootstrapSshBearerSession); |
| 64 | 64 | yield* ipc.handle(fetchSshSessionState); |
| 65 | yield* ipc.handle(issueSshWebSocketToken); | |
| 65 | yield* ipc.handle(issueSshWebSocketTicket); | |
| 66 | 66 | yield* ipc.handle(resolveSshPasswordPrompt); |
| 67 | 67 | |
| 68 | 68 | yield* ipc.handle(getServerExposureState); |
apps/desktop/src/ipc/methods/sshEnvironment.test.ts created+115| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import { SshHttpBridgeError } from "@t3tools/ssh/errors"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Exit from "effect/Exit"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 9 | ||
| 10 | import { | |
| 11 | DesktopSshEnvironmentRequestError, | |
| 12 | fetchSshEnvironmentDescriptor, | |
| 13 | } from "./sshEnvironment.ts"; | |
| 14 | ||
| 15 | function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { | |
| 16 | return HttpClientResponse.fromWeb( | |
| 17 | request, | |
| 18 | new Response(JSON.stringify(body), { | |
| 19 | status, | |
| 20 | headers: { "content-type": "application/json" }, | |
| 21 | }), | |
| 22 | ); | |
| 23 | } | |
| 24 | ||
| 25 | function makeHttpClientLayer( | |
| 26 | handler: ( | |
| 27 | request: HttpClientRequest.HttpClientRequest, | |
| 28 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>, | |
| 29 | ) { | |
| 30 | return Layer.succeed( | |
| 31 | HttpClient.HttpClient, | |
| 32 | HttpClient.make((request) => handler(request)), | |
| 33 | ); | |
| 34 | } | |
| 35 | ||
| 36 | describe("SSH environment IPC", () => { | |
| 37 | it.effect("fetches and decodes the remote environment descriptor", () => { | |
| 38 | const requestUrls: string[] = []; | |
| 39 | const layer = makeHttpClientLayer((request) => | |
| 40 | Effect.sync(() => { | |
| 41 | requestUrls.push(request.url); | |
| 42 | return jsonResponse(request, { | |
| 43 | environmentId: "remote-env", | |
| 44 | label: "Remote Devbox", | |
| 45 | platform: { os: "linux", arch: "x64" }, | |
| 46 | serverVersion: "1.2.3", | |
| 47 | capabilities: { repositoryIdentity: true }, | |
| 48 | }); | |
| 49 | }), | |
| 50 | ); | |
| 51 | ||
| 52 | return Effect.gen(function* () { | |
| 53 | const descriptor = yield* fetchSshEnvironmentDescriptor.handler({ | |
| 54 | httpBaseUrl: "http://127.0.0.1:41773/", | |
| 55 | }); | |
| 56 | ||
| 57 | assert.deepEqual(descriptor, { | |
| 58 | environmentId: "remote-env", | |
| 59 | label: "Remote Devbox", | |
| 60 | platform: { os: "linux", arch: "x64" }, | |
| 61 | serverVersion: "1.2.3", | |
| 62 | capabilities: { repositoryIdentity: true }, | |
| 63 | }); | |
| 64 | assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]); | |
| 65 | }).pipe(Effect.provide(layer)); | |
| 66 | }); | |
| 67 | ||
| 68 | it.effect("wraps schema decode failures in a typed request error", () => { | |
| 69 | const layer = makeHttpClientLayer((request) => | |
| 70 | Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), | |
| 71 | ); | |
| 72 | ||
| 73 | return Effect.gen(function* () { | |
| 74 | const exit = yield* Effect.exit( | |
| 75 | fetchSshEnvironmentDescriptor.handler({ | |
| 76 | httpBaseUrl: "http://127.0.0.1:41773/", | |
| 77 | }), | |
| 78 | ); | |
| 79 | assert(Exit.isFailure(exit)); | |
| 80 | const failure = Cause.findErrorOption(exit.cause); | |
| 81 | assert(Option.isSome(failure)); | |
| 82 | const error = failure.value; | |
| 83 | ||
| 84 | assert.instanceOf(error, DesktopSshEnvironmentRequestError); | |
| 85 | assert.equal(error.operation, "fetch-environment-descriptor"); | |
| 86 | assert.equal(error.cause instanceof SshHttpBridgeError, false); | |
| 87 | }).pipe(Effect.provide(layer)); | |
| 88 | }); | |
| 89 | ||
| 90 | it.effect("rejects non-loopback HTTP endpoints before issuing a request", () => { | |
| 91 | let requestCount = 0; | |
| 92 | const layer = makeHttpClientLayer((request) => | |
| 93 | Effect.sync(() => { | |
| 94 | requestCount += 1; | |
| 95 | return jsonResponse(request, {}); | |
| 96 | }), | |
| 97 | ); | |
| 98 | ||
| 99 | return Effect.gen(function* () { | |
| 100 | const exit = yield* Effect.exit( | |
| 101 | fetchSshEnvironmentDescriptor.handler({ | |
| 102 | httpBaseUrl: "http://remote.example.com:41773/", | |
| 103 | }), | |
| 104 | ); | |
| 105 | assert(Exit.isFailure(exit)); | |
| 106 | const failure = Cause.findErrorOption(exit.cause); | |
| 107 | assert(Option.isSome(failure)); | |
| 108 | const error = failure.value; | |
| 109 | ||
| 110 | assert.instanceOf(error, DesktopSshEnvironmentRequestError); | |
| 111 | assert.instanceOf(error.cause, SshHttpBridgeError); | |
| 112 | assert.equal(requestCount, 0); | |
| 113 | }).pipe(Effect.provide(layer)); | |
| 114 | }); | |
| 115 | }); |
apps/desktop/src/ipc/methods/sshEnvironment.ts+63-15| ... | ... | @@ -1,3 +1,9 @@ |
| 1 | import { | |
| 2 | bootstrapRemoteBearerSession, | |
| 3 | fetchRemoteEnvironmentDescriptor, | |
| 4 | fetchRemoteSessionState, | |
| 5 | issueRemoteWebSocketTicket, | |
| 6 | } from "@t3tools/client-runtime"; | |
| 1 | 7 | import { |
| 2 | 8 | DesktopDiscoveredSshHostSchema, |
| 3 | 9 | DesktopSshBearerBootstrapInputSchema, |
| ... | ... | @@ -9,10 +15,12 @@ import { |
| 9 | 15 | DesktopSshPasswordPromptCancelledType, |
| 10 | 16 | DesktopSshPasswordPromptResolutionInputSchema, |
| 11 | 17 | ExecutionEnvironmentDescriptor, |
| 12 | AuthBearerBootstrapResult, | |
| 18 | AuthAccessTokenResult, | |
| 13 | 19 | AuthSessionState, |
| 14 | AuthWebSocketTokenResult, | |
| 20 | AuthWebSocketTicketResult, | |
| 15 | 21 | } from "@t3tools/contracts"; |
| 22 | import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; | |
| 23 | import * as Data from "effect/Data"; | |
| 16 | 24 | import * as Effect from "effect/Effect"; |
| 17 | 25 | import * as Schema from "effect/Schema"; |
| 18 | 26 | |
| ... | ... | @@ -20,7 +28,34 @@ import * as IpcChannels from "../channels.ts"; |
| 20 | 28 | import { makeIpcMethod } from "../DesktopIpc.ts"; |
| 21 | 29 | import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; |
| 22 | 30 | import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; |
| 23 | import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts"; | |
| 31 | ||
| 32 | type DesktopSshEnvironmentRequestOperation = | |
| 33 | | "fetch-environment-descriptor" | |
| 34 | | "bootstrap-bearer-session" | |
| 35 | | "fetch-session-state" | |
| 36 | | "issue-websocket-ticket"; | |
| 37 | ||
| 38 | export class DesktopSshEnvironmentRequestError extends Data.TaggedError( | |
| 39 | "DesktopSshEnvironmentRequestError", | |
| 40 | )<{ | |
| 41 | readonly operation: DesktopSshEnvironmentRequestOperation; | |
| 42 | readonly cause: unknown; | |
| 43 | }> { | |
| 44 | override get message() { | |
| 45 | return `SSH remote API request failed during ${this.operation}.`; | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | const withLoopbackSshApi = | |
| 50 | <A, E, R>( | |
| 51 | operation: DesktopSshEnvironmentRequestOperation, | |
| 52 | use: (httpBaseUrl: string) => Effect.Effect<A, E, R>, | |
| 53 | ) => | |
| 54 | (httpBaseUrl: string): Effect.Effect<A, DesktopSshEnvironmentRequestError, R> => | |
| 55 | resolveLoopbackSshHttpBaseUrl(httpBaseUrl).pipe( | |
| 56 | Effect.flatMap(use), | |
| 57 | Effect.mapError((cause) => new DesktopSshEnvironmentRequestError({ operation, cause })), | |
| 58 | ); | |
| 24 | 59 | |
| 25 | 60 | export const discoverSshHosts = makeIpcMethod({ |
| 26 | 61 | channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, |
| ... | ... | @@ -69,21 +104,26 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({ |
| 69 | 104 | payload: DesktopSshHttpBaseUrlInputSchema, |
| 70 | 105 | result: ExecutionEnvironmentDescriptor, |
| 71 | 106 | handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) { |
| 72 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 73 | return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl }); | |
| 107 | return yield* withLoopbackSshApi("fetch-environment-descriptor", (resolvedHttpBaseUrl) => | |
| 108 | fetchRemoteEnvironmentDescriptor({ httpBaseUrl: resolvedHttpBaseUrl }), | |
| 109 | )(httpBaseUrl); | |
| 74 | 110 | }), |
| 75 | 111 | }); |
| 76 | 112 | |
| 77 | 113 | export const bootstrapSshBearerSession = makeIpcMethod({ |
| 78 | 114 | channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, |
| 79 | 115 | payload: DesktopSshBearerBootstrapInputSchema, |
| 80 | result: AuthBearerBootstrapResult, | |
| 116 | result: AuthAccessTokenResult, | |
| 81 | 117 | handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({ |
| 82 | 118 | httpBaseUrl, |
| 83 | 119 | credential, |
| 84 | 120 | }) { |
| 85 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 86 | return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential }); | |
| 121 | return yield* withLoopbackSshApi("bootstrap-bearer-session", (resolvedHttpBaseUrl) => | |
| 122 | bootstrapRemoteBearerSession({ | |
| 123 | httpBaseUrl: resolvedHttpBaseUrl, | |
| 124 | credential, | |
| 125 | }), | |
| 126 | )(httpBaseUrl); | |
| 87 | 127 | }), |
| 88 | 128 | }); |
| 89 | 129 | |
| ... | ... | @@ -95,21 +135,29 @@ export const fetchSshSessionState = makeIpcMethod({ |
| 95 | 135 | httpBaseUrl, |
| 96 | 136 | bearerToken, |
| 97 | 137 | }) { |
| 98 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 99 | return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken }); | |
| 138 | return yield* withLoopbackSshApi("fetch-session-state", (resolvedHttpBaseUrl) => | |
| 139 | fetchRemoteSessionState({ | |
| 140 | httpBaseUrl: resolvedHttpBaseUrl, | |
| 141 | bearerToken, | |
| 142 | }), | |
| 143 | )(httpBaseUrl); | |
| 100 | 144 | }), |
| 101 | 145 | }); |
| 102 | 146 | |
| 103 | export const issueSshWebSocketToken = makeIpcMethod({ | |
| 147 | export const issueSshWebSocketTicket = makeIpcMethod({ | |
| 104 | 148 | channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, |
| 105 | 149 | payload: DesktopSshBearerRequestInputSchema, |
| 106 | result: AuthWebSocketTokenResult, | |
| 107 | handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({ | |
| 150 | result: AuthWebSocketTicketResult, | |
| 151 | handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({ | |
| 108 | 152 | httpBaseUrl, |
| 109 | 153 | bearerToken, |
| 110 | 154 | }) { |
| 111 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 112 | return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); | |
| 155 | return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) => | |
| 156 | issueRemoteWebSocketTicket({ | |
| 157 | httpBaseUrl: resolvedHttpBaseUrl, | |
| 158 | bearerToken, | |
| 159 | }), | |
| 160 | )(httpBaseUrl); | |
| 113 | 161 | }), |
| 114 | 162 | }); |
| 115 | 163 |
apps/desktop/src/main.ts+1-2| ... | ... | @@ -40,7 +40,6 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; |
| 40 | 40 | import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; |
| 41 | 41 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 42 | 42 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 43 | import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; | |
| 44 | 43 | import * as DesktopState from "./app/DesktopState.ts"; |
| 45 | 44 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 46 | 45 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| ... | ... | @@ -116,7 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll( |
| 116 | 115 | DesktopObservability.layer, |
| 117 | 116 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); |
| 118 | 117 | |
| 119 | const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( | |
| 118 | const desktopSshLayer = desktopSshEnvironmentLayer.pipe( | |
| 120 | 119 | Layer.provideMerge(DesktopSshPasswordPrompts.layer()), |
| 121 | 120 | ); |
| 122 | 121 |
apps/desktop/src/preload.ts+1-1| ... | ... | @@ -66,7 +66,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 66 | 66 | }), |
| 67 | 67 | fetchSshSessionState: (httpBaseUrl, bearerToken) => |
| 68 | 68 | ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), |
| 69 | issueSshWebSocketToken: (httpBaseUrl, bearerToken) => | |
| 69 | issueSshWebSocketTicket: (httpBaseUrl, bearerToken) => | |
| 70 | 70 | ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), |
| 71 | 71 | onSshPasswordPrompt: (listener) => { |
| 72 | 72 | const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { |
apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts deleted-79| ... | ... | @@ -1,79 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import { SshHttpBridgeError } from "@t3tools/ssh/errors"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 6 | ||
| 7 | import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts"; | |
| 8 | ||
| 9 | function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { | |
| 10 | return HttpClientResponse.fromWeb( | |
| 11 | request, | |
| 12 | new Response(JSON.stringify(body), { | |
| 13 | status, | |
| 14 | headers: { "content-type": "application/json" }, | |
| 15 | }), | |
| 16 | ); | |
| 17 | } | |
| 18 | ||
| 19 | function makeLayer( | |
| 20 | handler: ( | |
| 21 | request: HttpClientRequest.HttpClientRequest, | |
| 22 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse>, | |
| 23 | ) { | |
| 24 | return DesktopSshRemoteApi.layer.pipe( | |
| 25 | Layer.provide( | |
| 26 | Layer.succeed( | |
| 27 | HttpClient.HttpClient, | |
| 28 | HttpClient.make((request) => handler(request)), | |
| 29 | ), | |
| 30 | ), | |
| 31 | ); | |
| 32 | } | |
| 33 | ||
| 34 | describe("DesktopSshRemoteApi", () => { | |
| 35 | it.effect("fetches and decodes the remote environment descriptor", () => { | |
| 36 | const requestUrls: string[] = []; | |
| 37 | const layer = makeLayer((request) => | |
| 38 | Effect.sync(() => { | |
| 39 | requestUrls.push(request.url); | |
| 40 | return jsonResponse(request, { | |
| 41 | environmentId: "remote-env", | |
| 42 | label: "Remote Devbox", | |
| 43 | platform: { os: "linux", arch: "x64" }, | |
| 44 | serverVersion: "1.2.3", | |
| 45 | capabilities: { repositoryIdentity: true }, | |
| 46 | }); | |
| 47 | }), | |
| 48 | ); | |
| 49 | ||
| 50 | return Effect.gen(function* () { | |
| 51 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 52 | const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({ | |
| 53 | httpBaseUrl: "http://127.0.0.1:41773/", | |
| 54 | }); | |
| 55 | ||
| 56 | assert.equal(descriptor.label, "Remote Devbox"); | |
| 57 | assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]); | |
| 58 | }).pipe(Effect.provide(layer)); | |
| 59 | }); | |
| 60 | ||
| 61 | it.effect("wraps schema decode failures in a typed remote api error", () => { | |
| 62 | const layer = makeLayer((request) => | |
| 63 | Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), | |
| 64 | ); | |
| 65 | ||
| 66 | return Effect.gen(function* () { | |
| 67 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 68 | const error = yield* remoteApi | |
| 69 | .fetchEnvironmentDescriptor({ | |
| 70 | httpBaseUrl: "http://127.0.0.1:41773/", | |
| 71 | }) | |
| 72 | .pipe(Effect.flip); | |
| 73 | ||
| 74 | assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError); | |
| 75 | assert.equal(error.operation, "fetch-environment-descriptor"); | |
| 76 | assert.equal(error.cause instanceof SshHttpBridgeError, false); | |
| 77 | }).pipe(Effect.provide(layer)); | |
| 78 | }); | |
| 79 | }); |
apps/desktop/src/ssh/DesktopSshRemoteApi.ts deleted-124| ... | ... | @@ -1,124 +0,0 @@ |
| 1 | import { | |
| 2 | AuthBearerBootstrapResult, | |
| 3 | AuthSessionState, | |
| 4 | AuthWebSocketTokenResult, | |
| 5 | type AuthBearerBootstrapResult as AuthBearerBootstrapResultType, | |
| 6 | type AuthSessionState as AuthSessionStateType, | |
| 7 | type AuthWebSocketTokenResult as AuthWebSocketTokenResultType, | |
| 8 | ExecutionEnvironmentDescriptor, | |
| 9 | type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { SshHttpBridgeError } from "@t3tools/ssh/errors"; | |
| 12 | import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel"; | |
| 13 | import * as Context from "effect/Context"; | |
| 14 | import * as Data from "effect/Data"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Schema from "effect/Schema"; | |
| 18 | import { HttpClient } from "effect/unstable/http"; | |
| 19 | ||
| 20 | export type DesktopSshRemoteApiOperation = | |
| 21 | | "fetch-environment-descriptor" | |
| 22 | | "bootstrap-bearer-session" | |
| 23 | | "fetch-session-state" | |
| 24 | | "issue-websocket-token"; | |
| 25 | ||
| 26 | export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{ | |
| 27 | readonly operation: DesktopSshRemoteApiOperation; | |
| 28 | readonly cause: SshHttpBridgeError | Schema.SchemaError; | |
| 29 | }> { | |
| 30 | override get message() { | |
| 31 | return `SSH remote API request failed during ${this.operation}.`; | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | export interface DesktopSshRemoteApiShape { | |
| 36 | readonly fetchEnvironmentDescriptor: (input: { | |
| 37 | readonly httpBaseUrl: string; | |
| 38 | }) => Effect.Effect<ExecutionEnvironmentDescriptorType, DesktopSshRemoteApiError>; | |
| 39 | readonly bootstrapBearerSession: (input: { | |
| 40 | readonly httpBaseUrl: string; | |
| 41 | readonly credential: string; | |
| 42 | }) => Effect.Effect<AuthBearerBootstrapResultType, DesktopSshRemoteApiError>; | |
| 43 | readonly fetchSessionState: (input: { | |
| 44 | readonly httpBaseUrl: string; | |
| 45 | readonly bearerToken: string; | |
| 46 | }) => Effect.Effect<AuthSessionStateType, DesktopSshRemoteApiError>; | |
| 47 | readonly issueWebSocketToken: (input: { | |
| 48 | readonly httpBaseUrl: string; | |
| 49 | readonly bearerToken: string; | |
| 50 | }) => Effect.Effect<AuthWebSocketTokenResultType, DesktopSshRemoteApiError>; | |
| 51 | } | |
| 52 | ||
| 53 | export class DesktopSshRemoteApi extends Context.Service< | |
| 54 | DesktopSshRemoteApi, | |
| 55 | DesktopSshRemoteApiShape | |
| 56 | >()("@t3tools/desktop/ssh/DesktopSshRemoteApi") {} | |
| 57 | ||
| 58 | const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect( | |
| 59 | ExecutionEnvironmentDescriptor, | |
| 60 | ); | |
| 61 | const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult); | |
| 62 | const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState); | |
| 63 | const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult); | |
| 64 | ||
| 65 | const mapError = | |
| 66 | (operation: DesktopSshRemoteApiOperation) => | |
| 67 | (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError => | |
| 68 | new DesktopSshRemoteApiError({ operation, cause }); | |
| 69 | ||
| 70 | const make = Effect.gen(function* () { | |
| 71 | const httpClient = yield* HttpClient.HttpClient; | |
| 72 | const provideHttpClient = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) => | |
| 73 | effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); | |
| 74 | ||
| 75 | return DesktopSshRemoteApi.of({ | |
| 76 | fetchEnvironmentDescriptor: ({ httpBaseUrl }) => | |
| 77 | fetchLoopbackSshJson<unknown>({ | |
| 78 | httpBaseUrl, | |
| 79 | pathname: "/.well-known/t3/environment", | |
| 80 | }).pipe( | |
| 81 | Effect.flatMap(decodeExecutionEnvironmentDescriptor), | |
| 82 | Effect.mapError(mapError("fetch-environment-descriptor")), | |
| 83 | provideHttpClient, | |
| 84 | Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"), | |
| 85 | ), | |
| 86 | bootstrapBearerSession: ({ httpBaseUrl, credential }) => | |
| 87 | fetchLoopbackSshJson<unknown>({ | |
| 88 | httpBaseUrl, | |
| 89 | pathname: "/api/auth/bootstrap/bearer", | |
| 90 | method: "POST", | |
| 91 | body: { credential }, | |
| 92 | }).pipe( | |
| 93 | Effect.flatMap(decodeAuthBearerBootstrapResult), | |
| 94 | Effect.mapError(mapError("bootstrap-bearer-session")), | |
| 95 | provideHttpClient, | |
| 96 | Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"), | |
| 97 | ), | |
| 98 | fetchSessionState: ({ httpBaseUrl, bearerToken }) => | |
| 99 | fetchLoopbackSshJson<unknown>({ | |
| 100 | httpBaseUrl, | |
| 101 | pathname: "/api/auth/session", | |
| 102 | bearerToken, | |
| 103 | }).pipe( | |
| 104 | Effect.flatMap(decodeAuthSessionState), | |
| 105 | Effect.mapError(mapError("fetch-session-state")), | |
| 106 | provideHttpClient, | |
| 107 | Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"), | |
| 108 | ), | |
| 109 | issueWebSocketToken: ({ httpBaseUrl, bearerToken }) => | |
| 110 | fetchLoopbackSshJson<unknown>({ | |
| 111 | httpBaseUrl, | |
| 112 | pathname: "/api/auth/ws-token", | |
| 113 | method: "POST", | |
| 114 | bearerToken, | |
| 115 | }).pipe( | |
| 116 | Effect.flatMap(decodeAuthWebSocketTokenResult), | |
| 117 | Effect.mapError(mapError("issue-websocket-token")), | |
| 118 | provideHttpClient, | |
| 119 | Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"), | |
| 120 | ), | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 124 | export const layer = Layer.effect(DesktopSshRemoteApi, make); |
apps/mobile/src/lib/connection.test.ts+15-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | 2 | |
| 3 | import { redactPairingCredential } from "./connection"; | |
| 3 | import { mobileAuthClientMetadata, redactPairingCredential } from "./connection"; | |
| 4 | 4 | |
| 5 | 5 | vi.mock("./runtime", () => ({ |
| 6 | 6 | mobileRemoteHttpRuntime: { |
| ... | ... | @@ -8,7 +8,21 @@ vi.mock("./runtime", () => ({ |
| 8 | 8 | }, |
| 9 | 9 | })); |
| 10 | 10 | |
| 11 | vi.mock("react-native", () => ({ | |
| 12 | Platform: { | |
| 13 | OS: "ios", | |
| 14 | }, | |
| 15 | })); | |
| 16 | ||
| 11 | 17 | describe("mobile remote connection records", () => { |
| 18 | it("identifies mobile token exchanges for authorized-client presentation", () => { | |
| 19 | expect(mobileAuthClientMetadata()).toEqual({ | |
| 20 | label: "T3 Code Mobile", | |
| 21 | deviceType: "mobile", | |
| 22 | os: "iOS", | |
| 23 | }); | |
| 24 | }); | |
| 25 | ||
| 12 | 26 | it("removes one-time bootstrap credentials before persisting pairing URLs", () => { |
| 13 | 27 | expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe( |
| 14 | 28 | "https://desktop.example/", |
apps/mobile/src/lib/connection.ts+12-2| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 2 | import { |
| 3 | 3 | bootstrapRemoteBearerSession, |
| 4 | 4 | fetchRemoteEnvironmentDescriptor, |
| 5 | 5 | } from "@t3tools/client-runtime"; |
| 6 | 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; |
| 7 | import { Platform } from "react-native"; | |
| 7 | 8 | import { mobileRemoteHttpRuntime } from "./runtime"; |
| 8 | 9 | |
| 9 | 10 | export interface RemoteConnectionInput { |
| ... | ... | @@ -36,6 +37,14 @@ export function redactPairingCredential(pairingUrl: string): string { |
| 36 | 37 | } |
| 37 | 38 | } |
| 38 | 39 | |
| 40 | export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { | |
| 41 | return { | |
| 42 | label: "T3 Code Mobile", | |
| 43 | deviceType: "mobile", | |
| 44 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), | |
| 45 | }; | |
| 46 | } | |
| 47 | ||
| 39 | 48 | export async function bootstrapRemoteConnection( |
| 40 | 49 | input: RemoteConnectionInput, |
| 41 | 50 | ): Promise<SavedRemoteConnection> { |
| ... | ... | @@ -53,6 +62,7 @@ export async function bootstrapRemoteConnection( |
| 53 | 62 | bootstrapRemoteBearerSession({ |
| 54 | 63 | httpBaseUrl: target.httpBaseUrl, |
| 55 | 64 | credential: target.credential, |
| 65 | clientMetadata: mobileAuthClientMetadata(), | |
| 56 | 66 | }), |
| 57 | 67 | ); |
| 58 | 68 | |
| ... | ... | @@ -63,6 +73,6 @@ export async function bootstrapRemoteConnection( |
| 63 | 73 | displayUrl: target.httpBaseUrl, |
| 64 | 74 | httpBaseUrl: target.httpBaseUrl, |
| 65 | 75 | wsBaseUrl: target.wsBaseUrl, |
| 66 | bearerToken: bootstrap.sessionToken, | |
| 76 | bearerToken: bootstrap.access_token, | |
| 67 | 77 | }; |
| 68 | 78 | } |
apps/server/src/auth/EnvironmentAuth.test.ts created+261| ... | ... | @@ -0,0 +1,261 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { AuthAdministrativeScopes } from "@t3tools/contracts"; | |
| 3 | import { expect, it } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | ||
| 7 | import type { ServerConfigShape } from "../config.ts"; | |
| 8 | import { ServerConfig } from "../config.ts"; | |
| 9 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 10 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | |
| 11 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | |
| 12 | ||
| 13 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | |
| 14 | ||
| 15 | const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 16 | Layer.effect( | |
| 17 | ServerConfig, | |
| 18 | Effect.gen(function* () { | |
| 19 | const config = yield* ServerConfig; | |
| 20 | return { | |
| 21 | ...config, | |
| 22 | ...overrides, | |
| 23 | } satisfies ServerConfigShape; | |
| 24 | }), | |
| 25 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); | |
| 26 | ||
| 27 | const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 28 | EnvironmentAuth.layer.pipe( | |
| 29 | Layer.provide(SqlitePersistenceMemory), | |
| 30 | Layer.provide(ServerSecretStore.layer), | |
| 31 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 32 | ); | |
| 33 | ||
| 34 | const makeCookieRequest = ( | |
| 35 | sessionToken: string, | |
| 36 | ): Parameters<EnvironmentAuth.EnvironmentAuthShape["authenticateHttpRequest"]>[0] => | |
| 37 | ({ | |
| 38 | cookies: { | |
| 39 | t3_session: sessionToken, | |
| 40 | }, | |
| 41 | headers: {}, | |
| 42 | }) as unknown as Parameters<EnvironmentAuth.EnvironmentAuthShape["authenticateHttpRequest"]>[0]; | |
| 43 | ||
| 44 | const requestMetadata = { | |
| 45 | deviceType: "desktop" as const, | |
| 46 | os: "macOS", | |
| 47 | browser: "Chrome", | |
| 48 | ipAddress: "192.168.1.23", | |
| 49 | }; | |
| 50 | ||
| 51 | it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | |
| 52 | it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => | |
| 53 | Effect.sync(() => { | |
| 54 | const error = EnvironmentAuth.toBootstrapExchangeError( | |
| 55 | new PairingGrantStore.BootstrapCredentialInvalidError({ | |
| 56 | message: "Unknown bootstrap credential.", | |
| 57 | }), | |
| 58 | ); | |
| 59 | ||
| 60 | expect(error._tag).toBe("ServerAuthInvalidCredentialError"); | |
| 61 | if (error._tag === "ServerAuthInvalidCredentialError") { | |
| 62 | expect(error.reason).toBe("invalid_credential"); | |
| 63 | } | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("maps unexpected bootstrap failures to 500", () => | |
| 68 | Effect.sync(() => { | |
| 69 | const error = EnvironmentAuth.toBootstrapExchangeError( | |
| 70 | new PairingGrantStore.BootstrapCredentialInternalError({ | |
| 71 | message: "Failed to consume bootstrap credential.", | |
| 72 | cause: new Error("sqlite is unavailable"), | |
| 73 | }), | |
| 74 | ); | |
| 75 | ||
| 76 | expect(error._tag).toBe("ServerAuthInternalError"); | |
| 77 | expect(error.message).toBe("Failed to validate bootstrap credential."); | |
| 78 | }), | |
| 79 | ); | |
| 80 | ||
| 81 | it.effect("issues standard pairing credentials by default", () => | |
| 82 | Effect.gen(function* () { | |
| 83 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 84 | ||
| 85 | const pairingCredential = yield* serverAuth.issuePairingCredential(); | |
| 86 | const exchanged = yield* serverAuth.createBrowserSession( | |
| 87 | pairingCredential.credential, | |
| 88 | requestMetadata, | |
| 89 | ); | |
| 90 | const verified = yield* serverAuth.authenticateHttpRequest( | |
| 91 | makeCookieRequest(exchanged.sessionToken), | |
| 92 | ); | |
| 93 | ||
| 94 | expect(verified.sessionId.length).toBeGreaterThan(0); | |
| 95 | expect(verified.scopes).toEqual([ | |
| 96 | "orchestration:read", | |
| 97 | "orchestration:operate", | |
| 98 | "terminal:operate", | |
| 99 | "review:write", | |
| 100 | "relay:read", | |
| 101 | ]); | |
| 102 | expect(verified.subject).toBe("one-time-token"); | |
| 103 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | |
| 104 | ); | |
| 105 | ||
| 106 | it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => | |
| 107 | Effect.gen(function* () { | |
| 108 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 109 | const pairingCredential = yield* serverAuth.issuePairingCredential(); | |
| 110 | ||
| 111 | const error = yield* serverAuth | |
| 112 | .exchangeBootstrapCredentialForAccessToken( | |
| 113 | pairingCredential.credential, | |
| 114 | ["orchestration:read", "access:write"], | |
| 115 | requestMetadata, | |
| 116 | ) | |
| 117 | .pipe(Effect.flip); | |
| 118 | ||
| 119 | expect(error._tag).toBe("ServerAuthInvalidRequestError"); | |
| 120 | if (error._tag === "ServerAuthInvalidRequestError") { | |
| 121 | expect(error.reason).toBe("scope_not_granted"); | |
| 122 | } | |
| 123 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | |
| 124 | ); | |
| 125 | ||
| 126 | it.effect("inherits a constrained pairing grant when token exchange omits scope", () => | |
| 127 | Effect.gen(function* () { | |
| 128 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 129 | const pairingCredential = yield* serverAuth.issuePairingCredential({ | |
| 130 | scopes: ["orchestration:read"], | |
| 131 | }); | |
| 132 | ||
| 133 | const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | |
| 134 | pairingCredential.credential, | |
| 135 | undefined, | |
| 136 | requestMetadata, | |
| 137 | ); | |
| 138 | ||
| 139 | expect(token.scope).toBe("orchestration:read"); | |
| 140 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | |
| 141 | ); | |
| 142 | ||
| 143 | it.effect("keeps user-issued administrative pairing links manageable", () => | |
| 144 | Effect.gen(function* () { | |
| 145 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 146 | const pairingCredential = yield* serverAuth.issuePairingCredential({ | |
| 147 | scopes: AuthAdministrativeScopes, | |
| 148 | }); | |
| 149 | const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 150 | ||
| 151 | expect( | |
| 152 | listedPairingLinks.find((pairingLink) => pairingLink.id === pairingCredential.id)?.subject, | |
| 153 | ).toBe("one-time-token"); | |
| 154 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | |
| 155 | ); | |
| 156 | ||
| 157 | it.effect("issues startup pairing URLs that bootstrap administrative sessions", () => | |
| 158 | Effect.gen(function* () { | |
| 159 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 160 | ||
| 161 | const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); | |
| 162 | const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); | |
| 163 | const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 164 | expect(token).toBeTruthy(); | |
| 165 | expect( | |
| 166 | listedPairingLinks.some( | |
| 167 | (pairingLink) => pairingLink.subject === "administrative-bootstrap", | |
| 168 | ), | |
| 169 | ).toBe(false); | |
| 170 | ||
| 171 | const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata); | |
| 172 | const verified = yield* serverAuth.authenticateHttpRequest( | |
| 173 | makeCookieRequest(exchanged.sessionToken), | |
| 174 | ); | |
| 175 | ||
| 176 | expect(verified.scopes).toEqual([ | |
| 177 | "orchestration:read", | |
| 178 | "orchestration:operate", | |
| 179 | "terminal:operate", | |
| 180 | "review:write", | |
| 181 | "relay:read", | |
| 182 | "access:read", | |
| 183 | "access:write", | |
| 184 | "relay:write", | |
| 185 | ]); | |
| 186 | expect(verified.subject).toBe("administrative-bootstrap"); | |
| 187 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | |
| 188 | ); | |
| 189 | ||
| 190 | it.effect( | |
| 191 | "lists pairing links and revokes other sessions while keeping the administrative session", | |
| 192 | () => | |
| 193 | Effect.gen(function* () { | |
| 194 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 195 | ||
| 196 | const administrativeExchange = yield* serverAuth.createBrowserSession( | |
| 197 | "desktop-bootstrap-token", | |
| 198 | requestMetadata, | |
| 199 | ); | |
| 200 | const administrativeSession = yield* serverAuth.authenticateHttpRequest( | |
| 201 | makeCookieRequest(administrativeExchange.sessionToken), | |
| 202 | ); | |
| 203 | const pairingCredential = yield* serverAuth.issuePairingCredential({ | |
| 204 | label: "Julius iPhone", | |
| 205 | }); | |
| 206 | const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 207 | const clientExchange = yield* serverAuth.createBrowserSession( | |
| 208 | pairingCredential.credential, | |
| 209 | { | |
| 210 | ...requestMetadata, | |
| 211 | deviceType: "mobile", | |
| 212 | os: "iOS", | |
| 213 | browser: "Safari", | |
| 214 | ipAddress: "192.168.1.88", | |
| 215 | }, | |
| 216 | ); | |
| 217 | const clientSession = yield* serverAuth.authenticateHttpRequest( | |
| 218 | makeCookieRequest(clientExchange.sessionToken), | |
| 219 | ); | |
| 220 | const clientsBeforeRevoke = yield* serverAuth.listClientSessions( | |
| 221 | administrativeSession.sessionId, | |
| 222 | ); | |
| 223 | const revokedCount = yield* serverAuth.revokeOtherClientSessions( | |
| 224 | administrativeSession.sessionId, | |
| 225 | ); | |
| 226 | const clientsAfterRevoke = yield* serverAuth.listClientSessions( | |
| 227 | administrativeSession.sessionId, | |
| 228 | ); | |
| 229 | ||
| 230 | expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id); | |
| 231 | expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe( | |
| 232 | "Julius iPhone", | |
| 233 | ); | |
| 234 | expect(clientsBeforeRevoke).toHaveLength(2); | |
| 235 | expect( | |
| 236 | clientsBeforeRevoke.find((entry) => entry.sessionId === administrativeSession.sessionId) | |
| 237 | ?.current, | |
| 238 | ).toBe(true); | |
| 239 | expect( | |
| 240 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current, | |
| 241 | ).toBe(false); | |
| 242 | expect( | |
| 243 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client | |
| 244 | .label, | |
| 245 | ).toBe("Julius iPhone"); | |
| 246 | expect( | |
| 247 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client | |
| 248 | .deviceType, | |
| 249 | ).toBe("mobile"); | |
| 250 | expect(revokedCount).toBe(1); | |
| 251 | expect(clientsAfterRevoke).toHaveLength(1); | |
| 252 | expect(clientsAfterRevoke[0]?.sessionId).toBe(administrativeSession.sessionId); | |
| 253 | }).pipe( | |
| 254 | Effect.provide( | |
| 255 | makeEnvironmentAuthLayer({ | |
| 256 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 257 | }), | |
| 258 | ), | |
| 259 | ), | |
| 260 | ); | |
| 261 | }); |
apps/server/src/auth/EnvironmentAuth.ts created+662| ... | ... | @@ -0,0 +1,662 @@ |
| 1 | import { | |
| 2 | AuthAccessTokenType, | |
| 3 | AuthAccessWriteScope, | |
| 4 | AuthAdministrativeScopes, | |
| 5 | AuthStandardClientScopes, | |
| 6 | type AuthAccessTokenResult, | |
| 7 | type AuthBrowserSessionResult, | |
| 8 | type AuthClientMetadata, | |
| 9 | type AuthClientSession, | |
| 10 | type AuthCreatePairingCredentialInput, | |
| 11 | type AuthEnvironmentScope, | |
| 12 | type AuthPairingLink, | |
| 13 | type AuthPairingCredentialResult, | |
| 14 | type AuthSessionId, | |
| 15 | type AuthSessionState, | |
| 16 | type ServerAuthDescriptor, | |
| 17 | type ServerAuthSessionMethod, | |
| 18 | type AuthWebSocketTicketResult, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | |
| 21 | import * as Context from "effect/Context"; | |
| 22 | import * as Data from "effect/Data"; | |
| 23 | import * as DateTime from "effect/DateTime"; | |
| 24 | import * as Duration from "effect/Duration"; | |
| 25 | import * as Effect from "effect/Effect"; | |
| 26 | import * as Layer from "effect/Layer"; | |
| 27 | import * as Option from "effect/Option"; | |
| 28 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 29 | ||
| 30 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; | |
| 31 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | |
| 32 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | |
| 33 | import * as SessionStore from "./SessionStore.ts"; | |
| 34 | import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; | |
| 35 | ||
| 36 | export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | |
| 37 | export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; | |
| 38 | ||
| 39 | export interface IssuedPairingLink { | |
| 40 | readonly id: string; | |
| 41 | readonly credential: string; | |
| 42 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 43 | readonly subject: string; | |
| 44 | readonly label?: string; | |
| 45 | readonly createdAt: DateTime.Utc; | |
| 46 | readonly expiresAt: DateTime.Utc; | |
| 47 | } | |
| 48 | ||
| 49 | export interface IssuedBearerSession { | |
| 50 | readonly sessionId: AuthSessionId; | |
| 51 | readonly token: string; | |
| 52 | readonly method: "bearer-access-token"; | |
| 53 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 54 | readonly subject: string; | |
| 55 | readonly client: AuthClientMetadata; | |
| 56 | readonly expiresAt: DateTime.Utc; | |
| 57 | } | |
| 58 | ||
| 59 | export interface AuthenticatedSession { | |
| 60 | readonly sessionId: AuthSessionId; | |
| 61 | readonly subject: string; | |
| 62 | readonly method: ServerAuthSessionMethod; | |
| 63 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 64 | readonly expiresAt?: DateTime.DateTime; | |
| 65 | } | |
| 66 | ||
| 67 | export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{ | |
| 68 | readonly message: string; | |
| 69 | readonly cause?: unknown; | |
| 70 | }> {} | |
| 71 | ||
| 72 | export class ServerAuthInvalidCredentialError extends Data.TaggedError( | |
| 73 | "ServerAuthInvalidCredentialError", | |
| 74 | )<{ | |
| 75 | readonly reason: "missing_credential" | "invalid_credential"; | |
| 76 | readonly cause?: unknown; | |
| 77 | }> {} | |
| 78 | ||
| 79 | export class ServerAuthInvalidRequestError extends Data.TaggedError( | |
| 80 | "ServerAuthInvalidRequestError", | |
| 81 | )<{ | |
| 82 | readonly reason: "invalid_scope" | "scope_not_granted"; | |
| 83 | }> {} | |
| 84 | ||
| 85 | export class ServerAuthForbiddenOperationError extends Data.TaggedError( | |
| 86 | "ServerAuthForbiddenOperationError", | |
| 87 | )<{ | |
| 88 | readonly reason: "current_session_revoke_not_allowed"; | |
| 89 | }> {} | |
| 90 | ||
| 91 | export interface EnvironmentAuthShape { | |
| 92 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | |
| 93 | readonly getSessionState: ( | |
| 94 | request: HttpServerRequest.HttpServerRequest, | |
| 95 | ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>; | |
| 96 | readonly createBrowserSession: ( | |
| 97 | credential: string, | |
| 98 | requestMetadata: AuthClientMetadata, | |
| 99 | ) => Effect.Effect< | |
| 100 | { | |
| 101 | readonly response: AuthBrowserSessionResult; | |
| 102 | readonly sessionToken: string; | |
| 103 | }, | |
| 104 | ServerAuthInvalidCredentialError | ServerAuthInternalError | |
| 105 | >; | |
| 106 | readonly exchangeBootstrapCredentialForAccessToken: ( | |
| 107 | credential: string, | |
| 108 | requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined, | |
| 109 | requestMetadata: AuthClientMetadata, | |
| 110 | ) => Effect.Effect< | |
| 111 | AuthAccessTokenResult, | |
| 112 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError | |
| 113 | >; | |
| 114 | readonly createPairingLink: (input?: { | |
| 115 | readonly ttl?: Duration.Duration; | |
| 116 | readonly label?: string; | |
| 117 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 118 | readonly subject?: string; | |
| 119 | }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>; | |
| 120 | readonly issuePairingCredential: ( | |
| 121 | input?: AuthCreatePairingCredentialInput, | |
| 122 | ) => Effect.Effect<AuthPairingCredentialResult, ServerAuthInternalError>; | |
| 123 | readonly issueStartupPairingCredential: () => Effect.Effect< | |
| 124 | AuthPairingCredentialResult, | |
| 125 | ServerAuthInternalError | |
| 126 | >; | |
| 127 | readonly listPairingLinks: (input?: { | |
| 128 | readonly excludeSubjects?: ReadonlyArray<string>; | |
| 129 | }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>; | |
| 130 | readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>; | |
| 131 | readonly issueSession: (input?: { | |
| 132 | readonly ttl?: Duration.Duration; | |
| 133 | readonly subject?: string; | |
| 134 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 135 | readonly label?: string; | |
| 136 | }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>; | |
| 137 | readonly listSessions: () => Effect.Effect< | |
| 138 | ReadonlyArray<AuthClientSession>, | |
| 139 | ServerAuthInternalError | |
| 140 | >; | |
| 141 | readonly revokeSession: ( | |
| 142 | sessionId: AuthSessionId, | |
| 143 | ) => Effect.Effect<boolean, ServerAuthInternalError>; | |
| 144 | readonly revokeOtherSessionsExcept: ( | |
| 145 | sessionId: AuthSessionId, | |
| 146 | ) => Effect.Effect<number, ServerAuthInternalError>; | |
| 147 | readonly listClientSessions: ( | |
| 148 | currentSessionId: AuthSessionId, | |
| 149 | ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>; | |
| 150 | readonly revokeClientSession: ( | |
| 151 | currentSessionId: AuthSessionId, | |
| 152 | targetSessionId: AuthSessionId, | |
| 153 | ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>; | |
| 154 | readonly revokeOtherClientSessions: ( | |
| 155 | currentSessionId: AuthSessionId, | |
| 156 | ) => Effect.Effect<number, ServerAuthInternalError>; | |
| 157 | readonly authenticateHttpRequest: ( | |
| 158 | request: HttpServerRequest.HttpServerRequest, | |
| 159 | ) => Effect.Effect< | |
| 160 | AuthenticatedSession, | |
| 161 | ServerAuthInvalidCredentialError | ServerAuthInternalError | |
| 162 | >; | |
| 163 | readonly authenticateWebSocketUpgrade: ( | |
| 164 | request: HttpServerRequest.HttpServerRequest, | |
| 165 | ) => Effect.Effect< | |
| 166 | AuthenticatedSession, | |
| 167 | ServerAuthInvalidCredentialError | ServerAuthInternalError | |
| 168 | >; | |
| 169 | readonly issueWebSocketTicket: ( | |
| 170 | session: Pick<AuthenticatedSession, "sessionId">, | |
| 171 | ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>; | |
| 172 | readonly issueStartupPairingUrl: ( | |
| 173 | baseUrl: string, | |
| 174 | ) => Effect.Effect<string, ServerAuthInternalError>; | |
| 175 | } | |
| 176 | ||
| 177 | export class EnvironmentAuth extends Context.Service<EnvironmentAuth, EnvironmentAuthShape>()( | |
| 178 | "t3/auth/EnvironmentAuth", | |
| 179 | ) {} | |
| 180 | ||
| 181 | type BootstrapExchangeResult = { | |
| 182 | readonly response: AuthBrowserSessionResult; | |
| 183 | readonly sessionToken: string; | |
| 184 | }; | |
| 185 | ||
| 186 | const AUTHORIZATION_PREFIX = "Bearer "; | |
| 187 | const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; | |
| 188 | ||
| 189 | const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { | |
| 190 | const leftCanManage = left.scopes.includes(AuthAccessWriteScope); | |
| 191 | const rightCanManage = right.scopes.includes(AuthAccessWriteScope); | |
| 192 | if (leftCanManage !== rightCanManage) { | |
| 193 | return leftCanManage ? -1 : 1; | |
| 194 | } | |
| 195 | if (left.connected !== right.connected) { | |
| 196 | return left.connected ? -1 : 1; | |
| 197 | } | |
| 198 | return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; | |
| 199 | }; | |
| 200 | ||
| 201 | const toInternalError = | |
| 202 | (message: string) => | |
| 203 | (cause: unknown): ServerAuthInternalError => | |
| 204 | new ServerAuthInternalError({ message, cause }); | |
| 205 | ||
| 206 | export function toBootstrapExchangeError( | |
| 207 | cause: PairingGrantStore.BootstrapCredentialError, | |
| 208 | ): ServerAuthInvalidCredentialError | ServerAuthInternalError { | |
| 209 | if (cause._tag === "BootstrapCredentialInternalError") { | |
| 210 | return new ServerAuthInternalError({ | |
| 211 | message: "Failed to validate bootstrap credential.", | |
| 212 | cause, | |
| 213 | }); | |
| 214 | } | |
| 215 | ||
| 216 | return new ServerAuthInvalidCredentialError({ | |
| 217 | reason: "invalid_credential", | |
| 218 | cause, | |
| 219 | }); | |
| 220 | } | |
| 221 | ||
| 222 | const mapSessionVerificationErrors = <A, R>( | |
| 223 | effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>, | |
| 224 | ): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> => | |
| 225 | effect.pipe( | |
| 226 | Effect.catchTags({ | |
| 227 | SessionCredentialInvalidError: (cause) => | |
| 228 | Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })), | |
| 229 | SessionCredentialInternalError: (cause) => | |
| 230 | Effect.fail( | |
| 231 | new ServerAuthInternalError({ | |
| 232 | message: "Failed to validate session credential.", | |
| 233 | cause, | |
| 234 | }), | |
| 235 | ), | |
| 236 | }), | |
| 237 | ); | |
| 238 | ||
| 239 | function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { | |
| 240 | const header = request.headers["authorization"]; | |
| 241 | if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { | |
| 242 | return null; | |
| 243 | } | |
| 244 | const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); | |
| 245 | return token.length > 0 ? token : null; | |
| 246 | } | |
| 247 | ||
| 248 | export const make = Effect.fn("makeEnvironmentAuth")(function* () { | |
| 249 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | |
| 250 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | |
| 251 | const sessions = yield* SessionStore.SessionStore; | |
| 252 | const descriptor = yield* policy.getDescriptor(); | |
| 253 | ||
| 254 | const authenticateToken = ( | |
| 255 | token: string, | |
| 256 | ): Effect.Effect< | |
| 257 | AuthenticatedSession, | |
| 258 | ServerAuthInvalidCredentialError | ServerAuthInternalError | |
| 259 | > => | |
| 260 | sessions.verify(token).pipe( | |
| 261 | Effect.tapErrorTag("SessionCredentialInvalidError", (cause) => | |
| 262 | Effect.logWarning("Rejected authenticated session credential.").pipe( | |
| 263 | Effect.annotateLogs({ | |
| 264 | reason: cause.message, | |
| 265 | }), | |
| 266 | ), | |
| 267 | ), | |
| 268 | Effect.map((session) => ({ | |
| 269 | sessionId: session.sessionId, | |
| 270 | subject: session.subject, | |
| 271 | method: session.method, | |
| 272 | scopes: session.scopes, | |
| 273 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | |
| 274 | })), | |
| 275 | mapSessionVerificationErrors, | |
| 276 | ); | |
| 277 | ||
| 278 | const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { | |
| 279 | const cookieToken = request.cookies[sessions.cookieName]; | |
| 280 | const bearerToken = parseBearerToken(request); | |
| 281 | const credential = cookieToken ?? bearerToken; | |
| 282 | if (!credential) { | |
| 283 | return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); | |
| 284 | } | |
| 285 | return authenticateToken(credential); | |
| 286 | }; | |
| 287 | ||
| 288 | const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => | |
| 289 | authenticateRequest(request).pipe( | |
| 290 | Effect.map( | |
| 291 | (session) => | |
| 292 | ({ | |
| 293 | authenticated: true, | |
| 294 | auth: descriptor, | |
| 295 | scopes: session.scopes, | |
| 296 | sessionMethod: session.method, | |
| 297 | ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), | |
| 298 | }) satisfies AuthSessionState, | |
| 299 | ), | |
| 300 | Effect.catchTag("ServerAuthInvalidCredentialError", () => | |
| 301 | Effect.succeed({ | |
| 302 | authenticated: false, | |
| 303 | auth: descriptor, | |
| 304 | } satisfies AuthSessionState), | |
| 305 | ), | |
| 306 | Effect.withSpan("EnvironmentAuth.getSessionState"), | |
| 307 | ); | |
| 308 | ||
| 309 | const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = ( | |
| 310 | credential, | |
| 311 | requestMetadata, | |
| 312 | ) => | |
| 313 | bootstrapCredentials.consume(credential).pipe( | |
| 314 | Effect.mapError(toBootstrapExchangeError), | |
| 315 | Effect.flatMap((grant) => | |
| 316 | sessions | |
| 317 | .issue({ | |
| 318 | method: "browser-session-cookie", | |
| 319 | subject: grant.subject, | |
| 320 | scopes: grant.scopes, | |
| 321 | client: { | |
| 322 | ...requestMetadata, | |
| 323 | ...(grant.label ? { label: grant.label } : {}), | |
| 324 | }, | |
| 325 | }) | |
| 326 | .pipe( | |
| 327 | Effect.mapError( | |
| 328 | (cause) => | |
| 329 | new ServerAuthInternalError({ | |
| 330 | message: "Failed to issue authenticated session.", | |
| 331 | cause, | |
| 332 | }), | |
| 333 | ), | |
| 334 | ), | |
| 335 | ), | |
| 336 | Effect.map( | |
| 337 | (session) => | |
| 338 | ({ | |
| 339 | response: { | |
| 340 | authenticated: true, | |
| 341 | scopes: session.scopes, | |
| 342 | sessionMethod: session.method, | |
| 343 | expiresAt: DateTime.toUtc(session.expiresAt), | |
| 344 | } satisfies AuthBrowserSessionResult, | |
| 345 | sessionToken: session.token, | |
| 346 | }) satisfies BootstrapExchangeResult, | |
| 347 | ), | |
| 348 | Effect.withSpan("EnvironmentAuth.createBrowserSession"), | |
| 349 | ); | |
| 350 | ||
| 351 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = | |
| 352 | (credential, requestedScopes, requestMetadata) => | |
| 353 | bootstrapCredentials.consume(credential).pipe( | |
| 354 | Effect.mapError(toBootstrapExchangeError), | |
| 355 | Effect.flatMap((grant) => | |
| 356 | Effect.gen(function* () { | |
| 357 | const grantedScopes = requestedScopes ?? grant.scopes; | |
| 358 | if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { | |
| 359 | return yield* new ServerAuthInvalidRequestError({ | |
| 360 | reason: "scope_not_granted", | |
| 361 | }); | |
| 362 | } | |
| 363 | return yield* sessions | |
| 364 | .issue({ | |
| 365 | method: "bearer-access-token", | |
| 366 | subject: grant.subject, | |
| 367 | scopes: grantedScopes, | |
| 368 | client: { | |
| 369 | ...requestMetadata, | |
| 370 | ...(grant.label ? { label: grant.label } : {}), | |
| 371 | }, | |
| 372 | }) | |
| 373 | .pipe( | |
| 374 | Effect.mapError( | |
| 375 | (cause) => | |
| 376 | new ServerAuthInternalError({ | |
| 377 | message: "Failed to issue authenticated access token.", | |
| 378 | cause, | |
| 379 | }), | |
| 380 | ), | |
| 381 | ); | |
| 382 | }), | |
| 383 | ), | |
| 384 | Effect.flatMap((session) => | |
| 385 | DateTime.now.pipe( | |
| 386 | Effect.map( | |
| 387 | (now) => | |
| 388 | ({ | |
| 389 | access_token: session.token, | |
| 390 | issued_token_type: AuthAccessTokenType, | |
| 391 | token_type: "Bearer", | |
| 392 | expires_in: Math.max( | |
| 393 | 0, | |
| 394 | Math.floor( | |
| 395 | (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000, | |
| 396 | ), | |
| 397 | ), | |
| 398 | scope: encodeOAuthScope(session.scopes), | |
| 399 | }) satisfies AuthAccessTokenResult, | |
| 400 | ), | |
| 401 | ), | |
| 402 | ), | |
| 403 | Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), | |
| 404 | ); | |
| 405 | ||
| 406 | const issuePairingCredentialForSubject = (input: { | |
| 407 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 408 | readonly subject: string; | |
| 409 | readonly label?: string; | |
| 410 | }) => | |
| 411 | createPairingLink({ | |
| 412 | scopes: input.scopes, | |
| 413 | subject: input.subject, | |
| 414 | ...(input.label ? { label: input.label } : {}), | |
| 415 | }).pipe( | |
| 416 | Effect.map( | |
| 417 | (issued) => | |
| 418 | ({ | |
| 419 | id: issued.id, | |
| 420 | credential: issued.credential, | |
| 421 | ...(issued.label ? { label: issued.label } : {}), | |
| 422 | expiresAt: issued.expiresAt, | |
| 423 | }) satisfies AuthPairingCredentialResult, | |
| 424 | ), | |
| 425 | ); | |
| 426 | ||
| 427 | const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn( | |
| 428 | "EnvironmentAuth.createPairingLink", | |
| 429 | )( | |
| 430 | function* (input) { | |
| 431 | const createdAt = yield* DateTime.now; | |
| 432 | const issued = yield* bootstrapCredentials.issueOneTimeToken({ | |
| 433 | scopes: input?.scopes ?? AuthStandardClientScopes, | |
| 434 | subject: input?.subject ?? "one-time-token", | |
| 435 | ...(input?.ttl ? { ttl: input.ttl } : {}), | |
| 436 | ...(input?.label ? { label: input.label } : {}), | |
| 437 | }); | |
| 438 | return { | |
| 439 | id: issued.id, | |
| 440 | credential: issued.credential, | |
| 441 | scopes: input?.scopes ?? AuthStandardClientScopes, | |
| 442 | subject: input?.subject ?? "one-time-token", | |
| 443 | ...(issued.label ? { label: issued.label } : {}), | |
| 444 | createdAt: DateTime.toUtc(createdAt), | |
| 445 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 446 | } satisfies IssuedPairingLink; | |
| 447 | }, | |
| 448 | Effect.mapError(toInternalError("Failed to create pairing link.")), | |
| 449 | ); | |
| 450 | ||
| 451 | const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) => | |
| 452 | bootstrapCredentials.listActive().pipe( | |
| 453 | Effect.map((pairingLinks) => { | |
| 454 | const excludedSubjects = input?.excludeSubjects ?? [ | |
| 455 | INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, | |
| 456 | ]; | |
| 457 | return pairingLinks | |
| 458 | .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject)) | |
| 459 | .toSorted( | |
| 460 | (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, | |
| 461 | ); | |
| 462 | }), | |
| 463 | Effect.mapError(toInternalError("Failed to list pairing links.")), | |
| 464 | Effect.withSpan("EnvironmentAuth.listPairingLinks"), | |
| 465 | ); | |
| 466 | ||
| 467 | const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) => | |
| 468 | bootstrapCredentials | |
| 469 | .revoke(id) | |
| 470 | .pipe( | |
| 471 | Effect.mapError(toInternalError("Failed to revoke pairing link.")), | |
| 472 | Effect.withSpan("EnvironmentAuth.revokePairingLink"), | |
| 473 | ); | |
| 474 | ||
| 475 | const issueSession: EnvironmentAuthShape["issueSession"] = (input) => | |
| 476 | sessions | |
| 477 | .issue({ | |
| 478 | subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | |
| 479 | method: "bearer-access-token", | |
| 480 | scopes: input?.scopes ?? AuthAdministrativeScopes, | |
| 481 | client: { | |
| 482 | ...(input?.label ? { label: input.label } : {}), | |
| 483 | deviceType: "bot", | |
| 484 | }, | |
| 485 | ...(input?.ttl ? { ttl: input.ttl } : {}), | |
| 486 | }) | |
| 487 | .pipe( | |
| 488 | Effect.map( | |
| 489 | (issued) => | |
| 490 | ({ | |
| 491 | sessionId: issued.sessionId, | |
| 492 | token: issued.token, | |
| 493 | method: "bearer-access-token", | |
| 494 | scopes: issued.scopes, | |
| 495 | subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | |
| 496 | client: issued.client, | |
| 497 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 498 | }) satisfies IssuedBearerSession, | |
| 499 | ), | |
| 500 | Effect.mapError(toInternalError("Failed to issue session token.")), | |
| 501 | Effect.withSpan("EnvironmentAuth.issueSession"), | |
| 502 | ); | |
| 503 | ||
| 504 | const listSessions: EnvironmentAuthShape["listSessions"] = () => | |
| 505 | sessions.listActive().pipe( | |
| 506 | Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), | |
| 507 | Effect.mapError(toInternalError("Failed to list sessions.")), | |
| 508 | Effect.withSpan("EnvironmentAuth.listSessions"), | |
| 509 | ); | |
| 510 | ||
| 511 | const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) => | |
| 512 | sessions | |
| 513 | .revoke(sessionId) | |
| 514 | .pipe( | |
| 515 | Effect.mapError(toInternalError("Failed to revoke session.")), | |
| 516 | Effect.withSpan("EnvironmentAuth.revokeSession"), | |
| 517 | ); | |
| 518 | ||
| 519 | const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = ( | |
| 520 | sessionId, | |
| 521 | ) => | |
| 522 | sessions | |
| 523 | .revokeAllExcept(sessionId) | |
| 524 | .pipe( | |
| 525 | Effect.mapError(toInternalError("Failed to revoke other sessions.")), | |
| 526 | Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), | |
| 527 | ); | |
| 528 | ||
| 529 | const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) => | |
| 530 | issuePairingCredentialForSubject({ | |
| 531 | scopes: input?.scopes ?? AuthStandardClientScopes, | |
| 532 | subject: "one-time-token", | |
| 533 | ...(input?.label ? { label: input.label } : {}), | |
| 534 | }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); | |
| 535 | ||
| 536 | const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () => | |
| 537 | issuePairingCredentialForSubject({ | |
| 538 | scopes: AuthAdministrativeScopes, | |
| 539 | subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, | |
| 540 | }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); | |
| 541 | ||
| 542 | const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) => | |
| 543 | listSessions().pipe( | |
| 544 | Effect.map((clientSessions) => | |
| 545 | clientSessions.map( | |
| 546 | (clientSession): AuthClientSession => ({ | |
| 547 | ...clientSession, | |
| 548 | current: clientSession.sessionId === currentSessionId, | |
| 549 | }), | |
| 550 | ), | |
| 551 | ), | |
| 552 | Effect.withSpan("EnvironmentAuth.listClientSessions"), | |
| 553 | ); | |
| 554 | ||
| 555 | const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn( | |
| 556 | "EnvironmentAuth.revokeClientSession", | |
| 557 | )(function* (currentSessionId, targetSessionId) { | |
| 558 | if (currentSessionId === targetSessionId) { | |
| 559 | return yield* new ServerAuthForbiddenOperationError({ | |
| 560 | reason: "current_session_revoke_not_allowed", | |
| 561 | }); | |
| 562 | } | |
| 563 | return yield* revokeSession(targetSessionId); | |
| 564 | }); | |
| 565 | ||
| 566 | const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = ( | |
| 567 | currentSessionId, | |
| 568 | ) => | |
| 569 | revokeOtherSessionsExcept(currentSessionId).pipe( | |
| 570 | Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), | |
| 571 | ); | |
| 572 | ||
| 573 | const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) => | |
| 574 | issueStartupPairingCredential().pipe( | |
| 575 | Effect.map((issued) => { | |
| 576 | const url = new URL(baseUrl); | |
| 577 | url.pathname = "/pair"; | |
| 578 | url.searchParams.delete("token"); | |
| 579 | url.hash = new URLSearchParams([["token", issued.credential]]).toString(); | |
| 580 | return url.toString(); | |
| 581 | }), | |
| 582 | Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"), | |
| 583 | ); | |
| 584 | ||
| 585 | const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) => | |
| 586 | sessions.issueWebSocketToken(session.sessionId).pipe( | |
| 587 | Effect.mapError( | |
| 588 | (cause) => | |
| 589 | new ServerAuthInternalError({ | |
| 590 | message: "Failed to issue websocket token.", | |
| 591 | cause, | |
| 592 | }), | |
| 593 | ), | |
| 594 | Effect.map( | |
| 595 | (issued) => | |
| 596 | ({ | |
| 597 | ticket: issued.token, | |
| 598 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 599 | }) satisfies AuthWebSocketTicketResult, | |
| 600 | ), | |
| 601 | Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"), | |
| 602 | ); | |
| 603 | ||
| 604 | const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) => | |
| 605 | authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")); | |
| 606 | ||
| 607 | const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] = | |
| 608 | Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) { | |
| 609 | const requestUrl = HttpServerRequest.toURL(request); | |
| 610 | if (Option.isSome(requestUrl)) { | |
| 611 | const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM); | |
| 612 | if (websocketTicket && websocketTicket.trim().length > 0) { | |
| 613 | return yield* sessions.verifyWebSocketToken(websocketTicket).pipe( | |
| 614 | Effect.map((session) => ({ | |
| 615 | sessionId: session.sessionId, | |
| 616 | subject: session.subject, | |
| 617 | method: session.method, | |
| 618 | scopes: session.scopes, | |
| 619 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | |
| 620 | })), | |
| 621 | mapSessionVerificationErrors, | |
| 622 | ); | |
| 623 | } | |
| 624 | } | |
| 625 | ||
| 626 | return yield* authenticateRequest(request); | |
| 627 | }); | |
| 628 | ||
| 629 | return { | |
| 630 | getDescriptor: () => | |
| 631 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), | |
| 632 | getSessionState, | |
| 633 | createBrowserSession, | |
| 634 | exchangeBootstrapCredentialForAccessToken, | |
| 635 | createPairingLink, | |
| 636 | issuePairingCredential, | |
| 637 | issueStartupPairingCredential, | |
| 638 | listPairingLinks, | |
| 639 | revokePairingLink, | |
| 640 | issueSession, | |
| 641 | listSessions, | |
| 642 | revokeSession, | |
| 643 | revokeOtherSessionsExcept, | |
| 644 | listClientSessions, | |
| 645 | revokeClientSession, | |
| 646 | revokeOtherClientSessions, | |
| 647 | authenticateHttpRequest, | |
| 648 | authenticateWebSocketUpgrade, | |
| 649 | issueWebSocketTicket, | |
| 650 | issueStartupPairingUrl, | |
| 651 | } satisfies EnvironmentAuthShape; | |
| 652 | }); | |
| 653 | ||
| 654 | export const layer = Layer.effect(EnvironmentAuth, make()).pipe( | |
| 655 | Layer.provideMerge(PairingGrantStore.layer), | |
| 656 | Layer.provideMerge(SessionStore.layer), | |
| 657 | Layer.provideMerge(EnvironmentAuthPolicy.layer), | |
| 658 | ); | |
| 659 | ||
| 660 | export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); | |
| 661 | ||
| 662 | export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); |
apps/server/src/auth/EnvironmentAuthAdmin.test.ts created+125| ... | ... | @@ -0,0 +1,125 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import type { ServerConfigShape } from "../config.ts"; | |
| 7 | import { ServerConfig } from "../config.ts"; | |
| 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 9 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | |
| 10 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | |
| 11 | import * as SessionStore from "./SessionStore.ts"; | |
| 12 | ||
| 13 | const makeServerConfigLayer = ( | |
| 14 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 15 | ) => | |
| 16 | Layer.effect( | |
| 17 | ServerConfig, | |
| 18 | Effect.gen(function* () { | |
| 19 | const config = yield* ServerConfig; | |
| 20 | return { | |
| 21 | ...config, | |
| 22 | ...overrides, | |
| 23 | } satisfies ServerConfigShape; | |
| 24 | }), | |
| 25 | ).pipe( | |
| 26 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), | |
| 27 | ); | |
| 28 | ||
| 29 | const makeEnvironmentAuthLayer = ( | |
| 30 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 31 | ) => | |
| 32 | EnvironmentAuth.layer.pipe( | |
| 33 | Layer.provideMerge(ServerSecretStore.layer), | |
| 34 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 35 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 36 | ); | |
| 37 | ||
| 38 | it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) => { | |
| 39 | it.effect("creates, lists, and revokes client pairing links", () => | |
| 40 | Effect.gen(function* () { | |
| 41 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 42 | ||
| 43 | const created = yield* environmentAuth.createPairingLink({ | |
| 44 | scopes: ["orchestration:read"], | |
| 45 | subject: "one-time-token", | |
| 46 | label: "CI phone", | |
| 47 | }); | |
| 48 | const listedBeforeRevoke = yield* environmentAuth.listPairingLinks(); | |
| 49 | const revoked = yield* environmentAuth.revokePairingLink(created.id); | |
| 50 | const listedAfterRevoke = yield* environmentAuth.listPairingLinks(); | |
| 51 | ||
| 52 | expect(created.scopes).toEqual(["orchestration:read"]); | |
| 53 | expect(created.credential.length).toBeGreaterThan(0); | |
| 54 | expect(listedBeforeRevoke).toHaveLength(1); | |
| 55 | expect(listedBeforeRevoke[0]?.id).toBe(created.id); | |
| 56 | expect(listedBeforeRevoke[0]?.label).toBe("CI phone"); | |
| 57 | expect(listedBeforeRevoke[0]?.credential).toBe(created.credential); | |
| 58 | expect(revoked).toBe(true); | |
| 59 | expect(listedAfterRevoke).toHaveLength(0); | |
| 60 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | |
| 61 | ); | |
| 62 | ||
| 63 | it.effect("issues bearer access token sessions without exposing raw tokens", () => | |
| 64 | Effect.gen(function* () { | |
| 65 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 66 | const sessionCredentials = yield* SessionStore.SessionStore; | |
| 67 | ||
| 68 | const issued = yield* environmentAuth.issueSession({ | |
| 69 | label: "deploy-bot", | |
| 70 | }); | |
| 71 | const verified = yield* sessionCredentials.verify(issued.token); | |
| 72 | const listedBeforeRevoke = yield* environmentAuth.listSessions(); | |
| 73 | const revoked = yield* environmentAuth.revokeSession(issued.sessionId); | |
| 74 | const listedAfterRevoke = yield* environmentAuth.listSessions(); | |
| 75 | ||
| 76 | expect(issued.method).toBe("bearer-access-token"); | |
| 77 | expect(issued.scopes).toEqual([ | |
| 78 | "orchestration:read", | |
| 79 | "orchestration:operate", | |
| 80 | "terminal:operate", | |
| 81 | "review:write", | |
| 82 | "relay:read", | |
| 83 | "access:read", | |
| 84 | "access:write", | |
| 85 | "relay:write", | |
| 86 | ]); | |
| 87 | expect(issued.client.deviceType).toBe("bot"); | |
| 88 | expect(issued.client.label).toBe("deploy-bot"); | |
| 89 | expect(verified.sessionId).toBe(issued.sessionId); | |
| 90 | expect(verified.scopes).toEqual([ | |
| 91 | "orchestration:read", | |
| 92 | "orchestration:operate", | |
| 93 | "terminal:operate", | |
| 94 | "review:write", | |
| 95 | "relay:read", | |
| 96 | "access:read", | |
| 97 | "access:write", | |
| 98 | "relay:write", | |
| 99 | ]); | |
| 100 | expect(verified.method).toBe("bearer-access-token"); | |
| 101 | expect(listedBeforeRevoke).toHaveLength(1); | |
| 102 | expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); | |
| 103 | expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false); | |
| 104 | expect(revoked).toBe(true); | |
| 105 | expect(listedAfterRevoke).toHaveLength(0); | |
| 106 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | |
| 107 | ); | |
| 108 | ||
| 109 | it.effect("surfaces lastConnectedAt through the listed session view", () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 112 | const sessionCredentials = yield* SessionStore.SessionStore; | |
| 113 | ||
| 114 | const issued = yield* environmentAuth.issueSession({ | |
| 115 | label: "remote-ipad", | |
| 116 | }); | |
| 117 | const beforeConnect = yield* environmentAuth.listSessions(); | |
| 118 | yield* sessionCredentials.markConnected(issued.sessionId); | |
| 119 | const afterConnect = yield* environmentAuth.listSessions(); | |
| 120 | ||
| 121 | expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); | |
| 122 | expect(afterConnect[0]?.lastConnectedAt).not.toBeNull(); | |
| 123 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | |
| 124 | ); | |
| 125 | }); |
apps/server/src/auth/EnvironmentAuthPolicy.test.ts created+114| ... | ... | @@ -0,0 +1,114 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import type { ServerConfigShape } from "../config.ts"; | |
| 7 | import { ServerConfig } from "../config.ts"; | |
| 8 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; | |
| 9 | ||
| 10 | const makeEnvironmentAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 11 | EnvironmentAuthPolicy.layer.pipe( | |
| 12 | Layer.provide( | |
| 13 | Layer.effect( | |
| 14 | ServerConfig, | |
| 15 | Effect.gen(function* () { | |
| 16 | const config = yield* ServerConfig; | |
| 17 | return { | |
| 18 | ...config, | |
| 19 | ...overrides, | |
| 20 | } satisfies ServerConfigShape; | |
| 21 | }), | |
| 22 | ).pipe( | |
| 23 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), | |
| 24 | ), | |
| 25 | ), | |
| 26 | ); | |
| 27 | ||
| 28 | it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { | |
| 29 | it.effect("uses desktop-managed-local policy for desktop mode", () => | |
| 30 | Effect.gen(function* () { | |
| 31 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | |
| 32 | const descriptor = yield* policy.getDescriptor(); | |
| 33 | ||
| 34 | expect(descriptor.policy).toBe("desktop-managed-local"); | |
| 35 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); | |
| 36 | expect(descriptor.sessionCookieName).toBe("t3_session_3773"); | |
| 37 | }).pipe( | |
| 38 | Effect.provide( | |
| 39 | makeEnvironmentAuthPolicyLayer({ | |
| 40 | mode: "desktop", | |
| 41 | port: 3773, | |
| 42 | }), | |
| 43 | ), | |
| 44 | ), | |
| 45 | ); | |
| 46 | ||
| 47 | it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => | |
| 48 | Effect.gen(function* () { | |
| 49 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | |
| 50 | const descriptor = yield* policy.getDescriptor(); | |
| 51 | ||
| 52 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 53 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]); | |
| 54 | }).pipe( | |
| 55 | Effect.provide( | |
| 56 | makeEnvironmentAuthPolicyLayer({ | |
| 57 | mode: "desktop", | |
| 58 | host: "0.0.0.0", | |
| 59 | }), | |
| 60 | ), | |
| 61 | ), | |
| 62 | ); | |
| 63 | ||
| 64 | it.effect("uses loopback-browser policy for loopback web hosts", () => | |
| 65 | Effect.gen(function* () { | |
| 66 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | |
| 67 | const descriptor = yield* policy.getDescriptor(); | |
| 68 | ||
| 69 | expect(descriptor.policy).toBe("loopback-browser"); | |
| 70 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 71 | expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 72 | }).pipe( | |
| 73 | Effect.provide( | |
| 74 | makeEnvironmentAuthPolicyLayer({ | |
| 75 | mode: "web", | |
| 76 | host: "127.0.0.1", | |
| 77 | }), | |
| 78 | ), | |
| 79 | ), | |
| 80 | ); | |
| 81 | ||
| 82 | it.effect("uses remote-reachable policy for wildcard web hosts", () => | |
| 83 | Effect.gen(function* () { | |
| 84 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | |
| 85 | const descriptor = yield* policy.getDescriptor(); | |
| 86 | ||
| 87 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 88 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 89 | }).pipe( | |
| 90 | Effect.provide( | |
| 91 | makeEnvironmentAuthPolicyLayer({ | |
| 92 | mode: "web", | |
| 93 | host: "0.0.0.0", | |
| 94 | }), | |
| 95 | ), | |
| 96 | ), | |
| 97 | ); | |
| 98 | ||
| 99 | it.effect("uses remote-reachable policy for non-loopback web hosts", () => | |
| 100 | Effect.gen(function* () { | |
| 101 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | |
| 102 | const descriptor = yield* policy.getDescriptor(); | |
| 103 | ||
| 104 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 105 | }).pipe( | |
| 106 | Effect.provide( | |
| 107 | makeEnvironmentAuthPolicyLayer({ | |
| 108 | mode: "web", | |
| 109 | host: "192.168.1.50", | |
| 110 | }), | |
| 111 | ), | |
| 112 | ), | |
| 113 | ); | |
| 114 | }); |
apps/server/src/auth/EnvironmentAuthPolicy.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import type { ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import { ServerConfig } from "../config.ts"; | |
| 7 | import { resolveSessionCookieName } from "./utils.ts"; | |
| 8 | import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; | |
| 9 | ||
| 10 | export interface EnvironmentAuthPolicyShape { | |
| 11 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | |
| 12 | } | |
| 13 | ||
| 14 | export class EnvironmentAuthPolicy extends Context.Service< | |
| 15 | EnvironmentAuthPolicy, | |
| 16 | EnvironmentAuthPolicyShape | |
| 17 | >()("t3/auth/EnvironmentAuthPolicy") {} | |
| 18 | ||
| 19 | export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { | |
| 20 | const config = yield* ServerConfig; | |
| 21 | const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); | |
| 22 | ||
| 23 | const policy = | |
| 24 | config.mode === "desktop" | |
| 25 | ? isRemoteReachable | |
| 26 | ? "remote-reachable" | |
| 27 | : "desktop-managed-local" | |
| 28 | : isRemoteReachable | |
| 29 | ? "remote-reachable" | |
| 30 | : "loopback-browser"; | |
| 31 | ||
| 32 | const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] = | |
| 33 | policy === "desktop-managed-local" | |
| 34 | ? ["desktop-bootstrap"] | |
| 35 | : config.mode === "desktop" && policy === "remote-reachable" | |
| 36 | ? ["desktop-bootstrap", "one-time-token"] | |
| 37 | : ["one-time-token"]; | |
| 38 | ||
| 39 | const descriptor: ServerAuthDescriptor = { | |
| 40 | policy, | |
| 41 | bootstrapMethods, | |
| 42 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | |
| 43 | sessionCookieName: resolveSessionCookieName({ | |
| 44 | mode: config.mode, | |
| 45 | port: config.port, | |
| 46 | }), | |
| 47 | }; | |
| 48 | ||
| 49 | return { | |
| 50 | getDescriptor: () => | |
| 51 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")), | |
| 52 | } satisfies EnvironmentAuthPolicyShape; | |
| 53 | }); | |
| 54 | ||
| 55 | export const layer = Layer.effect(EnvironmentAuthPolicy, make()); |
apps/server/src/auth/Layers/AuthControlPlane.test.ts deleted-112| ... | ... | @@ -1,112 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import type { ServerConfigShape } from "../../config.ts"; | |
| 7 | import { ServerConfig } from "../../config.ts"; | |
| 8 | import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 9 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 10 | import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 11 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 12 | import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; | |
| 13 | import { makeAuthControlPlane } from "./AuthControlPlane.ts"; | |
| 14 | import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 15 | ||
| 16 | const makeServerConfigLayer = ( | |
| 17 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 18 | ) => | |
| 19 | Layer.effect( | |
| 20 | ServerConfig, | |
| 21 | Effect.gen(function* () { | |
| 22 | const config = yield* ServerConfig; | |
| 23 | return { | |
| 24 | ...config, | |
| 25 | ...overrides, | |
| 26 | } satisfies ServerConfigShape; | |
| 27 | }), | |
| 28 | ).pipe( | |
| 29 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), | |
| 30 | ); | |
| 31 | ||
| 32 | const makeAuthControlPlaneLayer = ( | |
| 33 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 34 | ) => | |
| 35 | Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe( | |
| 36 | Layer.provideMerge(BootstrapCredentialServiceLive), | |
| 37 | Layer.provideMerge(SessionCredentialServiceLive), | |
| 38 | Layer.provideMerge(ServerSecretStoreLive), | |
| 39 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 40 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 41 | ); | |
| 42 | ||
| 43 | it.layer(NodeServices.layer)("AuthControlPlane", (it) => { | |
| 44 | it.effect("creates, lists, and revokes client pairing links", () => | |
| 45 | Effect.gen(function* () { | |
| 46 | const authControlPlane = yield* AuthControlPlane; | |
| 47 | ||
| 48 | const created = yield* authControlPlane.createPairingLink({ | |
| 49 | role: "client", | |
| 50 | subject: "one-time-token", | |
| 51 | label: "CI phone", | |
| 52 | }); | |
| 53 | const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 54 | const revoked = yield* authControlPlane.revokePairingLink(created.id); | |
| 55 | const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 56 | ||
| 57 | expect(created.role).toBe("client"); | |
| 58 | expect(created.credential.length).toBeGreaterThan(0); | |
| 59 | expect(listedBeforeRevoke).toHaveLength(1); | |
| 60 | expect(listedBeforeRevoke[0]?.id).toBe(created.id); | |
| 61 | expect(listedBeforeRevoke[0]?.label).toBe("CI phone"); | |
| 62 | expect(listedBeforeRevoke[0]?.credential).toBe(created.credential); | |
| 63 | expect(revoked).toBe(true); | |
| 64 | expect(listedAfterRevoke).toHaveLength(0); | |
| 65 | }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 66 | ); | |
| 67 | ||
| 68 | it.effect("issues bearer sessions and lists them without exposing raw tokens", () => | |
| 69 | Effect.gen(function* () { | |
| 70 | const authControlPlane = yield* AuthControlPlane; | |
| 71 | const sessionCredentials = yield* SessionCredentialService; | |
| 72 | ||
| 73 | const issued = yield* authControlPlane.issueSession({ | |
| 74 | label: "deploy-bot", | |
| 75 | }); | |
| 76 | const verified = yield* sessionCredentials.verify(issued.token); | |
| 77 | const listedBeforeRevoke = yield* authControlPlane.listSessions(); | |
| 78 | const revoked = yield* authControlPlane.revokeSession(issued.sessionId); | |
| 79 | const listedAfterRevoke = yield* authControlPlane.listSessions(); | |
| 80 | ||
| 81 | expect(issued.method).toBe("bearer-session-token"); | |
| 82 | expect(issued.role).toBe("owner"); | |
| 83 | expect(issued.client.deviceType).toBe("bot"); | |
| 84 | expect(issued.client.label).toBe("deploy-bot"); | |
| 85 | expect(verified.sessionId).toBe(issued.sessionId); | |
| 86 | expect(verified.role).toBe("owner"); | |
| 87 | expect(verified.method).toBe("bearer-session-token"); | |
| 88 | expect(listedBeforeRevoke).toHaveLength(1); | |
| 89 | expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); | |
| 90 | expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false); | |
| 91 | expect(revoked).toBe(true); | |
| 92 | expect(listedAfterRevoke).toHaveLength(0); | |
| 93 | }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 94 | ); | |
| 95 | ||
| 96 | it.effect("surfaces lastConnectedAt through the listed session view", () => | |
| 97 | Effect.gen(function* () { | |
| 98 | const authControlPlane = yield* AuthControlPlane; | |
| 99 | const sessionCredentials = yield* SessionCredentialService; | |
| 100 | ||
| 101 | const issued = yield* authControlPlane.issueSession({ | |
| 102 | label: "remote-ipad", | |
| 103 | }); | |
| 104 | const beforeConnect = yield* authControlPlane.listSessions(); | |
| 105 | yield* sessionCredentials.markConnected(issued.sessionId); | |
| 106 | const afterConnect = yield* authControlPlane.listSessions(); | |
| 107 | ||
| 108 | expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); | |
| 109 | expect(afterConnect[0]?.lastConnectedAt).not.toBeNull(); | |
| 110 | }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 111 | ); | |
| 112 | }); |
apps/server/src/auth/Layers/AuthControlPlane.ts deleted-186| ... | ... | @@ -1,186 +0,0 @@ |
| 1 | import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 7 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 8 | import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 9 | import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 10 | import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 11 | import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts"; | |
| 12 | import { | |
| 13 | AuthControlPlane, | |
| 14 | AuthControlPlaneError, | |
| 15 | DEFAULT_SESSION_SUBJECT, | |
| 16 | } from "../Services/AuthControlPlane.ts"; | |
| 17 | import type { | |
| 18 | AuthControlPlaneShape, | |
| 19 | IssuedBearerSession, | |
| 20 | IssuedPairingLink, | |
| 21 | } from "../Services/AuthControlPlane.ts"; | |
| 22 | ||
| 23 | const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { | |
| 24 | if (left.role !== right.role) { | |
| 25 | return left.role === "owner" ? -1 : 1; | |
| 26 | } | |
| 27 | if (left.connected !== right.connected) { | |
| 28 | return left.connected ? -1 : 1; | |
| 29 | } | |
| 30 | return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; | |
| 31 | }; | |
| 32 | ||
| 33 | const toAuthControlPlaneError = | |
| 34 | (message: string) => | |
| 35 | (cause: unknown): AuthControlPlaneError => | |
| 36 | new AuthControlPlaneError({ | |
| 37 | message, | |
| 38 | cause, | |
| 39 | }); | |
| 40 | ||
| 41 | export const makeAuthControlPlane = Effect.gen(function* () { | |
| 42 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 43 | const sessions = yield* SessionCredentialService; | |
| 44 | ||
| 45 | const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) => | |
| 46 | Effect.gen(function* () { | |
| 47 | const createdAt = yield* DateTime.now; | |
| 48 | const issued = yield* bootstrapCredentials.issueOneTimeToken({ | |
| 49 | role: input?.role ?? "client", | |
| 50 | subject: input?.subject ?? "one-time-token", | |
| 51 | ...(input?.ttl ? { ttl: input.ttl } : {}), | |
| 52 | ...(input?.label ? { label: input.label } : {}), | |
| 53 | }); | |
| 54 | return { | |
| 55 | id: issued.id, | |
| 56 | credential: issued.credential, | |
| 57 | role: input?.role ?? "client", | |
| 58 | subject: input?.subject ?? "one-time-token", | |
| 59 | ...(issued.label ? { label: issued.label } : {}), | |
| 60 | createdAt: DateTime.toUtc(createdAt), | |
| 61 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 62 | } satisfies IssuedPairingLink; | |
| 63 | }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link."))); | |
| 64 | ||
| 65 | const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) => | |
| 66 | bootstrapCredentials.listActive().pipe( | |
| 67 | Effect.map((pairingLinks) => { | |
| 68 | const activeLinks: Array<AuthPairingLink> = []; | |
| 69 | for (const pairingLink of pairingLinks) { | |
| 70 | if (input?.role && pairingLink.role !== input.role) { | |
| 71 | continue; | |
| 72 | } | |
| 73 | if (input?.excludeSubjects?.includes(pairingLink.subject)) { | |
| 74 | continue; | |
| 75 | } | |
| 76 | activeLinks.push( | |
| 77 | pairingLink.label | |
| 78 | ? ({ | |
| 79 | id: pairingLink.id, | |
| 80 | credential: pairingLink.credential, | |
| 81 | role: pairingLink.role, | |
| 82 | subject: pairingLink.subject, | |
| 83 | label: pairingLink.label, | |
| 84 | createdAt: pairingLink.createdAt, | |
| 85 | expiresAt: pairingLink.expiresAt, | |
| 86 | } satisfies AuthPairingLink) | |
| 87 | : ({ | |
| 88 | id: pairingLink.id, | |
| 89 | credential: pairingLink.credential, | |
| 90 | role: pairingLink.role, | |
| 91 | subject: pairingLink.subject, | |
| 92 | createdAt: pairingLink.createdAt, | |
| 93 | expiresAt: pairingLink.expiresAt, | |
| 94 | } satisfies AuthPairingLink), | |
| 95 | ); | |
| 96 | } | |
| 97 | return activeLinks.toSorted( | |
| 98 | (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, | |
| 99 | ); | |
| 100 | }), | |
| 101 | Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")), | |
| 102 | ); | |
| 103 | ||
| 104 | const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) => | |
| 105 | bootstrapCredentials | |
| 106 | .revoke(id) | |
| 107 | .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link."))); | |
| 108 | ||
| 109 | const issueSession: AuthControlPlaneShape["issueSession"] = (input) => | |
| 110 | sessions | |
| 111 | .issue({ | |
| 112 | subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | |
| 113 | method: "bearer-session-token", | |
| 114 | role: input?.role ?? "owner", | |
| 115 | client: { | |
| 116 | ...(input?.label ? { label: input.label } : {}), | |
| 117 | deviceType: "bot", | |
| 118 | }, | |
| 119 | ...(input?.ttl ? { ttl: input.ttl } : {}), | |
| 120 | }) | |
| 121 | .pipe( | |
| 122 | Effect.flatMap((issued) => { | |
| 123 | if (issued.method !== "bearer-session-token") { | |
| 124 | return Effect.fail( | |
| 125 | new AuthControlPlaneError({ | |
| 126 | message: "CLI session issuance produced an unexpected session method.", | |
| 127 | }), | |
| 128 | ); | |
| 129 | } | |
| 130 | ||
| 131 | return Effect.succeed({ | |
| 132 | sessionId: issued.sessionId, | |
| 133 | token: issued.token, | |
| 134 | method: "bearer-session-token" as const, | |
| 135 | role: issued.role, | |
| 136 | subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | |
| 137 | client: issued.client, | |
| 138 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 139 | } satisfies IssuedBearerSession); | |
| 140 | }), | |
| 141 | Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")), | |
| 142 | ); | |
| 143 | ||
| 144 | const listSessions: AuthControlPlaneShape["listSessions"] = () => | |
| 145 | sessions.listActive().pipe( | |
| 146 | Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), | |
| 147 | Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")), | |
| 148 | ); | |
| 149 | ||
| 150 | const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) => | |
| 151 | sessions | |
| 152 | .revoke(sessionId) | |
| 153 | .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session."))); | |
| 154 | ||
| 155 | const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = ( | |
| 156 | sessionId, | |
| 157 | ) => | |
| 158 | sessions | |
| 159 | .revokeAllExcept(sessionId) | |
| 160 | .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions."))); | |
| 161 | ||
| 162 | return { | |
| 163 | createPairingLink, | |
| 164 | listPairingLinks, | |
| 165 | revokePairingLink, | |
| 166 | issueSession, | |
| 167 | listSessions, | |
| 168 | revokeSession, | |
| 169 | revokeOtherSessionsExcept, | |
| 170 | } satisfies AuthControlPlaneShape; | |
| 171 | }); | |
| 172 | ||
| 173 | export const AuthCoreLive = Layer.mergeAll( | |
| 174 | BootstrapCredentialServiceLive, | |
| 175 | SessionCredentialServiceLive, | |
| 176 | ); | |
| 177 | ||
| 178 | export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive); | |
| 179 | ||
| 180 | export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive)); | |
| 181 | ||
| 182 | export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane); | |
| 183 | ||
| 184 | export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe( | |
| 185 | Layer.provideMerge(AuthRuntimeLive), | |
| 186 | ); |
apps/server/src/auth/Layers/BootstrapCredentialService.test.ts deleted-153| ... | ... | @@ -1,153 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | ||
| 8 | import type { ServerConfigShape } from "../../config.ts"; | |
| 9 | import { ServerConfig } from "../../config.ts"; | |
| 10 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 11 | import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 12 | import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 13 | ||
| 14 | const makeServerConfigLayer = ( | |
| 15 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 16 | ) => | |
| 17 | Layer.effect( | |
| 18 | ServerConfig, | |
| 19 | Effect.gen(function* () { | |
| 20 | const config = yield* ServerConfig; | |
| 21 | return { | |
| 22 | ...config, | |
| 23 | ...overrides, | |
| 24 | } satisfies ServerConfigShape; | |
| 25 | }), | |
| 26 | ).pipe( | |
| 27 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), | |
| 28 | ); | |
| 29 | ||
| 30 | const makeBootstrapCredentialLayer = ( | |
| 31 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 32 | ) => | |
| 33 | BootstrapCredentialServiceLive.pipe( | |
| 34 | Layer.provide(SqlitePersistenceMemory), | |
| 35 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 36 | ); | |
| 37 | ||
| 38 | it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { | |
| 39 | it.effect("issues pairing tokens in a short manual-entry format", () => | |
| 40 | Effect.gen(function* () { | |
| 41 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 42 | const issued = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 43 | ||
| 44 | expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/); | |
| 45 | }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 46 | ); | |
| 47 | ||
| 48 | it.effect("issues one-time bootstrap tokens that can only be consumed once", () => | |
| 49 | Effect.gen(function* () { | |
| 50 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 51 | const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); | |
| 52 | const first = yield* bootstrapCredentials.consume(issued.credential); | |
| 53 | const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); | |
| 54 | ||
| 55 | expect(first.method).toBe("one-time-token"); | |
| 56 | expect(first.role).toBe("client"); | |
| 57 | expect(first.subject).toBe("one-time-token"); | |
| 58 | expect(first.label).toBe("Julius iPhone"); | |
| 59 | expect(issued.label).toBe("Julius iPhone"); | |
| 60 | expect(second._tag).toBe("BootstrapCredentialError"); | |
| 61 | expect(second.message).toContain("Unknown bootstrap credential"); | |
| 62 | }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 63 | ); | |
| 64 | ||
| 65 | it.effect("atomically consumes a one-time token when multiple requests race", () => | |
| 66 | Effect.gen(function* () { | |
| 67 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 68 | const token = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 69 | const results = yield* Effect.all( | |
| 70 | Array.from({ length: 8 }, () => | |
| 71 | Effect.result(bootstrapCredentials.consume(token.credential)), | |
| 72 | ), | |
| 73 | { | |
| 74 | concurrency: "unbounded", | |
| 75 | }, | |
| 76 | ); | |
| 77 | ||
| 78 | const successes = results.filter((result) => result._tag === "Success"); | |
| 79 | const failures = results.filter((result) => result._tag === "Failure"); | |
| 80 | ||
| 81 | expect(successes).toHaveLength(1); | |
| 82 | expect(failures).toHaveLength(7); | |
| 83 | for (const failure of failures) { | |
| 84 | expect(failure.failure._tag).toBe("BootstrapCredentialError"); | |
| 85 | expect(failure.failure.message).toContain("Unknown bootstrap credential"); | |
| 86 | } | |
| 87 | }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 88 | ); | |
| 89 | ||
| 90 | it.effect("seeds the desktop bootstrap credential as a one-time grant", () => | |
| 91 | Effect.gen(function* () { | |
| 92 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 93 | const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); | |
| 94 | const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 95 | ||
| 96 | expect(first.method).toBe("desktop-bootstrap"); | |
| 97 | expect(first.role).toBe("owner"); | |
| 98 | expect(first.subject).toBe("desktop-bootstrap"); | |
| 99 | expect(second._tag).toBe("BootstrapCredentialError"); | |
| 100 | expect(second.status).toBe(401); | |
| 101 | }).pipe( | |
| 102 | Effect.provide( | |
| 103 | makeBootstrapCredentialLayer({ | |
| 104 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 105 | }), | |
| 106 | ), | |
| 107 | ), | |
| 108 | ); | |
| 109 | ||
| 110 | it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => | |
| 111 | Effect.gen(function* () { | |
| 112 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 113 | ||
| 114 | yield* TestClock.adjust(Duration.minutes(6)); | |
| 115 | const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 116 | ||
| 117 | expect(expired._tag).toBe("BootstrapCredentialError"); | |
| 118 | expect(expired.status).toBe(401); | |
| 119 | expect(expired.message).toContain("Bootstrap credential expired"); | |
| 120 | }).pipe( | |
| 121 | Effect.provide( | |
| 122 | Layer.merge( | |
| 123 | makeBootstrapCredentialLayer({ | |
| 124 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 125 | }), | |
| 126 | TestClock.layer(), | |
| 127 | ), | |
| 128 | ), | |
| 129 | ), | |
| 130 | ); | |
| 131 | ||
| 132 | it.effect("lists and revokes active pairing links", () => | |
| 133 | Effect.gen(function* () { | |
| 134 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 135 | const first = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 136 | const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" }); | |
| 137 | ||
| 138 | const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); | |
| 139 | expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); | |
| 140 | expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); | |
| 141 | ||
| 142 | const revoked = yield* bootstrapCredentials.revoke(first.id); | |
| 143 | const activeAfterRevoke = yield* bootstrapCredentials.listActive(); | |
| 144 | const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential)); | |
| 145 | ||
| 146 | expect(revoked).toBe(true); | |
| 147 | expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); | |
| 148 | expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); | |
| 149 | expect(revokedConsume.message).toContain("no longer available"); | |
| 150 | expect(revokedConsume.status).toBe(401); | |
| 151 | }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 152 | ); | |
| 153 | }); |
apps/server/src/auth/Layers/BootstrapCredentialService.ts deleted-304| ... | ... | @@ -1,304 +0,0 @@ |
| 1 | import type { AuthPairingLink } from "@t3tools/contracts"; | |
| 2 | import * as Crypto from "effect/Crypto"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as PubSub from "effect/PubSub"; | |
| 8 | import * as Ref from "effect/Ref"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | ||
| 12 | import { ServerConfig } from "../../config.ts"; | |
| 13 | import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts"; | |
| 14 | import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts"; | |
| 15 | import { | |
| 16 | BootstrapCredentialError, | |
| 17 | BootstrapCredentialService, | |
| 18 | type BootstrapCredentialChange, | |
| 19 | type BootstrapCredentialServiceShape, | |
| 20 | type BootstrapGrant, | |
| 21 | type IssuedBootstrapCredential, | |
| 22 | } from "../Services/BootstrapCredentialService.ts"; | |
| 23 | ||
| 24 | interface StoredBootstrapGrant extends BootstrapGrant { | |
| 25 | readonly remainingUses: number | "unbounded"; | |
| 26 | } | |
| 27 | ||
| 28 | type ConsumeResult = | |
| 29 | | { | |
| 30 | readonly _tag: "error"; | |
| 31 | readonly reason: "not-found" | "expired"; | |
| 32 | readonly error: BootstrapCredentialError; | |
| 33 | } | |
| 34 | | { | |
| 35 | readonly _tag: "success"; | |
| 36 | readonly grant: BootstrapGrant; | |
| 37 | }; | |
| 38 | ||
| 39 | const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); | |
| 40 | const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; | |
| 41 | const PAIRING_TOKEN_LENGTH = 12; | |
| 42 | ||
| 43 | const generatePairingToken = (): string => { | |
| 44 | const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH)); | |
| 45 | ||
| 46 | return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join(""); | |
| 47 | }; | |
| 48 | ||
| 49 | export const makeBootstrapCredentialService = Effect.gen(function* () { | |
| 50 | const crypto = yield* Crypto.Crypto; | |
| 51 | const config = yield* ServerConfig; | |
| 52 | const pairingLinks = yield* AuthPairingLinkRepository; | |
| 53 | const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>()); | |
| 54 | const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>(); | |
| 55 | ||
| 56 | const invalidBootstrapCredentialError = (message: string) => | |
| 57 | new BootstrapCredentialError({ | |
| 58 | message, | |
| 59 | status: 401, | |
| 60 | }); | |
| 61 | ||
| 62 | const internalBootstrapCredentialError = (message: string, cause: unknown) => | |
| 63 | new BootstrapCredentialError({ | |
| 64 | message, | |
| 65 | status: 500, | |
| 66 | cause, | |
| 67 | }); | |
| 68 | ||
| 69 | const seedGrant = (credential: string, grant: StoredBootstrapGrant) => | |
| 70 | Ref.update(seededGrantsRef, (current) => { | |
| 71 | const next = new Map(current); | |
| 72 | next.set(credential, grant); | |
| 73 | return next; | |
| 74 | }); | |
| 75 | ||
| 76 | const emitUpsert = (pairingLink: AuthPairingLink) => | |
| 77 | PubSub.publish(changesPubSub, { | |
| 78 | type: "pairingLinkUpserted", | |
| 79 | pairingLink, | |
| 80 | }).pipe(Effect.asVoid); | |
| 81 | ||
| 82 | const emitRemoved = (id: string) => | |
| 83 | PubSub.publish(changesPubSub, { | |
| 84 | type: "pairingLinkRemoved", | |
| 85 | id, | |
| 86 | }).pipe(Effect.asVoid); | |
| 87 | ||
| 88 | if (config.desktopBootstrapToken) { | |
| 89 | const now = yield* DateTime.now; | |
| 90 | yield* seedGrant(config.desktopBootstrapToken, { | |
| 91 | method: "desktop-bootstrap", | |
| 92 | role: "owner", | |
| 93 | subject: "desktop-bootstrap", | |
| 94 | expiresAt: DateTime.add(now, { | |
| 95 | milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), | |
| 96 | }), | |
| 97 | remainingUses: 1, | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | const toBootstrapCredentialError = (message: string) => (cause: unknown) => | |
| 102 | internalBootstrapCredentialError(message, cause); | |
| 103 | ||
| 104 | const listActive: BootstrapCredentialServiceShape["listActive"] = () => | |
| 105 | Effect.gen(function* () { | |
| 106 | const now = yield* DateTime.now; | |
| 107 | const rows = yield* pairingLinks.listActive({ now }); | |
| 108 | ||
| 109 | return rows.map((row) => | |
| 110 | row.label | |
| 111 | ? ({ | |
| 112 | id: row.id, | |
| 113 | credential: row.credential, | |
| 114 | role: row.role, | |
| 115 | subject: row.subject, | |
| 116 | label: row.label, | |
| 117 | createdAt: row.createdAt, | |
| 118 | expiresAt: row.expiresAt, | |
| 119 | } satisfies AuthPairingLink) | |
| 120 | : ({ | |
| 121 | id: row.id, | |
| 122 | credential: row.credential, | |
| 123 | role: row.role, | |
| 124 | subject: row.subject, | |
| 125 | createdAt: row.createdAt, | |
| 126 | expiresAt: row.expiresAt, | |
| 127 | } satisfies AuthPairingLink), | |
| 128 | ); | |
| 129 | }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links."))); | |
| 130 | ||
| 131 | const revoke: BootstrapCredentialServiceShape["revoke"] = (id) => | |
| 132 | Effect.gen(function* () { | |
| 133 | const revokedAt = yield* DateTime.now; | |
| 134 | const revoked = yield* pairingLinks.revoke({ | |
| 135 | id, | |
| 136 | revokedAt, | |
| 137 | }); | |
| 138 | if (revoked) { | |
| 139 | yield* emitRemoved(id); | |
| 140 | } | |
| 141 | return revoked; | |
| 142 | }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link."))); | |
| 143 | ||
| 144 | const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => | |
| 145 | Effect.gen(function* () { | |
| 146 | const id = yield* crypto.randomUUIDv4; | |
| 147 | const credential = generatePairingToken(); | |
| 148 | const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; | |
| 149 | const now = yield* DateTime.now; | |
| 150 | const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); | |
| 151 | const issued: IssuedBootstrapCredential = { | |
| 152 | id, | |
| 153 | credential, | |
| 154 | ...(input?.label ? { label: input.label } : {}), | |
| 155 | expiresAt, | |
| 156 | }; | |
| 157 | yield* pairingLinks.create({ | |
| 158 | id, | |
| 159 | credential, | |
| 160 | method: "one-time-token", | |
| 161 | role: input?.role ?? "client", | |
| 162 | subject: input?.subject ?? "one-time-token", | |
| 163 | label: input?.label ?? null, | |
| 164 | createdAt: now, | |
| 165 | expiresAt: expiresAt, | |
| 166 | }); | |
| 167 | yield* emitUpsert({ | |
| 168 | id, | |
| 169 | credential, | |
| 170 | role: input?.role ?? "client", | |
| 171 | subject: input?.subject ?? "one-time-token", | |
| 172 | ...(input?.label ? { label: input.label } : {}), | |
| 173 | createdAt: now, | |
| 174 | expiresAt, | |
| 175 | }); | |
| 176 | return issued; | |
| 177 | }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential."))); | |
| 178 | ||
| 179 | const consume: BootstrapCredentialServiceShape["consume"] = (credential) => | |
| 180 | Effect.gen(function* () { | |
| 181 | const now = yield* DateTime.now; | |
| 182 | const seededResult: ConsumeResult = yield* Ref.modify( | |
| 183 | seededGrantsRef, | |
| 184 | (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => { | |
| 185 | const grant = current.get(credential); | |
| 186 | if (!grant) { | |
| 187 | return [ | |
| 188 | { | |
| 189 | _tag: "error", | |
| 190 | reason: "not-found", | |
| 191 | error: invalidBootstrapCredentialError("Unknown bootstrap credential."), | |
| 192 | }, | |
| 193 | current, | |
| 194 | ]; | |
| 195 | } | |
| 196 | ||
| 197 | const next = new Map(current); | |
| 198 | if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { | |
| 199 | next.delete(credential); | |
| 200 | return [ | |
| 201 | { | |
| 202 | _tag: "error", | |
| 203 | reason: "expired", | |
| 204 | error: invalidBootstrapCredentialError("Bootstrap credential expired."), | |
| 205 | }, | |
| 206 | next, | |
| 207 | ]; | |
| 208 | } | |
| 209 | ||
| 210 | const remainingUses = grant.remainingUses; | |
| 211 | if (typeof remainingUses === "number") { | |
| 212 | if (remainingUses <= 1) { | |
| 213 | next.delete(credential); | |
| 214 | } else { | |
| 215 | next.set(credential, { | |
| 216 | ...grant, | |
| 217 | remainingUses: remainingUses - 1, | |
| 218 | }); | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | return [ | |
| 223 | { | |
| 224 | _tag: "success", | |
| 225 | grant: { | |
| 226 | method: grant.method, | |
| 227 | role: grant.role, | |
| 228 | subject: grant.subject, | |
| 229 | ...(grant.label ? { label: grant.label } : {}), | |
| 230 | expiresAt: grant.expiresAt, | |
| 231 | } satisfies BootstrapGrant, | |
| 232 | }, | |
| 233 | next, | |
| 234 | ]; | |
| 235 | }, | |
| 236 | ); | |
| 237 | ||
| 238 | if (seededResult._tag === "success") { | |
| 239 | return seededResult.grant; | |
| 240 | } | |
| 241 | if (seededResult.reason !== "not-found") { | |
| 242 | return yield* seededResult.error; | |
| 243 | } | |
| 244 | ||
| 245 | const consumed = yield* pairingLinks.consumeAvailable({ | |
| 246 | credential, | |
| 247 | consumedAt: now, | |
| 248 | now, | |
| 249 | }); | |
| 250 | ||
| 251 | if (Option.isSome(consumed)) { | |
| 252 | yield* emitRemoved(consumed.value.id); | |
| 253 | return { | |
| 254 | method: consumed.value.method, | |
| 255 | role: consumed.value.role, | |
| 256 | subject: consumed.value.subject, | |
| 257 | ...(consumed.value.label ? { label: consumed.value.label } : {}), | |
| 258 | expiresAt: consumed.value.expiresAt, | |
| 259 | } satisfies BootstrapGrant; | |
| 260 | } | |
| 261 | ||
| 262 | const matching = yield* pairingLinks.getByCredential({ credential }); | |
| 263 | if (Option.isNone(matching)) { | |
| 264 | return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | |
| 265 | } | |
| 266 | ||
| 267 | if (matching.value.revokedAt !== null) { | |
| 268 | return yield* invalidBootstrapCredentialError( | |
| 269 | "Bootstrap credential is no longer available.", | |
| 270 | ); | |
| 271 | } | |
| 272 | ||
| 273 | if (matching.value.consumedAt !== null) { | |
| 274 | return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | |
| 275 | } | |
| 276 | ||
| 277 | if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { | |
| 278 | return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); | |
| 279 | } | |
| 280 | ||
| 281 | return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); | |
| 282 | }).pipe( | |
| 283 | Effect.mapError((cause) => | |
| 284 | cause instanceof BootstrapCredentialError | |
| 285 | ? cause | |
| 286 | : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), | |
| 287 | ), | |
| 288 | ); | |
| 289 | ||
| 290 | return { | |
| 291 | issueOneTimeToken, | |
| 292 | listActive, | |
| 293 | get streamChanges() { | |
| 294 | return Stream.fromPubSub(changesPubSub); | |
| 295 | }, | |
| 296 | revoke, | |
| 297 | consume, | |
| 298 | } satisfies BootstrapCredentialServiceShape; | |
| 299 | }); | |
| 300 | ||
| 301 | export const BootstrapCredentialServiceLive = Layer.effect( | |
| 302 | BootstrapCredentialService, | |
| 303 | makeBootstrapCredentialService, | |
| 304 | ).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive)); |
apps/server/src/auth/Layers/ServerAuth.test.ts deleted-183| ... | ... | @@ -1,183 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import type { ServerConfigShape } from "../../config.ts"; | |
| 7 | import { ServerConfig } from "../../config.ts"; | |
| 8 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 9 | import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; | |
| 10 | import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts"; | |
| 11 | import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts"; | |
| 12 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 13 | ||
| 14 | const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 15 | Layer.effect( | |
| 16 | ServerConfig, | |
| 17 | Effect.gen(function* () { | |
| 18 | const config = yield* ServerConfig; | |
| 19 | return { | |
| 20 | ...config, | |
| 21 | ...overrides, | |
| 22 | } satisfies ServerConfigShape; | |
| 23 | }), | |
| 24 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); | |
| 25 | ||
| 26 | const makeServerAuthLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 27 | ServerAuthLive.pipe( | |
| 28 | Layer.provide(SqlitePersistenceMemory), | |
| 29 | Layer.provide(ServerSecretStoreLive), | |
| 30 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 31 | ); | |
| 32 | ||
| 33 | const makeCookieRequest = ( | |
| 34 | sessionToken: string, | |
| 35 | ): Parameters<ServerAuthShape["authenticateHttpRequest"]>[0] => | |
| 36 | ({ | |
| 37 | cookies: { | |
| 38 | t3_session: sessionToken, | |
| 39 | }, | |
| 40 | headers: {}, | |
| 41 | }) as unknown as Parameters<ServerAuthShape["authenticateHttpRequest"]>[0]; | |
| 42 | ||
| 43 | const requestMetadata = { | |
| 44 | deviceType: "desktop" as const, | |
| 45 | os: "macOS", | |
| 46 | browser: "Chrome", | |
| 47 | ipAddress: "192.168.1.23", | |
| 48 | }; | |
| 49 | ||
| 50 | it.layer(NodeServices.layer)("ServerAuthLive", (it) => { | |
| 51 | it.effect("maps invalid bootstrap credential failures to 401", () => | |
| 52 | Effect.sync(() => { | |
| 53 | const error = toBootstrapExchangeAuthError( | |
| 54 | new BootstrapCredentialError({ | |
| 55 | message: "Unknown bootstrap credential.", | |
| 56 | status: 401, | |
| 57 | }), | |
| 58 | ); | |
| 59 | ||
| 60 | expect(error.status).toBe(401); | |
| 61 | expect(error.message).toBe("Invalid bootstrap credential."); | |
| 62 | }), | |
| 63 | ); | |
| 64 | ||
| 65 | it.effect("maps unexpected bootstrap failures to 500", () => | |
| 66 | Effect.sync(() => { | |
| 67 | const error = toBootstrapExchangeAuthError( | |
| 68 | new BootstrapCredentialError({ | |
| 69 | message: "Failed to consume bootstrap credential.", | |
| 70 | status: 500, | |
| 71 | cause: new Error("sqlite is unavailable"), | |
| 72 | }), | |
| 73 | ); | |
| 74 | ||
| 75 | expect(error.status).toBe(500); | |
| 76 | expect(error.message).toBe("Failed to validate bootstrap credential."); | |
| 77 | }), | |
| 78 | ); | |
| 79 | ||
| 80 | it.effect("issues client pairing credentials by default", () => | |
| 81 | Effect.gen(function* () { | |
| 82 | const serverAuth = yield* ServerAuth; | |
| 83 | ||
| 84 | const pairingCredential = yield* serverAuth.issuePairingCredential(); | |
| 85 | const exchanged = yield* serverAuth.exchangeBootstrapCredential( | |
| 86 | pairingCredential.credential, | |
| 87 | requestMetadata, | |
| 88 | ); | |
| 89 | const verified = yield* serverAuth.authenticateHttpRequest( | |
| 90 | makeCookieRequest(exchanged.sessionToken), | |
| 91 | ); | |
| 92 | ||
| 93 | expect(verified.sessionId.length).toBeGreaterThan(0); | |
| 94 | expect(verified.role).toBe("client"); | |
| 95 | expect(verified.subject).toBe("one-time-token"); | |
| 96 | }).pipe(Effect.provide(makeServerAuthLayer())), | |
| 97 | ); | |
| 98 | ||
| 99 | it.effect("issues startup pairing URLs that bootstrap owner sessions", () => | |
| 100 | Effect.gen(function* () { | |
| 101 | const serverAuth = yield* ServerAuth; | |
| 102 | ||
| 103 | const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); | |
| 104 | const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); | |
| 105 | const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 106 | expect(token).toBeTruthy(); | |
| 107 | expect( | |
| 108 | listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"), | |
| 109 | ).toBe(false); | |
| 110 | ||
| 111 | const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata); | |
| 112 | const verified = yield* serverAuth.authenticateHttpRequest( | |
| 113 | makeCookieRequest(exchanged.sessionToken), | |
| 114 | ); | |
| 115 | ||
| 116 | expect(verified.role).toBe("owner"); | |
| 117 | expect(verified.subject).toBe("owner-bootstrap"); | |
| 118 | }).pipe(Effect.provide(makeServerAuthLayer())), | |
| 119 | ); | |
| 120 | ||
| 121 | it.effect("lists pairing links and revokes other client sessions while keeping the owner", () => | |
| 122 | Effect.gen(function* () { | |
| 123 | const serverAuth = yield* ServerAuth; | |
| 124 | ||
| 125 | const ownerExchange = yield* serverAuth.exchangeBootstrapCredential( | |
| 126 | "desktop-bootstrap-token", | |
| 127 | requestMetadata, | |
| 128 | ); | |
| 129 | const ownerSession = yield* serverAuth.authenticateHttpRequest( | |
| 130 | makeCookieRequest(ownerExchange.sessionToken), | |
| 131 | ); | |
| 132 | const pairingCredential = yield* serverAuth.issuePairingCredential({ | |
| 133 | label: "Julius iPhone", | |
| 134 | }); | |
| 135 | const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 136 | const clientExchange = yield* serverAuth.exchangeBootstrapCredential( | |
| 137 | pairingCredential.credential, | |
| 138 | { | |
| 139 | ...requestMetadata, | |
| 140 | deviceType: "mobile", | |
| 141 | os: "iOS", | |
| 142 | browser: "Safari", | |
| 143 | ipAddress: "192.168.1.88", | |
| 144 | }, | |
| 145 | ); | |
| 146 | const clientSession = yield* serverAuth.authenticateHttpRequest( | |
| 147 | makeCookieRequest(clientExchange.sessionToken), | |
| 148 | ); | |
| 149 | const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); | |
| 150 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId); | |
| 151 | const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); | |
| 152 | ||
| 153 | expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id); | |
| 154 | expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe( | |
| 155 | "Julius iPhone", | |
| 156 | ); | |
| 157 | expect(clientsBeforeRevoke).toHaveLength(2); | |
| 158 | expect( | |
| 159 | clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current, | |
| 160 | ).toBe(true); | |
| 161 | expect( | |
| 162 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current, | |
| 163 | ).toBe(false); | |
| 164 | expect( | |
| 165 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client | |
| 166 | .label, | |
| 167 | ).toBe("Julius iPhone"); | |
| 168 | expect( | |
| 169 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client | |
| 170 | .deviceType, | |
| 171 | ).toBe("mobile"); | |
| 172 | expect(revokedCount).toBe(1); | |
| 173 | expect(clientsAfterRevoke).toHaveLength(1); | |
| 174 | expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId); | |
| 175 | }).pipe( | |
| 176 | Effect.provide( | |
| 177 | makeServerAuthLayer({ | |
| 178 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 179 | }), | |
| 180 | ), | |
| 181 | ), | |
| 182 | ); | |
| 183 | }); |
apps/server/src/auth/Layers/ServerAuth.ts deleted-398| ... | ... | @@ -1,398 +0,0 @@ |
| 1 | import { | |
| 2 | type AuthBearerBootstrapResult, | |
| 3 | type AuthClientSession, | |
| 4 | type AuthBootstrapResult, | |
| 5 | type AuthPairingCredentialResult, | |
| 6 | type AuthSessionState, | |
| 7 | type AuthWebSocketTokenResult, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as DateTime from "effect/DateTime"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 14 | ||
| 15 | import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; | |
| 16 | import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; | |
| 17 | import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 18 | import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; | |
| 19 | import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; | |
| 20 | import { | |
| 21 | ServerAuth, | |
| 22 | type AuthenticatedSession, | |
| 23 | AuthError, | |
| 24 | type ServerAuthShape, | |
| 25 | } from "../Services/ServerAuth.ts"; | |
| 26 | import { | |
| 27 | SessionCredentialError, | |
| 28 | SessionCredentialService, | |
| 29 | } from "../Services/SessionCredentialService.ts"; | |
| 30 | import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; | |
| 31 | ||
| 32 | type BootstrapExchangeResult = { | |
| 33 | readonly response: AuthBootstrapResult; | |
| 34 | readonly sessionToken: string; | |
| 35 | }; | |
| 36 | ||
| 37 | const AUTHORIZATION_PREFIX = "Bearer "; | |
| 38 | const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken"; | |
| 39 | ||
| 40 | export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { | |
| 41 | if (cause.status === 500) { | |
| 42 | return new AuthError({ | |
| 43 | message: "Failed to validate bootstrap credential.", | |
| 44 | status: 500, | |
| 45 | cause, | |
| 46 | }); | |
| 47 | } | |
| 48 | ||
| 49 | return new AuthError({ | |
| 50 | message: "Invalid bootstrap credential.", | |
| 51 | status: 401, | |
| 52 | cause, | |
| 53 | }); | |
| 54 | } | |
| 55 | ||
| 56 | function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { | |
| 57 | const header = request.headers["authorization"]; | |
| 58 | if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { | |
| 59 | return null; | |
| 60 | } | |
| 61 | const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); | |
| 62 | return token.length > 0 ? token : null; | |
| 63 | } | |
| 64 | ||
| 65 | export const makeServerAuth = Effect.gen(function* () { | |
| 66 | const policy = yield* ServerAuthPolicy; | |
| 67 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 68 | const authControlPlane = yield* AuthControlPlane; | |
| 69 | const sessions = yield* SessionCredentialService; | |
| 70 | const descriptor = yield* policy.getDescriptor(); | |
| 71 | ||
| 72 | const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> => | |
| 73 | sessions.verify(token).pipe( | |
| 74 | Effect.tapError((cause: SessionCredentialError) => | |
| 75 | Effect.logWarning("Rejected authenticated session credential.").pipe( | |
| 76 | Effect.annotateLogs({ | |
| 77 | reason: cause.message, | |
| 78 | }), | |
| 79 | ), | |
| 80 | ), | |
| 81 | Effect.map((session) => ({ | |
| 82 | sessionId: session.sessionId, | |
| 83 | subject: session.subject, | |
| 84 | method: session.method, | |
| 85 | role: session.role, | |
| 86 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | |
| 87 | })), | |
| 88 | Effect.mapError( | |
| 89 | (cause) => | |
| 90 | new AuthError({ | |
| 91 | message: "Unauthorized request.", | |
| 92 | status: 401, | |
| 93 | cause, | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | ||
| 98 | const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { | |
| 99 | const cookieToken = request.cookies[sessions.cookieName]; | |
| 100 | const bearerToken = parseBearerToken(request); | |
| 101 | const credential = cookieToken ?? bearerToken; | |
| 102 | if (!credential) { | |
| 103 | return Effect.fail( | |
| 104 | new AuthError({ | |
| 105 | message: "Authentication required.", | |
| 106 | status: 401, | |
| 107 | }), | |
| 108 | ); | |
| 109 | } | |
| 110 | return authenticateToken(credential); | |
| 111 | }; | |
| 112 | ||
| 113 | const getSessionState: ServerAuthShape["getSessionState"] = (request) => | |
| 114 | authenticateRequest(request).pipe( | |
| 115 | Effect.map( | |
| 116 | (session) => | |
| 117 | ({ | |
| 118 | authenticated: true, | |
| 119 | auth: descriptor, | |
| 120 | role: session.role, | |
| 121 | sessionMethod: session.method, | |
| 122 | ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), | |
| 123 | }) satisfies AuthSessionState, | |
| 124 | ), | |
| 125 | Effect.catchTag("AuthError", () => | |
| 126 | Effect.succeed({ | |
| 127 | authenticated: false, | |
| 128 | auth: descriptor, | |
| 129 | } satisfies AuthSessionState), | |
| 130 | ), | |
| 131 | ); | |
| 132 | ||
| 133 | const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = ( | |
| 134 | credential, | |
| 135 | requestMetadata, | |
| 136 | ) => | |
| 137 | bootstrapCredentials.consume(credential).pipe( | |
| 138 | Effect.mapError(toBootstrapExchangeAuthError), | |
| 139 | Effect.flatMap((grant) => | |
| 140 | sessions | |
| 141 | .issue({ | |
| 142 | method: "browser-session-cookie", | |
| 143 | subject: grant.subject, | |
| 144 | role: grant.role, | |
| 145 | client: { | |
| 146 | ...requestMetadata, | |
| 147 | ...(grant.label ? { label: grant.label } : {}), | |
| 148 | }, | |
| 149 | }) | |
| 150 | .pipe( | |
| 151 | Effect.mapError( | |
| 152 | (cause) => | |
| 153 | new AuthError({ | |
| 154 | message: "Failed to issue authenticated session.", | |
| 155 | cause, | |
| 156 | }), | |
| 157 | ), | |
| 158 | ), | |
| 159 | ), | |
| 160 | Effect.map( | |
| 161 | (session) => | |
| 162 | ({ | |
| 163 | response: { | |
| 164 | authenticated: true, | |
| 165 | role: session.role, | |
| 166 | sessionMethod: session.method, | |
| 167 | expiresAt: DateTime.toUtc(session.expiresAt), | |
| 168 | } satisfies AuthBootstrapResult, | |
| 169 | sessionToken: session.token, | |
| 170 | }) satisfies BootstrapExchangeResult, | |
| 171 | ), | |
| 172 | ); | |
| 173 | ||
| 174 | const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] = | |
| 175 | (credential, requestMetadata) => | |
| 176 | bootstrapCredentials.consume(credential).pipe( | |
| 177 | Effect.mapError(toBootstrapExchangeAuthError), | |
| 178 | Effect.flatMap((grant) => | |
| 179 | sessions | |
| 180 | .issue({ | |
| 181 | method: "bearer-session-token", | |
| 182 | subject: grant.subject, | |
| 183 | role: grant.role, | |
| 184 | client: { | |
| 185 | ...requestMetadata, | |
| 186 | ...(grant.label ? { label: grant.label } : {}), | |
| 187 | }, | |
| 188 | }) | |
| 189 | .pipe( | |
| 190 | Effect.mapError( | |
| 191 | (cause) => | |
| 192 | new AuthError({ | |
| 193 | message: "Failed to issue authenticated session.", | |
| 194 | cause, | |
| 195 | }), | |
| 196 | ), | |
| 197 | ), | |
| 198 | ), | |
| 199 | Effect.map( | |
| 200 | (session) => | |
| 201 | ({ | |
| 202 | authenticated: true, | |
| 203 | role: session.role, | |
| 204 | sessionMethod: "bearer-session-token", | |
| 205 | expiresAt: DateTime.toUtc(session.expiresAt), | |
| 206 | sessionToken: session.token, | |
| 207 | }) satisfies AuthBearerBootstrapResult, | |
| 208 | ), | |
| 209 | ); | |
| 210 | ||
| 211 | const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) => | |
| 212 | authControlPlane | |
| 213 | .createPairingLink({ | |
| 214 | role: input?.role ?? "client", | |
| 215 | subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token", | |
| 216 | ...(input?.label ? { label: input.label } : {}), | |
| 217 | }) | |
| 218 | .pipe( | |
| 219 | Effect.mapError( | |
| 220 | (cause) => | |
| 221 | new AuthError({ | |
| 222 | message: "Failed to issue pairing credential.", | |
| 223 | cause, | |
| 224 | }), | |
| 225 | ), | |
| 226 | Effect.map( | |
| 227 | (issued) => | |
| 228 | ({ | |
| 229 | id: issued.id, | |
| 230 | credential: issued.credential, | |
| 231 | ...(issued.label ? { label: issued.label } : {}), | |
| 232 | expiresAt: issued.expiresAt, | |
| 233 | }) satisfies AuthPairingCredentialResult, | |
| 234 | ), | |
| 235 | ); | |
| 236 | ||
| 237 | const listPairingLinks: ServerAuthShape["listPairingLinks"] = () => | |
| 238 | authControlPlane | |
| 239 | .listPairingLinks({ | |
| 240 | role: "client", | |
| 241 | excludeSubjects: ["owner-bootstrap"], | |
| 242 | }) | |
| 243 | .pipe( | |
| 244 | Effect.mapError( | |
| 245 | (cause) => | |
| 246 | new AuthError({ | |
| 247 | message: "Failed to load pairing links.", | |
| 248 | cause, | |
| 249 | }), | |
| 250 | ), | |
| 251 | ); | |
| 252 | ||
| 253 | const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) => | |
| 254 | authControlPlane.revokePairingLink(id).pipe( | |
| 255 | Effect.mapError( | |
| 256 | (cause) => | |
| 257 | new AuthError({ | |
| 258 | message: "Failed to revoke pairing link.", | |
| 259 | cause, | |
| 260 | }), | |
| 261 | ), | |
| 262 | ); | |
| 263 | ||
| 264 | const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) => | |
| 265 | authControlPlane.listSessions().pipe( | |
| 266 | Effect.mapError( | |
| 267 | (cause) => | |
| 268 | new AuthError({ | |
| 269 | message: "Failed to load paired clients.", | |
| 270 | cause, | |
| 271 | }), | |
| 272 | ), | |
| 273 | Effect.map((clientSessions) => | |
| 274 | clientSessions.map( | |
| 275 | (clientSession): AuthClientSession => ({ | |
| 276 | ...clientSession, | |
| 277 | current: clientSession.sessionId === currentSessionId, | |
| 278 | }), | |
| 279 | ), | |
| 280 | ), | |
| 281 | ); | |
| 282 | ||
| 283 | const revokeClientSession: ServerAuthShape["revokeClientSession"] = ( | |
| 284 | currentSessionId, | |
| 285 | targetSessionId, | |
| 286 | ) => | |
| 287 | Effect.gen(function* () { | |
| 288 | if (currentSessionId === targetSessionId) { | |
| 289 | return yield* new AuthError({ | |
| 290 | message: "Use revoke other clients to keep the current owner session active.", | |
| 291 | status: 403, | |
| 292 | }); | |
| 293 | } | |
| 294 | return yield* authControlPlane.revokeSession(targetSessionId).pipe( | |
| 295 | Effect.mapError( | |
| 296 | (cause) => | |
| 297 | new AuthError({ | |
| 298 | message: "Failed to revoke client session.", | |
| 299 | cause, | |
| 300 | }), | |
| 301 | ), | |
| 302 | ); | |
| 303 | }); | |
| 304 | ||
| 305 | const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = ( | |
| 306 | currentSessionId, | |
| 307 | ) => | |
| 308 | authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe( | |
| 309 | Effect.mapError( | |
| 310 | (cause) => | |
| 311 | new AuthError({ | |
| 312 | message: "Failed to revoke other client sessions.", | |
| 313 | cause, | |
| 314 | }), | |
| 315 | ), | |
| 316 | ); | |
| 317 | ||
| 318 | const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => | |
| 319 | issuePairingCredential({ role: "owner" }).pipe( | |
| 320 | Effect.map((issued) => { | |
| 321 | const url = new URL(baseUrl); | |
| 322 | url.pathname = "/pair"; | |
| 323 | url.searchParams.delete("token"); | |
| 324 | url.hash = new URLSearchParams([["token", issued.credential]]).toString(); | |
| 325 | return url.toString(); | |
| 326 | }), | |
| 327 | ); | |
| 328 | ||
| 329 | const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) => | |
| 330 | sessions.issueWebSocketToken(session.sessionId).pipe( | |
| 331 | Effect.mapError( | |
| 332 | (cause) => | |
| 333 | new AuthError({ | |
| 334 | message: "Failed to issue websocket token.", | |
| 335 | cause, | |
| 336 | }), | |
| 337 | ), | |
| 338 | Effect.map( | |
| 339 | (issued) => | |
| 340 | ({ | |
| 341 | token: issued.token, | |
| 342 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 343 | }) satisfies AuthWebSocketTokenResult, | |
| 344 | ), | |
| 345 | ); | |
| 346 | ||
| 347 | const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) => | |
| 348 | Effect.gen(function* () { | |
| 349 | const requestUrl = HttpServerRequest.toURL(request); | |
| 350 | if (Option.isSome(requestUrl)) { | |
| 351 | const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM); | |
| 352 | if (websocketToken && websocketToken.trim().length > 0) { | |
| 353 | return yield* sessions.verifyWebSocketToken(websocketToken).pipe( | |
| 354 | Effect.map((session) => ({ | |
| 355 | sessionId: session.sessionId, | |
| 356 | subject: session.subject, | |
| 357 | method: session.method, | |
| 358 | role: session.role, | |
| 359 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | |
| 360 | })), | |
| 361 | Effect.mapError( | |
| 362 | (cause) => | |
| 363 | new AuthError({ | |
| 364 | message: "Unauthorized request.", | |
| 365 | status: 401, | |
| 366 | cause, | |
| 367 | }), | |
| 368 | ), | |
| 369 | ); | |
| 370 | } | |
| 371 | } | |
| 372 | ||
| 373 | return yield* authenticateRequest(request); | |
| 374 | }); | |
| 375 | ||
| 376 | return { | |
| 377 | getDescriptor: () => Effect.succeed(descriptor), | |
| 378 | getSessionState, | |
| 379 | exchangeBootstrapCredential, | |
| 380 | exchangeBootstrapCredentialForBearerSession, | |
| 381 | issuePairingCredential, | |
| 382 | listPairingLinks, | |
| 383 | revokePairingLink, | |
| 384 | listClientSessions, | |
| 385 | revokeClientSession, | |
| 386 | revokeOtherClientSessions, | |
| 387 | authenticateHttpRequest: authenticateRequest, | |
| 388 | authenticateWebSocketUpgrade, | |
| 389 | issueWebSocketToken, | |
| 390 | issueStartupPairingUrl, | |
| 391 | } satisfies ServerAuthShape; | |
| 392 | }); | |
| 393 | ||
| 394 | export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe( | |
| 395 | Layer.provideMerge(AuthControlPlaneLive), | |
| 396 | Layer.provideMerge(AuthCoreLive), | |
| 397 | Layer.provideMerge(ServerAuthPolicyLive), | |
| 398 | ); |
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts deleted-115| ... | ... | @@ -1,115 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import type { ServerConfigShape } from "../../config.ts"; | |
| 7 | import { ServerConfig } from "../../config.ts"; | |
| 8 | import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; | |
| 9 | import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; | |
| 10 | ||
| 11 | const makeServerAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 12 | ServerAuthPolicyLive.pipe( | |
| 13 | Layer.provide( | |
| 14 | Layer.effect( | |
| 15 | ServerConfig, | |
| 16 | Effect.gen(function* () { | |
| 17 | const config = yield* ServerConfig; | |
| 18 | return { | |
| 19 | ...config, | |
| 20 | ...overrides, | |
| 21 | } satisfies ServerConfigShape; | |
| 22 | }), | |
| 23 | ).pipe( | |
| 24 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), | |
| 25 | ), | |
| 26 | ), | |
| 27 | ); | |
| 28 | ||
| 29 | it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { | |
| 30 | it.effect("uses desktop-managed-local policy for desktop mode", () => | |
| 31 | Effect.gen(function* () { | |
| 32 | const policy = yield* ServerAuthPolicy; | |
| 33 | const descriptor = yield* policy.getDescriptor(); | |
| 34 | ||
| 35 | expect(descriptor.policy).toBe("desktop-managed-local"); | |
| 36 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); | |
| 37 | expect(descriptor.sessionCookieName).toBe("t3_session_3773"); | |
| 38 | }).pipe( | |
| 39 | Effect.provide( | |
| 40 | makeServerAuthPolicyLayer({ | |
| 41 | mode: "desktop", | |
| 42 | port: 3773, | |
| 43 | }), | |
| 44 | ), | |
| 45 | ), | |
| 46 | ); | |
| 47 | ||
| 48 | it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => | |
| 49 | Effect.gen(function* () { | |
| 50 | const policy = yield* ServerAuthPolicy; | |
| 51 | const descriptor = yield* policy.getDescriptor(); | |
| 52 | ||
| 53 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 54 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]); | |
| 55 | }).pipe( | |
| 56 | Effect.provide( | |
| 57 | makeServerAuthPolicyLayer({ | |
| 58 | mode: "desktop", | |
| 59 | host: "0.0.0.0", | |
| 60 | }), | |
| 61 | ), | |
| 62 | ), | |
| 63 | ); | |
| 64 | ||
| 65 | it.effect("uses loopback-browser policy for loopback web hosts", () => | |
| 66 | Effect.gen(function* () { | |
| 67 | const policy = yield* ServerAuthPolicy; | |
| 68 | const descriptor = yield* policy.getDescriptor(); | |
| 69 | ||
| 70 | expect(descriptor.policy).toBe("loopback-browser"); | |
| 71 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 72 | expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 73 | }).pipe( | |
| 74 | Effect.provide( | |
| 75 | makeServerAuthPolicyLayer({ | |
| 76 | mode: "web", | |
| 77 | host: "127.0.0.1", | |
| 78 | }), | |
| 79 | ), | |
| 80 | ), | |
| 81 | ); | |
| 82 | ||
| 83 | it.effect("uses remote-reachable policy for wildcard web hosts", () => | |
| 84 | Effect.gen(function* () { | |
| 85 | const policy = yield* ServerAuthPolicy; | |
| 86 | const descriptor = yield* policy.getDescriptor(); | |
| 87 | ||
| 88 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 89 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 90 | }).pipe( | |
| 91 | Effect.provide( | |
| 92 | makeServerAuthPolicyLayer({ | |
| 93 | mode: "web", | |
| 94 | host: "0.0.0.0", | |
| 95 | }), | |
| 96 | ), | |
| 97 | ), | |
| 98 | ); | |
| 99 | ||
| 100 | it.effect("uses remote-reachable policy for non-loopback web hosts", () => | |
| 101 | Effect.gen(function* () { | |
| 102 | const policy = yield* ServerAuthPolicy; | |
| 103 | const descriptor = yield* policy.getDescriptor(); | |
| 104 | ||
| 105 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 106 | }).pipe( | |
| 107 | Effect.provide( | |
| 108 | makeServerAuthPolicyLayer({ | |
| 109 | mode: "web", | |
| 110 | host: "192.168.1.50", | |
| 111 | }), | |
| 112 | ), | |
| 113 | ), | |
| 114 | ); | |
| 115 | }); |
apps/server/src/auth/Layers/ServerAuthPolicy.ts deleted-45| ... | ... | @@ -1,45 +0,0 @@ |
| 1 | import type { ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | ||
| 5 | import { ServerConfig } from "../../config.ts"; | |
| 6 | import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; | |
| 7 | import { resolveSessionCookieName } from "../utils.ts"; | |
| 8 | import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; | |
| 9 | ||
| 10 | export const makeServerAuthPolicy = Effect.gen(function* () { | |
| 11 | const config = yield* ServerConfig; | |
| 12 | const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); | |
| 13 | ||
| 14 | const policy = | |
| 15 | config.mode === "desktop" | |
| 16 | ? isRemoteReachable | |
| 17 | ? "remote-reachable" | |
| 18 | : "desktop-managed-local" | |
| 19 | : isRemoteReachable | |
| 20 | ? "remote-reachable" | |
| 21 | : "loopback-browser"; | |
| 22 | ||
| 23 | const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] = | |
| 24 | policy === "desktop-managed-local" | |
| 25 | ? ["desktop-bootstrap"] | |
| 26 | : config.mode === "desktop" && policy === "remote-reachable" | |
| 27 | ? ["desktop-bootstrap", "one-time-token"] | |
| 28 | : ["one-time-token"]; | |
| 29 | ||
| 30 | const descriptor: ServerAuthDescriptor = { | |
| 31 | policy, | |
| 32 | bootstrapMethods, | |
| 33 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 34 | sessionCookieName: resolveSessionCookieName({ | |
| 35 | mode: config.mode, | |
| 36 | port: config.port, | |
| 37 | }), | |
| 38 | }; | |
| 39 | ||
| 40 | return { | |
| 41 | getDescriptor: () => Effect.succeed(descriptor), | |
| 42 | } satisfies ServerAuthPolicyShape; | |
| 43 | }); | |
| 44 | ||
| 45 | export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy); |
apps/server/src/auth/Layers/ServerSecretStore.test.ts deleted-268| ... | ... | @@ -1,268 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Ref from "effect/Ref"; | |
| 9 | import * as PlatformError from "effect/PlatformError"; | |
| 10 | ||
| 11 | import { ServerConfig } from "../../config.ts"; | |
| 12 | import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts"; | |
| 13 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 14 | ||
| 15 | const makeServerConfigLayer = () => | |
| 16 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); | |
| 17 | ||
| 18 | const makeServerSecretStoreLayer = () => | |
| 19 | Layer.provide(ServerSecretStoreLive, makeServerConfigLayer()); | |
| 20 | ||
| 21 | const PermissionDeniedFileSystemLayer = Layer.effect( | |
| 22 | FileSystem.FileSystem, | |
| 23 | Effect.gen(function* () { | |
| 24 | const fileSystem = yield* FileSystem.FileSystem; | |
| 25 | ||
| 26 | return { | |
| 27 | ...fileSystem, | |
| 28 | readFile: (path) => | |
| 29 | Effect.fail( | |
| 30 | PlatformError.systemError({ | |
| 31 | _tag: "PermissionDenied", | |
| 32 | module: "FileSystem", | |
| 33 | method: "readFile", | |
| 34 | pathOrDescriptor: path, | |
| 35 | description: "Permission denied while reading secret file.", | |
| 36 | }), | |
| 37 | ), | |
| 38 | } satisfies FileSystem.FileSystem; | |
| 39 | }), | |
| 40 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 41 | ||
| 42 | const makePermissionDeniedSecretStoreLayer = () => | |
| 43 | ServerSecretStoreLive.pipe( | |
| 44 | Layer.provide(makeServerConfigLayer()), | |
| 45 | Layer.provideMerge(PermissionDeniedFileSystemLayer), | |
| 46 | ); | |
| 47 | ||
| 48 | const RenameFailureFileSystemLayer = Layer.effect( | |
| 49 | FileSystem.FileSystem, | |
| 50 | Effect.gen(function* () { | |
| 51 | const fileSystem = yield* FileSystem.FileSystem; | |
| 52 | ||
| 53 | return { | |
| 54 | ...fileSystem, | |
| 55 | rename: (from, to) => | |
| 56 | Effect.fail( | |
| 57 | PlatformError.systemError({ | |
| 58 | _tag: "PermissionDenied", | |
| 59 | module: "FileSystem", | |
| 60 | method: "rename", | |
| 61 | pathOrDescriptor: `${String(from)} -> ${String(to)}`, | |
| 62 | description: "Permission denied while persisting secret file.", | |
| 63 | }), | |
| 64 | ), | |
| 65 | } satisfies FileSystem.FileSystem; | |
| 66 | }), | |
| 67 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 68 | ||
| 69 | const makeRenameFailureSecretStoreLayer = () => | |
| 70 | ServerSecretStoreLive.pipe( | |
| 71 | Layer.provide(makeServerConfigLayer()), | |
| 72 | Layer.provideMerge(RenameFailureFileSystemLayer), | |
| 73 | ); | |
| 74 | ||
| 75 | const RemoveFailureFileSystemLayer = Layer.effect( | |
| 76 | FileSystem.FileSystem, | |
| 77 | Effect.gen(function* () { | |
| 78 | const fileSystem = yield* FileSystem.FileSystem; | |
| 79 | ||
| 80 | return { | |
| 81 | ...fileSystem, | |
| 82 | remove: (path, options) => | |
| 83 | Effect.fail( | |
| 84 | PlatformError.systemError({ | |
| 85 | _tag: "PermissionDenied", | |
| 86 | module: "FileSystem", | |
| 87 | method: "remove", | |
| 88 | pathOrDescriptor: String(path), | |
| 89 | description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, | |
| 90 | }), | |
| 91 | ), | |
| 92 | } satisfies FileSystem.FileSystem; | |
| 93 | }), | |
| 94 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 95 | ||
| 96 | const makeRemoveFailureSecretStoreLayer = () => | |
| 97 | ServerSecretStoreLive.pipe( | |
| 98 | Layer.provide(makeServerConfigLayer()), | |
| 99 | Layer.provideMerge(RemoveFailureFileSystemLayer), | |
| 100 | ); | |
| 101 | ||
| 102 | const ConcurrentReadMissFileSystemLayer = Layer.effect( | |
| 103 | FileSystem.FileSystem, | |
| 104 | Effect.gen(function* () { | |
| 105 | const fileSystem = yield* FileSystem.FileSystem; | |
| 106 | const readCountRef = yield* Ref.make(0); | |
| 107 | const readBarrier = yield* Deferred.make<void>(); | |
| 108 | ||
| 109 | return { | |
| 110 | ...fileSystem, | |
| 111 | readFile: (path) => | |
| 112 | String(path).endsWith("/session-signing-key.bin") | |
| 113 | ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe( | |
| 114 | Effect.flatMap((count) => { | |
| 115 | if (count > 2) { | |
| 116 | return fileSystem.readFile(path); | |
| 117 | } | |
| 118 | return Effect.gen(function* () { | |
| 119 | if (count === 2) { | |
| 120 | yield* Deferred.succeed(readBarrier, void 0); | |
| 121 | } | |
| 122 | yield* Deferred.await(readBarrier); | |
| 123 | return yield* Effect.failCause( | |
| 124 | Cause.fail( | |
| 125 | PlatformError.systemError({ | |
| 126 | _tag: "NotFound", | |
| 127 | module: "FileSystem", | |
| 128 | method: "readFile", | |
| 129 | pathOrDescriptor: String(path), | |
| 130 | description: "Secret file does not exist yet.", | |
| 131 | }), | |
| 132 | ), | |
| 133 | ); | |
| 134 | }); | |
| 135 | }), | |
| 136 | ) | |
| 137 | : fileSystem.readFile(path), | |
| 138 | } satisfies FileSystem.FileSystem; | |
| 139 | }), | |
| 140 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 141 | ||
| 142 | const makeConcurrentCreateSecretStoreLayer = () => | |
| 143 | ServerSecretStoreLive.pipe( | |
| 144 | Layer.provide(makeServerConfigLayer()), | |
| 145 | Layer.provideMerge(ConcurrentReadMissFileSystemLayer), | |
| 146 | ); | |
| 147 | ||
| 148 | it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { | |
| 149 | it.effect("returns null when a secret file does not exist", () => | |
| 150 | Effect.gen(function* () { | |
| 151 | const secretStore = yield* ServerSecretStore; | |
| 152 | ||
| 153 | const secret = yield* secretStore.get("missing-secret"); | |
| 154 | ||
| 155 | expect(secret).toBeNull(); | |
| 156 | }).pipe(Effect.provide(makeServerSecretStoreLayer())), | |
| 157 | ); | |
| 158 | ||
| 159 | it.effect("reuses an existing secret instead of regenerating it", () => | |
| 160 | Effect.gen(function* () { | |
| 161 | const secretStore = yield* ServerSecretStore; | |
| 162 | ||
| 163 | const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); | |
| 164 | const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); | |
| 165 | ||
| 166 | expect(Array.from(second)).toEqual(Array.from(first)); | |
| 167 | }).pipe(Effect.provide(makeServerSecretStoreLayer())), | |
| 168 | ); | |
| 169 | ||
| 170 | it.effect("returns the persisted secret when concurrent creators race", () => | |
| 171 | Effect.gen(function* () { | |
| 172 | const secretStore = yield* ServerSecretStore; | |
| 173 | ||
| 174 | const [first, second] = yield* Effect.all( | |
| 175 | [ | |
| 176 | secretStore.getOrCreateRandom("session-signing-key", 32), | |
| 177 | secretStore.getOrCreateRandom("session-signing-key", 32), | |
| 178 | ], | |
| 179 | { concurrency: "unbounded" }, | |
| 180 | ); | |
| 181 | const persisted = yield* secretStore.get("session-signing-key"); | |
| 182 | ||
| 183 | expect(persisted).not.toBeNull(); | |
| 184 | expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array())); | |
| 185 | expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array())); | |
| 186 | }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), | |
| 187 | ); | |
| 188 | ||
| 189 | it.effect("uses restrictive permissions for the secret directory and files", () => | |
| 190 | Effect.gen(function* () { | |
| 191 | const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; | |
| 192 | const recordingFileSystemLayer = Layer.effect( | |
| 193 | FileSystem.FileSystem, | |
| 194 | Effect.gen(function* () { | |
| 195 | const fileSystem = yield* FileSystem.FileSystem; | |
| 196 | ||
| 197 | return { | |
| 198 | ...fileSystem, | |
| 199 | makeDirectory: () => Effect.void, | |
| 200 | writeFile: () => Effect.void, | |
| 201 | rename: () => Effect.void, | |
| 202 | chmod: (path, mode) => | |
| 203 | Effect.sync(() => { | |
| 204 | chmodCalls.push({ path: String(path), mode }); | |
| 205 | }), | |
| 206 | } satisfies FileSystem.FileSystem; | |
| 207 | }), | |
| 208 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 209 | ||
| 210 | const secretStore = yield* Effect.service(ServerSecretStore).pipe( | |
| 211 | Effect.provide( | |
| 212 | ServerSecretStoreLive.pipe( | |
| 213 | Layer.provide(makeServerConfigLayer()), | |
| 214 | Layer.provideMerge(recordingFileSystemLayer), | |
| 215 | ), | |
| 216 | ), | |
| 217 | ); | |
| 218 | ||
| 219 | yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); | |
| 220 | ||
| 221 | expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( | |
| 222 | true, | |
| 223 | ); | |
| 224 | expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); | |
| 225 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 226 | ); | |
| 227 | ||
| 228 | it.effect("propagates read failures other than missing-file errors", () => | |
| 229 | Effect.gen(function* () { | |
| 230 | const secretStore = yield* ServerSecretStore; | |
| 231 | ||
| 232 | const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); | |
| 233 | ||
| 234 | expect(error).toBeInstanceOf(SecretStoreError); | |
| 235 | expect(error.message).toContain("Failed to read secret session-signing-key."); | |
| 236 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 237 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 238 | }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), | |
| 239 | ); | |
| 240 | ||
| 241 | it.effect("propagates write failures instead of treating them as success", () => | |
| 242 | Effect.gen(function* () { | |
| 243 | const secretStore = yield* ServerSecretStore; | |
| 244 | ||
| 245 | const error = yield* Effect.flip( | |
| 246 | secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), | |
| 247 | ); | |
| 248 | ||
| 249 | expect(error).toBeInstanceOf(SecretStoreError); | |
| 250 | expect(error.message).toContain("Failed to persist secret session-signing-key."); | |
| 251 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 252 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 253 | }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), | |
| 254 | ); | |
| 255 | ||
| 256 | it.effect("propagates remove failures other than missing-file errors", () => | |
| 257 | Effect.gen(function* () { | |
| 258 | const secretStore = yield* ServerSecretStore; | |
| 259 | ||
| 260 | const error = yield* Effect.flip(secretStore.remove("session-signing-key")); | |
| 261 | ||
| 262 | expect(error).toBeInstanceOf(SecretStoreError); | |
| 263 | expect(error.message).toContain("Failed to remove secret session-signing-key."); | |
| 264 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 265 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 266 | }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), | |
| 267 | ); | |
| 268 | }); |
apps/server/src/auth/Layers/ServerSecretStore.ts deleted-152| ... | ... | @@ -1,152 +0,0 @@ |
| 1 | import * as Crypto from "node:crypto"; | |
| 2 | ||
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as Predicate from "effect/Predicate"; | |
| 8 | import * as PlatformError from "effect/PlatformError"; | |
| 9 | ||
| 10 | import { ServerConfig } from "../../config.ts"; | |
| 11 | import { | |
| 12 | SecretStoreError, | |
| 13 | ServerSecretStore, | |
| 14 | type ServerSecretStoreShape, | |
| 15 | } from "../Services/ServerSecretStore.ts"; | |
| 16 | ||
| 17 | export const makeServerSecretStore = Effect.gen(function* () { | |
| 18 | const fileSystem = yield* FileSystem.FileSystem; | |
| 19 | const path = yield* Path.Path; | |
| 20 | const serverConfig = yield* ServerConfig; | |
| 21 | ||
| 22 | yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); | |
| 23 | yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( | |
| 24 | Effect.mapError( | |
| 25 | (cause) => | |
| 26 | new SecretStoreError({ | |
| 27 | message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, | |
| 28 | cause, | |
| 29 | }), | |
| 30 | ), | |
| 31 | ); | |
| 32 | ||
| 33 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); | |
| 34 | ||
| 35 | const isPlatformError = (u: unknown): u is PlatformError.PlatformError => | |
| 36 | Predicate.isTagged(u, "PlatformError"); | |
| 37 | ||
| 38 | const get: ServerSecretStoreShape["get"] = (name) => | |
| 39 | fileSystem.readFile(resolveSecretPath(name)).pipe( | |
| 40 | Effect.map((bytes) => Uint8Array.from(bytes)), | |
| 41 | Effect.catch((cause) => | |
| 42 | cause.reason._tag === "NotFound" | |
| 43 | ? Effect.succeed(null) | |
| 44 | : Effect.fail( | |
| 45 | new SecretStoreError({ | |
| 46 | message: `Failed to read secret ${name}.`, | |
| 47 | cause, | |
| 48 | }), | |
| 49 | ), | |
| 50 | ), | |
| 51 | ); | |
| 52 | ||
| 53 | const set: ServerSecretStoreShape["set"] = (name, value) => { | |
| 54 | const secretPath = resolveSecretPath(name); | |
| 55 | const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; | |
| 56 | return Effect.gen(function* () { | |
| 57 | yield* fileSystem.writeFile(tempPath, value); | |
| 58 | yield* fileSystem.chmod(tempPath, 0o600); | |
| 59 | yield* fileSystem.rename(tempPath, secretPath); | |
| 60 | yield* fileSystem.chmod(secretPath, 0o600); | |
| 61 | }).pipe( | |
| 62 | Effect.catch((cause) => | |
| 63 | fileSystem.remove(tempPath).pipe( | |
| 64 | Effect.ignore, | |
| 65 | Effect.flatMap(() => | |
| 66 | Effect.fail( | |
| 67 | new SecretStoreError({ | |
| 68 | message: `Failed to persist secret ${name}.`, | |
| 69 | cause, | |
| 70 | }), | |
| 71 | ), | |
| 72 | ), | |
| 73 | ), | |
| 74 | ), | |
| 75 | ); | |
| 76 | }; | |
| 77 | ||
| 78 | const create: ServerSecretStoreShape["set"] = (name, value) => { | |
| 79 | const secretPath = resolveSecretPath(name); | |
| 80 | return Effect.scoped( | |
| 81 | Effect.gen(function* () { | |
| 82 | const file = yield* fileSystem.open(secretPath, { | |
| 83 | flag: "wx", | |
| 84 | mode: 0o600, | |
| 85 | }); | |
| 86 | yield* file.writeAll(value); | |
| 87 | yield* file.sync; | |
| 88 | yield* fileSystem.chmod(secretPath, 0o600); | |
| 89 | }), | |
| 90 | ).pipe( | |
| 91 | Effect.mapError( | |
| 92 | (cause) => | |
| 93 | new SecretStoreError({ | |
| 94 | message: `Failed to persist secret ${name}.`, | |
| 95 | cause, | |
| 96 | }), | |
| 97 | ), | |
| 98 | ); | |
| 99 | }; | |
| 100 | ||
| 101 | const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => | |
| 102 | get(name).pipe( | |
| 103 | Effect.flatMap((existing) => { | |
| 104 | if (existing) { | |
| 105 | return Effect.succeed(existing); | |
| 106 | } | |
| 107 | ||
| 108 | const generated = Crypto.randomBytes(bytes); | |
| 109 | return create(name, generated).pipe( | |
| 110 | Effect.as(Uint8Array.from(generated)), | |
| 111 | Effect.catchTag("SecretStoreError", (error) => | |
| 112 | isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists" | |
| 113 | ? get(name).pipe( | |
| 114 | Effect.flatMap((created) => | |
| 115 | created !== null | |
| 116 | ? Effect.succeed(created) | |
| 117 | : Effect.fail( | |
| 118 | new SecretStoreError({ | |
| 119 | message: `Failed to read secret ${name} after concurrent creation.`, | |
| 120 | }), | |
| 121 | ), | |
| 122 | ), | |
| 123 | ) | |
| 124 | : Effect.fail(error), | |
| 125 | ), | |
| 126 | ); | |
| 127 | }), | |
| 128 | ); | |
| 129 | ||
| 130 | const remove: ServerSecretStoreShape["remove"] = (name) => | |
| 131 | fileSystem.remove(resolveSecretPath(name)).pipe( | |
| 132 | Effect.catch((cause) => | |
| 133 | cause.reason._tag === "NotFound" | |
| 134 | ? Effect.void | |
| 135 | : Effect.fail( | |
| 136 | new SecretStoreError({ | |
| 137 | message: `Failed to remove secret ${name}.`, | |
| 138 | cause, | |
| 139 | }), | |
| 140 | ), | |
| 141 | ), | |
| 142 | ); | |
| 143 | ||
| 144 | return { | |
| 145 | get, | |
| 146 | set, | |
| 147 | getOrCreateRandom, | |
| 148 | remove, | |
| 149 | } satisfies ServerSecretStoreShape; | |
| 150 | }); | |
| 151 | ||
| 152 | export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore); |
apps/server/src/auth/Layers/SessionCredentialService.test.ts deleted-193| ... | ... | @@ -1,193 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | ||
| 8 | import type { ServerConfigShape } from "../../config.ts"; | |
| 9 | import { ServerConfig } from "../../config.ts"; | |
| 10 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 11 | import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 12 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 13 | import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 14 | ||
| 15 | const makeServerConfigLayer = ( | |
| 16 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 17 | ) => | |
| 18 | Layer.effect( | |
| 19 | ServerConfig, | |
| 20 | Effect.gen(function* () { | |
| 21 | const config = yield* ServerConfig; | |
| 22 | return { | |
| 23 | ...config, | |
| 24 | ...overrides, | |
| 25 | } satisfies ServerConfigShape; | |
| 26 | }), | |
| 27 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); | |
| 28 | ||
| 29 | const makeSessionCredentialLayer = ( | |
| 30 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 31 | ) => | |
| 32 | SessionCredentialServiceLive.pipe( | |
| 33 | Layer.provide(SqlitePersistenceMemory), | |
| 34 | Layer.provide(ServerSecretStoreLive), | |
| 35 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 36 | ); | |
| 37 | ||
| 38 | it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { | |
| 39 | it.effect("issues and verifies signed browser session tokens", () => | |
| 40 | Effect.gen(function* () { | |
| 41 | const sessions = yield* SessionCredentialService; | |
| 42 | const issued = yield* sessions.issue({ | |
| 43 | subject: "desktop-bootstrap", | |
| 44 | role: "owner", | |
| 45 | client: { | |
| 46 | label: "Desktop app", | |
| 47 | deviceType: "desktop", | |
| 48 | os: "macOS", | |
| 49 | browser: "Electron", | |
| 50 | ipAddress: "127.0.0.1", | |
| 51 | }, | |
| 52 | }); | |
| 53 | const verified = yield* sessions.verify(issued.token); | |
| 54 | ||
| 55 | expect(verified.method).toBe("browser-session-cookie"); | |
| 56 | expect(verified.subject).toBe("desktop-bootstrap"); | |
| 57 | expect(verified.role).toBe("owner"); | |
| 58 | expect(verified.client.label).toBe("Desktop app"); | |
| 59 | expect(verified.client.browser).toBe("Electron"); | |
| 60 | expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); | |
| 61 | }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 62 | ); | |
| 63 | it.effect("rejects malformed session tokens", () => | |
| 64 | Effect.gen(function* () { | |
| 65 | const sessions = yield* SessionCredentialService; | |
| 66 | const error = yield* Effect.flip(sessions.verify("not-a-session-token")); | |
| 67 | ||
| 68 | expect(error._tag).toBe("SessionCredentialError"); | |
| 69 | expect(error.message).toContain("Malformed session token"); | |
| 70 | }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 71 | ); | |
| 72 | it.effect("verifies session tokens against the Effect clock", () => | |
| 73 | Effect.gen(function* () { | |
| 74 | const sessions = yield* SessionCredentialService; | |
| 75 | const issued = yield* sessions.issue({ | |
| 76 | method: "bearer-session-token", | |
| 77 | subject: "test-clock", | |
| 78 | }); | |
| 79 | const verified = yield* sessions.verify(issued.token); | |
| 80 | ||
| 81 | expect(verified.method).toBe("bearer-session-token"); | |
| 82 | expect(verified.subject).toBe("test-clock"); | |
| 83 | expect(verified.role).toBe("client"); | |
| 84 | }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 85 | ); | |
| 86 | ||
| 87 | it.effect("rejects websocket tokens once the parent session has expired", () => | |
| 88 | Effect.gen(function* () { | |
| 89 | const sessions = yield* SessionCredentialService; | |
| 90 | const issued = yield* sessions.issue({ | |
| 91 | method: "bearer-session-token", | |
| 92 | subject: "short-lived", | |
| 93 | ttl: Duration.seconds(1), | |
| 94 | }); | |
| 95 | const websocket = yield* sessions.issueWebSocketToken(issued.sessionId); | |
| 96 | ||
| 97 | yield* TestClock.adjust(Duration.seconds(2)); | |
| 98 | ||
| 99 | const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); | |
| 100 | expect(error.message).toContain("expired"); | |
| 101 | }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 102 | ); | |
| 103 | ||
| 104 | it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => | |
| 105 | Effect.gen(function* () { | |
| 106 | const sessions = yield* SessionCredentialService; | |
| 107 | const owner = yield* sessions.issue({ | |
| 108 | subject: "desktop-bootstrap", | |
| 109 | role: "owner", | |
| 110 | client: { | |
| 111 | label: "Desktop app", | |
| 112 | deviceType: "desktop", | |
| 113 | os: "macOS", | |
| 114 | browser: "Electron", | |
| 115 | }, | |
| 116 | }); | |
| 117 | const client = yield* sessions.issue({ | |
| 118 | subject: "one-time-token", | |
| 119 | role: "client", | |
| 120 | client: { | |
| 121 | label: "Julius iPhone", | |
| 122 | deviceType: "mobile", | |
| 123 | os: "iOS", | |
| 124 | browser: "Safari", | |
| 125 | ipAddress: "192.168.1.88", | |
| 126 | }, | |
| 127 | }); | |
| 128 | ||
| 129 | yield* sessions.markConnected(client.sessionId); | |
| 130 | const beforeRevoke = yield* sessions.listActive(); | |
| 131 | const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId); | |
| 132 | const afterRevoke = yield* sessions.listActive(); | |
| 133 | const revokedClient = yield* Effect.flip(sessions.verify(client.token)); | |
| 134 | ||
| 135 | expect(beforeRevoke).toHaveLength(2); | |
| 136 | expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( | |
| 137 | true, | |
| 138 | ); | |
| 139 | expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe( | |
| 140 | "Julius iPhone", | |
| 141 | ); | |
| 142 | expect( | |
| 143 | beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType, | |
| 144 | ).toBe("desktop"); | |
| 145 | expect(revokedCount).toBe(1); | |
| 146 | expect(afterRevoke).toHaveLength(1); | |
| 147 | expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId); | |
| 148 | expect(revokedClient.message).toContain("revoked"); | |
| 149 | }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 150 | ); | |
| 151 | ||
| 152 | it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () => | |
| 153 | Effect.gen(function* () { | |
| 154 | const sessions = yield* SessionCredentialService; | |
| 155 | const issued = yield* sessions.issue({ | |
| 156 | subject: "reconnect-test", | |
| 157 | method: "bearer-session-token", | |
| 158 | }); | |
| 159 | ||
| 160 | const beforeConnect = yield* sessions.listActive(); | |
| 161 | expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); | |
| 162 | ||
| 163 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 164 | yield* sessions.markConnected(issued.sessionId); | |
| 165 | const firstConnect = yield* sessions.listActive(); | |
| 166 | const firstConnectedAt = firstConnect[0]?.lastConnectedAt; | |
| 167 | ||
| 168 | expect(firstConnect[0]?.connected).toBe(true); | |
| 169 | expect(firstConnectedAt).not.toBeNull(); | |
| 170 | ||
| 171 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 172 | yield* sessions.markConnected(issued.sessionId); | |
| 173 | const stillConnected = yield* sessions.listActive(); | |
| 174 | ||
| 175 | expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); | |
| 176 | ||
| 177 | yield* sessions.markDisconnected(issued.sessionId); | |
| 178 | yield* sessions.markDisconnected(issued.sessionId); | |
| 179 | const afterDisconnect = yield* sessions.listActive(); | |
| 180 | ||
| 181 | expect(afterDisconnect[0]?.connected).toBe(false); | |
| 182 | expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); | |
| 183 | ||
| 184 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 185 | yield* sessions.markConnected(issued.sessionId); | |
| 186 | const afterReconnect = yield* sessions.listActive(); | |
| 187 | ||
| 188 | expect(afterReconnect[0]?.connected).toBe(true); | |
| 189 | expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull(); | |
| 190 | expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); | |
| 191 | }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 192 | ); | |
| 193 | }); |
apps/server/src/auth/Layers/SessionCredentialService.ts deleted-530| ... | ... | @@ -1,530 +0,0 @@ |
| 1 | import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts"; | |
| 2 | import * as Clock from "effect/Clock"; | |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as DateTime from "effect/DateTime"; | |
| 5 | import * as Duration from "effect/Duration"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as PubSub from "effect/PubSub"; | |
| 9 | import * as Ref from "effect/Ref"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import * as Stream from "effect/Stream"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | ||
| 14 | import { ServerConfig } from "../../config.ts"; | |
| 15 | import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; | |
| 16 | import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; | |
| 17 | import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; | |
| 18 | import { | |
| 19 | SessionCredentialError, | |
| 20 | SessionCredentialService, | |
| 21 | type IssuedSession, | |
| 22 | type SessionCredentialChange, | |
| 23 | type SessionCredentialServiceShape, | |
| 24 | type VerifiedSession, | |
| 25 | } from "../Services/SessionCredentialService.ts"; | |
| 26 | import { | |
| 27 | base64UrlDecodeUtf8, | |
| 28 | base64UrlEncode, | |
| 29 | resolveSessionCookieName, | |
| 30 | signPayload, | |
| 31 | timingSafeEqualBase64Url, | |
| 32 | } from "../utils.ts"; | |
| 33 | ||
| 34 | const SIGNING_SECRET_NAME = "server-signing-key"; | |
| 35 | const DEFAULT_SESSION_TTL = Duration.days(30); | |
| 36 | const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); | |
| 37 | ||
| 38 | const SessionClaims = Schema.Struct({ | |
| 39 | v: Schema.Literal(1), | |
| 40 | kind: Schema.Literal("session"), | |
| 41 | sid: AuthSessionId, | |
| 42 | sub: Schema.String, | |
| 43 | role: Schema.Literals(["owner", "client"]), | |
| 44 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 45 | iat: Schema.Number, | |
| 46 | exp: Schema.Number, | |
| 47 | }); | |
| 48 | type SessionClaims = typeof SessionClaims.Type; | |
| 49 | ||
| 50 | const WebSocketClaims = Schema.Struct({ | |
| 51 | v: Schema.Literal(1), | |
| 52 | kind: Schema.Literal("websocket"), | |
| 53 | sid: AuthSessionId, | |
| 54 | iat: Schema.Number, | |
| 55 | exp: Schema.Number, | |
| 56 | }); | |
| 57 | type WebSocketClaims = typeof WebSocketClaims.Type; | |
| 58 | ||
| 59 | const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims)); | |
| 60 | const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims)); | |
| 61 | ||
| 62 | function createDefaultClientMetadata(): AuthClientMetadata { | |
| 63 | return { | |
| 64 | deviceType: "unknown", | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | function toClientMetadata(record: { | |
| 69 | readonly label: string | null; | |
| 70 | readonly ipAddress: string | null; | |
| 71 | readonly userAgent: string | null; | |
| 72 | readonly deviceType: AuthClientMetadata["deviceType"]; | |
| 73 | readonly os: string | null; | |
| 74 | readonly browser: string | null; | |
| 75 | }): AuthClientMetadata { | |
| 76 | return { | |
| 77 | ...(record.label ? { label: record.label } : {}), | |
| 78 | ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), | |
| 79 | ...(record.userAgent ? { userAgent: record.userAgent } : {}), | |
| 80 | deviceType: record.deviceType, | |
| 81 | ...(record.os ? { os: record.os } : {}), | |
| 82 | ...(record.browser ? { browser: record.browser } : {}), | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession { | |
| 87 | return { | |
| 88 | ...input, | |
| 89 | current: false, | |
| 90 | }; | |
| 91 | } | |
| 92 | ||
| 93 | export const makeSessionCredentialService = Effect.gen(function* () { | |
| 94 | const crypto = yield* Crypto.Crypto; | |
| 95 | const serverConfig = yield* ServerConfig; | |
| 96 | const secretStore = yield* ServerSecretStore; | |
| 97 | const authSessions = yield* AuthSessionRepository; | |
| 98 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); | |
| 99 | const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); | |
| 100 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); | |
| 101 | const cookieName = resolveSessionCookieName({ | |
| 102 | mode: serverConfig.mode, | |
| 103 | port: serverConfig.port, | |
| 104 | }); | |
| 105 | ||
| 106 | const toSessionCredentialError = (message: string) => (cause: unknown) => | |
| 107 | new SessionCredentialError({ | |
| 108 | message, | |
| 109 | cause, | |
| 110 | }); | |
| 111 | ||
| 112 | const emitUpsert = (clientSession: AuthClientSession) => | |
| 113 | PubSub.publish(changesPubSub, { | |
| 114 | type: "clientUpserted", | |
| 115 | clientSession, | |
| 116 | }).pipe(Effect.asVoid); | |
| 117 | ||
| 118 | const emitRemoved = (sessionId: AuthSessionId) => | |
| 119 | PubSub.publish(changesPubSub, { | |
| 120 | type: "clientRemoved", | |
| 121 | sessionId, | |
| 122 | }).pipe(Effect.asVoid); | |
| 123 | ||
| 124 | const loadActiveSession = (sessionId: AuthSessionId) => | |
| 125 | Effect.gen(function* () { | |
| 126 | const row = yield* authSessions.getById({ sessionId }); | |
| 127 | if (Option.isNone(row) || row.value.revokedAt !== null) { | |
| 128 | return Option.none<AuthClientSession>(); | |
| 129 | } | |
| 130 | ||
| 131 | const connectedSessions = yield* Ref.get(connectedSessionsRef); | |
| 132 | return Option.some( | |
| 133 | toAuthClientSession({ | |
| 134 | sessionId: row.value.sessionId, | |
| 135 | subject: row.value.subject, | |
| 136 | role: row.value.role, | |
| 137 | method: row.value.method, | |
| 138 | client: toClientMetadata(row.value.client), | |
| 139 | issuedAt: row.value.issuedAt, | |
| 140 | expiresAt: row.value.expiresAt, | |
| 141 | lastConnectedAt: row.value.lastConnectedAt, | |
| 142 | connected: connectedSessions.has(row.value.sessionId), | |
| 143 | }), | |
| 144 | ); | |
| 145 | }); | |
| 146 | ||
| 147 | const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) => | |
| 148 | Ref.modify(connectedSessionsRef, (current) => { | |
| 149 | const next = new Map(current); | |
| 150 | const wasDisconnected = !next.has(sessionId); | |
| 151 | next.set(sessionId, (next.get(sessionId) ?? 0) + 1); | |
| 152 | return [wasDisconnected, next] as const; | |
| 153 | }).pipe( | |
| 154 | Effect.flatMap((wasDisconnected) => | |
| 155 | wasDisconnected | |
| 156 | ? DateTime.now.pipe( | |
| 157 | Effect.flatMap((lastConnectedAt) => | |
| 158 | authSessions.setLastConnectedAt({ | |
| 159 | sessionId, | |
| 160 | lastConnectedAt, | |
| 161 | }), | |
| 162 | ), | |
| 163 | ) | |
| 164 | : Effect.void, | |
| 165 | ), | |
| 166 | Effect.flatMap(() => loadActiveSession(sessionId)), | |
| 167 | Effect.flatMap((session) => | |
| 168 | Option.isSome(session) ? emitUpsert(session.value) : Effect.void, | |
| 169 | ), | |
| 170 | Effect.catchCause((cause) => | |
| 171 | Effect.logError("Failed to publish connected-session auth update.").pipe( | |
| 172 | Effect.annotateLogs({ | |
| 173 | sessionId, | |
| 174 | cause, | |
| 175 | }), | |
| 176 | ), | |
| 177 | ), | |
| 178 | ); | |
| 179 | ||
| 180 | const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) => | |
| 181 | Ref.update(connectedSessionsRef, (current) => { | |
| 182 | const next = new Map(current); | |
| 183 | const remaining = (next.get(sessionId) ?? 0) - 1; | |
| 184 | if (remaining > 0) { | |
| 185 | next.set(sessionId, remaining); | |
| 186 | } else { | |
| 187 | next.delete(sessionId); | |
| 188 | } | |
| 189 | return next; | |
| 190 | }).pipe( | |
| 191 | Effect.flatMap(() => loadActiveSession(sessionId)), | |
| 192 | Effect.flatMap((session) => | |
| 193 | Option.isSome(session) ? emitUpsert(session.value) : Effect.void, | |
| 194 | ), | |
| 195 | Effect.catchCause((cause) => | |
| 196 | Effect.logError("Failed to publish disconnected-session auth update.").pipe( | |
| 197 | Effect.annotateLogs({ | |
| 198 | sessionId, | |
| 199 | cause, | |
| 200 | }), | |
| 201 | ), | |
| 202 | ), | |
| 203 | ); | |
| 204 | ||
| 205 | const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); | |
| 206 | const issue: SessionCredentialServiceShape["issue"] = (input) => | |
| 207 | Effect.gen(function* () { | |
| 208 | const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); | |
| 209 | const issuedAt = yield* DateTime.now; | |
| 210 | const expiresAt = DateTime.add(issuedAt, { | |
| 211 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), | |
| 212 | }); | |
| 213 | const claims: SessionClaims = { | |
| 214 | v: 1, | |
| 215 | kind: "session", | |
| 216 | sid: sessionId, | |
| 217 | sub: input?.subject ?? "browser", | |
| 218 | role: input?.role ?? "client", | |
| 219 | method: input?.method ?? "browser-session-cookie", | |
| 220 | iat: issuedAt.epochMilliseconds, | |
| 221 | exp: expiresAt.epochMilliseconds, | |
| 222 | }; | |
| 223 | ||
| 224 | const encodedPayload = yield* encodeClaims(claims).pipe( | |
| 225 | Effect.map(base64UrlEncode), | |
| 226 | Effect.mapError( | |
| 227 | (cause) => new SessionCredentialError({ message: "Failed to encode claims", cause }), | |
| 228 | ), | |
| 229 | ); | |
| 230 | const signature = signPayload(encodedPayload, signingSecret); | |
| 231 | const client = input?.client ?? createDefaultClientMetadata(); | |
| 232 | yield* authSessions.create({ | |
| 233 | sessionId, | |
| 234 | subject: claims.sub, | |
| 235 | role: claims.role, | |
| 236 | method: claims.method, | |
| 237 | client: { | |
| 238 | label: client.label ?? null, | |
| 239 | ipAddress: client.ipAddress ?? null, | |
| 240 | userAgent: client.userAgent ?? null, | |
| 241 | deviceType: client.deviceType, | |
| 242 | os: client.os ?? null, | |
| 243 | browser: client.browser ?? null, | |
| 244 | }, | |
| 245 | issuedAt, | |
| 246 | expiresAt, | |
| 247 | }); | |
| 248 | yield* emitUpsert( | |
| 249 | toAuthClientSession({ | |
| 250 | sessionId, | |
| 251 | subject: claims.sub, | |
| 252 | role: claims.role, | |
| 253 | method: claims.method, | |
| 254 | client, | |
| 255 | issuedAt, | |
| 256 | expiresAt, | |
| 257 | lastConnectedAt: null, | |
| 258 | connected: false, | |
| 259 | }), | |
| 260 | ); | |
| 261 | ||
| 262 | return { | |
| 263 | sessionId, | |
| 264 | token: `${encodedPayload}.${signature}`, | |
| 265 | method: claims.method, | |
| 266 | client, | |
| 267 | expiresAt: expiresAt, | |
| 268 | role: claims.role, | |
| 269 | } satisfies IssuedSession; | |
| 270 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential."))); | |
| 271 | ||
| 272 | const verify: SessionCredentialServiceShape["verify"] = (token) => | |
| 273 | Effect.gen(function* () { | |
| 274 | const [encodedPayload, signature] = token.split("."); | |
| 275 | if (!encodedPayload || !signature) { | |
| 276 | return yield* new SessionCredentialError({ | |
| 277 | message: "Malformed session token.", | |
| 278 | }); | |
| 279 | } | |
| 280 | ||
| 281 | const expectedSignature = signPayload(encodedPayload, signingSecret); | |
| 282 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | |
| 283 | return yield* new SessionCredentialError({ | |
| 284 | message: "Invalid session token signature.", | |
| 285 | }); | |
| 286 | } | |
| 287 | ||
| 288 | const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | |
| 289 | Effect.mapError( | |
| 290 | (cause) => | |
| 291 | new SessionCredentialError({ | |
| 292 | message: "Invalid session token payload.", | |
| 293 | cause, | |
| 294 | }), | |
| 295 | ), | |
| 296 | ); | |
| 297 | ||
| 298 | const now = yield* Clock.currentTimeMillis; | |
| 299 | if (claims.exp <= now) { | |
| 300 | return yield* new SessionCredentialError({ | |
| 301 | message: "Session token expired.", | |
| 302 | }); | |
| 303 | } | |
| 304 | ||
| 305 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | |
| 306 | if (Option.isNone(row)) { | |
| 307 | return yield* new SessionCredentialError({ | |
| 308 | message: "Unknown session token.", | |
| 309 | }); | |
| 310 | } | |
| 311 | if (row.value.revokedAt !== null) { | |
| 312 | return yield* new SessionCredentialError({ | |
| 313 | message: "Session token revoked.", | |
| 314 | }); | |
| 315 | } | |
| 316 | ||
| 317 | const expiresAt = DateTime.make(claims.exp); | |
| 318 | if (Option.isNone(expiresAt)) { | |
| 319 | return yield* new SessionCredentialError({ | |
| 320 | message: "Invalid `exp` claim", | |
| 321 | }); | |
| 322 | } | |
| 323 | ||
| 324 | return { | |
| 325 | sessionId: claims.sid, | |
| 326 | token, | |
| 327 | method: claims.method, | |
| 328 | client: toClientMetadata(row.value.client), | |
| 329 | expiresAt: expiresAt.value, | |
| 330 | subject: claims.sub, | |
| 331 | role: claims.role, | |
| 332 | } satisfies VerifiedSession; | |
| 333 | }).pipe( | |
| 334 | Effect.mapError((cause) => | |
| 335 | cause instanceof SessionCredentialError | |
| 336 | ? cause | |
| 337 | : new SessionCredentialError({ | |
| 338 | message: "Failed to verify session credential.", | |
| 339 | cause, | |
| 340 | }), | |
| 341 | ), | |
| 342 | ); | |
| 343 | ||
| 344 | const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); | |
| 345 | const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = ( | |
| 346 | sessionId, | |
| 347 | input, | |
| 348 | ) => | |
| 349 | Effect.gen(function* () { | |
| 350 | const issuedAt = yield* DateTime.now; | |
| 351 | const expiresAt = DateTime.add(issuedAt, { | |
| 352 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), | |
| 353 | }); | |
| 354 | const claims: WebSocketClaims = { | |
| 355 | v: 1, | |
| 356 | kind: "websocket", | |
| 357 | sid: sessionId, | |
| 358 | iat: issuedAt.epochMilliseconds, | |
| 359 | exp: expiresAt.epochMilliseconds, | |
| 360 | }; | |
| 361 | const encodedPayload = yield* encodeWsClaims(claims).pipe( | |
| 362 | Effect.map(base64UrlEncode), | |
| 363 | Effect.mapError( | |
| 364 | (cause) => new SessionCredentialError({ message: "Failed to encode claims", cause }), | |
| 365 | ), | |
| 366 | ); | |
| 367 | const signature = signPayload(encodedPayload, signingSecret); | |
| 368 | return { | |
| 369 | token: `${encodedPayload}.${signature}`, | |
| 370 | expiresAt, | |
| 371 | }; | |
| 372 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token."))); | |
| 373 | ||
| 374 | const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) => | |
| 375 | Effect.gen(function* () { | |
| 376 | const [encodedPayload, signature] = token.split("."); | |
| 377 | if (!encodedPayload || !signature) { | |
| 378 | return yield* new SessionCredentialError({ | |
| 379 | message: "Malformed websocket token.", | |
| 380 | }); | |
| 381 | } | |
| 382 | ||
| 383 | const expectedSignature = signPayload(encodedPayload, signingSecret); | |
| 384 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | |
| 385 | return yield* new SessionCredentialError({ | |
| 386 | message: "Invalid websocket token signature.", | |
| 387 | }); | |
| 388 | } | |
| 389 | ||
| 390 | const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | |
| 391 | Effect.mapError( | |
| 392 | (cause) => | |
| 393 | new SessionCredentialError({ | |
| 394 | message: "Invalid websocket token payload.", | |
| 395 | cause, | |
| 396 | }), | |
| 397 | ), | |
| 398 | ); | |
| 399 | ||
| 400 | const now = yield* Clock.currentTimeMillis; | |
| 401 | if (claims.exp <= now) { | |
| 402 | return yield* new SessionCredentialError({ | |
| 403 | message: "Websocket token expired.", | |
| 404 | }); | |
| 405 | } | |
| 406 | ||
| 407 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | |
| 408 | if (Option.isNone(row)) { | |
| 409 | return yield* new SessionCredentialError({ | |
| 410 | message: "Unknown websocket session.", | |
| 411 | }); | |
| 412 | } | |
| 413 | if (row.value.expiresAt.epochMilliseconds <= now) { | |
| 414 | return yield* new SessionCredentialError({ | |
| 415 | message: "Websocket session expired.", | |
| 416 | }); | |
| 417 | } | |
| 418 | if (row.value.revokedAt !== null) { | |
| 419 | return yield* new SessionCredentialError({ | |
| 420 | message: "Websocket session revoked.", | |
| 421 | }); | |
| 422 | } | |
| 423 | ||
| 424 | return { | |
| 425 | sessionId: row.value.sessionId, | |
| 426 | token, | |
| 427 | method: row.value.method, | |
| 428 | client: toClientMetadata(row.value.client), | |
| 429 | expiresAt: row.value.expiresAt, | |
| 430 | subject: row.value.subject, | |
| 431 | role: row.value.role, | |
| 432 | } satisfies VerifiedSession; | |
| 433 | }).pipe( | |
| 434 | Effect.mapError((cause) => | |
| 435 | cause instanceof SessionCredentialError | |
| 436 | ? cause | |
| 437 | : new SessionCredentialError({ | |
| 438 | message: "Failed to verify websocket token.", | |
| 439 | cause, | |
| 440 | }), | |
| 441 | ), | |
| 442 | ); | |
| 443 | ||
| 444 | const listActive: SessionCredentialServiceShape["listActive"] = () => | |
| 445 | Effect.gen(function* () { | |
| 446 | const now = yield* DateTime.now; | |
| 447 | const connectedSessions = yield* Ref.get(connectedSessionsRef); | |
| 448 | const rows = yield* authSessions.listActive({ now }); | |
| 449 | ||
| 450 | return rows.map((row) => | |
| 451 | toAuthClientSession({ | |
| 452 | sessionId: row.sessionId, | |
| 453 | subject: row.subject, | |
| 454 | role: row.role, | |
| 455 | method: row.method, | |
| 456 | client: toClientMetadata(row.client), | |
| 457 | issuedAt: row.issuedAt, | |
| 458 | expiresAt: row.expiresAt, | |
| 459 | lastConnectedAt: row.lastConnectedAt, | |
| 460 | connected: connectedSessions.has(row.sessionId), | |
| 461 | }), | |
| 462 | ); | |
| 463 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions."))); | |
| 464 | ||
| 465 | const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) => | |
| 466 | Effect.gen(function* () { | |
| 467 | const revokedAt = yield* DateTime.now; | |
| 468 | const revoked = yield* authSessions.revoke({ | |
| 469 | sessionId, | |
| 470 | revokedAt, | |
| 471 | }); | |
| 472 | if (revoked) { | |
| 473 | yield* Ref.update(connectedSessionsRef, (current) => { | |
| 474 | const next = new Map(current); | |
| 475 | next.delete(sessionId); | |
| 476 | return next; | |
| 477 | }); | |
| 478 | yield* emitRemoved(sessionId); | |
| 479 | } | |
| 480 | return revoked; | |
| 481 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session."))); | |
| 482 | ||
| 483 | const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) => | |
| 484 | Effect.gen(function* () { | |
| 485 | const revokedAt = yield* DateTime.now; | |
| 486 | const revokedSessionIds = yield* authSessions.revokeAllExcept({ | |
| 487 | currentSessionId: sessionId, | |
| 488 | revokedAt, | |
| 489 | }); | |
| 490 | if (revokedSessionIds.length > 0) { | |
| 491 | yield* Ref.update(connectedSessionsRef, (current) => { | |
| 492 | const next = new Map(current); | |
| 493 | for (const revokedSessionId of revokedSessionIds) { | |
| 494 | next.delete(revokedSessionId); | |
| 495 | } | |
| 496 | return next; | |
| 497 | }); | |
| 498 | yield* Effect.forEach( | |
| 499 | revokedSessionIds, | |
| 500 | (revokedSessionId) => emitRemoved(revokedSessionId), | |
| 501 | { | |
| 502 | concurrency: "unbounded", | |
| 503 | discard: true, | |
| 504 | }, | |
| 505 | ); | |
| 506 | } | |
| 507 | return revokedSessionIds.length; | |
| 508 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); | |
| 509 | ||
| 510 | return { | |
| 511 | cookieName, | |
| 512 | issue, | |
| 513 | verify, | |
| 514 | issueWebSocketToken, | |
| 515 | verifyWebSocketToken, | |
| 516 | listActive, | |
| 517 | get streamChanges() { | |
| 518 | return Stream.fromPubSub(changesPubSub); | |
| 519 | }, | |
| 520 | revoke, | |
| 521 | revokeAllExcept, | |
| 522 | markConnected, | |
| 523 | markDisconnected, | |
| 524 | } satisfies SessionCredentialServiceShape; | |
| 525 | }); | |
| 526 | ||
| 527 | export const SessionCredentialServiceLive = Layer.effect( | |
| 528 | SessionCredentialService, | |
| 529 | makeSessionCredentialService, | |
| 530 | ).pipe(Layer.provideMerge(AuthSessionRepositoryLive)); |
apps/server/src/auth/PairingGrantStore.test.ts created+167| ... | ... | @@ -0,0 +1,167 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | ||
| 8 | import type { ServerConfigShape } from "../config.ts"; | |
| 9 | import { ServerConfig } from "../config.ts"; | |
| 10 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 11 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | |
| 12 | ||
| 13 | const makeServerConfigLayer = ( | |
| 14 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 15 | ) => | |
| 16 | Layer.effect( | |
| 17 | ServerConfig, | |
| 18 | Effect.gen(function* () { | |
| 19 | const config = yield* ServerConfig; | |
| 20 | return { | |
| 21 | ...config, | |
| 22 | ...overrides, | |
| 23 | } satisfies ServerConfigShape; | |
| 24 | }), | |
| 25 | ).pipe( | |
| 26 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), | |
| 27 | ); | |
| 28 | ||
| 29 | const makePairingGrantStoreLayer = ( | |
| 30 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 31 | ) => | |
| 32 | PairingGrantStore.layer.pipe( | |
| 33 | Layer.provide(SqlitePersistenceMemory), | |
| 34 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 35 | ); | |
| 36 | ||
| 37 | it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | |
| 38 | it.effect("issues pairing tokens in a short manual-entry format", () => | |
| 39 | Effect.gen(function* () { | |
| 40 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | |
| 41 | const issued = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 42 | ||
| 43 | expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/); | |
| 44 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | |
| 45 | ); | |
| 46 | ||
| 47 | it.effect("issues one-time bootstrap tokens that can only be consumed once", () => | |
| 48 | Effect.gen(function* () { | |
| 49 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | |
| 50 | const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); | |
| 51 | const first = yield* bootstrapCredentials.consume(issued.credential); | |
| 52 | const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); | |
| 53 | ||
| 54 | expect(first.method).toBe("one-time-token"); | |
| 55 | expect(first.scopes).toEqual([ | |
| 56 | "orchestration:read", | |
| 57 | "orchestration:operate", | |
| 58 | "terminal:operate", | |
| 59 | "review:write", | |
| 60 | "relay:read", | |
| 61 | ]); | |
| 62 | expect(first.subject).toBe("one-time-token"); | |
| 63 | expect(first.label).toBe("Julius iPhone"); | |
| 64 | expect(issued.label).toBe("Julius iPhone"); | |
| 65 | expect(second._tag).toBe("BootstrapCredentialInvalidError"); | |
| 66 | expect(second.message).toContain("Unknown bootstrap credential"); | |
| 67 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | |
| 68 | ); | |
| 69 | ||
| 70 | it.effect("atomically consumes a one-time token when multiple requests race", () => | |
| 71 | Effect.gen(function* () { | |
| 72 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | |
| 73 | const token = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 74 | const results = yield* Effect.all( | |
| 75 | Array.from({ length: 8 }, () => | |
| 76 | Effect.result(bootstrapCredentials.consume(token.credential)), | |
| 77 | ), | |
| 78 | { | |
| 79 | concurrency: "unbounded", | |
| 80 | }, | |
| 81 | ); | |
| 82 | ||
| 83 | const successes = results.filter((result) => result._tag === "Success"); | |
| 84 | const failures = results.filter((result) => result._tag === "Failure"); | |
| 85 | ||
| 86 | expect(successes).toHaveLength(1); | |
| 87 | expect(failures).toHaveLength(7); | |
| 88 | for (const failure of failures) { | |
| 89 | expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError"); | |
| 90 | expect(failure.failure.message).toContain("Unknown bootstrap credential"); | |
| 91 | } | |
| 92 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | |
| 93 | ); | |
| 94 | ||
| 95 | it.effect("seeds the desktop bootstrap credential as a one-time grant", () => | |
| 96 | Effect.gen(function* () { | |
| 97 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | |
| 98 | const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); | |
| 99 | const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 100 | ||
| 101 | expect(first.method).toBe("desktop-bootstrap"); | |
| 102 | expect(first.scopes).toEqual([ | |
| 103 | "orchestration:read", | |
| 104 | "orchestration:operate", | |
| 105 | "terminal:operate", | |
| 106 | "review:write", | |
| 107 | "relay:read", | |
| 108 | "access:read", | |
| 109 | "access:write", | |
| 110 | "relay:write", | |
| 111 | ]); | |
| 112 | expect(first.subject).toBe("desktop-bootstrap"); | |
| 113 | expect(second._tag).toBe("BootstrapCredentialInvalidError"); | |
| 114 | }).pipe( | |
| 115 | Effect.provide( | |
| 116 | makePairingGrantStoreLayer({ | |
| 117 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 118 | }), | |
| 119 | ), | |
| 120 | ), | |
| 121 | ); | |
| 122 | ||
| 123 | it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => | |
| 124 | Effect.gen(function* () { | |
| 125 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | |
| 126 | ||
| 127 | yield* TestClock.adjust(Duration.minutes(6)); | |
| 128 | const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 129 | ||
| 130 | expect(expired._tag).toBe("BootstrapCredentialInvalidError"); | |
| 131 | expect(expired.message).toContain("Bootstrap credential expired"); | |
| 132 | }).pipe( | |
| 133 | Effect.provide( | |
| 134 | Layer.merge( | |
| 135 | makePairingGrantStoreLayer({ | |
| 136 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 137 | }), | |
| 138 | TestClock.layer(), | |
| 139 | ), | |
| 140 | ), | |
| 141 | ), | |
| 142 | ); | |
| 143 | ||
| 144 | it.effect("lists and revokes active pairing links", () => | |
| 145 | Effect.gen(function* () { | |
| 146 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | |
| 147 | const first = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 148 | const second = yield* bootstrapCredentials.issueOneTimeToken({ | |
| 149 | scopes: ["orchestration:read", "access:write"], | |
| 150 | }); | |
| 151 | ||
| 152 | const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); | |
| 153 | expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); | |
| 154 | expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); | |
| 155 | ||
| 156 | const revoked = yield* bootstrapCredentials.revoke(first.id); | |
| 157 | const activeAfterRevoke = yield* bootstrapCredentials.listActive(); | |
| 158 | const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential)); | |
| 159 | ||
| 160 | expect(revoked).toBe(true); | |
| 161 | expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); | |
| 162 | expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); | |
| 163 | expect(revokedConsume.message).toContain("no longer available"); | |
| 164 | expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError"); | |
| 165 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | |
| 166 | ); | |
| 167 | }); |
apps/server/src/auth/PairingGrantStore.ts created+386| ... | ... | @@ -0,0 +1,386 @@ |
| 1 | import { | |
| 2 | AuthAdministrativeScopes, | |
| 3 | AuthStandardClientScopes, | |
| 4 | type AuthEnvironmentScope, | |
| 5 | type AuthPairingLink, | |
| 6 | type ServerAuthBootstrapMethod, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Context from "effect/Context"; | |
| 9 | import * as Crypto from "effect/Crypto"; | |
| 10 | import * as Data from "effect/Data"; | |
| 11 | import * as DateTime from "effect/DateTime"; | |
| 12 | import * as Duration from "effect/Duration"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import * as PubSub from "effect/PubSub"; | |
| 16 | import * as Ref from "effect/Ref"; | |
| 17 | import * as Stream from "effect/Stream"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | ||
| 20 | import { ServerConfig } from "../config.ts"; | |
| 21 | import { AuthPairingLinkRepositoryLive } from "../persistence/Layers/AuthPairingLinks.ts"; | |
| 22 | import { AuthPairingLinkRepository } from "../persistence/Services/AuthPairingLinks.ts"; | |
| 23 | ||
| 24 | export interface BootstrapGrant { | |
| 25 | readonly method: ServerAuthBootstrapMethod; | |
| 26 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 27 | readonly subject: string; | |
| 28 | readonly label?: string; | |
| 29 | readonly expiresAt: DateTime.DateTime; | |
| 30 | } | |
| 31 | ||
| 32 | export class BootstrapCredentialInvalidError extends Data.TaggedError( | |
| 33 | "BootstrapCredentialInvalidError", | |
| 34 | )<{ | |
| 35 | readonly message: string; | |
| 36 | }> {} | |
| 37 | ||
| 38 | export class BootstrapCredentialInternalError extends Data.TaggedError( | |
| 39 | "BootstrapCredentialInternalError", | |
| 40 | )<{ | |
| 41 | readonly message: string; | |
| 42 | readonly cause?: unknown; | |
| 43 | }> {} | |
| 44 | ||
| 45 | export type BootstrapCredentialError = | |
| 46 | | BootstrapCredentialInvalidError | |
| 47 | | BootstrapCredentialInternalError; | |
| 48 | ||
| 49 | export interface IssuedBootstrapCredential { | |
| 50 | readonly id: string; | |
| 51 | readonly credential: string; | |
| 52 | readonly label?: string; | |
| 53 | readonly expiresAt: DateTime.Utc; | |
| 54 | } | |
| 55 | ||
| 56 | export type BootstrapCredentialChange = | |
| 57 | | { | |
| 58 | readonly type: "pairingLinkUpserted"; | |
| 59 | readonly pairingLink: AuthPairingLink; | |
| 60 | } | |
| 61 | | { | |
| 62 | readonly type: "pairingLinkRemoved"; | |
| 63 | readonly id: string; | |
| 64 | }; | |
| 65 | ||
| 66 | export interface PairingGrantStoreShape { | |
| 67 | readonly issueOneTimeToken: (input?: { | |
| 68 | readonly ttl?: Duration.Duration; | |
| 69 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 70 | readonly subject?: string; | |
| 71 | readonly label?: string; | |
| 72 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>; | |
| 73 | readonly listActive: () => Effect.Effect< | |
| 74 | ReadonlyArray<AuthPairingLink>, | |
| 75 | BootstrapCredentialInternalError | |
| 76 | >; | |
| 77 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; | |
| 78 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>; | |
| 79 | readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | |
| 80 | } | |
| 81 | ||
| 82 | export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()( | |
| 83 | "t3/auth/PairingGrantStore", | |
| 84 | ) {} | |
| 85 | ||
| 86 | interface StoredBootstrapGrant extends BootstrapGrant { | |
| 87 | readonly remainingUses: number | "unbounded"; | |
| 88 | } | |
| 89 | ||
| 90 | type ConsumeResult = | |
| 91 | | { | |
| 92 | readonly _tag: "error"; | |
| 93 | readonly reason: "not-found" | "expired"; | |
| 94 | readonly error: BootstrapCredentialError; | |
| 95 | } | |
| 96 | | { | |
| 97 | readonly _tag: "success"; | |
| 98 | readonly grant: BootstrapGrant; | |
| 99 | }; | |
| 100 | ||
| 101 | const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); | |
| 102 | const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; | |
| 103 | const PAIRING_TOKEN_LENGTH = 12; | |
| 104 | const PAIRING_TOKEN_REJECTION_LIMIT = | |
| 105 | Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; | |
| 106 | ||
| 107 | const invalidBootstrapCredentialError = (message: string) => | |
| 108 | new BootstrapCredentialInvalidError({ | |
| 109 | message, | |
| 110 | }); | |
| 111 | ||
| 112 | const internalBootstrapCredentialError = (message: string, cause: unknown) => | |
| 113 | new BootstrapCredentialInternalError({ | |
| 114 | message, | |
| 115 | cause, | |
| 116 | }); | |
| 117 | ||
| 118 | export const make = Effect.fn("makePairingGrantStore")(function* () { | |
| 119 | const crypto = yield* Crypto.Crypto; | |
| 120 | const config = yield* ServerConfig; | |
| 121 | const pairingLinks = yield* AuthPairingLinkRepository; | |
| 122 | const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>()); | |
| 123 | const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>(); | |
| 124 | const generatePairingToken = Effect.gen(function* () { | |
| 125 | let credential = ""; | |
| 126 | while (credential.length < PAIRING_TOKEN_LENGTH) { | |
| 127 | const bytes = yield* crypto.randomBytes(PAIRING_TOKEN_LENGTH); | |
| 128 | for (const byte of bytes) { | |
| 129 | if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) { | |
| 130 | continue; | |
| 131 | } | |
| 132 | credential += PAIRING_TOKEN_ALPHABET[byte % PAIRING_TOKEN_ALPHABET.length]!; | |
| 133 | if (credential.length === PAIRING_TOKEN_LENGTH) { | |
| 134 | return credential; | |
| 135 | } | |
| 136 | } | |
| 137 | } | |
| 138 | return credential; | |
| 139 | }); | |
| 140 | ||
| 141 | const seedGrant = (credential: string, grant: StoredBootstrapGrant) => | |
| 142 | Ref.update(seededGrantsRef, (current) => { | |
| 143 | const next = new Map(current); | |
| 144 | next.set(credential, grant); | |
| 145 | return next; | |
| 146 | }); | |
| 147 | ||
| 148 | const emitUpsert = (pairingLink: AuthPairingLink) => | |
| 149 | PubSub.publish(changesPubSub, { | |
| 150 | type: "pairingLinkUpserted", | |
| 151 | pairingLink, | |
| 152 | }).pipe(Effect.asVoid); | |
| 153 | ||
| 154 | const emitRemoved = (id: string) => | |
| 155 | PubSub.publish(changesPubSub, { | |
| 156 | type: "pairingLinkRemoved", | |
| 157 | id, | |
| 158 | }).pipe(Effect.asVoid); | |
| 159 | ||
| 160 | if (config.desktopBootstrapToken) { | |
| 161 | const now = yield* DateTime.now; | |
| 162 | yield* seedGrant(config.desktopBootstrapToken, { | |
| 163 | method: "desktop-bootstrap", | |
| 164 | scopes: AuthAdministrativeScopes, | |
| 165 | subject: "desktop-bootstrap", | |
| 166 | expiresAt: DateTime.add(now, { | |
| 167 | milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), | |
| 168 | }), | |
| 169 | remainingUses: 1, | |
| 170 | }); | |
| 171 | } | |
| 172 | ||
| 173 | const toBootstrapCredentialError = (message: string) => (cause: unknown) => | |
| 174 | internalBootstrapCredentialError(message, cause); | |
| 175 | ||
| 176 | const listActive: PairingGrantStoreShape["listActive"] = Effect.fn( | |
| 177 | "PairingGrantStore.listActive", | |
| 178 | )( | |
| 179 | function* () { | |
| 180 | const now = yield* DateTime.now; | |
| 181 | const rows = yield* pairingLinks.listActive({ now }); | |
| 182 | ||
| 183 | return rows.map((row) => | |
| 184 | row.label | |
| 185 | ? ({ | |
| 186 | id: row.id, | |
| 187 | credential: row.credential, | |
| 188 | scopes: row.scopes, | |
| 189 | subject: row.subject, | |
| 190 | label: row.label, | |
| 191 | createdAt: row.createdAt, | |
| 192 | expiresAt: row.expiresAt, | |
| 193 | } satisfies AuthPairingLink) | |
| 194 | : ({ | |
| 195 | id: row.id, | |
| 196 | credential: row.credential, | |
| 197 | scopes: row.scopes, | |
| 198 | subject: row.subject, | |
| 199 | createdAt: row.createdAt, | |
| 200 | expiresAt: row.expiresAt, | |
| 201 | } satisfies AuthPairingLink), | |
| 202 | ); | |
| 203 | }, | |
| 204 | Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")), | |
| 205 | ); | |
| 206 | ||
| 207 | const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")( | |
| 208 | function* (id) { | |
| 209 | const revokedAt = yield* DateTime.now; | |
| 210 | const revoked = yield* pairingLinks.revoke({ | |
| 211 | id, | |
| 212 | revokedAt, | |
| 213 | }); | |
| 214 | if (revoked) { | |
| 215 | yield* emitRemoved(id); | |
| 216 | } | |
| 217 | return revoked; | |
| 218 | }, | |
| 219 | Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")), | |
| 220 | ); | |
| 221 | ||
| 222 | const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn( | |
| 223 | "PairingGrantStore.issueOneTimeToken", | |
| 224 | )( | |
| 225 | function* (input) { | |
| 226 | const id = yield* crypto.randomUUIDv4; | |
| 227 | const credential = yield* generatePairingToken; | |
| 228 | const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; | |
| 229 | const now = yield* DateTime.now; | |
| 230 | const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); | |
| 231 | const issued: IssuedBootstrapCredential = { | |
| 232 | id, | |
| 233 | credential, | |
| 234 | ...(input?.label ? { label: input.label } : {}), | |
| 235 | expiresAt, | |
| 236 | }; | |
| 237 | yield* pairingLinks.create({ | |
| 238 | id, | |
| 239 | credential, | |
| 240 | method: "one-time-token", | |
| 241 | scopes: input?.scopes ?? AuthStandardClientScopes, | |
| 242 | subject: input?.subject ?? "one-time-token", | |
| 243 | label: input?.label ?? null, | |
| 244 | createdAt: now, | |
| 245 | expiresAt: expiresAt, | |
| 246 | }); | |
| 247 | yield* emitUpsert({ | |
| 248 | id, | |
| 249 | credential, | |
| 250 | scopes: input?.scopes ?? AuthStandardClientScopes, | |
| 251 | subject: input?.subject ?? "one-time-token", | |
| 252 | ...(input?.label ? { label: input.label } : {}), | |
| 253 | createdAt: now, | |
| 254 | expiresAt, | |
| 255 | }); | |
| 256 | return issued; | |
| 257 | }, | |
| 258 | Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")), | |
| 259 | ); | |
| 260 | ||
| 261 | const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( | |
| 262 | function* (credential) { | |
| 263 | const now = yield* DateTime.now; | |
| 264 | const seededResult: ConsumeResult = yield* Ref.modify( | |
| 265 | seededGrantsRef, | |
| 266 | (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => { | |
| 267 | const grant = current.get(credential); | |
| 268 | if (!grant) { | |
| 269 | return [ | |
| 270 | { | |
| 271 | _tag: "error", | |
| 272 | reason: "not-found", | |
| 273 | error: invalidBootstrapCredentialError("Unknown bootstrap credential."), | |
| 274 | }, | |
| 275 | current, | |
| 276 | ]; | |
| 277 | } | |
| 278 | ||
| 279 | const next = new Map(current); | |
| 280 | if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { | |
| 281 | next.delete(credential); | |
| 282 | return [ | |
| 283 | { | |
| 284 | _tag: "error", | |
| 285 | reason: "expired", | |
| 286 | error: invalidBootstrapCredentialError("Bootstrap credential expired."), | |
| 287 | }, | |
| 288 | next, | |
| 289 | ]; | |
| 290 | } | |
| 291 | ||
| 292 | const remainingUses = grant.remainingUses; | |
| 293 | if (typeof remainingUses === "number") { | |
| 294 | if (remainingUses <= 1) { | |
| 295 | next.delete(credential); | |
| 296 | } else { | |
| 297 | next.set(credential, { | |
| 298 | ...grant, | |
| 299 | remainingUses: remainingUses - 1, | |
| 300 | }); | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | return [ | |
| 305 | { | |
| 306 | _tag: "success", | |
| 307 | grant: { | |
| 308 | method: grant.method, | |
| 309 | scopes: grant.scopes, | |
| 310 | subject: grant.subject, | |
| 311 | ...(grant.label ? { label: grant.label } : {}), | |
| 312 | expiresAt: grant.expiresAt, | |
| 313 | } satisfies BootstrapGrant, | |
| 314 | }, | |
| 315 | next, | |
| 316 | ]; | |
| 317 | }, | |
| 318 | ); | |
| 319 | ||
| 320 | if (seededResult._tag === "success") { | |
| 321 | return seededResult.grant; | |
| 322 | } | |
| 323 | if (seededResult.reason !== "not-found") { | |
| 324 | return yield* seededResult.error; | |
| 325 | } | |
| 326 | ||
| 327 | const consumed = yield* pairingLinks.consumeAvailable({ | |
| 328 | credential, | |
| 329 | consumedAt: now, | |
| 330 | now, | |
| 331 | }); | |
| 332 | ||
| 333 | if (Option.isSome(consumed)) { | |
| 334 | yield* emitRemoved(consumed.value.id); | |
| 335 | return { | |
| 336 | method: consumed.value.method, | |
| 337 | scopes: consumed.value.scopes, | |
| 338 | subject: consumed.value.subject, | |
| 339 | ...(consumed.value.label ? { label: consumed.value.label } : {}), | |
| 340 | expiresAt: consumed.value.expiresAt, | |
| 341 | } satisfies BootstrapGrant; | |
| 342 | } | |
| 343 | ||
| 344 | const matching = yield* pairingLinks.getByCredential({ credential }); | |
| 345 | if (Option.isNone(matching)) { | |
| 346 | return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | |
| 347 | } | |
| 348 | ||
| 349 | if (matching.value.revokedAt !== null) { | |
| 350 | return yield* invalidBootstrapCredentialError( | |
| 351 | "Bootstrap credential is no longer available.", | |
| 352 | ); | |
| 353 | } | |
| 354 | ||
| 355 | if (matching.value.consumedAt !== null) { | |
| 356 | return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | |
| 357 | } | |
| 358 | ||
| 359 | if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { | |
| 360 | return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); | |
| 361 | } | |
| 362 | ||
| 363 | return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); | |
| 364 | }, | |
| 365 | Effect.mapError((cause) => | |
| 366 | cause._tag === "BootstrapCredentialInvalidError" || | |
| 367 | cause._tag === "BootstrapCredentialInternalError" | |
| 368 | ? cause | |
| 369 | : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), | |
| 370 | ), | |
| 371 | ); | |
| 372 | ||
| 373 | return { | |
| 374 | issueOneTimeToken, | |
| 375 | listActive, | |
| 376 | get streamChanges() { | |
| 377 | return Stream.fromPubSub(changesPubSub); | |
| 378 | }, | |
| 379 | revoke, | |
| 380 | consume, | |
| 381 | } satisfies PairingGrantStoreShape; | |
| 382 | }); | |
| 383 | ||
| 384 | export const layer = Layer.effect(PairingGrantStore, make()).pipe( | |
| 385 | Layer.provideMerge(AuthPairingLinkRepositoryLive), | |
| 386 | ); |
apps/server/src/auth/ServerSecretStore.test.ts created+267| ... | ... | @@ -0,0 +1,267 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Ref from "effect/Ref"; | |
| 9 | import * as PlatformError from "effect/PlatformError"; | |
| 10 | ||
| 11 | import { ServerConfig } from "../config.ts"; | |
| 12 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | |
| 13 | ||
| 14 | const makeServerConfigLayer = () => | |
| 15 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); | |
| 16 | ||
| 17 | const makeServerSecretStoreLayer = () => | |
| 18 | Layer.provide(ServerSecretStore.layer, makeServerConfigLayer()); | |
| 19 | ||
| 20 | const PermissionDeniedFileSystemLayer = Layer.effect( | |
| 21 | FileSystem.FileSystem, | |
| 22 | Effect.gen(function* () { | |
| 23 | const fileSystem = yield* FileSystem.FileSystem; | |
| 24 | ||
| 25 | return { | |
| 26 | ...fileSystem, | |
| 27 | readFile: (path) => | |
| 28 | Effect.fail( | |
| 29 | PlatformError.systemError({ | |
| 30 | _tag: "PermissionDenied", | |
| 31 | module: "FileSystem", | |
| 32 | method: "readFile", | |
| 33 | pathOrDescriptor: path, | |
| 34 | description: "Permission denied while reading secret file.", | |
| 35 | }), | |
| 36 | ), | |
| 37 | } satisfies FileSystem.FileSystem; | |
| 38 | }), | |
| 39 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 40 | ||
| 41 | const makePermissionDeniedSecretStoreLayer = () => | |
| 42 | ServerSecretStore.layer.pipe( | |
| 43 | Layer.provide(makeServerConfigLayer()), | |
| 44 | Layer.provideMerge(PermissionDeniedFileSystemLayer), | |
| 45 | ); | |
| 46 | ||
| 47 | const RenameFailureFileSystemLayer = Layer.effect( | |
| 48 | FileSystem.FileSystem, | |
| 49 | Effect.gen(function* () { | |
| 50 | const fileSystem = yield* FileSystem.FileSystem; | |
| 51 | ||
| 52 | return { | |
| 53 | ...fileSystem, | |
| 54 | rename: (from, to) => | |
| 55 | Effect.fail( | |
| 56 | PlatformError.systemError({ | |
| 57 | _tag: "PermissionDenied", | |
| 58 | module: "FileSystem", | |
| 59 | method: "rename", | |
| 60 | pathOrDescriptor: `${String(from)} -> ${String(to)}`, | |
| 61 | description: "Permission denied while persisting secret file.", | |
| 62 | }), | |
| 63 | ), | |
| 64 | } satisfies FileSystem.FileSystem; | |
| 65 | }), | |
| 66 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 67 | ||
| 68 | const makeRenameFailureSecretStoreLayer = () => | |
| 69 | ServerSecretStore.layer.pipe( | |
| 70 | Layer.provide(makeServerConfigLayer()), | |
| 71 | Layer.provideMerge(RenameFailureFileSystemLayer), | |
| 72 | ); | |
| 73 | ||
| 74 | const RemoveFailureFileSystemLayer = Layer.effect( | |
| 75 | FileSystem.FileSystem, | |
| 76 | Effect.gen(function* () { | |
| 77 | const fileSystem = yield* FileSystem.FileSystem; | |
| 78 | ||
| 79 | return { | |
| 80 | ...fileSystem, | |
| 81 | remove: (path, options) => | |
| 82 | Effect.fail( | |
| 83 | PlatformError.systemError({ | |
| 84 | _tag: "PermissionDenied", | |
| 85 | module: "FileSystem", | |
| 86 | method: "remove", | |
| 87 | pathOrDescriptor: String(path), | |
| 88 | description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, | |
| 89 | }), | |
| 90 | ), | |
| 91 | } satisfies FileSystem.FileSystem; | |
| 92 | }), | |
| 93 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 94 | ||
| 95 | const makeRemoveFailureSecretStoreLayer = () => | |
| 96 | ServerSecretStore.layer.pipe( | |
| 97 | Layer.provide(makeServerConfigLayer()), | |
| 98 | Layer.provideMerge(RemoveFailureFileSystemLayer), | |
| 99 | ); | |
| 100 | ||
| 101 | const ConcurrentReadMissFileSystemLayer = Layer.effect( | |
| 102 | FileSystem.FileSystem, | |
| 103 | Effect.gen(function* () { | |
| 104 | const fileSystem = yield* FileSystem.FileSystem; | |
| 105 | const readCountRef = yield* Ref.make(0); | |
| 106 | const readBarrier = yield* Deferred.make<void>(); | |
| 107 | ||
| 108 | return { | |
| 109 | ...fileSystem, | |
| 110 | readFile: (path) => | |
| 111 | String(path).endsWith("/session-signing-key.bin") | |
| 112 | ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe( | |
| 113 | Effect.flatMap((count) => { | |
| 114 | if (count > 2) { | |
| 115 | return fileSystem.readFile(path); | |
| 116 | } | |
| 117 | return Effect.gen(function* () { | |
| 118 | if (count === 2) { | |
| 119 | yield* Deferred.succeed(readBarrier, void 0); | |
| 120 | } | |
| 121 | yield* Deferred.await(readBarrier); | |
| 122 | return yield* Effect.failCause( | |
| 123 | Cause.fail( | |
| 124 | PlatformError.systemError({ | |
| 125 | _tag: "NotFound", | |
| 126 | module: "FileSystem", | |
| 127 | method: "readFile", | |
| 128 | pathOrDescriptor: String(path), | |
| 129 | description: "Secret file does not exist yet.", | |
| 130 | }), | |
| 131 | ), | |
| 132 | ); | |
| 133 | }); | |
| 134 | }), | |
| 135 | ) | |
| 136 | : fileSystem.readFile(path), | |
| 137 | } satisfies FileSystem.FileSystem; | |
| 138 | }), | |
| 139 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 140 | ||
| 141 | const makeConcurrentCreateSecretStoreLayer = () => | |
| 142 | ServerSecretStore.layer.pipe( | |
| 143 | Layer.provide(makeServerConfigLayer()), | |
| 144 | Layer.provideMerge(ConcurrentReadMissFileSystemLayer), | |
| 145 | ); | |
| 146 | ||
| 147 | it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { | |
| 148 | it.effect("returns null when a secret file does not exist", () => | |
| 149 | Effect.gen(function* () { | |
| 150 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 151 | ||
| 152 | const secret = yield* secretStore.get("missing-secret"); | |
| 153 | ||
| 154 | expect(secret).toBeNull(); | |
| 155 | }).pipe(Effect.provide(makeServerSecretStoreLayer())), | |
| 156 | ); | |
| 157 | ||
| 158 | it.effect("reuses an existing secret instead of regenerating it", () => | |
| 159 | Effect.gen(function* () { | |
| 160 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 161 | ||
| 162 | const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); | |
| 163 | const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); | |
| 164 | ||
| 165 | expect(Array.from(second)).toEqual(Array.from(first)); | |
| 166 | }).pipe(Effect.provide(makeServerSecretStoreLayer())), | |
| 167 | ); | |
| 168 | ||
| 169 | it.effect("returns the persisted secret when concurrent creators race", () => | |
| 170 | Effect.gen(function* () { | |
| 171 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 172 | ||
| 173 | const [first, second] = yield* Effect.all( | |
| 174 | [ | |
| 175 | secretStore.getOrCreateRandom("session-signing-key", 32), | |
| 176 | secretStore.getOrCreateRandom("session-signing-key", 32), | |
| 177 | ], | |
| 178 | { concurrency: "unbounded" }, | |
| 179 | ); | |
| 180 | const persisted = yield* secretStore.get("session-signing-key"); | |
| 181 | ||
| 182 | expect(persisted).not.toBeNull(); | |
| 183 | expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array())); | |
| 184 | expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array())); | |
| 185 | }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), | |
| 186 | ); | |
| 187 | ||
| 188 | it.effect("uses restrictive permissions for the secret directory and files", () => | |
| 189 | Effect.gen(function* () { | |
| 190 | const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; | |
| 191 | const recordingFileSystemLayer = Layer.effect( | |
| 192 | FileSystem.FileSystem, | |
| 193 | Effect.gen(function* () { | |
| 194 | const fileSystem = yield* FileSystem.FileSystem; | |
| 195 | ||
| 196 | return { | |
| 197 | ...fileSystem, | |
| 198 | makeDirectory: () => Effect.void, | |
| 199 | writeFile: () => Effect.void, | |
| 200 | rename: () => Effect.void, | |
| 201 | chmod: (path, mode) => | |
| 202 | Effect.sync(() => { | |
| 203 | chmodCalls.push({ path: String(path), mode }); | |
| 204 | }), | |
| 205 | } satisfies FileSystem.FileSystem; | |
| 206 | }), | |
| 207 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 208 | ||
| 209 | const secretStore = yield* Effect.service(ServerSecretStore.ServerSecretStore).pipe( | |
| 210 | Effect.provide( | |
| 211 | ServerSecretStore.layer.pipe( | |
| 212 | Layer.provide(makeServerConfigLayer()), | |
| 213 | Layer.provideMerge(recordingFileSystemLayer), | |
| 214 | ), | |
| 215 | ), | |
| 216 | ); | |
| 217 | ||
| 218 | yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); | |
| 219 | ||
| 220 | expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( | |
| 221 | true, | |
| 222 | ); | |
| 223 | expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); | |
| 224 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 225 | ); | |
| 226 | ||
| 227 | it.effect("propagates read failures other than missing-file errors", () => | |
| 228 | Effect.gen(function* () { | |
| 229 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 230 | ||
| 231 | const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); | |
| 232 | ||
| 233 | expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); | |
| 234 | expect(error.message).toContain("Failed to read secret session-signing-key."); | |
| 235 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 236 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 237 | }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), | |
| 238 | ); | |
| 239 | ||
| 240 | it.effect("propagates write failures instead of treating them as success", () => | |
| 241 | Effect.gen(function* () { | |
| 242 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 243 | ||
| 244 | const error = yield* Effect.flip( | |
| 245 | secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), | |
| 246 | ); | |
| 247 | ||
| 248 | expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); | |
| 249 | expect(error.message).toContain("Failed to persist secret session-signing-key."); | |
| 250 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 251 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 252 | }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), | |
| 253 | ); | |
| 254 | ||
| 255 | it.effect("propagates remove failures other than missing-file errors", () => | |
| 256 | Effect.gen(function* () { | |
| 257 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 258 | ||
| 259 | const error = yield* Effect.flip(secretStore.remove("session-signing-key")); | |
| 260 | ||
| 261 | expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); | |
| 262 | expect(error.message).toContain("Failed to remove secret session-signing-key."); | |
| 263 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 264 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 265 | }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), | |
| 266 | ); | |
| 267 | }); |
apps/server/src/auth/ServerSecretStore.ts created+193| ... | ... | @@ -0,0 +1,193 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Crypto from "effect/Crypto"; | |
| 3 | import * as Data from "effect/Data"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as Predicate from "effect/Predicate"; | |
| 9 | import * as PlatformError from "effect/PlatformError"; | |
| 10 | ||
| 11 | import { ServerConfig } from "../config.ts"; | |
| 12 | ||
| 13 | export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ | |
| 14 | readonly message: string; | |
| 15 | readonly cause?: unknown; | |
| 16 | }> {} | |
| 17 | ||
| 18 | export interface ServerSecretStoreShape { | |
| 19 | readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>; | |
| 20 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | |
| 21 | readonly getOrCreateRandom: ( | |
| 22 | name: string, | |
| 23 | bytes: number, | |
| 24 | ) => Effect.Effect<Uint8Array, SecretStoreError>; | |
| 25 | readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>; | |
| 26 | } | |
| 27 | ||
| 28 | export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()( | |
| 29 | "t3/auth/ServerSecretStore", | |
| 30 | ) {} | |
| 31 | ||
| 32 | export const make = Effect.fn("makeServerSecretStore")(function* () { | |
| 33 | const crypto = yield* Crypto.Crypto; | |
| 34 | const fileSystem = yield* FileSystem.FileSystem; | |
| 35 | const path = yield* Path.Path; | |
| 36 | const serverConfig = yield* ServerConfig; | |
| 37 | ||
| 38 | yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); | |
| 39 | yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( | |
| 40 | Effect.mapError( | |
| 41 | (cause) => | |
| 42 | new SecretStoreError({ | |
| 43 | message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, | |
| 44 | cause, | |
| 45 | }), | |
| 46 | ), | |
| 47 | ); | |
| 48 | ||
| 49 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); | |
| 50 | ||
| 51 | const isPlatformError = (u: unknown): u is PlatformError.PlatformError => | |
| 52 | Predicate.isTagged(u, "PlatformError"); | |
| 53 | ||
| 54 | const get: ServerSecretStoreShape["get"] = (name) => | |
| 55 | fileSystem.readFile(resolveSecretPath(name)).pipe( | |
| 56 | Effect.map((bytes) => Uint8Array.from(bytes)), | |
| 57 | Effect.catch((cause) => | |
| 58 | cause.reason._tag === "NotFound" | |
| 59 | ? Effect.succeed(null) | |
| 60 | : Effect.fail( | |
| 61 | new SecretStoreError({ | |
| 62 | message: `Failed to read secret ${name}.`, | |
| 63 | cause, | |
| 64 | }), | |
| 65 | ), | |
| 66 | ), | |
| 67 | Effect.withSpan("ServerSecretStore.get"), | |
| 68 | ); | |
| 69 | ||
| 70 | const set: ServerSecretStoreShape["set"] = (name, value) => { | |
| 71 | const secretPath = resolveSecretPath(name); | |
| 72 | return crypto.randomUUIDv4.pipe( | |
| 73 | Effect.mapError( | |
| 74 | (cause) => | |
| 75 | new SecretStoreError({ | |
| 76 | message: `Failed to create temporary path for secret ${name}.`, | |
| 77 | cause, | |
| 78 | }), | |
| 79 | ), | |
| 80 | Effect.flatMap((uuid) => { | |
| 81 | const tempPath = `${secretPath}.${uuid}.tmp`; | |
| 82 | return Effect.gen(function* () { | |
| 83 | yield* fileSystem.writeFile(tempPath, value); | |
| 84 | yield* fileSystem.chmod(tempPath, 0o600); | |
| 85 | yield* fileSystem.rename(tempPath, secretPath); | |
| 86 | yield* fileSystem.chmod(secretPath, 0o600); | |
| 87 | }).pipe( | |
| 88 | Effect.catch((cause) => | |
| 89 | fileSystem.remove(tempPath).pipe( | |
| 90 | Effect.ignore, | |
| 91 | Effect.flatMap(() => | |
| 92 | Effect.fail( | |
| 93 | new SecretStoreError({ | |
| 94 | message: `Failed to persist secret ${name}.`, | |
| 95 | cause, | |
| 96 | }), | |
| 97 | ), | |
| 98 | ), | |
| 99 | ), | |
| 100 | ), | |
| 101 | ); | |
| 102 | }), | |
| 103 | Effect.withSpan("ServerSecretStore.set"), | |
| 104 | ); | |
| 105 | }; | |
| 106 | ||
| 107 | const create: ServerSecretStoreShape["set"] = (name, value) => { | |
| 108 | const secretPath = resolveSecretPath(name); | |
| 109 | return Effect.scoped( | |
| 110 | Effect.gen(function* () { | |
| 111 | const file = yield* fileSystem.open(secretPath, { | |
| 112 | flag: "wx", | |
| 113 | mode: 0o600, | |
| 114 | }); | |
| 115 | yield* file.writeAll(value); | |
| 116 | yield* file.sync; | |
| 117 | yield* fileSystem.chmod(secretPath, 0o600); | |
| 118 | }), | |
| 119 | ).pipe( | |
| 120 | Effect.mapError( | |
| 121 | (cause) => | |
| 122 | new SecretStoreError({ | |
| 123 | message: `Failed to persist secret ${name}.`, | |
| 124 | cause, | |
| 125 | }), | |
| 126 | ), | |
| 127 | ); | |
| 128 | }; | |
| 129 | ||
| 130 | const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => | |
| 131 | get(name).pipe( | |
| 132 | Effect.flatMap((existing) => { | |
| 133 | if (existing) { | |
| 134 | return Effect.succeed(existing); | |
| 135 | } | |
| 136 | ||
| 137 | return crypto.randomBytes(bytes).pipe( | |
| 138 | Effect.mapError( | |
| 139 | (cause) => | |
| 140 | new SecretStoreError({ | |
| 141 | message: `Failed to generate random bytes for secret ${name}.`, | |
| 142 | cause, | |
| 143 | }), | |
| 144 | ), | |
| 145 | Effect.flatMap((generated) => | |
| 146 | create(name, generated).pipe( | |
| 147 | Effect.as(Uint8Array.from(generated)), | |
| 148 | Effect.catchTag("SecretStoreError", (error) => | |
| 149 | isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists" | |
| 150 | ? get(name).pipe( | |
| 151 | Effect.flatMap((created) => | |
| 152 | created !== null | |
| 153 | ? Effect.succeed(created) | |
| 154 | : Effect.fail( | |
| 155 | new SecretStoreError({ | |
| 156 | message: `Failed to read secret ${name} after concurrent creation.`, | |
| 157 | }), | |
| 158 | ), | |
| 159 | ), | |
| 160 | ) | |
| 161 | : Effect.fail(error), | |
| 162 | ), | |
| 163 | ), | |
| 164 | ), | |
| 165 | ); | |
| 166 | }), | |
| 167 | Effect.withSpan("ServerSecretStore.getOrCreateRandom"), | |
| 168 | ); | |
| 169 | ||
| 170 | const remove: ServerSecretStoreShape["remove"] = (name) => | |
| 171 | fileSystem.remove(resolveSecretPath(name)).pipe( | |
| 172 | Effect.catch((cause) => | |
| 173 | cause.reason._tag === "NotFound" | |
| 174 | ? Effect.void | |
| 175 | : Effect.fail( | |
| 176 | new SecretStoreError({ | |
| 177 | message: `Failed to remove secret ${name}.`, | |
| 178 | cause, | |
| 179 | }), | |
| 180 | ), | |
| 181 | ), | |
| 182 | Effect.withSpan("ServerSecretStore.remove"), | |
| 183 | ); | |
| 184 | ||
| 185 | return { | |
| 186 | get, | |
| 187 | set, | |
| 188 | getOrCreateRandom, | |
| 189 | remove, | |
| 190 | } satisfies ServerSecretStoreShape; | |
| 191 | }); | |
| 192 | ||
| 193 | export const layer = Layer.effect(ServerSecretStore, make()); |
apps/server/src/auth/Services/AuthControlPlane.ts deleted-73| ... | ... | @@ -1,73 +0,0 @@ |
| 1 | import type { | |
| 2 | AuthClientMetadata, | |
| 3 | AuthClientSession, | |
| 4 | AuthPairingLink, | |
| 5 | AuthSessionId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Data from "effect/Data"; | |
| 8 | import * as DateTime from "effect/DateTime"; | |
| 9 | import * as Duration from "effect/Duration"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Context from "effect/Context"; | |
| 12 | import type { SessionRole } from "./SessionCredentialService.ts"; | |
| 13 | ||
| 14 | export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | |
| 15 | ||
| 16 | export interface IssuedPairingLink { | |
| 17 | readonly id: string; | |
| 18 | readonly credential: string; | |
| 19 | readonly role: SessionRole; | |
| 20 | readonly subject: string; | |
| 21 | readonly label?: string; | |
| 22 | readonly createdAt: DateTime.Utc; | |
| 23 | readonly expiresAt: DateTime.Utc; | |
| 24 | } | |
| 25 | ||
| 26 | export interface IssuedBearerSession { | |
| 27 | readonly sessionId: AuthSessionId; | |
| 28 | readonly token: string; | |
| 29 | readonly method: "bearer-session-token"; | |
| 30 | readonly role: SessionRole; | |
| 31 | readonly subject: string; | |
| 32 | readonly client: AuthClientMetadata; | |
| 33 | readonly expiresAt: DateTime.Utc; | |
| 34 | } | |
| 35 | ||
| 36 | export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{ | |
| 37 | readonly message: string; | |
| 38 | readonly cause?: unknown; | |
| 39 | }> {} | |
| 40 | ||
| 41 | export interface AuthControlPlaneShape { | |
| 42 | readonly createPairingLink: (input?: { | |
| 43 | readonly ttl?: Duration.Duration; | |
| 44 | readonly label?: string; | |
| 45 | readonly role?: SessionRole; | |
| 46 | readonly subject?: string; | |
| 47 | }) => Effect.Effect<IssuedPairingLink, AuthControlPlaneError>; | |
| 48 | readonly listPairingLinks: (input?: { | |
| 49 | readonly role?: SessionRole; | |
| 50 | readonly excludeSubjects?: ReadonlyArray<string>; | |
| 51 | }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthControlPlaneError>; | |
| 52 | readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthControlPlaneError>; | |
| 53 | readonly issueSession: (input?: { | |
| 54 | readonly ttl?: Duration.Duration; | |
| 55 | readonly subject?: string; | |
| 56 | readonly role?: SessionRole; | |
| 57 | readonly label?: string; | |
| 58 | }) => Effect.Effect<IssuedBearerSession, AuthControlPlaneError>; | |
| 59 | readonly listSessions: () => Effect.Effect< | |
| 60 | ReadonlyArray<AuthClientSession>, | |
| 61 | AuthControlPlaneError | |
| 62 | >; | |
| 63 | readonly revokeSession: ( | |
| 64 | sessionId: AuthSessionId, | |
| 65 | ) => Effect.Effect<boolean, AuthControlPlaneError>; | |
| 66 | readonly revokeOtherSessionsExcept: ( | |
| 67 | sessionId: AuthSessionId, | |
| 68 | ) => Effect.Effect<number, AuthControlPlaneError>; | |
| 69 | } | |
| 70 | ||
| 71 | export class AuthControlPlane extends Context.Service<AuthControlPlane, AuthControlPlaneShape>()( | |
| 72 | "t3/auth/Services/AuthControlPlane", | |
| 73 | ) {} |
apps/server/src/auth/Services/BootstrapCredentialService.ts deleted-61| ... | ... | @@ -1,61 +0,0 @@ |
| 1 | import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts"; | |
| 2 | import * as Data from "effect/Data"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Context from "effect/Context"; | |
| 6 | import type * as Effect from "effect/Effect"; | |
| 7 | import type * as Stream from "effect/Stream"; | |
| 8 | ||
| 9 | export type BootstrapCredentialRole = "owner" | "client"; | |
| 10 | ||
| 11 | export interface BootstrapGrant { | |
| 12 | readonly method: ServerAuthBootstrapMethod; | |
| 13 | readonly role: BootstrapCredentialRole; | |
| 14 | readonly subject: string; | |
| 15 | readonly label?: string; | |
| 16 | readonly expiresAt: DateTime.DateTime; | |
| 17 | } | |
| 18 | ||
| 19 | export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{ | |
| 20 | readonly message: string; | |
| 21 | readonly status?: 401 | 500; | |
| 22 | readonly cause?: unknown; | |
| 23 | }> {} | |
| 24 | ||
| 25 | export interface IssuedBootstrapCredential { | |
| 26 | readonly id: string; | |
| 27 | readonly credential: string; | |
| 28 | readonly label?: string; | |
| 29 | readonly expiresAt: DateTime.Utc; | |
| 30 | } | |
| 31 | ||
| 32 | export type BootstrapCredentialChange = | |
| 33 | | { | |
| 34 | readonly type: "pairingLinkUpserted"; | |
| 35 | readonly pairingLink: AuthPairingLink; | |
| 36 | } | |
| 37 | | { | |
| 38 | readonly type: "pairingLinkRemoved"; | |
| 39 | readonly id: string; | |
| 40 | }; | |
| 41 | ||
| 42 | export interface BootstrapCredentialServiceShape { | |
| 43 | readonly issueOneTimeToken: (input?: { | |
| 44 | readonly ttl?: Duration.Duration; | |
| 45 | readonly role?: BootstrapCredentialRole; | |
| 46 | readonly subject?: string; | |
| 47 | readonly label?: string; | |
| 48 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialError>; | |
| 49 | readonly listActive: () => Effect.Effect< | |
| 50 | ReadonlyArray<AuthPairingLink>, | |
| 51 | BootstrapCredentialError | |
| 52 | >; | |
| 53 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; | |
| 54 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialError>; | |
| 55 | readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | |
| 56 | } | |
| 57 | ||
| 58 | export class BootstrapCredentialService extends Context.Service< | |
| 59 | BootstrapCredentialService, | |
| 60 | BootstrapCredentialServiceShape | |
| 61 | >()("t3/auth/Services/BootstrapCredentialService") {} |
apps/server/src/auth/Services/ServerAuth.ts deleted-86| ... | ... | @@ -1,86 +0,0 @@ |
| 1 | import type { | |
| 2 | AuthBearerBootstrapResult, | |
| 3 | AuthBootstrapResult, | |
| 4 | AuthClientMetadata, | |
| 5 | AuthClientSession, | |
| 6 | AuthCreatePairingCredentialInput, | |
| 7 | AuthPairingLink, | |
| 8 | AuthPairingCredentialResult, | |
| 9 | AuthSessionId, | |
| 10 | AuthSessionState, | |
| 11 | ServerAuthDescriptor, | |
| 12 | ServerAuthSessionMethod, | |
| 13 | AuthWebSocketTokenResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import * as Data from "effect/Data"; | |
| 16 | import * as DateTime from "effect/DateTime"; | |
| 17 | import * as Context from "effect/Context"; | |
| 18 | import type * as Effect from "effect/Effect"; | |
| 19 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 20 | import type { SessionRole } from "./SessionCredentialService.ts"; | |
| 21 | ||
| 22 | export interface AuthenticatedSession { | |
| 23 | readonly sessionId: AuthSessionId; | |
| 24 | readonly subject: string; | |
| 25 | readonly method: ServerAuthSessionMethod; | |
| 26 | readonly role: SessionRole; | |
| 27 | readonly expiresAt?: DateTime.DateTime; | |
| 28 | } | |
| 29 | ||
| 30 | export class AuthError extends Data.TaggedError("AuthError")<{ | |
| 31 | readonly message: string; | |
| 32 | readonly status?: 400 | 401 | 403 | 500; | |
| 33 | readonly cause?: unknown; | |
| 34 | }> {} | |
| 35 | ||
| 36 | export interface ServerAuthShape { | |
| 37 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | |
| 38 | readonly getSessionState: ( | |
| 39 | request: HttpServerRequest.HttpServerRequest, | |
| 40 | ) => Effect.Effect<AuthSessionState, never>; | |
| 41 | readonly exchangeBootstrapCredential: ( | |
| 42 | credential: string, | |
| 43 | requestMetadata: AuthClientMetadata, | |
| 44 | ) => Effect.Effect< | |
| 45 | { | |
| 46 | readonly response: AuthBootstrapResult; | |
| 47 | readonly sessionToken: string; | |
| 48 | }, | |
| 49 | AuthError | |
| 50 | >; | |
| 51 | readonly exchangeBootstrapCredentialForBearerSession: ( | |
| 52 | credential: string, | |
| 53 | requestMetadata: AuthClientMetadata, | |
| 54 | ) => Effect.Effect<AuthBearerBootstrapResult, AuthError>; | |
| 55 | readonly issuePairingCredential: ( | |
| 56 | input?: AuthCreatePairingCredentialInput & { | |
| 57 | readonly role?: SessionRole; | |
| 58 | }, | |
| 59 | ) => Effect.Effect<AuthPairingCredentialResult, AuthError>; | |
| 60 | readonly listPairingLinks: () => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthError>; | |
| 61 | readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthError>; | |
| 62 | readonly listClientSessions: ( | |
| 63 | currentSessionId: AuthSessionId, | |
| 64 | ) => Effect.Effect<ReadonlyArray<AuthClientSession>, AuthError>; | |
| 65 | readonly revokeClientSession: ( | |
| 66 | currentSessionId: AuthSessionId, | |
| 67 | targetSessionId: AuthSessionId, | |
| 68 | ) => Effect.Effect<boolean, AuthError>; | |
| 69 | readonly revokeOtherClientSessions: ( | |
| 70 | currentSessionId: AuthSessionId, | |
| 71 | ) => Effect.Effect<number, AuthError>; | |
| 72 | readonly authenticateHttpRequest: ( | |
| 73 | request: HttpServerRequest.HttpServerRequest, | |
| 74 | ) => Effect.Effect<AuthenticatedSession, AuthError>; | |
| 75 | readonly authenticateWebSocketUpgrade: ( | |
| 76 | request: HttpServerRequest.HttpServerRequest, | |
| 77 | ) => Effect.Effect<AuthenticatedSession, AuthError>; | |
| 78 | readonly issueWebSocketToken: ( | |
| 79 | session: AuthenticatedSession, | |
| 80 | ) => Effect.Effect<AuthWebSocketTokenResult, AuthError>; | |
| 81 | readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect<string, AuthError>; | |
| 82 | } | |
| 83 | ||
| 84 | export class ServerAuth extends Context.Service<ServerAuth, ServerAuthShape>()( | |
| 85 | "t3/auth/Services/ServerAuth", | |
| 86 | ) {} |
apps/server/src/auth/Services/ServerAuthPolicy.ts deleted-11| ... | ... | @@ -1,11 +0,0 @@ |
| 1 | import type { ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import type * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | export interface ServerAuthPolicyShape { | |
| 6 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | |
| 7 | } | |
| 8 | ||
| 9 | export class ServerAuthPolicy extends Context.Service<ServerAuthPolicy, ServerAuthPolicyShape>()( | |
| 10 | "t3/auth/Services/ServerAuthPolicy", | |
| 11 | ) {} |
apps/server/src/auth/Services/ServerSecretStore.ts deleted-22| ... | ... | @@ -1,22 +0,0 @@ |
| 1 | import * as Data from "effect/Data"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import type * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ | |
| 6 | readonly message: string; | |
| 7 | readonly cause?: unknown; | |
| 8 | }> {} | |
| 9 | ||
| 10 | export interface ServerSecretStoreShape { | |
| 11 | readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>; | |
| 12 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | |
| 13 | readonly getOrCreateRandom: ( | |
| 14 | name: string, | |
| 15 | bytes: number, | |
| 16 | ) => Effect.Effect<Uint8Array, SecretStoreError>; | |
| 17 | readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>; | |
| 18 | } | |
| 19 | ||
| 20 | export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()( | |
| 21 | "t3/auth/Services/ServerSecretStore", | |
| 22 | ) {} |
apps/server/src/auth/Services/SessionCredentialService.ts deleted-91| ... | ... | @@ -1,91 +0,0 @@ |
| 1 | import type { | |
| 2 | AuthClientMetadata, | |
| 3 | AuthClientSession, | |
| 4 | AuthSessionId, | |
| 5 | ServerAuthSessionMethod, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Data from "effect/Data"; | |
| 8 | import * as DateTime from "effect/DateTime"; | |
| 9 | import * as Duration from "effect/Duration"; | |
| 10 | import * as Context from "effect/Context"; | |
| 11 | import type * as Effect from "effect/Effect"; | |
| 12 | import type * as Stream from "effect/Stream"; | |
| 13 | ||
| 14 | export type SessionRole = "owner" | "client"; | |
| 15 | ||
| 16 | export interface IssuedSession { | |
| 17 | readonly sessionId: AuthSessionId; | |
| 18 | readonly token: string; | |
| 19 | readonly method: ServerAuthSessionMethod; | |
| 20 | readonly client: AuthClientMetadata; | |
| 21 | readonly expiresAt: DateTime.DateTime; | |
| 22 | readonly role: SessionRole; | |
| 23 | } | |
| 24 | ||
| 25 | export interface VerifiedSession { | |
| 26 | readonly sessionId: AuthSessionId; | |
| 27 | readonly token: string; | |
| 28 | readonly method: ServerAuthSessionMethod; | |
| 29 | readonly client: AuthClientMetadata; | |
| 30 | readonly expiresAt?: DateTime.DateTime; | |
| 31 | readonly subject: string; | |
| 32 | readonly role: SessionRole; | |
| 33 | } | |
| 34 | ||
| 35 | export type SessionCredentialChange = | |
| 36 | | { | |
| 37 | readonly type: "clientUpserted"; | |
| 38 | readonly clientSession: AuthClientSession; | |
| 39 | } | |
| 40 | | { | |
| 41 | readonly type: "clientRemoved"; | |
| 42 | readonly sessionId: AuthSessionId; | |
| 43 | }; | |
| 44 | ||
| 45 | export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ | |
| 46 | readonly message: string; | |
| 47 | readonly cause?: unknown; | |
| 48 | }> {} | |
| 49 | ||
| 50 | export interface SessionCredentialServiceShape { | |
| 51 | readonly cookieName: string; | |
| 52 | readonly issue: (input?: { | |
| 53 | readonly ttl?: Duration.Duration; | |
| 54 | readonly subject?: string; | |
| 55 | readonly method?: ServerAuthSessionMethod; | |
| 56 | readonly role?: SessionRole; | |
| 57 | readonly client?: AuthClientMetadata; | |
| 58 | }) => Effect.Effect<IssuedSession, SessionCredentialError>; | |
| 59 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | |
| 60 | readonly issueWebSocketToken: ( | |
| 61 | sessionId: AuthSessionId, | |
| 62 | input?: { | |
| 63 | readonly ttl?: Duration.Duration; | |
| 64 | }, | |
| 65 | ) => Effect.Effect< | |
| 66 | { | |
| 67 | readonly token: string; | |
| 68 | readonly expiresAt: DateTime.DateTime; | |
| 69 | }, | |
| 70 | SessionCredentialError | |
| 71 | >; | |
| 72 | readonly verifyWebSocketToken: ( | |
| 73 | token: string, | |
| 74 | ) => Effect.Effect<VerifiedSession, SessionCredentialError>; | |
| 75 | readonly listActive: () => Effect.Effect< | |
| 76 | ReadonlyArray<AuthClientSession>, | |
| 77 | SessionCredentialError | |
| 78 | >; | |
| 79 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; | |
| 80 | readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>; | |
| 81 | readonly revokeAllExcept: ( | |
| 82 | sessionId: AuthSessionId, | |
| 83 | ) => Effect.Effect<number, SessionCredentialError>; | |
| 84 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | |
| 85 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | |
| 86 | } | |
| 87 | ||
| 88 | export class SessionCredentialService extends Context.Service< | |
| 89 | SessionCredentialService, | |
| 90 | SessionCredentialServiceShape | |
| 91 | >()("t3/auth/Services/SessionCredentialService") {} |
apps/server/src/auth/SessionStore.test.ts created+243| ... | ... | @@ -0,0 +1,243 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | ||
| 8 | import type { ServerConfigShape } from "../config.ts"; | |
| 9 | import { ServerConfig } from "../config.ts"; | |
| 10 | import { PersistenceSqlError } from "../persistence/Errors.ts"; | |
| 11 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 12 | import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts"; | |
| 13 | import * as SessionStore from "./SessionStore.ts"; | |
| 14 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | |
| 15 | ||
| 16 | const makeServerConfigLayer = ( | |
| 17 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 18 | ) => | |
| 19 | Layer.effect( | |
| 20 | ServerConfig, | |
| 21 | Effect.gen(function* () { | |
| 22 | const config = yield* ServerConfig; | |
| 23 | return { | |
| 24 | ...config, | |
| 25 | ...overrides, | |
| 26 | } satisfies ServerConfigShape; | |
| 27 | }), | |
| 28 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); | |
| 29 | ||
| 30 | const makeSessionStoreLayer = ( | |
| 31 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 32 | ) => | |
| 33 | SessionStore.layer.pipe( | |
| 34 | Layer.provide(SqlitePersistenceMemory), | |
| 35 | Layer.provide(ServerSecretStore.layer), | |
| 36 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 37 | ); | |
| 38 | ||
| 39 | const repositoryFailure = new PersistenceSqlError({ | |
| 40 | operation: "AuthSessionRepository.getById:query", | |
| 41 | detail: "sqlite is unavailable", | |
| 42 | }); | |
| 43 | ||
| 44 | const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessionRepository, { | |
| 45 | create: () => Effect.void, | |
| 46 | getById: () => Effect.fail(repositoryFailure), | |
| 47 | listActive: () => Effect.succeed([]), | |
| 48 | revoke: () => Effect.succeed(false), | |
| 49 | revokeAllExcept: () => Effect.succeed([]), | |
| 50 | setLastConnectedAt: () => Effect.void, | |
| 51 | }); | |
| 52 | ||
| 53 | const failingSessionLookupCredentialLayer = Layer.effect( | |
| 54 | SessionStore.SessionStore, | |
| 55 | SessionStore.make(), | |
| 56 | ).pipe( | |
| 57 | Layer.provide(failingSessionLookupRepositoryLayer), | |
| 58 | Layer.provide(ServerSecretStore.layer), | |
| 59 | Layer.provide(SqlitePersistenceMemory), | |
| 60 | Layer.provide(makeServerConfigLayer()), | |
| 61 | ); | |
| 62 | ||
| 63 | it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | |
| 64 | it.effect("issues and verifies signed browser session tokens", () => | |
| 65 | Effect.gen(function* () { | |
| 66 | const sessions = yield* SessionStore.SessionStore; | |
| 67 | const issued = yield* sessions.issue({ | |
| 68 | subject: "desktop-bootstrap", | |
| 69 | scopes: ["orchestration:read", "access:write"], | |
| 70 | client: { | |
| 71 | label: "Desktop app", | |
| 72 | deviceType: "desktop", | |
| 73 | os: "macOS", | |
| 74 | browser: "Electron", | |
| 75 | ipAddress: "127.0.0.1", | |
| 76 | }, | |
| 77 | }); | |
| 78 | const verified = yield* sessions.verify(issued.token); | |
| 79 | ||
| 80 | expect(verified.method).toBe("browser-session-cookie"); | |
| 81 | expect(verified.subject).toBe("desktop-bootstrap"); | |
| 82 | expect(verified.scopes).toEqual(["orchestration:read", "access:write"]); | |
| 83 | expect(verified.client.label).toBe("Desktop app"); | |
| 84 | expect(verified.client.browser).toBe("Electron"); | |
| 85 | expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); | |
| 86 | }).pipe(Effect.provide(makeSessionStoreLayer())), | |
| 87 | ); | |
| 88 | it.effect("rejects malformed session tokens", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const sessions = yield* SessionStore.SessionStore; | |
| 91 | const error = yield* Effect.flip(sessions.verify("not-a-session-token")); | |
| 92 | ||
| 93 | expect(error._tag).toBe("SessionCredentialInvalidError"); | |
| 94 | expect(error.message).toContain("Malformed session token"); | |
| 95 | }).pipe(Effect.provide(makeSessionStoreLayer())), | |
| 96 | ); | |
| 97 | it.effect("preserves repository failures while verifying session and websocket credentials", () => | |
| 98 | Effect.gen(function* () { | |
| 99 | const sessions = yield* SessionStore.SessionStore; | |
| 100 | const issued = yield* sessions.issue({ | |
| 101 | method: "bearer-access-token", | |
| 102 | subject: "repository-failure", | |
| 103 | }); | |
| 104 | const websocket = yield* sessions.issueWebSocketToken(issued.sessionId); | |
| 105 | ||
| 106 | const sessionError = yield* Effect.flip(sessions.verify(issued.token)); | |
| 107 | const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); | |
| 108 | ||
| 109 | expect(sessionError._tag).toBe("SessionCredentialInternalError"); | |
| 110 | expect(websocketError._tag).toBe("SessionCredentialInternalError"); | |
| 111 | expect(sessionError.cause).toBe(repositoryFailure); | |
| 112 | expect(websocketError.cause).toBe(repositoryFailure); | |
| 113 | }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), | |
| 114 | ); | |
| 115 | it.effect("verifies session tokens against the Effect clock", () => | |
| 116 | Effect.gen(function* () { | |
| 117 | const sessions = yield* SessionStore.SessionStore; | |
| 118 | const issued = yield* sessions.issue({ | |
| 119 | method: "bearer-access-token", | |
| 120 | subject: "test-clock", | |
| 121 | }); | |
| 122 | const verified = yield* sessions.verify(issued.token); | |
| 123 | ||
| 124 | expect(verified.method).toBe("bearer-access-token"); | |
| 125 | expect(verified.subject).toBe("test-clock"); | |
| 126 | expect(verified.scopes).toEqual([ | |
| 127 | "orchestration:read", | |
| 128 | "orchestration:operate", | |
| 129 | "terminal:operate", | |
| 130 | "review:write", | |
| 131 | "relay:read", | |
| 132 | ]); | |
| 133 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), | |
| 134 | ); | |
| 135 | ||
| 136 | it.effect("rejects websocket tokens once the parent session has expired", () => | |
| 137 | Effect.gen(function* () { | |
| 138 | const sessions = yield* SessionStore.SessionStore; | |
| 139 | const issued = yield* sessions.issue({ | |
| 140 | method: "bearer-access-token", | |
| 141 | subject: "short-lived", | |
| 142 | ttl: Duration.seconds(1), | |
| 143 | }); | |
| 144 | const websocket = yield* sessions.issueWebSocketToken(issued.sessionId); | |
| 145 | ||
| 146 | yield* TestClock.adjust(Duration.seconds(2)); | |
| 147 | ||
| 148 | const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); | |
| 149 | expect(error.message).toContain("expired"); | |
| 150 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), | |
| 151 | ); | |
| 152 | ||
| 153 | it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => | |
| 154 | Effect.gen(function* () { | |
| 155 | const sessions = yield* SessionStore.SessionStore; | |
| 156 | const administrative = yield* sessions.issue({ | |
| 157 | subject: "desktop-bootstrap", | |
| 158 | scopes: ["orchestration:read", "access:write"], | |
| 159 | client: { | |
| 160 | label: "Desktop app", | |
| 161 | deviceType: "desktop", | |
| 162 | os: "macOS", | |
| 163 | browser: "Electron", | |
| 164 | }, | |
| 165 | }); | |
| 166 | const client = yield* sessions.issue({ | |
| 167 | subject: "one-time-token", | |
| 168 | scopes: ["orchestration:read"], | |
| 169 | client: { | |
| 170 | label: "Julius iPhone", | |
| 171 | deviceType: "mobile", | |
| 172 | os: "iOS", | |
| 173 | browser: "Safari", | |
| 174 | ipAddress: "192.168.1.88", | |
| 175 | }, | |
| 176 | }); | |
| 177 | ||
| 178 | yield* sessions.markConnected(client.sessionId); | |
| 179 | const beforeRevoke = yield* sessions.listActive(); | |
| 180 | const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId); | |
| 181 | const afterRevoke = yield* sessions.listActive(); | |
| 182 | const revokedClient = yield* Effect.flip(sessions.verify(client.token)); | |
| 183 | ||
| 184 | expect(beforeRevoke).toHaveLength(2); | |
| 185 | expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( | |
| 186 | true, | |
| 187 | ); | |
| 188 | expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe( | |
| 189 | "Julius iPhone", | |
| 190 | ); | |
| 191 | expect( | |
| 192 | beforeRevoke.find((entry) => entry.sessionId === administrative.sessionId)?.client | |
| 193 | .deviceType, | |
| 194 | ).toBe("desktop"); | |
| 195 | expect(revokedCount).toBe(1); | |
| 196 | expect(afterRevoke).toHaveLength(1); | |
| 197 | expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId); | |
| 198 | expect(revokedClient.message).toContain("revoked"); | |
| 199 | }).pipe(Effect.provide(makeSessionStoreLayer())), | |
| 200 | ); | |
| 201 | ||
| 202 | it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () => | |
| 203 | Effect.gen(function* () { | |
| 204 | const sessions = yield* SessionStore.SessionStore; | |
| 205 | const issued = yield* sessions.issue({ | |
| 206 | subject: "reconnect-test", | |
| 207 | method: "bearer-access-token", | |
| 208 | }); | |
| 209 | ||
| 210 | const beforeConnect = yield* sessions.listActive(); | |
| 211 | expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); | |
| 212 | ||
| 213 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 214 | yield* sessions.markConnected(issued.sessionId); | |
| 215 | const firstConnect = yield* sessions.listActive(); | |
| 216 | const firstConnectedAt = firstConnect[0]?.lastConnectedAt; | |
| 217 | ||
| 218 | expect(firstConnect[0]?.connected).toBe(true); | |
| 219 | expect(firstConnectedAt).not.toBeNull(); | |
| 220 | ||
| 221 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 222 | yield* sessions.markConnected(issued.sessionId); | |
| 223 | const stillConnected = yield* sessions.listActive(); | |
| 224 | ||
| 225 | expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); | |
| 226 | ||
| 227 | yield* sessions.markDisconnected(issued.sessionId); | |
| 228 | yield* sessions.markDisconnected(issued.sessionId); | |
| 229 | const afterDisconnect = yield* sessions.listActive(); | |
| 230 | ||
| 231 | expect(afterDisconnect[0]?.connected).toBe(false); | |
| 232 | expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); | |
| 233 | ||
| 234 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 235 | yield* sessions.markConnected(issued.sessionId); | |
| 236 | const afterReconnect = yield* sessions.listActive(); | |
| 237 | ||
| 238 | expect(afterReconnect[0]?.connected).toBe(true); | |
| 239 | expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull(); | |
| 240 | expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); | |
| 241 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), | |
| 242 | ); | |
| 243 | }); |
apps/server/src/auth/SessionStore.ts created+637| ... | ... | @@ -0,0 +1,637 @@ |
| 1 | import { | |
| 2 | AuthSessionId, | |
| 3 | AuthStandardClientScopes, | |
| 4 | AuthEnvironmentScopes, | |
| 5 | type AuthClientMetadata, | |
| 6 | type AuthClientSession, | |
| 7 | type AuthEnvironmentScope, | |
| 8 | type ServerAuthSessionMethod, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Clock from "effect/Clock"; | |
| 11 | import * as Context from "effect/Context"; | |
| 12 | import * as Crypto from "effect/Crypto"; | |
| 13 | import * as Data from "effect/Data"; | |
| 14 | import * as DateTime from "effect/DateTime"; | |
| 15 | import * as Duration from "effect/Duration"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as Layer from "effect/Layer"; | |
| 18 | import * as PubSub from "effect/PubSub"; | |
| 19 | import * as Ref from "effect/Ref"; | |
| 20 | import * as Schema from "effect/Schema"; | |
| 21 | import * as Stream from "effect/Stream"; | |
| 22 | import * as Option from "effect/Option"; | |
| 23 | ||
| 24 | import { ServerConfig } from "../config.ts"; | |
| 25 | import { AuthSessionRepositoryLive } from "../persistence/Layers/AuthSessions.ts"; | |
| 26 | import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts"; | |
| 27 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | |
| 28 | import { | |
| 29 | base64UrlDecodeUtf8, | |
| 30 | base64UrlEncode, | |
| 31 | resolveSessionCookieName, | |
| 32 | signPayload, | |
| 33 | timingSafeEqualBase64Url, | |
| 34 | } from "./utils.ts"; | |
| 35 | ||
| 36 | export interface IssuedSession { | |
| 37 | readonly sessionId: AuthSessionId; | |
| 38 | readonly token: string; | |
| 39 | readonly method: ServerAuthSessionMethod; | |
| 40 | readonly client: AuthClientMetadata; | |
| 41 | readonly expiresAt: DateTime.DateTime; | |
| 42 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 43 | } | |
| 44 | ||
| 45 | export interface VerifiedSession { | |
| 46 | readonly sessionId: AuthSessionId; | |
| 47 | readonly token: string; | |
| 48 | readonly method: ServerAuthSessionMethod; | |
| 49 | readonly client: AuthClientMetadata; | |
| 50 | readonly expiresAt?: DateTime.DateTime; | |
| 51 | readonly subject: string; | |
| 52 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 53 | } | |
| 54 | ||
| 55 | export type SessionCredentialChange = | |
| 56 | | { | |
| 57 | readonly type: "clientUpserted"; | |
| 58 | readonly clientSession: AuthClientSession; | |
| 59 | } | |
| 60 | | { | |
| 61 | readonly type: "clientRemoved"; | |
| 62 | readonly sessionId: AuthSessionId; | |
| 63 | }; | |
| 64 | ||
| 65 | export class SessionCredentialInvalidError extends Data.TaggedError( | |
| 66 | "SessionCredentialInvalidError", | |
| 67 | )<{ | |
| 68 | readonly message: string; | |
| 69 | readonly cause?: unknown; | |
| 70 | }> {} | |
| 71 | ||
| 72 | export class SessionCredentialInternalError extends Data.TaggedError( | |
| 73 | "SessionCredentialInternalError", | |
| 74 | )<{ | |
| 75 | readonly message: string; | |
| 76 | readonly cause?: unknown; | |
| 77 | }> {} | |
| 78 | ||
| 79 | export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError; | |
| 80 | ||
| 81 | export interface SessionStoreShape { | |
| 82 | readonly cookieName: string; | |
| 83 | readonly issue: (input?: { | |
| 84 | readonly ttl?: Duration.Duration; | |
| 85 | readonly subject?: string; | |
| 86 | readonly method?: ServerAuthSessionMethod; | |
| 87 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 88 | readonly client?: AuthClientMetadata; | |
| 89 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; | |
| 90 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | |
| 91 | readonly issueWebSocketToken: ( | |
| 92 | sessionId: AuthSessionId, | |
| 93 | input?: { | |
| 94 | readonly ttl?: Duration.Duration; | |
| 95 | }, | |
| 96 | ) => Effect.Effect< | |
| 97 | { | |
| 98 | readonly token: string; | |
| 99 | readonly expiresAt: DateTime.DateTime; | |
| 100 | }, | |
| 101 | SessionCredentialInternalError | |
| 102 | >; | |
| 103 | readonly verifyWebSocketToken: ( | |
| 104 | token: string, | |
| 105 | ) => Effect.Effect<VerifiedSession, SessionCredentialError>; | |
| 106 | readonly listActive: () => Effect.Effect< | |
| 107 | ReadonlyArray<AuthClientSession>, | |
| 108 | SessionCredentialInternalError | |
| 109 | >; | |
| 110 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; | |
| 111 | readonly revoke: ( | |
| 112 | sessionId: AuthSessionId, | |
| 113 | ) => Effect.Effect<boolean, SessionCredentialInternalError>; | |
| 114 | readonly revokeAllExcept: ( | |
| 115 | sessionId: AuthSessionId, | |
| 116 | ) => Effect.Effect<number, SessionCredentialInternalError>; | |
| 117 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | |
| 118 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | |
| 119 | } | |
| 120 | ||
| 121 | export class SessionStore extends Context.Service<SessionStore, SessionStoreShape>()( | |
| 122 | "t3/auth/SessionStore", | |
| 123 | ) {} | |
| 124 | ||
| 125 | const SIGNING_SECRET_NAME = "server-signing-key"; | |
| 126 | const DEFAULT_SESSION_TTL = Duration.days(30); | |
| 127 | const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); | |
| 128 | ||
| 129 | const SessionClaims = Schema.Struct({ | |
| 130 | v: Schema.Literal(1), | |
| 131 | kind: Schema.Literal("session"), | |
| 132 | sid: AuthSessionId, | |
| 133 | sub: Schema.String, | |
| 134 | scopes: AuthEnvironmentScopes, | |
| 135 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token"]), | |
| 136 | iat: Schema.Number, | |
| 137 | exp: Schema.Number, | |
| 138 | }); | |
| 139 | type SessionClaims = typeof SessionClaims.Type; | |
| 140 | ||
| 141 | const WebSocketClaims = Schema.Struct({ | |
| 142 | v: Schema.Literal(1), | |
| 143 | kind: Schema.Literal("websocket"), | |
| 144 | sid: AuthSessionId, | |
| 145 | iat: Schema.Number, | |
| 146 | exp: Schema.Number, | |
| 147 | }); | |
| 148 | type WebSocketClaims = typeof WebSocketClaims.Type; | |
| 149 | ||
| 150 | const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims)); | |
| 151 | const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims)); | |
| 152 | ||
| 153 | function createDefaultClientMetadata(): AuthClientMetadata { | |
| 154 | return { | |
| 155 | deviceType: "unknown", | |
| 156 | }; | |
| 157 | } | |
| 158 | ||
| 159 | function toClientMetadata(record: { | |
| 160 | readonly label: string | null; | |
| 161 | readonly ipAddress: string | null; | |
| 162 | readonly userAgent: string | null; | |
| 163 | readonly deviceType: AuthClientMetadata["deviceType"]; | |
| 164 | readonly os: string | null; | |
| 165 | readonly browser: string | null; | |
| 166 | }): AuthClientMetadata { | |
| 167 | return { | |
| 168 | ...(record.label ? { label: record.label } : {}), | |
| 169 | ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), | |
| 170 | ...(record.userAgent ? { userAgent: record.userAgent } : {}), | |
| 171 | deviceType: record.deviceType, | |
| 172 | ...(record.os ? { os: record.os } : {}), | |
| 173 | ...(record.browser ? { browser: record.browser } : {}), | |
| 174 | }; | |
| 175 | } | |
| 176 | ||
| 177 | function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession { | |
| 178 | return { | |
| 179 | ...input, | |
| 180 | current: false, | |
| 181 | }; | |
| 182 | } | |
| 183 | ||
| 184 | const toSessionCredentialInternalError = (message: string) => (cause: unknown) => | |
| 185 | new SessionCredentialInternalError({ | |
| 186 | message, | |
| 187 | cause, | |
| 188 | }); | |
| 189 | ||
| 190 | export const make = Effect.fn("makeSessionStore")(function* () { | |
| 191 | const crypto = yield* Crypto.Crypto; | |
| 192 | const serverConfig = yield* ServerConfig; | |
| 193 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 194 | const authSessions = yield* AuthSessionRepository; | |
| 195 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); | |
| 196 | const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); | |
| 197 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); | |
| 198 | const cookieName = resolveSessionCookieName({ | |
| 199 | mode: serverConfig.mode, | |
| 200 | port: serverConfig.port, | |
| 201 | }); | |
| 202 | ||
| 203 | const emitUpsert = (clientSession: AuthClientSession) => | |
| 204 | PubSub.publish(changesPubSub, { | |
| 205 | type: "clientUpserted", | |
| 206 | clientSession, | |
| 207 | }).pipe(Effect.asVoid); | |
| 208 | ||
| 209 | const emitRemoved = (sessionId: AuthSessionId) => | |
| 210 | PubSub.publish(changesPubSub, { | |
| 211 | type: "clientRemoved", | |
| 212 | sessionId, | |
| 213 | }).pipe(Effect.asVoid); | |
| 214 | ||
| 215 | const loadActiveSession = (sessionId: AuthSessionId) => | |
| 216 | Effect.gen(function* () { | |
| 217 | const row = yield* authSessions.getById({ sessionId }); | |
| 218 | if (Option.isNone(row) || row.value.revokedAt !== null) { | |
| 219 | return Option.none<AuthClientSession>(); | |
| 220 | } | |
| 221 | ||
| 222 | const connectedSessions = yield* Ref.get(connectedSessionsRef); | |
| 223 | return Option.some( | |
| 224 | toAuthClientSession({ | |
| 225 | sessionId: row.value.sessionId, | |
| 226 | subject: row.value.subject, | |
| 227 | scopes: row.value.scopes, | |
| 228 | method: row.value.method, | |
| 229 | client: toClientMetadata(row.value.client), | |
| 230 | issuedAt: row.value.issuedAt, | |
| 231 | expiresAt: row.value.expiresAt, | |
| 232 | lastConnectedAt: row.value.lastConnectedAt, | |
| 233 | connected: connectedSessions.has(row.value.sessionId), | |
| 234 | }), | |
| 235 | ); | |
| 236 | }); | |
| 237 | ||
| 238 | const markConnected: SessionStoreShape["markConnected"] = (sessionId) => | |
| 239 | Ref.modify(connectedSessionsRef, (current) => { | |
| 240 | const next = new Map(current); | |
| 241 | const wasDisconnected = !next.has(sessionId); | |
| 242 | next.set(sessionId, (next.get(sessionId) ?? 0) + 1); | |
| 243 | return [wasDisconnected, next] as const; | |
| 244 | }).pipe( | |
| 245 | Effect.flatMap((wasDisconnected) => | |
| 246 | wasDisconnected | |
| 247 | ? DateTime.now.pipe( | |
| 248 | Effect.flatMap((lastConnectedAt) => | |
| 249 | authSessions.setLastConnectedAt({ | |
| 250 | sessionId, | |
| 251 | lastConnectedAt, | |
| 252 | }), | |
| 253 | ), | |
| 254 | ) | |
| 255 | : Effect.void, | |
| 256 | ), | |
| 257 | Effect.flatMap(() => loadActiveSession(sessionId)), | |
| 258 | Effect.flatMap((session) => | |
| 259 | Option.isSome(session) ? emitUpsert(session.value) : Effect.void, | |
| 260 | ), | |
| 261 | Effect.catchCause((cause) => | |
| 262 | Effect.logError("Failed to publish connected-session auth update.").pipe( | |
| 263 | Effect.annotateLogs({ | |
| 264 | sessionId, | |
| 265 | cause, | |
| 266 | }), | |
| 267 | ), | |
| 268 | ), | |
| 269 | Effect.withSpan("SessionStore.markConnected"), | |
| 270 | ); | |
| 271 | ||
| 272 | const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) => | |
| 273 | Ref.update(connectedSessionsRef, (current) => { | |
| 274 | const next = new Map(current); | |
| 275 | const remaining = (next.get(sessionId) ?? 0) - 1; | |
| 276 | if (remaining > 0) { | |
| 277 | next.set(sessionId, remaining); | |
| 278 | } else { | |
| 279 | next.delete(sessionId); | |
| 280 | } | |
| 281 | return next; | |
| 282 | }).pipe( | |
| 283 | Effect.flatMap(() => loadActiveSession(sessionId)), | |
| 284 | Effect.flatMap((session) => | |
| 285 | Option.isSome(session) ? emitUpsert(session.value) : Effect.void, | |
| 286 | ), | |
| 287 | Effect.catchCause((cause) => | |
| 288 | Effect.logError("Failed to publish disconnected-session auth update.").pipe( | |
| 289 | Effect.annotateLogs({ | |
| 290 | sessionId, | |
| 291 | cause, | |
| 292 | }), | |
| 293 | ), | |
| 294 | ), | |
| 295 | Effect.withSpan("SessionStore.markDisconnected"), | |
| 296 | ); | |
| 297 | ||
| 298 | const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); | |
| 299 | const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")( | |
| 300 | function* (input) { | |
| 301 | const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); | |
| 302 | const issuedAt = yield* DateTime.now; | |
| 303 | const expiresAt = DateTime.add(issuedAt, { | |
| 304 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), | |
| 305 | }); | |
| 306 | const claims: SessionClaims = { | |
| 307 | v: 1, | |
| 308 | kind: "session", | |
| 309 | sid: sessionId, | |
| 310 | sub: input?.subject ?? "browser", | |
| 311 | scopes: input?.scopes ?? AuthStandardClientScopes, | |
| 312 | method: input?.method ?? "browser-session-cookie", | |
| 313 | iat: issuedAt.epochMilliseconds, | |
| 314 | exp: expiresAt.epochMilliseconds, | |
| 315 | }; | |
| 316 | ||
| 317 | const encodedPayload = yield* encodeClaims(claims).pipe( | |
| 318 | Effect.map(base64UrlEncode), | |
| 319 | Effect.mapError( | |
| 320 | (cause) => | |
| 321 | new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), | |
| 322 | ), | |
| 323 | ); | |
| 324 | const signature = signPayload(encodedPayload, signingSecret); | |
| 325 | const client = input?.client ?? createDefaultClientMetadata(); | |
| 326 | yield* authSessions.create({ | |
| 327 | sessionId, | |
| 328 | subject: claims.sub, | |
| 329 | scopes: claims.scopes, | |
| 330 | method: claims.method, | |
| 331 | client: { | |
| 332 | label: client.label ?? null, | |
| 333 | ipAddress: client.ipAddress ?? null, | |
| 334 | userAgent: client.userAgent ?? null, | |
| 335 | deviceType: client.deviceType, | |
| 336 | os: client.os ?? null, | |
| 337 | browser: client.browser ?? null, | |
| 338 | }, | |
| 339 | issuedAt, | |
| 340 | expiresAt, | |
| 341 | }); | |
| 342 | yield* emitUpsert( | |
| 343 | toAuthClientSession({ | |
| 344 | sessionId, | |
| 345 | subject: claims.sub, | |
| 346 | scopes: claims.scopes, | |
| 347 | method: claims.method, | |
| 348 | client, | |
| 349 | issuedAt, | |
| 350 | expiresAt, | |
| 351 | lastConnectedAt: null, | |
| 352 | connected: false, | |
| 353 | }), | |
| 354 | ); | |
| 355 | ||
| 356 | return { | |
| 357 | sessionId, | |
| 358 | token: `${encodedPayload}.${signature}`, | |
| 359 | method: claims.method, | |
| 360 | client, | |
| 361 | expiresAt: expiresAt, | |
| 362 | scopes: claims.scopes, | |
| 363 | } satisfies IssuedSession; | |
| 364 | }, | |
| 365 | Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), | |
| 366 | ); | |
| 367 | ||
| 368 | const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")( | |
| 369 | function* (token) { | |
| 370 | const [encodedPayload, signature] = token.split("."); | |
| 371 | if (!encodedPayload || !signature) { | |
| 372 | return yield* new SessionCredentialInvalidError({ | |
| 373 | message: "Malformed session token.", | |
| 374 | }); | |
| 375 | } | |
| 376 | ||
| 377 | const expectedSignature = signPayload(encodedPayload, signingSecret); | |
| 378 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | |
| 379 | return yield* new SessionCredentialInvalidError({ | |
| 380 | message: "Invalid session token signature.", | |
| 381 | }); | |
| 382 | } | |
| 383 | ||
| 384 | const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | |
| 385 | Effect.mapError( | |
| 386 | (cause) => | |
| 387 | new SessionCredentialInvalidError({ | |
| 388 | message: "Invalid session token payload.", | |
| 389 | cause, | |
| 390 | }), | |
| 391 | ), | |
| 392 | ); | |
| 393 | ||
| 394 | const now = yield* Clock.currentTimeMillis; | |
| 395 | if (claims.exp <= now) { | |
| 396 | return yield* new SessionCredentialInvalidError({ | |
| 397 | message: "Session token expired.", | |
| 398 | }); | |
| 399 | } | |
| 400 | ||
| 401 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | |
| 402 | if (Option.isNone(row)) { | |
| 403 | return yield* new SessionCredentialInvalidError({ | |
| 404 | message: "Unknown session token.", | |
| 405 | }); | |
| 406 | } | |
| 407 | if (row.value.revokedAt !== null) { | |
| 408 | return yield* new SessionCredentialInvalidError({ | |
| 409 | message: "Session token revoked.", | |
| 410 | }); | |
| 411 | } | |
| 412 | ||
| 413 | const expiresAt = DateTime.make(claims.exp); | |
| 414 | if (Option.isNone(expiresAt)) { | |
| 415 | return yield* new SessionCredentialInvalidError({ | |
| 416 | message: "Invalid `exp` claim", | |
| 417 | }); | |
| 418 | } | |
| 419 | ||
| 420 | return { | |
| 421 | sessionId: claims.sid, | |
| 422 | token, | |
| 423 | method: claims.method, | |
| 424 | client: toClientMetadata(row.value.client), | |
| 425 | expiresAt: expiresAt.value, | |
| 426 | subject: claims.sub, | |
| 427 | scopes: claims.scopes, | |
| 428 | } satisfies VerifiedSession; | |
| 429 | }, | |
| 430 | Effect.mapError((cause) => | |
| 431 | cause._tag === "SessionCredentialInvalidError" | |
| 432 | ? cause | |
| 433 | : new SessionCredentialInternalError({ | |
| 434 | message: "Failed to verify session credential.", | |
| 435 | cause, | |
| 436 | }), | |
| 437 | ), | |
| 438 | ); | |
| 439 | ||
| 440 | const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); | |
| 441 | const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn( | |
| 442 | "SessionStore.issueWebSocketToken", | |
| 443 | )( | |
| 444 | function* (sessionId, input) { | |
| 445 | const issuedAt = yield* DateTime.now; | |
| 446 | const expiresAt = DateTime.add(issuedAt, { | |
| 447 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), | |
| 448 | }); | |
| 449 | const claims: WebSocketClaims = { | |
| 450 | v: 1, | |
| 451 | kind: "websocket", | |
| 452 | sid: sessionId, | |
| 453 | iat: issuedAt.epochMilliseconds, | |
| 454 | exp: expiresAt.epochMilliseconds, | |
| 455 | }; | |
| 456 | const encodedPayload = yield* encodeWsClaims(claims).pipe( | |
| 457 | Effect.map(base64UrlEncode), | |
| 458 | Effect.mapError( | |
| 459 | (cause) => | |
| 460 | new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), | |
| 461 | ), | |
| 462 | ); | |
| 463 | const signature = signPayload(encodedPayload, signingSecret); | |
| 464 | return { | |
| 465 | token: `${encodedPayload}.${signature}`, | |
| 466 | expiresAt, | |
| 467 | }; | |
| 468 | }, | |
| 469 | Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), | |
| 470 | ); | |
| 471 | ||
| 472 | const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn( | |
| 473 | "SessionStore.verifyWebSocketToken", | |
| 474 | )( | |
| 475 | function* (token) { | |
| 476 | const [encodedPayload, signature] = token.split("."); | |
| 477 | if (!encodedPayload || !signature) { | |
| 478 | return yield* new SessionCredentialInvalidError({ | |
| 479 | message: "Malformed websocket token.", | |
| 480 | }); | |
| 481 | } | |
| 482 | ||
| 483 | const expectedSignature = signPayload(encodedPayload, signingSecret); | |
| 484 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | |
| 485 | return yield* new SessionCredentialInvalidError({ | |
| 486 | message: "Invalid websocket token signature.", | |
| 487 | }); | |
| 488 | } | |
| 489 | ||
| 490 | const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | |
| 491 | Effect.mapError( | |
| 492 | (cause) => | |
| 493 | new SessionCredentialInvalidError({ | |
| 494 | message: "Invalid websocket token payload.", | |
| 495 | cause, | |
| 496 | }), | |
| 497 | ), | |
| 498 | ); | |
| 499 | ||
| 500 | const now = yield* Clock.currentTimeMillis; | |
| 501 | if (claims.exp <= now) { | |
| 502 | return yield* new SessionCredentialInvalidError({ | |
| 503 | message: "Websocket token expired.", | |
| 504 | }); | |
| 505 | } | |
| 506 | ||
| 507 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | |
| 508 | if (Option.isNone(row)) { | |
| 509 | return yield* new SessionCredentialInvalidError({ | |
| 510 | message: "Unknown websocket session.", | |
| 511 | }); | |
| 512 | } | |
| 513 | if (row.value.expiresAt.epochMilliseconds <= now) { | |
| 514 | return yield* new SessionCredentialInvalidError({ | |
| 515 | message: "Websocket session expired.", | |
| 516 | }); | |
| 517 | } | |
| 518 | if (row.value.revokedAt !== null) { | |
| 519 | return yield* new SessionCredentialInvalidError({ | |
| 520 | message: "Websocket session revoked.", | |
| 521 | }); | |
| 522 | } | |
| 523 | ||
| 524 | return { | |
| 525 | sessionId: row.value.sessionId, | |
| 526 | token, | |
| 527 | method: row.value.method, | |
| 528 | client: toClientMetadata(row.value.client), | |
| 529 | expiresAt: row.value.expiresAt, | |
| 530 | subject: row.value.subject, | |
| 531 | scopes: row.value.scopes, | |
| 532 | } satisfies VerifiedSession; | |
| 533 | }, | |
| 534 | Effect.mapError((cause) => | |
| 535 | cause._tag === "SessionCredentialInvalidError" | |
| 536 | ? cause | |
| 537 | : new SessionCredentialInternalError({ | |
| 538 | message: "Failed to verify websocket token.", | |
| 539 | cause, | |
| 540 | }), | |
| 541 | ), | |
| 542 | ); | |
| 543 | ||
| 544 | const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")( | |
| 545 | function* () { | |
| 546 | const now = yield* DateTime.now; | |
| 547 | const connectedSessions = yield* Ref.get(connectedSessionsRef); | |
| 548 | const rows = yield* authSessions.listActive({ now }); | |
| 549 | ||
| 550 | return rows.map((row) => | |
| 551 | toAuthClientSession({ | |
| 552 | sessionId: row.sessionId, | |
| 553 | subject: row.subject, | |
| 554 | scopes: row.scopes, | |
| 555 | method: row.method, | |
| 556 | client: toClientMetadata(row.client), | |
| 557 | issuedAt: row.issuedAt, | |
| 558 | expiresAt: row.expiresAt, | |
| 559 | lastConnectedAt: row.lastConnectedAt, | |
| 560 | connected: connectedSessions.has(row.sessionId), | |
| 561 | }), | |
| 562 | ); | |
| 563 | }, | |
| 564 | Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")), | |
| 565 | ); | |
| 566 | ||
| 567 | const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")( | |
| 568 | function* (sessionId) { | |
| 569 | const revokedAt = yield* DateTime.now; | |
| 570 | const revoked = yield* authSessions.revoke({ | |
| 571 | sessionId, | |
| 572 | revokedAt, | |
| 573 | }); | |
| 574 | if (revoked) { | |
| 575 | yield* Ref.update(connectedSessionsRef, (current) => { | |
| 576 | const next = new Map(current); | |
| 577 | next.delete(sessionId); | |
| 578 | return next; | |
| 579 | }); | |
| 580 | yield* emitRemoved(sessionId); | |
| 581 | } | |
| 582 | return revoked; | |
| 583 | }, | |
| 584 | Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")), | |
| 585 | ); | |
| 586 | ||
| 587 | const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn( | |
| 588 | "SessionStore.revokeAllExcept", | |
| 589 | )( | |
| 590 | function* (sessionId) { | |
| 591 | const revokedAt = yield* DateTime.now; | |
| 592 | const revokedSessionIds = yield* authSessions.revokeAllExcept({ | |
| 593 | currentSessionId: sessionId, | |
| 594 | revokedAt, | |
| 595 | }); | |
| 596 | if (revokedSessionIds.length > 0) { | |
| 597 | yield* Ref.update(connectedSessionsRef, (current) => { | |
| 598 | const next = new Map(current); | |
| 599 | for (const revokedSessionId of revokedSessionIds) { | |
| 600 | next.delete(revokedSessionId); | |
| 601 | } | |
| 602 | return next; | |
| 603 | }); | |
| 604 | yield* Effect.forEach( | |
| 605 | revokedSessionIds, | |
| 606 | (revokedSessionId) => emitRemoved(revokedSessionId), | |
| 607 | { | |
| 608 | concurrency: "unbounded", | |
| 609 | discard: true, | |
| 610 | }, | |
| 611 | ); | |
| 612 | } | |
| 613 | return revokedSessionIds.length; | |
| 614 | }, | |
| 615 | Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")), | |
| 616 | ); | |
| 617 | ||
| 618 | return { | |
| 619 | cookieName, | |
| 620 | issue, | |
| 621 | verify, | |
| 622 | issueWebSocketToken, | |
| 623 | verifyWebSocketToken, | |
| 624 | listActive, | |
| 625 | get streamChanges() { | |
| 626 | return Stream.fromPubSub(changesPubSub); | |
| 627 | }, | |
| 628 | revoke, | |
| 629 | revokeAllExcept, | |
| 630 | markConnected, | |
| 631 | markDisconnected, | |
| 632 | } satisfies SessionStoreShape; | |
| 633 | }); | |
| 634 | ||
| 635 | export const layer = Layer.effect(SessionStore, make()).pipe( | |
| 636 | Layer.provideMerge(AuthSessionRepositoryLive), | |
| 637 | ); |
apps/server/src/auth/http.ts+340-234| ... | ... | @@ -1,265 +1,371 @@ |
| 1 | 1 | import { |
| 2 | type AuthBearerBootstrapResult, | |
| 3 | AuthBootstrapInput, | |
| 4 | AuthCreatePairingCredentialInput, | |
| 5 | AuthRevokeClientSessionInput, | |
| 6 | AuthRevokePairingLinkInput, | |
| 7 | type AuthWebSocketTokenResult, | |
| 2 | AuthAccessReadScope, | |
| 3 | AuthAccessWriteScope, | |
| 4 | AuthStandardClientScopes, | |
| 5 | AuthOrchestrationOperateScope, | |
| 6 | AuthOrchestrationReadScope, | |
| 7 | AuthRelayReadScope, | |
| 8 | AuthRelayWriteScope, | |
| 9 | AuthReviewWriteScope, | |
| 10 | AuthTerminalOperateScope, | |
| 11 | EnvironmentAuthInvalidError, | |
| 12 | type EnvironmentAuthInvalidReason, | |
| 13 | EnvironmentHttpApi, | |
| 14 | EnvironmentInternalError, | |
| 15 | type EnvironmentInternalErrorReason, | |
| 16 | EnvironmentOperationForbiddenError, | |
| 17 | EnvironmentRequestInvalidError, | |
| 18 | type EnvironmentRequestInvalidReason, | |
| 19 | EnvironmentScopeRequiredError, | |
| 20 | EnvironmentAuthenticatedAuth, | |
| 21 | EnvironmentAuthenticatedPrincipal, | |
| 8 | 22 | } from "@t3tools/contracts"; |
| 23 | import type { AuthEnvironmentScope } from "@t3tools/contracts"; | |
| 24 | import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; | |
| 25 | import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 9 | 26 | import * as DateTime from "effect/DateTime"; |
| 10 | 27 | import * as Effect from "effect/Effect"; |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 28 | import * as Layer from "effect/Layer"; | |
| 29 | import * as Cookies from "effect/unstable/http/Cookies"; | |
| 30 | import * as HttpEffect from "effect/unstable/http/HttpEffect"; | |
| 31 | import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 32 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | |
| 13 | 33 | |
| 14 | import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; | |
| 15 | import { SessionCredentialService } from "./Services/SessionCredentialService.ts"; | |
| 34 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | |
| 35 | import * as SessionStore from "./SessionStore.ts"; | |
| 16 | 36 | import { deriveAuthClientMetadata } from "./utils.ts"; |
| 17 | import { browserApiCorsHeaders } from "../httpCors.ts"; | |
| 18 | 37 | |
| 19 | export const respondToAuthError = (error: AuthError) => | |
| 20 | Effect.gen(function* () { | |
| 21 | if ((error.status ?? 500) >= 500) { | |
| 22 | yield* Effect.logError("auth route failed", { | |
| 23 | message: error.message, | |
| 24 | cause: error.cause, | |
| 25 | }); | |
| 26 | } | |
| 27 | return HttpServerResponse.jsonUnsafe( | |
| 28 | { | |
| 29 | error: error.message, | |
| 30 | }, | |
| 31 | { status: error.status ?? 500, headers: browserApiCorsHeaders }, | |
| 32 | ); | |
| 33 | }); | |
| 38 | export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( | |
| 39 | Effect.map((span) => span.traceId), | |
| 40 | Effect.orElseSucceed(() => "unavailable"), | |
| 41 | ); | |
| 34 | 42 | |
| 35 | export const authSessionRouteLayer = HttpRouter.add( | |
| 36 | "GET", | |
| 37 | "/api/auth/session", | |
| 38 | Effect.gen(function* () { | |
| 43 | export function annotateEnvironmentRequest(endpoint: string) { | |
| 44 | return Effect.gen(function* () { | |
| 39 | 45 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 40 | const serverAuth = yield* ServerAuth; | |
| 41 | const session = yield* serverAuth.getSessionState(request); | |
| 42 | return HttpServerResponse.jsonUnsafe(session, { | |
| 43 | status: 200, | |
| 44 | headers: browserApiCorsHeaders, | |
| 46 | const url = HttpServerRequest.toURL(request); | |
| 47 | const traceId = yield* currentEnvironmentTraceId; | |
| 48 | ||
| 49 | yield* Effect.addFinalizer((exit) => | |
| 50 | exit._tag === "Failure" | |
| 51 | ? Effect.logWarning("environment api request failed", { | |
| 52 | endpoint, | |
| 53 | traceId, | |
| 54 | errorTag: causeErrorTag(exit.cause), | |
| 55 | cause: exit.cause, | |
| 56 | }) | |
| 57 | : Effect.void, | |
| 58 | ); | |
| 59 | yield* Effect.annotateLogsScoped({ "environment.endpoint": endpoint, traceId }); | |
| 60 | yield* Effect.annotateCurrentSpan({ | |
| 61 | "environment.endpoint": endpoint, | |
| 62 | "http.request.method": request.method, | |
| 63 | "url.path": url._tag === "Some" ? url.value.pathname : "unknown", | |
| 45 | 64 | }); |
| 46 | }), | |
| 47 | ); | |
| 65 | }); | |
| 66 | } | |
| 48 | 67 | |
| 49 | const PairingCredentialRequestHeaders = Schema.Struct({ | |
| 50 | "content-length": Schema.optionalKey(Schema.String), | |
| 51 | "content-type": Schema.optionalKey(Schema.String), | |
| 52 | "transfer-encoding": Schema.optionalKey(Schema.String), | |
| 53 | }); | |
| 68 | export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) { | |
| 69 | return currentEnvironmentTraceId.pipe( | |
| 70 | Effect.flatMap((traceId) => | |
| 71 | Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })), | |
| 72 | ), | |
| 73 | ); | |
| 74 | } | |
| 54 | 75 | |
| 55 | function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) { | |
| 56 | const contentLengthHeader = headers["content-length"]; | |
| 57 | if (typeof contentLengthHeader === "string") { | |
| 58 | const contentLength = Number.parseInt(contentLengthHeader, 10); | |
| 59 | if (Number.isFinite(contentLength)) { | |
| 60 | return contentLength > 0; | |
| 61 | } | |
| 62 | } | |
| 63 | return typeof headers["transfer-encoding"] === "string"; | |
| 76 | export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidReason) { | |
| 77 | return currentEnvironmentTraceId.pipe( | |
| 78 | Effect.flatMap((traceId) => | |
| 79 | Effect.fail(new EnvironmentRequestInvalidError({ code: "invalid_request", reason, traceId })), | |
| 80 | ), | |
| 81 | ); | |
| 64 | 82 | } |
| 65 | 83 | |
| 66 | export const authBootstrapRouteLayer = HttpRouter.add( | |
| 67 | "POST", | |
| 68 | "/api/auth/bootstrap", | |
| 69 | Effect.gen(function* () { | |
| 70 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 71 | const serverAuth = yield* ServerAuth; | |
| 72 | const sessions = yield* SessionCredentialService; | |
| 73 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( | |
| 74 | Effect.mapError( | |
| 75 | (cause) => | |
| 76 | new AuthError({ | |
| 77 | message: "Invalid bootstrap payload.", | |
| 78 | status: 400, | |
| 79 | cause, | |
| 80 | }), | |
| 84 | export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) { | |
| 85 | return currentEnvironmentTraceId.pipe( | |
| 86 | Effect.flatMap((traceId) => | |
| 87 | Effect.fail( | |
| 88 | new EnvironmentScopeRequiredError({ | |
| 89 | code: "insufficient_scope", | |
| 90 | requiredScope, | |
| 91 | traceId, | |
| 92 | }), | |
| 81 | 93 | ), |
| 82 | ); | |
| 83 | const result = yield* serverAuth.exchangeBootstrapCredential( | |
| 84 | payload.credential, | |
| 85 | deriveAuthClientMetadata({ request }), | |
| 86 | ); | |
| 87 | ||
| 88 | return yield* HttpServerResponse.jsonUnsafe(result.response, { | |
| 89 | status: 200, | |
| 90 | headers: browserApiCorsHeaders, | |
| 91 | }).pipe( | |
| 92 | HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, { | |
| 93 | expires: DateTime.toDate(result.response.expiresAt), | |
| 94 | httpOnly: true, | |
| 95 | path: "/", | |
| 96 | sameSite: "lax", | |
| 97 | }), | |
| 98 | ); | |
| 99 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 100 | ); | |
| 94 | ), | |
| 95 | ); | |
| 96 | } | |
| 101 | 97 | |
| 102 | export const authBearerBootstrapRouteLayer = HttpRouter.add( | |
| 103 | "POST", | |
| 104 | "/api/auth/bootstrap/bearer", | |
| 105 | Effect.gen(function* () { | |
| 106 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 107 | const serverAuth = yield* ServerAuth; | |
| 108 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( | |
| 109 | Effect.mapError( | |
| 110 | (cause) => | |
| 111 | new AuthError({ | |
| 112 | message: "Invalid bootstrap payload.", | |
| 113 | status: 400, | |
| 114 | cause, | |
| 115 | }), | |
| 98 | function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") { | |
| 99 | return currentEnvironmentTraceId.pipe( | |
| 100 | Effect.flatMap((traceId) => | |
| 101 | Effect.fail( | |
| 102 | new EnvironmentOperationForbiddenError({ | |
| 103 | code: "operation_forbidden", | |
| 104 | reason, | |
| 105 | traceId, | |
| 106 | }), | |
| 116 | 107 | ), |
| 117 | ); | |
| 118 | const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession( | |
| 119 | payload.credential, | |
| 120 | deriveAuthClientMetadata({ request }), | |
| 121 | ); | |
| 122 | return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, { | |
| 123 | status: 200, | |
| 124 | headers: browserApiCorsHeaders, | |
| 125 | }); | |
| 126 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 127 | ); | |
| 128 | ||
| 129 | export const authWebSocketTokenRouteLayer = HttpRouter.add( | |
| 130 | "POST", | |
| 131 | "/api/auth/ws-token", | |
| 132 | Effect.gen(function* () { | |
| 133 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 134 | const serverAuth = yield* ServerAuth; | |
| 135 | const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 136 | const result = yield* serverAuth.issueWebSocketToken(session); | |
| 137 | return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, { | |
| 138 | status: 200, | |
| 139 | headers: browserApiCorsHeaders, | |
| 140 | }); | |
| 141 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 142 | ); | |
| 108 | ), | |
| 109 | ); | |
| 110 | } | |
| 143 | 111 | |
| 144 | export const authPairingCredentialRouteLayer = HttpRouter.add( | |
| 145 | "POST", | |
| 146 | "/api/auth/pairing-token", | |
| 147 | Effect.gen(function* () { | |
| 148 | const serverAuth = yield* ServerAuth; | |
| 149 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 150 | const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 151 | if (session.role !== "owner") { | |
| 152 | return yield* new AuthError({ | |
| 153 | message: "Only owner sessions can create pairing credentials.", | |
| 154 | status: 403, | |
| 112 | export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) { | |
| 113 | return Effect.gen(function* () { | |
| 114 | const traceId = yield* currentEnvironmentTraceId; | |
| 115 | if (error !== undefined) { | |
| 116 | yield* Effect.logError("environment api operation failed", { | |
| 117 | reason, | |
| 118 | traceId, | |
| 119 | cause: error, | |
| 155 | 120 | }); |
| 156 | 121 | } |
| 157 | const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe( | |
| 158 | Effect.mapError( | |
| 159 | (cause) => | |
| 160 | new AuthError({ | |
| 161 | message: "Invalid pairing credential request headers.", | |
| 162 | status: 400, | |
| 163 | cause, | |
| 164 | }), | |
| 165 | ), | |
| 166 | ); | |
| 167 | const payload = hasRequestBody(headers) | |
| 168 | ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe( | |
| 169 | Effect.mapError( | |
| 170 | (cause) => | |
| 171 | new AuthError({ | |
| 172 | message: "Invalid pairing credential payload.", | |
| 173 | status: 400, | |
| 174 | cause, | |
| 175 | }), | |
| 176 | ), | |
| 177 | ) | |
| 178 | : {}; | |
| 179 | const result = yield* serverAuth.issuePairingCredential(payload); | |
| 180 | return HttpServerResponse.jsonUnsafe(result, { status: 200 }); | |
| 181 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 182 | ); | |
| 122 | return yield* new EnvironmentInternalError({ code: "internal_error", reason, traceId }); | |
| 123 | }); | |
| 124 | } | |
| 183 | 125 | |
| 184 | const authenticateOwnerSession = Effect.gen(function* () { | |
| 185 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 186 | const serverAuth = yield* ServerAuth; | |
| 187 | const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 188 | if (session.role !== "owner") { | |
| 189 | return yield* new AuthError({ | |
| 190 | message: "Only owner sessions can manage network access.", | |
| 191 | status: 403, | |
| 192 | }); | |
| 126 | export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( | |
| 127 | scope: AuthEnvironmentScope, | |
| 128 | ) { | |
| 129 | const session = yield* EnvironmentAuthenticatedPrincipal; | |
| 130 | if (!session.scopes.has(scope)) { | |
| 131 | return yield* failEnvironmentScopeRequired(scope); | |
| 193 | 132 | } |
| 194 | return { serverAuth, session } as const; | |
| 133 | return session; | |
| 195 | 134 | }); |
| 196 | 135 | |
| 197 | export const authPairingLinksRouteLayer = HttpRouter.add( | |
| 198 | "GET", | |
| 199 | "/api/auth/pairing-links", | |
| 136 | export const environmentAuthenticatedAuthLayer = Layer.effect( | |
| 137 | EnvironmentAuthenticatedAuth, | |
| 200 | 138 | Effect.gen(function* () { |
| 201 | const { serverAuth } = yield* authenticateOwnerSession; | |
| 202 | const pairingLinks = yield* serverAuth.listPairingLinks(); | |
| 203 | return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 }); | |
| 204 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 205 | ); | |
| 206 | ||
| 207 | export const authPairingLinksRevokeRouteLayer = HttpRouter.add( | |
| 208 | "POST", | |
| 209 | "/api/auth/pairing-links/revoke", | |
| 210 | Effect.gen(function* () { | |
| 211 | const { serverAuth } = yield* authenticateOwnerSession; | |
| 212 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe( | |
| 213 | Effect.mapError( | |
| 214 | (cause) => | |
| 215 | new AuthError({ | |
| 216 | message: "Invalid revoke pairing link payload.", | |
| 217 | status: 400, | |
| 218 | cause, | |
| 139 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 140 | return (httpEffect) => | |
| 141 | Effect.gen(function* () { | |
| 142 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 143 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( | |
| 144 | Effect.catchTags({ | |
| 145 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | |
| 146 | ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), | |
| 219 | 147 | }), |
| 220 | ), | |
| 221 | ); | |
| 222 | const revoked = yield* serverAuth.revokePairingLink(payload.id); | |
| 223 | return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); | |
| 224 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 148 | ); | |
| 149 | return yield* httpEffect.pipe( | |
| 150 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { | |
| 151 | ...session, | |
| 152 | scopes: new Set(session.scopes), | |
| 153 | }), | |
| 154 | ); | |
| 155 | }); | |
| 156 | }), | |
| 225 | 157 | ); |
| 226 | 158 | |
| 227 | export const authClientsRouteLayer = HttpRouter.add( | |
| 228 | "GET", | |
| 229 | "/api/auth/clients", | |
| 230 | Effect.gen(function* () { | |
| 231 | const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 232 | const clients = yield* serverAuth.listClientSessions(session.sessionId); | |
| 233 | return HttpServerResponse.jsonUnsafe(clients, { status: 200 }); | |
| 234 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 235 | ); | |
| 159 | export const authHttpApiLayer = HttpApiBuilder.group( | |
| 160 | EnvironmentHttpApi, | |
| 161 | "auth", | |
| 162 | Effect.fnUntraced(function* (handlers) { | |
| 163 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 164 | const sessions = yield* SessionStore.SessionStore; | |
| 236 | 165 | |
| 237 | export const authClientsRevokeRouteLayer = HttpRouter.add( | |
| 238 | "POST", | |
| 239 | "/api/auth/clients/revoke", | |
| 240 | Effect.gen(function* () { | |
| 241 | const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 242 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe( | |
| 243 | Effect.mapError( | |
| 244 | (cause) => | |
| 245 | new AuthError({ | |
| 246 | message: "Invalid revoke client payload.", | |
| 247 | status: 400, | |
| 248 | cause, | |
| 249 | }), | |
| 250 | ), | |
| 251 | ); | |
| 252 | const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId); | |
| 253 | return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); | |
| 254 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 255 | ); | |
| 166 | return handlers | |
| 167 | .handle( | |
| 168 | "session", | |
| 169 | Effect.fn("environment.auth.session")( | |
| 170 | function* (args) { | |
| 171 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 172 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 173 | return yield* serverAuth.getSessionState(request); | |
| 174 | }, | |
| 175 | Effect.catchTag("ServerAuthInternalError", (error) => | |
| 176 | failEnvironmentInternal("internal_error", error), | |
| 177 | ), | |
| 178 | ), | |
| 179 | ) | |
| 180 | .handle( | |
| 181 | "browserSession", | |
| 182 | Effect.fn("environment.auth.browserSession")( | |
| 183 | function* (args) { | |
| 184 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 185 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 186 | const result = yield* serverAuth.createBrowserSession( | |
| 187 | args.payload.credential, | |
| 188 | deriveAuthClientMetadata({ request }), | |
| 189 | ); | |
| 190 | const sessionCookies = yield* Effect.fromResult( | |
| 191 | Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { | |
| 192 | expires: DateTime.toDate(result.response.expiresAt), | |
| 193 | httpOnly: true, | |
| 194 | path: "/", | |
| 195 | sameSite: "lax", | |
| 196 | }), | |
| 197 | ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); | |
| 256 | 198 | |
| 257 | export const authClientsRevokeOthersRouteLayer = HttpRouter.add( | |
| 258 | "POST", | |
| 259 | "/api/auth/clients/revoke-others", | |
| 260 | Effect.gen(function* () { | |
| 261 | const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 262 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); | |
| 263 | return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 }); | |
| 264 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 199 | yield* HttpEffect.appendPreResponseHandler((_request, response) => | |
| 200 | Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), | |
| 201 | ); | |
| 202 | return result.response; | |
| 203 | }, | |
| 204 | Effect.catchTags({ | |
| 205 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | |
| 206 | ServerAuthInternalError: (error) => | |
| 207 | failEnvironmentInternal("browser_session_issuance_failed", error), | |
| 208 | }), | |
| 209 | ), | |
| 210 | ) | |
| 211 | .handle( | |
| 212 | "token", | |
| 213 | Effect.fn("environment.auth.token")( | |
| 214 | function* (args) { | |
| 215 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 216 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 217 | const requestedScopes = | |
| 218 | args.payload.scope === undefined | |
| 219 | ? undefined | |
| 220 | : parseAllowedOAuthScope({ | |
| 221 | value: args.payload.scope, | |
| 222 | allowedScopes: new Set<AuthEnvironmentScope>([ | |
| 223 | AuthOrchestrationReadScope, | |
| 224 | AuthOrchestrationOperateScope, | |
| 225 | AuthTerminalOperateScope, | |
| 226 | AuthReviewWriteScope, | |
| 227 | AuthAccessReadScope, | |
| 228 | AuthAccessWriteScope, | |
| 229 | AuthRelayReadScope, | |
| 230 | AuthRelayWriteScope, | |
| 231 | ]), | |
| 232 | }); | |
| 233 | if (requestedScopes === null) | |
| 234 | return yield* failEnvironmentInvalidRequest("invalid_scope"); | |
| 235 | return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | |
| 236 | args.payload.subject_token, | |
| 237 | requestedScopes, | |
| 238 | deriveAuthClientMetadata({ | |
| 239 | request, | |
| 240 | presented: { | |
| 241 | ...(args.payload.client_label ? { label: args.payload.client_label } : {}), | |
| 242 | ...(args.payload.client_device_type | |
| 243 | ? { deviceType: args.payload.client_device_type } | |
| 244 | : {}), | |
| 245 | ...(args.payload.client_os ? { os: args.payload.client_os } : {}), | |
| 246 | }, | |
| 247 | }), | |
| 248 | ); | |
| 249 | }, | |
| 250 | Effect.catchTags({ | |
| 251 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | |
| 252 | ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), | |
| 253 | ServerAuthInternalError: (error) => | |
| 254 | failEnvironmentInternal("access_token_issuance_failed", error), | |
| 255 | }), | |
| 256 | ), | |
| 257 | ) | |
| 258 | .handle( | |
| 259 | "webSocketTicket", | |
| 260 | Effect.fn("environment.auth.webSocketTicket")( | |
| 261 | function* (args) { | |
| 262 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 263 | const session = yield* EnvironmentAuthenticatedPrincipal; | |
| 264 | return yield* serverAuth.issueWebSocketTicket(session); | |
| 265 | }, | |
| 266 | Effect.catchTag("ServerAuthInternalError", (error) => | |
| 267 | failEnvironmentInternal("websocket_ticket_issuance_failed", error), | |
| 268 | ), | |
| 269 | ), | |
| 270 | ) | |
| 271 | .handle( | |
| 272 | "pairingCredential", | |
| 273 | Effect.fn("environment.auth.pairingCredential")( | |
| 274 | function* (args) { | |
| 275 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 276 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); | |
| 277 | const delegatedScopes = args.payload.scopes ?? AuthStandardClientScopes; | |
| 278 | if ( | |
| 279 | delegatedScopes.length === 0 || | |
| 280 | new Set<AuthEnvironmentScope>(delegatedScopes).size !== delegatedScopes.length | |
| 281 | ) { | |
| 282 | return yield* failEnvironmentInvalidRequest("invalid_scope"); | |
| 283 | } | |
| 284 | for (const delegatedScope of delegatedScopes) { | |
| 285 | if (!session.scopes.has(delegatedScope)) { | |
| 286 | return yield* failEnvironmentScopeRequired(delegatedScope); | |
| 287 | } | |
| 288 | } | |
| 289 | return yield* serverAuth.issuePairingCredential(args.payload); | |
| 290 | }, | |
| 291 | Effect.catchTag("ServerAuthInternalError", (error) => | |
| 292 | failEnvironmentInternal("pairing_credential_issuance_failed", error), | |
| 293 | ), | |
| 294 | ), | |
| 295 | ) | |
| 296 | .handle( | |
| 297 | "pairingLinks", | |
| 298 | Effect.fn("environment.auth.pairingLinks")( | |
| 299 | function* (args) { | |
| 300 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 301 | yield* requireEnvironmentScope(AuthAccessReadScope); | |
| 302 | return yield* serverAuth.listPairingLinks(); | |
| 303 | }, | |
| 304 | Effect.catchTag("ServerAuthInternalError", (error) => | |
| 305 | failEnvironmentInternal("pairing_links_load_failed", error), | |
| 306 | ), | |
| 307 | ), | |
| 308 | ) | |
| 309 | .handle( | |
| 310 | "revokePairingLink", | |
| 311 | Effect.fn("environment.auth.revokePairingLink")( | |
| 312 | function* (args) { | |
| 313 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 314 | yield* requireEnvironmentScope(AuthAccessWriteScope); | |
| 315 | const revoked = yield* serverAuth.revokePairingLink(args.payload.id); | |
| 316 | return { revoked }; | |
| 317 | }, | |
| 318 | Effect.catchTag("ServerAuthInternalError", (error) => | |
| 319 | failEnvironmentInternal("pairing_link_revoke_failed", error), | |
| 320 | ), | |
| 321 | ), | |
| 322 | ) | |
| 323 | .handle( | |
| 324 | "clients", | |
| 325 | Effect.fn("environment.auth.clients")( | |
| 326 | function* (args) { | |
| 327 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 328 | const session = yield* requireEnvironmentScope(AuthAccessReadScope); | |
| 329 | return yield* serverAuth.listClientSessions(session.sessionId); | |
| 330 | }, | |
| 331 | Effect.catchTag("ServerAuthInternalError", (error) => | |
| 332 | failEnvironmentInternal("client_sessions_load_failed", error), | |
| 333 | ), | |
| 334 | ), | |
| 335 | ) | |
| 336 | .handle( | |
| 337 | "revokeClient", | |
| 338 | Effect.fn("environment.auth.revokeClient")( | |
| 339 | function* (args) { | |
| 340 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 341 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); | |
| 342 | const revoked = yield* serverAuth.revokeClientSession( | |
| 343 | session.sessionId, | |
| 344 | args.payload.sessionId, | |
| 345 | ); | |
| 346 | return { revoked }; | |
| 347 | }, | |
| 348 | Effect.catchTags({ | |
| 349 | ServerAuthForbiddenOperationError: (error) => | |
| 350 | failEnvironmentOperationForbidden(error.reason), | |
| 351 | ServerAuthInternalError: (error) => | |
| 352 | failEnvironmentInternal("client_session_revoke_failed", error), | |
| 353 | }), | |
| 354 | ), | |
| 355 | ) | |
| 356 | .handle( | |
| 357 | "revokeOtherClients", | |
| 358 | Effect.fn("environment.auth.revokeOtherClients")( | |
| 359 | function* (args) { | |
| 360 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 361 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); | |
| 362 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); | |
| 363 | return { revokedCount }; | |
| 364 | }, | |
| 365 | Effect.catchTag("ServerAuthInternalError", (error) => | |
| 366 | failEnvironmentInternal("client_session_revoke_failed", error), | |
| 367 | ), | |
| 368 | ), | |
| 369 | ); | |
| 370 | }), | |
| 265 | 371 | ); |
apps/server/src/auth/utils.test.ts+28| ... | ... | @@ -23,4 +23,32 @@ describe("deriveAuthClientMetadata", () => { |
| 23 | 23 | os: "macOS", |
| 24 | 24 | }); |
| 25 | 25 | }); |
| 26 | ||
| 27 | it("applies client-presented display identity without replacing transport metadata", () => { | |
| 28 | const metadata = deriveAuthClientMetadata({ | |
| 29 | request: { | |
| 30 | headers: { | |
| 31 | "user-agent": | |
| 32 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36", | |
| 33 | }, | |
| 34 | source: { | |
| 35 | remoteAddress: "::ffff:192.168.213.72", | |
| 36 | }, | |
| 37 | } as never, | |
| 38 | presented: { | |
| 39 | label: "T3 Code Mobile", | |
| 40 | deviceType: "mobile", | |
| 41 | os: "iOS", | |
| 42 | }, | |
| 43 | }); | |
| 44 | ||
| 45 | expect(metadata).toMatchObject({ | |
| 46 | label: "T3 Code Mobile", | |
| 47 | browser: "Electron", | |
| 48 | deviceType: "mobile", | |
| 49 | ipAddress: "192.168.213.72", | |
| 50 | os: "iOS", | |
| 51 | }); | |
| 52 | expect(metadata.userAgent).toContain("Electron/36.3.2"); | |
| 53 | }); | |
| 26 | 54 | }); |
apps/server/src/auth/utils.ts+9-5| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | AuthClientMetadata, | |
| 3 | AuthClientMetadataDeviceType, | |
| 4 | AuthClientPresentationMetadata, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 3 | 7 | import * as Crypto from "node:crypto"; |
| 4 | 8 | |
| ... | ... | @@ -115,17 +119,17 @@ function readRemoteAddressFromSource(source: unknown): string | undefined { |
| 115 | 119 | |
| 116 | 120 | export function deriveAuthClientMetadata(input: { |
| 117 | 121 | readonly request: HttpServerRequest.HttpServerRequest; |
| 118 | readonly label?: string; | |
| 122 | readonly presented?: AuthClientPresentationMetadata; | |
| 119 | 123 | }): AuthClientMetadata { |
| 120 | 124 | const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); |
| 121 | 125 | const ipAddress = readRemoteAddressFromSource(input.request.source); |
| 122 | const os = inferOs(userAgent); | |
| 126 | const os = input.presented?.os ?? inferOs(userAgent); | |
| 123 | 127 | const browser = inferBrowser(userAgent); |
| 124 | 128 | return { |
| 125 | ...(input.label ? { label: input.label } : {}), | |
| 129 | ...(input.presented?.label ? { label: input.presented.label } : {}), | |
| 126 | 130 | ...(ipAddress ? { ipAddress } : {}), |
| 127 | 131 | ...(userAgent ? { userAgent } : {}), |
| 128 | deviceType: inferDeviceType(userAgent), | |
| 132 | deviceType: input.presented?.deviceType ?? inferDeviceType(userAgent), | |
| 129 | 133 | ...(os ? { os } : {}), |
| 130 | 134 | ...(browser ? { browser } : {}), |
| 131 | 135 | }; |
apps/server/src/bin.test.ts+35-15| ... | ... | @@ -6,12 +6,15 @@ import { join } from "node:path"; |
| 6 | 6 | |
| 7 | 7 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 8 | 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; | |
| 9 | 10 | import * as NetService from "@t3tools/shared/Net"; |
| 10 | 11 | import { assert, it } from "@effect/vitest"; |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| 12 | 13 | import * as Layer from "effect/Layer"; |
| 13 | 14 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; |
| 14 | 15 | import * as HttpServer from "effect/unstable/http/HttpServer"; |
| 16 | import * as HttpApi from "effect/unstable/httpapi/HttpApi"; | |
| 17 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | |
| 15 | 18 | import * as CliError from "effect/unstable/cli/CliError"; |
| 16 | 19 | import * as TestConsole from "effect/testing/TestConsole"; |
| 17 | 20 | import { Command } from "effect/unstable/cli"; |
| ... | ... | @@ -20,10 +23,7 @@ import { cli } from "./bin.ts"; |
| 20 | 23 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; |
| 21 | 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 22 | 25 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| 23 | import { | |
| 24 | orchestrationDispatchRouteLayer, | |
| 25 | orchestrationSnapshotRouteLayer, | |
| 26 | } from "./orchestration/http.ts"; | |
| 26 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; | |
| 27 | 27 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; |
| 28 | 28 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; |
| 29 | 29 | import { |
| ... | ... | @@ -31,10 +31,12 @@ import { |
| 31 | 31 | persistServerRuntimeState, |
| 32 | 32 | } from "./serverRuntimeState.ts"; |
| 33 | 33 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 34 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | |
| 35 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | |
| 34 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | |
| 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | |
| 36 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | |
| 36 | 37 | |
| 37 | 38 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 39 | class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} | |
| 38 | 40 | |
| 39 | 41 | const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args); |
| 40 | 42 | const runCliWithRuntime = (args: ReadonlyArray<string>) => |
| ... | ... | @@ -105,18 +107,18 @@ const readPersistedSnapshot = (baseDir: string) => |
| 105 | 107 | const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => |
| 106 | 108 | Effect.gen(function* () { |
| 107 | 109 | const config = yield* makeCliTestServerConfig(baseDir); |
| 108 | const routesLayer = Layer.mergeAll( | |
| 109 | orchestrationSnapshotRouteLayer, | |
| 110 | orchestrationDispatchRouteLayer, | |
| 110 | const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( | |
| 111 | Layer.provide(orchestrationHttpApiLayer), | |
| 112 | Layer.provide(environmentAuthenticatedAuthLayer), | |
| 111 | 113 | ); |
| 112 | 114 | const appLayer = HttpRouter.serve(routesLayer, { |
| 113 | 115 | disableListenLog: true, |
| 114 | 116 | disableLogger: true, |
| 115 | 117 | }).pipe( |
| 116 | 118 | Layer.provideMerge( |
| 117 | ServerAuthLive.pipe( | |
| 119 | EnvironmentAuth.layer.pipe( | |
| 118 | 120 | Layer.provideMerge(SqlitePersistenceLayerLive), |
| 119 | Layer.provide(ServerSecretStoreLive), | |
| 121 | Layer.provide(ServerSecretStore.layer), | |
| 120 | 122 | ), |
| 121 | 123 | ), |
| 122 | 124 | Layer.provideMerge(makeProjectPersistenceLayer(config)), |
| ... | ... | @@ -214,7 +216,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { |
| 214 | 216 | const issued = JSON.parse(issuedOutput.output) as { |
| 215 | 217 | readonly sessionId: string; |
| 216 | 218 | readonly token: string; |
| 217 | readonly role: string; | |
| 219 | readonly scopes: ReadonlyArray<string>; | |
| 218 | 220 | }; |
| 219 | 221 | const listedOutput = yield* captureStdout( |
| 220 | 222 | runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), |
| ... | ... | @@ -223,15 +225,33 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { |
| 223 | 225 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ |
| 224 | 226 | readonly sessionId: string; |
| 225 | 227 | readonly token?: string; |
| 226 | readonly role: string; | |
| 228 | readonly scopes: ReadonlyArray<string>; | |
| 227 | 229 | }>; |
| 228 | 230 | |
| 229 | 231 | assert.equal(typeof issued.sessionId, "string"); |
| 230 | 232 | assert.equal(typeof issued.token, "string"); |
| 231 | assert.equal(issued.role, "owner"); | |
| 233 | assert.deepEqual(issued.scopes, [ | |
| 234 | "orchestration:read", | |
| 235 | "orchestration:operate", | |
| 236 | "terminal:operate", | |
| 237 | "review:write", | |
| 238 | "relay:read", | |
| 239 | "access:read", | |
| 240 | "access:write", | |
| 241 | "relay:write", | |
| 242 | ]); | |
| 232 | 243 | assert.equal(listed.length, 1); |
| 233 | 244 | assert.equal(listed[0]?.sessionId, issued.sessionId); |
| 234 | assert.equal(listed[0]?.role, "owner"); | |
| 245 | assert.deepEqual(listed[0]?.scopes, [ | |
| 246 | "orchestration:read", | |
| 247 | "orchestration:operate", | |
| 248 | "terminal:operate", | |
| 249 | "review:write", | |
| 250 | "relay:read", | |
| 251 | "access:read", | |
| 252 | "access:write", | |
| 253 | "relay:write", | |
| 254 | ]); | |
| 235 | 255 | assert.equal("token" in (listed[0] ?? {}), false); |
| 236 | 256 | }), |
| 237 | 257 | ); |
apps/server/src/cli/auth.ts+33-34| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import { AuthSessionId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | AuthAdministrativeScopes, | |
| 3 | AuthSessionId, | |
| 4 | AuthStandardClientScopes, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import * as Console from "effect/Console"; |
| 3 | 7 | import * as Effect from "effect/Effect"; |
| 4 | 8 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -6,9 +10,8 @@ import * as Option from "effect/Option"; |
| 6 | 10 | import * as References from "effect/References"; |
| 7 | 11 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; |
| 8 | 12 | |
| 9 | import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; | |
| 10 | import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; | |
| 11 | import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; | |
| 13 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | |
| 14 | ||
| 12 | 15 | import { |
| 13 | 16 | formatIssuedPairingCredential, |
| 14 | 17 | formatIssuedSession, |
| ... | ... | @@ -23,9 +26,9 @@ import { |
| 23 | 26 | resolveCliAuthConfig, |
| 24 | 27 | } from "./config.ts"; |
| 25 | 28 | |
| 26 | const runWithAuthControlPlane = <A, E>( | |
| 29 | const runWithEnvironmentAuth = <A, E>( | |
| 27 | 30 | flags: CliAuthLocationFlags, |
| 28 | run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>, | |
| 31 | run: (environmentAuth: EnvironmentAuth.EnvironmentAuthShape) => Effect.Effect<A, E>, | |
| 29 | 32 | options?: { |
| 30 | 33 | readonly quietLogs?: boolean; |
| 31 | 34 | }, |
| ... | ... | @@ -35,11 +38,11 @@ const runWithAuthControlPlane = <A, E>( |
| 35 | 38 | const config = yield* resolveCliAuthConfig(flags, logLevel); |
| 36 | 39 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; |
| 37 | 40 | return yield* Effect.gen(function* () { |
| 38 | const authControlPlane = yield* AuthControlPlane; | |
| 39 | return yield* run(authControlPlane); | |
| 41 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 42 | return yield* run(environmentAuth); | |
| 40 | 43 | }).pipe( |
| 41 | 44 | Effect.provide( |
| 42 | Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( | |
| 45 | Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe( | |
| 43 | 46 | Layer.provide(Layer.succeed(ServerConfig, config)), |
| 44 | 47 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), |
| 45 | 48 | ), |
| ... | ... | @@ -58,11 +61,6 @@ const jsonFlag = Flag.boolean("json").pipe( |
| 58 | 61 | Flag.withDefault(false), |
| 59 | 62 | ); |
| 60 | 63 | |
| 61 | const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( | |
| 62 | Flag.withDescription("Role for the issued bearer session."), | |
| 63 | Flag.withDefault("owner"), | |
| 64 | ); | |
| 65 | ||
| 66 | 64 | const labelFlag = Flag.string("label").pipe( |
| 67 | 65 | Flag.withDescription("Optional human-readable label."), |
| 68 | 66 | Flag.optional, |
| ... | ... | @@ -92,12 +90,12 @@ const pairingCreateCommand = Command.make("create", { |
| 92 | 90 | }).pipe( |
| 93 | 91 | Command.withDescription("Issue a new client pairing token."), |
| 94 | 92 | Command.withHandler((flags) => |
| 95 | runWithAuthControlPlane( | |
| 93 | runWithEnvironmentAuth( | |
| 96 | 94 | flags, |
| 97 | (authControlPlane) => | |
| 95 | (environmentAuth) => | |
| 98 | 96 | Effect.gen(function* () { |
| 99 | const issued = yield* authControlPlane.createPairingLink({ | |
| 100 | role: "client", | |
| 97 | const issued = yield* environmentAuth.createPairingLink({ | |
| 98 | scopes: AuthStandardClientScopes, | |
| 101 | 99 | subject: "one-time-token", |
| 102 | 100 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), |
| 103 | 101 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), |
| ... | ... | @@ -121,11 +119,13 @@ const pairingListCommand = Command.make("list", { |
| 121 | 119 | }).pipe( |
| 122 | 120 | Command.withDescription("List active client pairing tokens without revealing their secrets."), |
| 123 | 121 | Command.withHandler((flags) => |
| 124 | runWithAuthControlPlane( | |
| 122 | runWithEnvironmentAuth( | |
| 125 | 123 | flags, |
| 126 | (authControlPlane) => | |
| 124 | (environmentAuth) => | |
| 127 | 125 | Effect.gen(function* () { |
| 128 | const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 126 | const pairingLinks = yield* environmentAuth.listPairingLinks({ | |
| 127 | excludeSubjects: [EnvironmentAuth.INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT], | |
| 128 | }); | |
| 129 | 129 | yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); |
| 130 | 130 | }), |
| 131 | 131 | { |
| ... | ... | @@ -141,9 +141,9 @@ const pairingRevokeCommand = Command.make("revoke", { |
| 141 | 141 | }).pipe( |
| 142 | 142 | Command.withDescription("Revoke an active client pairing token."), |
| 143 | 143 | Command.withHandler((flags) => |
| 144 | runWithAuthControlPlane(flags, (authControlPlane) => | |
| 144 | runWithEnvironmentAuth(flags, (environmentAuth) => | |
| 145 | 145 | Effect.gen(function* () { |
| 146 | const revoked = yield* authControlPlane.revokePairingLink(flags.id); | |
| 146 | const revoked = yield* environmentAuth.revokePairingLink(flags.id); | |
| 147 | 147 | yield* Console.log( |
| 148 | 148 | revoked |
| 149 | 149 | ? `Revoked pairing credential ${flags.id}.\n` |
| ... | ... | @@ -162,20 +162,19 @@ const pairingCommand = Command.make("pairing").pipe( |
| 162 | 162 | const sessionIssueCommand = Command.make("issue", { |
| 163 | 163 | ...authLocationFlags, |
| 164 | 164 | ttl: ttlFlag, |
| 165 | role: sessionRoleFlag, | |
| 166 | 165 | label: labelFlag, |
| 167 | 166 | subject: subjectFlag, |
| 168 | 167 | tokenOnly: tokenOnlyFlag, |
| 169 | 168 | json: jsonFlag, |
| 170 | 169 | }).pipe( |
| 171 | Command.withDescription("Issue a bearer session token for headless or remote clients."), | |
| 170 | Command.withDescription("Issue a scoped bearer access token for headless or remote clients."), | |
| 172 | 171 | Command.withHandler((flags) => |
| 173 | runWithAuthControlPlane( | |
| 172 | runWithEnvironmentAuth( | |
| 174 | 173 | flags, |
| 175 | (authControlPlane) => | |
| 174 | (environmentAuth) => | |
| 176 | 175 | Effect.gen(function* () { |
| 177 | const issued = yield* authControlPlane.issueSession({ | |
| 178 | role: flags.role, | |
| 176 | const issued = yield* environmentAuth.issueSession({ | |
| 177 | scopes: AuthAdministrativeScopes, | |
| 179 | 178 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), |
| 180 | 179 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), |
| 181 | 180 | ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), |
| ... | ... | @@ -200,11 +199,11 @@ const sessionListCommand = Command.make("list", { |
| 200 | 199 | }).pipe( |
| 201 | 200 | Command.withDescription("List active sessions without revealing bearer tokens."), |
| 202 | 201 | Command.withHandler((flags) => |
| 203 | runWithAuthControlPlane( | |
| 202 | runWithEnvironmentAuth( | |
| 204 | 203 | flags, |
| 205 | (authControlPlane) => | |
| 204 | (environmentAuth) => | |
| 206 | 205 | Effect.gen(function* () { |
| 207 | const sessions = yield* authControlPlane.listSessions(); | |
| 206 | const sessions = yield* environmentAuth.listSessions(); | |
| 208 | 207 | yield* Console.log(formatSessionList(sessions, { json: flags.json })); |
| 209 | 208 | }), |
| 210 | 209 | { |
| ... | ... | @@ -223,9 +222,9 @@ const sessionRevokeCommand = Command.make("revoke", { |
| 223 | 222 | }).pipe( |
| 224 | 223 | Command.withDescription("Revoke an active session."), |
| 225 | 224 | Command.withHandler((flags) => |
| 226 | runWithAuthControlPlane(flags, (authControlPlane) => | |
| 225 | runWithEnvironmentAuth(flags, (environmentAuth) => | |
| 227 | 226 | Effect.gen(function* () { |
| 228 | const revoked = yield* authControlPlane.revokeSession(flags.sessionId); | |
| 227 | const revoked = yield* environmentAuth.revokeSession(flags.sessionId); | |
| 229 | 228 | yield* Console.log( |
| 230 | 229 | revoked |
| 231 | 230 | ? `Revoked session ${flags.sessionId}.\n` |
apps/server/src/cli/project.ts+61-77| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import { |
| 2 | 2 | CommandId, |
| 3 | OrchestrationReadModel, | |
| 3 | AuthAdministrativeScopes, | |
| 4 | EnvironmentHttpApi, | |
| 5 | EnvironmentHttpCommonError, | |
| 6 | type OrchestrationReadModel, | |
| 4 | 7 | ProjectId, |
| 5 | 8 | type ClientOrchestrationCommand, |
| 6 | 9 | } from "@t3tools/contracts"; |
| ... | ... | @@ -18,16 +21,11 @@ import * as Path from "effect/Path"; |
| 18 | 21 | import * as References from "effect/References"; |
| 19 | 22 | import * as Schema from "effect/Schema"; |
| 20 | 23 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; |
| 21 | import { | |
| 22 | FetchHttpClient, | |
| 23 | HttpClient, | |
| 24 | HttpClientRequest, | |
| 25 | HttpClientResponse, | |
| 26 | } from "effect/unstable/http"; | |
| 27 | ||
| 28 | import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; | |
| 29 | import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; | |
| 30 | import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; | |
| 24 | import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; | |
| 25 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | |
| 26 | ||
| 27 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | |
| 28 | ||
| 31 | 29 | import { ServerConfig, type ServerConfigShape } from "../config.ts"; |
| 32 | 30 | import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; |
| 33 | 31 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| ... | ... | @@ -78,50 +76,53 @@ const ProjectCliRuntimeLive = Layer.mergeAll( |
| 78 | 76 | ); |
| 79 | 77 | |
| 80 | 78 | const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); |
| 81 | const OrchestrationHttpErrorResponse = Schema.Struct({ | |
| 82 | error: Schema.String, | |
| 83 | }); | |
| 84 | ||
| 79 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); | |
| 85 | 80 | const withProjectCliSessionToken = <A, E, R>( |
| 86 | authControlPlane: AuthControlPlaneShape, | |
| 81 | environmentAuth: EnvironmentAuth.EnvironmentAuthShape, | |
| 87 | 82 | run: (token: string) => Effect.Effect<A, E, R>, |
| 88 | 83 | ) => |
| 89 | 84 | Effect.acquireUseRelease( |
| 90 | authControlPlane.issueSession({ | |
| 91 | role: "owner", | |
| 85 | environmentAuth.issueSession({ | |
| 86 | scopes: AuthAdministrativeScopes, | |
| 92 | 87 | label: "t3 project cli", |
| 93 | 88 | }), |
| 94 | 89 | (issued) => run(issued.token), |
| 95 | (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), | |
| 90 | (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), | |
| 96 | 91 | ); |
| 97 | 92 | |
| 98 | 93 | const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 99 | 94 | effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); |
| 100 | 95 | |
| 101 | const runLiveServerRequest = <A, E extends Error, R>( | |
| 102 | request: HttpClientRequest.HttpClientRequest, | |
| 103 | handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>, | |
| 104 | ) => | |
| 105 | Effect.gen(function* () { | |
| 106 | const httpClient = yield* HttpClient.HttpClient; | |
| 107 | const response = yield* httpClient.execute(request); | |
| 108 | return yield* handle(response); | |
| 109 | }).pipe(withProjectCliLiveServerTimeout); | |
| 110 | ||
| 111 | const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => | |
| 112 | HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); | |
| 113 | ||
| 114 | const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => | |
| 115 | HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( | |
| 116 | Effect.map((body) => body.error), | |
| 117 | Effect.catch(() => Effect.succeed(null)), | |
| 118 | Effect.map((body) => { | |
| 119 | if (typeof body === "string" && body.trim().length > 0) { | |
| 120 | return body; | |
| 121 | } | |
| 122 | return `Server request failed with status ${response.status}.`; | |
| 96 | const failLiveServerRequest = (cause: unknown) => { | |
| 97 | if (isEnvironmentHttpCommonError(cause)) { | |
| 98 | return Effect.fail( | |
| 99 | new ProjectCommandError({ | |
| 100 | message: `Server request failed (${cause.code}, trace ${cause.traceId}).`, | |
| 101 | }), | |
| 102 | ); | |
| 103 | } | |
| 104 | if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { | |
| 105 | return Effect.fail( | |
| 106 | new ProjectCommandError({ | |
| 107 | message: `Server request failed with undeclared status ${cause.response.status}.`, | |
| 108 | }), | |
| 109 | ); | |
| 110 | } | |
| 111 | return Effect.fail( | |
| 112 | new ProjectCommandError({ | |
| 113 | message: `Failed to call running server: ${String(cause)}.`, | |
| 123 | 114 | }), |
| 124 | 115 | ); |
| 116 | }; | |
| 117 | ||
| 118 | const makeLiveServerClient = (origin: string) => | |
| 119 | Effect.gen(function* () { | |
| 120 | const httpClient = yield* HttpClient.HttpClient; | |
| 121 | return yield* HttpApiClient.makeWith(EnvironmentHttpApi, { | |
| 122 | baseUrl: origin, | |
| 123 | httpClient, | |
| 124 | }); | |
| 125 | }); | |
| 125 | 126 | |
| 126 | 127 | const normalizeWorkspaceRootForProjectCommand = Effect.fn( |
| 127 | 128 | "normalizeWorkspaceRootForProjectCommand", |
| ... | ... | @@ -193,42 +194,25 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( |
| 193 | 194 | }); |
| 194 | 195 | |
| 195 | 196 | const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => |
| 196 | runLiveServerRequest( | |
| 197 | HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( | |
| 198 | HttpClientRequest.acceptJson, | |
| 199 | HttpClientRequest.bearerToken(bearerToken), | |
| 200 | ), | |
| 201 | HttpClientResponse.matchStatus({ | |
| 202 | "2xx": decodeOrchestrationReadModelResponse, | |
| 203 | orElse: (response) => | |
| 204 | readErrorMessageFromResponse(response).pipe( | |
| 205 | Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))), | |
| 206 | ), | |
| 207 | }), | |
| 208 | ); | |
| 197 | Effect.gen(function* () { | |
| 198 | const client = yield* makeLiveServerClient(origin); | |
| 199 | return yield* client.orchestration.snapshot({ | |
| 200 | headers: { authorization: `Bearer ${bearerToken}` }, | |
| 201 | }); | |
| 202 | }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); | |
| 209 | 203 | |
| 210 | 204 | const dispatchLiveOrchestrationCommand = ( |
| 211 | 205 | origin: string, |
| 212 | 206 | bearerToken: string, |
| 213 | 207 | command: ProjectCliDispatchCommand, |
| 214 | 208 | ) => |
| 215 | HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( | |
| 216 | HttpClientRequest.acceptJson, | |
| 217 | HttpClientRequest.bearerToken(bearerToken), | |
| 218 | HttpClientRequest.bodyJson(command), | |
| 219 | Effect.flatMap((request) => | |
| 220 | runLiveServerRequest( | |
| 221 | request, | |
| 222 | HttpClientResponse.matchStatus({ | |
| 223 | "2xx": () => Effect.void, | |
| 224 | orElse: (response) => | |
| 225 | readErrorMessageFromResponse(response).pipe( | |
| 226 | Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))), | |
| 227 | ), | |
| 228 | }), | |
| 229 | ), | |
| 230 | ), | |
| 231 | ); | |
| 209 | Effect.gen(function* () { | |
| 210 | const client = yield* makeLiveServerClient(origin); | |
| 211 | yield* client.orchestration.dispatch({ | |
| 212 | headers: { authorization: `Bearer ${bearerToken}` }, | |
| 213 | payload: command, | |
| 214 | } as Parameters<typeof client.orchestration.dispatch>[0]); | |
| 215 | }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); | |
| 232 | 216 | |
| 233 | 217 | const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { |
| 234 | 218 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| ... | ... | @@ -236,13 +220,13 @@ const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { |
| 236 | 220 | }); |
| 237 | 221 | |
| 238 | 222 | const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( |
| 239 | function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { | |
| 223 | function* (environmentAuth: EnvironmentAuth.EnvironmentAuthShape, config: ServerConfigShape) { | |
| 240 | 224 | const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); |
| 241 | 225 | if (Option.isNone(runtimeState)) { |
| 242 | 226 | return Option.none<{ readonly origin: string }>(); |
| 243 | 227 | } |
| 244 | 228 | |
| 245 | const attempt = withProjectCliSessionToken(authControlPlane, (token) => | |
| 229 | const attempt = withProjectCliSessionToken(environmentAuth, (token) => | |
| 246 | 230 | fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( |
| 247 | 231 | Effect.as({ |
| 248 | 232 | origin: runtimeState.value.origin, |
| ... | ... | @@ -279,11 +263,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( |
| 279 | 263 | const minimumLogLevel = config.logLevel; |
| 280 | 264 | |
| 281 | 265 | return yield* Effect.gen(function* () { |
| 282 | const authControlPlane = yield* AuthControlPlane; | |
| 283 | const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); | |
| 266 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 267 | const liveMode = yield* tryResolveLiveProjectExecutionMode(environmentAuth, config); | |
| 284 | 268 | |
| 285 | 269 | if (Option.isSome(liveMode)) { |
| 286 | return yield* withProjectCliSessionToken(authControlPlane, (token) => | |
| 270 | return yield* withProjectCliSessionToken(environmentAuth, (token) => | |
| 287 | 271 | Effect.gen(function* () { |
| 288 | 272 | const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); |
| 289 | 273 | const output = yield* run({ |
| ... | ... | @@ -314,7 +298,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( |
| 314 | 298 | }).pipe(Effect.provide(offlineRuntimeLayer)); |
| 315 | 299 | }).pipe( |
| 316 | 300 | Effect.provide( |
| 317 | Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( | |
| 301 | Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).pipe( | |
| 318 | 302 | Layer.provideMerge(FetchHttpClient.layer), |
| 319 | 303 | Layer.provide(Layer.succeed(ServerConfig, config)), |
| 320 | 304 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), |
apps/server/src/cliAuthFormat.test.ts+6-6| ... | ... | @@ -13,7 +13,7 @@ it("formats issued pairing credentials with the secret and optional pair URL", ( |
| 13 | 13 | { |
| 14 | 14 | id: "pairing-1", |
| 15 | 15 | credential: "secret-pairing-token", |
| 16 | role: "client", | |
| 16 | scopes: ["orchestration:read"], | |
| 17 | 17 | subject: "one-time-token", |
| 18 | 18 | createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), |
| 19 | 19 | expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), |
| ... | ... | @@ -33,7 +33,7 @@ it("formats pairing listings without exposing the secret token", () => { |
| 33 | 33 | credential: "secret-pairing-token", |
| 34 | 34 | subject: "one-time-token", |
| 35 | 35 | label: "Phone", |
| 36 | role: "client", | |
| 36 | scopes: ["orchestration:read"], | |
| 37 | 37 | createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), |
| 38 | 38 | expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), |
| 39 | 39 | }, |
| ... | ... | @@ -50,8 +50,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings |
| 50 | 50 | { |
| 51 | 51 | sessionId: "session-1" as never, |
| 52 | 52 | token: "secret-session-token", |
| 53 | method: "bearer-session-token", | |
| 54 | role: "owner", | |
| 53 | method: "bearer-access-token", | |
| 54 | scopes: ["orchestration:read", "access:write"], | |
| 55 | 55 | subject: "cli-issued-session", |
| 56 | 56 | client: { |
| 57 | 57 | label: "deploy-bot", |
| ... | ... | @@ -66,8 +66,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings |
| 66 | 66 | [ |
| 67 | 67 | { |
| 68 | 68 | sessionId: "session-1" as never, |
| 69 | method: "bearer-session-token", | |
| 70 | role: "owner", | |
| 69 | method: "bearer-access-token", | |
| 70 | scopes: ["orchestration:read", "access:write"], | |
| 71 | 71 | subject: "cli-issued-session", |
| 72 | 72 | client: { |
| 73 | 73 | label: "deploy-bot", |
apps/server/src/cliAuthFormat.ts+10-8| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; |
| 2 | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | 3 | |
| 4 | import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; | |
| 4 | import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts"; | |
| 5 | 5 | |
| 6 | 6 | const newline = "\n"; |
| 7 | 7 | |
| ... | ... | @@ -47,7 +47,7 @@ export function formatIssuedPairingCredential( |
| 47 | 47 | id: credential.id, |
| 48 | 48 | credential: credential.credential, |
| 49 | 49 | ...(credential.label ? { label: credential.label } : {}), |
| 50 | role: credential.role, | |
| 50 | scopes: credential.scopes, | |
| 51 | 51 | expiresAt: toIsoString(credential.expiresAt), |
| 52 | 52 | ...(pairUrl ? { pairUrl } : {}), |
| 53 | 53 | }, |
| ... | ... | @@ -77,7 +77,7 @@ export function formatPairingCredentialList( |
| 77 | 77 | credentials.map((credential) => ({ |
| 78 | 78 | id: credential.id, |
| 79 | 79 | ...(credential.label ? { label: credential.label } : {}), |
| 80 | role: credential.role, | |
| 80 | scopes: credential.scopes, | |
| 81 | 81 | createdAt: toIsoString(credential.createdAt), |
| 82 | 82 | expiresAt: toIsoString(credential.expiresAt), |
| 83 | 83 | })), |
| ... | ... | @@ -95,7 +95,7 @@ export function formatPairingCredentialList( |
| 95 | 95 | .map((credential) => |
| 96 | 96 | [ |
| 97 | 97 | `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, |
| 98 | ` role: ${credential.role}`, | |
| 98 | ` scopes: ${credential.scopes.join(" ")}`, | |
| 99 | 99 | ` created: ${toIsoString(credential.createdAt)}`, |
| 100 | 100 | ` expires: ${toIsoString(credential.expiresAt)}`, |
| 101 | 101 | ].join(newline), |
| ... | ... | @@ -121,7 +121,7 @@ export function formatIssuedSession( |
| 121 | 121 | sessionId: session.sessionId, |
| 122 | 122 | token: session.token, |
| 123 | 123 | method: session.method, |
| 124 | role: session.role, | |
| 124 | scopes: session.scopes, | |
| 125 | 125 | subject: session.subject, |
| 126 | 126 | client: session.client, |
| 127 | 127 | expiresAt: toIsoString(session.expiresAt), |
| ... | ... | @@ -133,7 +133,8 @@ export function formatIssuedSession( |
| 133 | 133 | |
| 134 | 134 | return ( |
| 135 | 135 | [ |
| 136 | `Issued ${session.role} bearer session ${session.sessionId}.`, | |
| 136 | `Issued bearer access token ${session.sessionId}.`, | |
| 137 | `Scopes: ${session.scopes.join(" ")}`, | |
| 137 | 138 | `Token: ${session.token}`, |
| 138 | 139 | `Subject: ${session.subject}`, |
| 139 | 140 | `Client: ${formatClientMetadata(session.client)}`, |
| ... | ... | @@ -153,7 +154,7 @@ export function formatSessionList( |
| 153 | 154 | sessions.map((session) => ({ |
| 154 | 155 | sessionId: session.sessionId, |
| 155 | 156 | method: session.method, |
| 156 | role: session.role, | |
| 157 | scopes: session.scopes, | |
| 157 | 158 | subject: session.subject, |
| 158 | 159 | client: session.client, |
| 159 | 160 | connected: session.connected, |
| ... | ... | @@ -174,7 +175,8 @@ export function formatSessionList( |
| 174 | 175 | sessions |
| 175 | 176 | .map((session) => |
| 176 | 177 | [ |
| 177 | `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`, | |
| 178 | `${session.sessionId}${session.connected ? " connected" : ""}`, | |
| 179 | ` scopes: ${session.scopes.join(" ")}`, | |
| 178 | 180 | ` method: ${session.method}`, |
| 179 | 181 | ` subject: ${session.subject}`, |
| 180 | 182 | ` client: ${formatClientMetadata(session.client)}`, |
apps/server/src/http.ts+68-30| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | 1 | import Mime from "@effect/platform-node/Mime"; |
| 2 | import { | |
| 3 | AuthOrchestrationOperateScope, | |
| 4 | AuthOrchestrationReadScope, | |
| 5 | EnvironmentHttpApi, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 3 | 8 | import * as Data from "effect/Data"; |
| 4 | 9 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -13,7 +18,9 @@ import { |
| 13 | 18 | HttpRouter, |
| 14 | 19 | HttpServerResponse, |
| 15 | 20 | HttpServerRequest, |
| 21 | HttpServerRespondable, | |
| 16 | 22 | } from "effect/unstable/http"; |
| 23 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | |
| 17 | 24 | import { OtlpTracer } from "effect/unstable/observability"; |
| 18 | 25 | |
| 19 | 26 | import { |
| ... | ... | @@ -25,14 +32,15 @@ import { resolveAttachmentPathById } from "./attachmentStore.ts"; |
| 25 | 32 | import { resolveStaticDir, ServerConfig } from "./config.ts"; |
| 26 | 33 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; |
| 27 | 34 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; |
| 28 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | |
| 29 | import { respondToAuthError } from "./auth/http.ts"; | |
| 30 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | |
| 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | |
| 31 | 36 | import { |
| 32 | browserApiCorsAllowedHeaders, | |
| 33 | browserApiCorsAllowedMethods, | |
| 34 | browserApiCorsHeaders, | |
| 35 | } from "./httpCors.ts"; | |
| 37 | annotateEnvironmentRequest, | |
| 38 | failEnvironmentScopeRequired, | |
| 39 | failEnvironmentAuthInvalid, | |
| 40 | failEnvironmentInternal, | |
| 41 | } from "./auth/http.ts"; | |
| 42 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | |
| 43 | import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; | |
| 36 | 44 | |
| 37 | 45 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; |
| 38 | 46 | const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; |
| ... | ... | @@ -40,8 +48,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 40 | 48 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 41 | 49 | |
| 42 | 50 | export const browserApiCorsLayer = HttpRouter.cors({ |
| 43 | allowedMethods: [...browserApiCorsAllowedMethods], | |
| 44 | allowedHeaders: [...browserApiCorsAllowedHeaders], | |
| 51 | allowedMethods: browserApiCorsAllowedMethods, | |
| 52 | allowedHeaders: browserApiCorsAllowedHeaders, | |
| 45 | 53 | maxAge: 600, |
| 46 | 54 | }); |
| 47 | 55 | |
| ... | ... | @@ -61,23 +69,35 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { |
| 61 | 69 | return redirectUrl.toString(); |
| 62 | 70 | } |
| 63 | 71 | |
| 64 | const requireAuthenticatedRequest = Effect.gen(function* () { | |
| 65 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 66 | const serverAuth = yield* ServerAuth; | |
| 67 | yield* serverAuth.authenticateHttpRequest(request); | |
| 68 | }); | |
| 69 | ||
| 70 | export const serverEnvironmentRouteLayer = HttpRouter.add( | |
| 71 | "GET", | |
| 72 | "/.well-known/t3/environment", | |
| 72 | const authenticateRawRouteWithScope = ( | |
| 73 | scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, | |
| 74 | ) => | |
| 73 | 75 | Effect.gen(function* () { |
| 74 | const descriptor = yield* Effect.service(ServerEnvironment).pipe( | |
| 75 | Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor), | |
| 76 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 77 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 78 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( | |
| 79 | Effect.catchTags({ | |
| 80 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | |
| 81 | ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), | |
| 82 | }), | |
| 83 | ); | |
| 84 | if (!session.scopes.includes(scope)) { | |
| 85 | return yield* failEnvironmentScopeRequired(scope); | |
| 86 | } | |
| 87 | }); | |
| 88 | ||
| 89 | export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( | |
| 90 | EnvironmentHttpApi, | |
| 91 | "metadata", | |
| 92 | Effect.fnUntraced(function* (handlers) { | |
| 93 | const serverEnvironment = yield* ServerEnvironment; | |
| 94 | return handlers.handle( | |
| 95 | "descriptor", | |
| 96 | Effect.fn("environment.metadata.descriptor")(function* (args) { | |
| 97 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 98 | return yield* serverEnvironment.getDescriptor; | |
| 99 | }), | |
| 76 | 100 | ); |
| 77 | return HttpServerResponse.jsonUnsafe(descriptor, { | |
| 78 | status: 200, | |
| 79 | headers: browserApiCorsHeaders, | |
| 80 | }); | |
| 81 | 101 | }), |
| 82 | 102 | ); |
| 83 | 103 | |
| ... | ... | @@ -90,7 +110,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 90 | 110 | "POST", |
| 91 | 111 | OTLP_TRACES_PROXY_PATH, |
| 92 | 112 | Effect.gen(function* () { |
| 93 | yield* requireAuthenticatedRequest; | |
| 113 | yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope); | |
| 94 | 114 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 95 | 115 | const config = yield* ServerConfig; |
| 96 | 116 | const otlpTracesUrl = config.otlpTracesUrl; |
| ... | ... | @@ -132,14 +152,20 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 132 | 152 | Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), |
| 133 | 153 | ), |
| 134 | 154 | ); |
| 135 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 155 | }).pipe( | |
| 156 | Effect.catchTags({ | |
| 157 | EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, | |
| 158 | EnvironmentInternalError: HttpServerRespondable.toResponse, | |
| 159 | EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, | |
| 160 | }), | |
| 161 | ), | |
| 136 | 162 | ); |
| 137 | 163 | |
| 138 | 164 | export const attachmentsRouteLayer = HttpRouter.add( |
| 139 | 165 | "GET", |
| 140 | 166 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, |
| 141 | 167 | Effect.gen(function* () { |
| 142 | yield* requireAuthenticatedRequest; | |
| 168 | yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); | |
| 143 | 169 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 144 | 170 | const url = HttpServerRequest.toURL(request); |
| 145 | 171 | if (Option.isNone(url)) { |
| ... | ... | @@ -188,14 +214,20 @@ export const attachmentsRouteLayer = HttpRouter.add( |
| 188 | 214 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), |
| 189 | 215 | ), |
| 190 | 216 | ); |
| 191 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 217 | }).pipe( | |
| 218 | Effect.catchTags({ | |
| 219 | EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, | |
| 220 | EnvironmentInternalError: HttpServerRespondable.toResponse, | |
| 221 | EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, | |
| 222 | }), | |
| 223 | ), | |
| 192 | 224 | ); |
| 193 | 225 | |
| 194 | 226 | export const projectFaviconRouteLayer = HttpRouter.add( |
| 195 | 227 | "GET", |
| 196 | 228 | "/api/project-favicon", |
| 197 | 229 | Effect.gen(function* () { |
| 198 | yield* requireAuthenticatedRequest; | |
| 230 | yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); | |
| 199 | 231 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 200 | 232 | const url = HttpServerRequest.toURL(request); |
| 201 | 233 | if (Option.isNone(url)) { |
| ... | ... | @@ -229,7 +261,13 @@ export const projectFaviconRouteLayer = HttpRouter.add( |
| 229 | 261 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), |
| 230 | 262 | ), |
| 231 | 263 | ); |
| 232 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 264 | }).pipe( | |
| 265 | Effect.catchTags({ | |
| 266 | EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, | |
| 267 | EnvironmentInternalError: HttpServerRespondable.toResponse, | |
| 268 | EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, | |
| 269 | }), | |
| 270 | ), | |
| 233 | 271 | ); |
| 234 | 272 | |
| 235 | 273 | export const staticAndDevRouteLayer = HttpRouter.add( |
apps/server/src/orchestration/http.ts+48-84| ... | ... | @@ -1,95 +1,59 @@ |
| 1 | 1 | import { |
| 2 | ClientOrchestrationCommand, | |
| 3 | OrchestrationDispatchCommandError, | |
| 4 | OrchestrationGetSnapshotError, | |
| 5 | type OrchestrationReadModel, | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthOrchestrationReadScope, | |
| 4 | EnvironmentHttpApi, | |
| 6 | 5 | } from "@t3tools/contracts"; |
| 7 | 6 | import * as Effect from "effect/Effect"; |
| 8 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 7 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | |
| 9 | 8 | |
| 10 | import { ServerAuth } from "../auth/Services/ServerAuth.ts"; | |
| 11 | 9 | import { normalizeDispatchCommand } from "./Normalizer.ts"; |
| 10 | import { | |
| 11 | annotateEnvironmentRequest, | |
| 12 | failEnvironmentInternal, | |
| 13 | failEnvironmentInvalidRequest, | |
| 14 | requireEnvironmentScope, | |
| 15 | } from "../auth/http.ts"; | |
| 12 | 16 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 13 | 17 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; |
| 14 | 18 | |
| 15 | const respondToOrchestrationHttpError = ( | |
| 16 | error: OrchestrationDispatchCommandError | OrchestrationGetSnapshotError, | |
| 17 | ) => | |
| 18 | Effect.gen(function* () { | |
| 19 | if (error._tag === "OrchestrationGetSnapshotError") { | |
| 20 | yield* Effect.logError("orchestration http route failed", { | |
| 21 | message: error.message, | |
| 22 | cause: error.cause, | |
| 23 | }); | |
| 24 | return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 }); | |
| 25 | } | |
| 26 | ||
| 27 | return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 400 }); | |
| 28 | }); | |
| 29 | ||
| 30 | const authenticateOwnerSession = Effect.gen(function* () { | |
| 31 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 32 | const serverAuth = yield* ServerAuth; | |
| 33 | const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 34 | if (session.role !== "owner") { | |
| 35 | return yield* new OrchestrationDispatchCommandError({ | |
| 36 | message: "Only owner sessions can manage projects.", | |
| 37 | }); | |
| 38 | } | |
| 39 | return session; | |
| 40 | }); | |
| 41 | ||
| 42 | export const orchestrationSnapshotRouteLayer = HttpRouter.add( | |
| 43 | "GET", | |
| 44 | "/api/orchestration/snapshot", | |
| 45 | Effect.gen(function* () { | |
| 46 | yield* authenticateOwnerSession; | |
| 19 | export const orchestrationHttpApiLayer = HttpApiBuilder.group( | |
| 20 | EnvironmentHttpApi, | |
| 21 | "orchestration", | |
| 22 | Effect.fnUntraced(function* (handlers) { | |
| 47 | 23 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 48 | const snapshot = yield* projectionSnapshotQuery.getSnapshot().pipe( | |
| 49 | Effect.mapError( | |
| 50 | (cause) => | |
| 51 | new OrchestrationGetSnapshotError({ | |
| 52 | message: "Failed to load orchestration snapshot.", | |
| 53 | cause, | |
| 54 | }), | |
| 55 | ), | |
| 56 | ); | |
| 57 | return HttpServerResponse.jsonUnsafe(snapshot satisfies OrchestrationReadModel, { | |
| 58 | status: 200, | |
| 59 | }); | |
| 60 | }).pipe( | |
| 61 | Effect.catchTags({ | |
| 62 | OrchestrationDispatchCommandError: respondToOrchestrationHttpError, | |
| 63 | OrchestrationGetSnapshotError: respondToOrchestrationHttpError, | |
| 64 | }), | |
| 65 | ), | |
| 66 | ); | |
| 67 | ||
| 68 | export const orchestrationDispatchRouteLayer = HttpRouter.add( | |
| 69 | "POST", | |
| 70 | "/api/orchestration/dispatch", | |
| 71 | Effect.gen(function* () { | |
| 72 | yield* authenticateOwnerSession; | |
| 73 | 24 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 74 | const command = yield* HttpServerRequest.schemaBodyJson(ClientOrchestrationCommand).pipe( | |
| 75 | Effect.mapError( | |
| 76 | (cause) => | |
| 77 | new OrchestrationDispatchCommandError({ | |
| 78 | message: "Invalid orchestration command payload.", | |
| 79 | cause, | |
| 80 | }), | |
| 81 | ), | |
| 82 | ); | |
| 83 | const normalizedCommand = yield* normalizeDispatchCommand(command); | |
| 84 | const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe( | |
| 85 | Effect.mapError( | |
| 86 | (cause) => | |
| 87 | new OrchestrationDispatchCommandError({ | |
| 88 | message: "Failed to dispatch orchestration command.", | |
| 89 | cause, | |
| 90 | }), | |
| 91 | ), | |
| 92 | ); | |
| 93 | return HttpServerResponse.jsonUnsafe(result, { status: 200 }); | |
| 94 | }).pipe(Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError)), | |
| 25 | ||
| 26 | return handlers | |
| 27 | .handle( | |
| 28 | "snapshot", | |
| 29 | Effect.fn("environment.orchestration.snapshot")(function* (args) { | |
| 30 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 31 | yield* requireEnvironmentScope(AuthOrchestrationReadScope); | |
| 32 | return yield* projectionSnapshotQuery | |
| 33 | .getSnapshot() | |
| 34 | .pipe( | |
| 35 | Effect.catch((cause) => | |
| 36 | failEnvironmentInternal("orchestration_snapshot_failed", cause), | |
| 37 | ), | |
| 38 | ); | |
| 39 | }), | |
| 40 | ) | |
| 41 | .handle( | |
| 42 | "dispatch", | |
| 43 | Effect.fn("environment.orchestration.dispatch")(function* (args) { | |
| 44 | yield* annotateEnvironmentRequest(args.endpoint.name); | |
| 45 | yield* requireEnvironmentScope(AuthOrchestrationOperateScope); | |
| 46 | const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( | |
| 47 | Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), | |
| 48 | ); | |
| 49 | return yield* orchestrationEngine | |
| 50 | .dispatch(normalizedCommand) | |
| 51 | .pipe( | |
| 52 | Effect.catch((cause) => | |
| 53 | failEnvironmentInternal("orchestration_dispatch_failed", cause), | |
| 54 | ), | |
| 55 | ); | |
| 56 | }), | |
| 57 | ); | |
| 58 | }), | |
| 95 | 59 | ); |
apps/server/src/persistence/Layers/AuthPairingLinks.ts+5-5| ... | ... | @@ -38,7 +38,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 38 | 38 | id, |
| 39 | 39 | credential, |
| 40 | 40 | method, |
| 41 | role, | |
| 41 | scopes, | |
| 42 | 42 | subject, |
| 43 | 43 | label, |
| 44 | 44 | created_at, |
| ... | ... | @@ -50,7 +50,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 50 | 50 | ${input.id}, |
| 51 | 51 | ${input.credential}, |
| 52 | 52 | ${input.method}, |
| 53 | ${input.role}, | |
| 53 | ${JSON.stringify(input.scopes)}, | |
| 54 | 54 | ${input.subject}, |
| 55 | 55 | ${input.label}, |
| 56 | 56 | ${input.createdAt}, |
| ... | ... | @@ -76,7 +76,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 76 | 76 | id AS "id", |
| 77 | 77 | credential AS "credential", |
| 78 | 78 | method AS "method", |
| 79 | role AS "role", | |
| 79 | scopes AS "scopes", | |
| 80 | 80 | subject AS "subject", |
| 81 | 81 | label AS "label", |
| 82 | 82 | created_at AS "createdAt", |
| ... | ... | @@ -95,7 +95,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 95 | 95 | id AS "id", |
| 96 | 96 | credential AS "credential", |
| 97 | 97 | method AS "method", |
| 98 | role AS "role", | |
| 98 | scopes AS "scopes", | |
| 99 | 99 | subject AS "subject", |
| 100 | 100 | label AS "label", |
| 101 | 101 | created_at AS "createdAt", |
| ... | ... | @@ -133,7 +133,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 133 | 133 | id AS "id", |
| 134 | 134 | credential AS "credential", |
| 135 | 135 | method AS "method", |
| 136 | role AS "role", | |
| 136 | scopes AS "scopes", | |
| 137 | 137 | subject AS "subject", |
| 138 | 138 | label AS "label", |
| 139 | 139 | created_at AS "createdAt", |
apps/server/src/persistence/Layers/AuthSessions.ts+8-8| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { AuthSessionId } from "@t3tools/contracts"; | |
| 1 | import { AuthEnvironmentScopes, AuthSessionId, ServerAuthSessionMethod } from "@t3tools/contracts"; | |
| 2 | 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 3 | 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -26,8 +26,8 @@ import { |
| 26 | 26 | const AuthSessionDbRow = Schema.Struct({ |
| 27 | 27 | sessionId: AuthSessionId, |
| 28 | 28 | subject: Schema.String, |
| 29 | role: Schema.Literals(["owner", "client"]), | |
| 30 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 29 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), | |
| 30 | method: ServerAuthSessionMethod, | |
| 31 | 31 | clientLabel: Schema.NullOr(Schema.String), |
| 32 | 32 | clientIpAddress: Schema.NullOr(Schema.String), |
| 33 | 33 | clientUserAgent: Schema.NullOr(Schema.String), |
| ... | ... | @@ -44,7 +44,7 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco |
| 44 | 44 | return { |
| 45 | 45 | sessionId: row.sessionId, |
| 46 | 46 | subject: row.subject, |
| 47 | role: row.role, | |
| 47 | scopes: row.scopes, | |
| 48 | 48 | method: row.method, |
| 49 | 49 | client: { |
| 50 | 50 | label: row.clientLabel, |
| ... | ... | @@ -78,7 +78,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { |
| 78 | 78 | INSERT INTO auth_sessions ( |
| 79 | 79 | session_id, |
| 80 | 80 | subject, |
| 81 | role, | |
| 81 | scopes, | |
| 82 | 82 | method, |
| 83 | 83 | client_label, |
| 84 | 84 | client_ip_address, |
| ... | ... | @@ -93,7 +93,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { |
| 93 | 93 | VALUES ( |
| 94 | 94 | ${input.sessionId}, |
| 95 | 95 | ${input.subject}, |
| 96 | ${input.role}, | |
| 96 | ${JSON.stringify(input.scopes)}, | |
| 97 | 97 | ${input.method}, |
| 98 | 98 | ${input.client.label}, |
| 99 | 99 | ${input.client.ipAddress}, |
| ... | ... | @@ -116,7 +116,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { |
| 116 | 116 | SELECT |
| 117 | 117 | session_id AS "sessionId", |
| 118 | 118 | subject AS "subject", |
| 119 | role AS "role", | |
| 119 | scopes AS "scopes", | |
| 120 | 120 | method AS "method", |
| 121 | 121 | client_label AS "clientLabel", |
| 122 | 122 | client_ip_address AS "clientIpAddress", |
| ... | ... | @@ -141,7 +141,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { |
| 141 | 141 | SELECT |
| 142 | 142 | session_id AS "sessionId", |
| 143 | 143 | subject AS "subject", |
| 144 | role AS "role", | |
| 144 | scopes AS "scopes", | |
| 145 | 145 | method AS "method", |
| 146 | 146 | client_label AS "clientLabel", |
| 147 | 147 | client_ip_address AS "clientIpAddress", |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -43,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts" |
| 43 | 43 | import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; |
| 44 | 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; |
| 45 | 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; |
| 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; | |
| 46 | 47 | |
| 47 | 48 | /** |
| 48 | 49 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -85,6 +86,7 @@ export const migrationEntries = [ |
| 85 | 86 | [28, "ProjectionThreadSessionInstanceId", Migration0028], |
| 86 | 87 | [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], |
| 87 | 88 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], |
| 89 | [31, "AuthAuthorizationScopes", Migration0031], | |
| 88 | 90 | ] as const; |
| 89 | 91 | |
| 90 | 92 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 5 | ||
| 6 | import { runMigrations } from "../Migrations.ts"; | |
| 7 | import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | |
| 10 | ||
| 11 | layer("031_AuthAuthorizationScopes", (it) => { | |
| 12 | it.effect("invalidates role-based auth records and installs scoped auth tables", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const sql = yield* SqlClient.SqlClient; | |
| 15 | ||
| 16 | yield* runMigrations({ toMigrationInclusive: 30 }); | |
| 17 | ||
| 18 | yield* sql` | |
| 19 | INSERT INTO auth_pairing_links ( | |
| 20 | id, | |
| 21 | credential, | |
| 22 | method, | |
| 23 | role, | |
| 24 | subject, | |
| 25 | created_at, | |
| 26 | expires_at | |
| 27 | ) | |
| 28 | VALUES ( | |
| 29 | 'link-owner', | |
| 30 | 'bootstrap-owner', | |
| 31 | 'desktop-bootstrap', | |
| 32 | 'owner', | |
| 33 | 'desktop', | |
| 34 | '2026-05-29T00:00:00.000Z', | |
| 35 | '2026-05-29T01:00:00.000Z' | |
| 36 | ) | |
| 37 | `; | |
| 38 | yield* sql` | |
| 39 | INSERT INTO auth_sessions ( | |
| 40 | session_id, | |
| 41 | subject, | |
| 42 | role, | |
| 43 | method, | |
| 44 | issued_at, | |
| 45 | expires_at | |
| 46 | ) | |
| 47 | VALUES ( | |
| 48 | 'session-owner', | |
| 49 | 'desktop', | |
| 50 | 'owner', | |
| 51 | 'browser-session-cookie', | |
| 52 | '2026-05-29T00:00:00.000Z', | |
| 53 | '2026-05-29T01:00:00.000Z' | |
| 54 | ) | |
| 55 | `; | |
| 56 | ||
| 57 | yield* runMigrations({ toMigrationInclusive: 31 }); | |
| 58 | ||
| 59 | const pairingColumns = yield* sql<{ readonly name: string }>` | |
| 60 | PRAGMA table_info(auth_pairing_links) | |
| 61 | `; | |
| 62 | const sessionColumns = yield* sql<{ readonly name: string }>` | |
| 63 | PRAGMA table_info(auth_sessions) | |
| 64 | `; | |
| 65 | const pairingRows = yield* sql<{ readonly id: string }>` | |
| 66 | SELECT id FROM auth_pairing_links | |
| 67 | `; | |
| 68 | const sessionRows = yield* sql<{ readonly sessionId: string }>` | |
| 69 | SELECT session_id AS "sessionId" FROM auth_sessions | |
| 70 | `; | |
| 71 | ||
| 72 | assert.isTrue(pairingColumns.some((column) => column.name === "scopes")); | |
| 73 | assert.isFalse(pairingColumns.some((column) => column.name === "role")); | |
| 74 | assert.isTrue(sessionColumns.some((column) => column.name === "scopes")); | |
| 75 | assert.isFalse(sessionColumns.some((column) => column.name === "role")); | |
| 76 | assert.deepStrictEqual(pairingRows, []); | |
| 77 | assert.deepStrictEqual(sessionRows, []); | |
| 78 | }), | |
| 79 | ); | |
| 80 | }); |
apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | /** | |
| 5 | * This is an intentional alpha cutover: role-bearing credentials and sessions | |
| 6 | * cannot safely be assigned new capabilities implicitly. | |
| 7 | */ | |
| 8 | export default Effect.gen(function* () { | |
| 9 | const sql = yield* SqlClient.SqlClient; | |
| 10 | ||
| 11 | yield* sql`DROP TABLE IF EXISTS auth_pairing_links`; | |
| 12 | yield* sql`DROP TABLE IF EXISTS auth_sessions`; | |
| 13 | ||
| 14 | yield* sql` | |
| 15 | CREATE TABLE auth_pairing_links ( | |
| 16 | id TEXT PRIMARY KEY, | |
| 17 | credential TEXT NOT NULL UNIQUE, | |
| 18 | method TEXT NOT NULL, | |
| 19 | scopes TEXT NOT NULL, | |
| 20 | subject TEXT NOT NULL, | |
| 21 | label TEXT, | |
| 22 | created_at TEXT NOT NULL, | |
| 23 | expires_at TEXT NOT NULL, | |
| 24 | consumed_at TEXT, | |
| 25 | revoked_at TEXT | |
| 26 | ) | |
| 27 | `; | |
| 28 | ||
| 29 | yield* sql` | |
| 30 | CREATE INDEX idx_auth_pairing_links_active | |
| 31 | ON auth_pairing_links(revoked_at, consumed_at, expires_at) | |
| 32 | `; | |
| 33 | ||
| 34 | yield* sql` | |
| 35 | CREATE TABLE auth_sessions ( | |
| 36 | session_id TEXT PRIMARY KEY, | |
| 37 | subject TEXT NOT NULL, | |
| 38 | scopes TEXT NOT NULL, | |
| 39 | method TEXT NOT NULL, | |
| 40 | client_label TEXT, | |
| 41 | client_ip_address TEXT, | |
| 42 | client_user_agent TEXT, | |
| 43 | client_device_type TEXT NOT NULL DEFAULT 'unknown', | |
| 44 | client_os TEXT, | |
| 45 | client_browser TEXT, | |
| 46 | issued_at TEXT NOT NULL, | |
| 47 | expires_at TEXT NOT NULL, | |
| 48 | last_connected_at TEXT, | |
| 49 | revoked_at TEXT | |
| 50 | ) | |
| 51 | `; | |
| 52 | ||
| 53 | yield* sql` | |
| 54 | CREATE INDEX idx_auth_sessions_active | |
| 55 | ON auth_sessions(revoked_at, expires_at, issued_at) | |
| 56 | `; | |
| 57 | }); |
apps/server/src/persistence/Services/AuthPairingLinks.ts+3-2| ... | ... | @@ -2,6 +2,7 @@ import * as Option from "effect/Option"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | import * as Context from "effect/Context"; |
| 4 | 4 | import type * as Effect from "effect/Effect"; |
| 5 | import { AuthEnvironmentScopes } from "@t3tools/contracts"; | |
| 5 | 6 | |
| 6 | 7 | import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; |
| 7 | 8 | |
| ... | ... | @@ -9,7 +10,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ |
| 9 | 10 | id: Schema.String, |
| 10 | 11 | credential: Schema.String, |
| 11 | 12 | method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), |
| 12 | role: Schema.Literals(["owner", "client"]), | |
| 13 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), | |
| 13 | 14 | subject: Schema.String, |
| 14 | 15 | label: Schema.NullOr(Schema.String), |
| 15 | 16 | createdAt: Schema.DateTimeUtcFromString, |
| ... | ... | @@ -23,7 +24,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ |
| 23 | 24 | id: Schema.String, |
| 24 | 25 | credential: Schema.String, |
| 25 | 26 | method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), |
| 26 | role: Schema.Literals(["owner", "client"]), | |
| 27 | scopes: AuthEnvironmentScopes, | |
| 27 | 28 | subject: Schema.String, |
| 28 | 29 | label: Schema.NullOr(Schema.String), |
| 29 | 30 | createdAt: Schema.DateTimeUtcFromString, |
apps/server/src/persistence/Services/AuthSessions.ts+10-5| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | AuthClientMetadataDeviceType, | |
| 3 | AuthEnvironmentScopes, | |
| 4 | AuthSessionId, | |
| 5 | ServerAuthSessionMethod, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import * as Option from "effect/Option"; |
| 3 | 8 | import * as Schema from "effect/Schema"; |
| 4 | 9 | import * as Context from "effect/Context"; |
| ... | ... | @@ -19,8 +24,8 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe |
| 19 | 24 | export const AuthSessionRecord = Schema.Struct({ |
| 20 | 25 | sessionId: AuthSessionId, |
| 21 | 26 | subject: Schema.String, |
| 22 | role: Schema.Literals(["owner", "client"]), | |
| 23 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 27 | scopes: AuthEnvironmentScopes, | |
| 28 | method: ServerAuthSessionMethod, | |
| 24 | 29 | client: AuthSessionClientMetadataRecord, |
| 25 | 30 | issuedAt: Schema.DateTimeUtcFromString, |
| 26 | 31 | expiresAt: Schema.DateTimeUtcFromString, |
| ... | ... | @@ -32,8 +37,8 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type; |
| 32 | 37 | export const CreateAuthSessionInput = Schema.Struct({ |
| 33 | 38 | sessionId: AuthSessionId, |
| 34 | 39 | subject: Schema.String, |
| 35 | role: Schema.Literals(["owner", "client"]), | |
| 36 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 40 | scopes: AuthEnvironmentScopes, | |
| 41 | method: ServerAuthSessionMethod, | |
| 37 | 42 | client: AuthSessionClientMetadataRecord, |
| 38 | 43 | issuedAt: Schema.DateTimeUtcFromString, |
| 39 | 44 | expiresAt: Schema.DateTimeUtcFromString, |
apps/server/src/server.test.ts+395-93| ... | ... | @@ -3,6 +3,9 @@ import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 3 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | AuthAccessTokenType, | |
| 7 | AuthEnvironmentBootstrapTokenType, | |
| 8 | AuthTokenExchangeGrantType, | |
| 6 | 9 | CommandId, |
| 7 | 10 | DEFAULT_SERVER_SETTINGS, |
| 8 | 11 | EnvironmentId, |
| ... | ... | @@ -46,6 +49,7 @@ import { |
| 46 | 49 | HttpClient, |
| 47 | 50 | HttpRouter, |
| 48 | 51 | HttpServer, |
| 52 | UrlParams, | |
| 49 | 53 | } from "effect/unstable/http"; |
| 50 | 54 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 51 | 55 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| ... | ... | @@ -114,8 +118,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 114 | 118 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 115 | 119 | import * as ReviewService from "./review/ReviewService.ts"; |
| 116 | 120 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 117 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | |
| 118 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | |
| 121 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | |
| 122 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | |
| 119 | 123 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 120 | 124 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 121 | 125 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | ... | @@ -215,7 +219,10 @@ const browserOtlpTracingLayer = Layer.mergeAll( |
| 215 | 219 | ); |
| 216 | 220 | |
| 217 | 221 | const makeAuthTestLayer = () => |
| 218 | ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive)); | |
| 222 | EnvironmentAuth.layer.pipe( | |
| 223 | Layer.provide(SqlitePersistenceMemory), | |
| 224 | Layer.provide(ServerSecretStore.layer), | |
| 225 | ); | |
| 219 | 226 | |
| 220 | 227 | const makeBrowserOtlpPayload = (spanName: string) => |
| 221 | 228 | Effect.gen(function* () { |
| ... | ... | @@ -799,7 +806,7 @@ const bootstrapBrowserSession = ( |
| 799 | 806 | }, |
| 800 | 807 | ) => |
| 801 | 808 | Effect.gen(function* () { |
| 802 | const response = yield* HttpClient.post("/api/auth/bootstrap", { | |
| 809 | const response = yield* HttpClient.post("/api/auth/browser-session", { | |
| 803 | 810 | headers: options?.headers, |
| 804 | 811 | body: yield* HttpBody.json({ credential }), |
| 805 | 812 | }); |
| ... | ... | @@ -815,23 +822,46 @@ const bootstrapBrowserSession = ( |
| 815 | 822 | }; |
| 816 | 823 | }); |
| 817 | 824 | |
| 818 | const bootstrapBearerSession = ( | |
| 825 | const exchangeAccessToken = ( | |
| 819 | 826 | credential = defaultDesktopBootstrapToken, |
| 820 | 827 | options?: { |
| 821 | 828 | readonly headers?: Record<string, string>; |
| 829 | readonly scope?: string; | |
| 830 | readonly clientMetadata?: { | |
| 831 | readonly label?: string; | |
| 832 | readonly deviceType?: string; | |
| 833 | readonly os?: string; | |
| 834 | }; | |
| 822 | 835 | }, |
| 823 | 836 | ) => |
| 824 | 837 | Effect.gen(function* () { |
| 825 | const response = yield* HttpClient.post("/api/auth/bootstrap/bearer", { | |
| 838 | const response = yield* HttpClient.post("/oauth/token", { | |
| 826 | 839 | headers: options?.headers, |
| 827 | body: yield* HttpBody.json({ credential }), | |
| 840 | body: HttpBody.urlParams( | |
| 841 | UrlParams.fromInput({ | |
| 842 | grant_type: AuthTokenExchangeGrantType, | |
| 843 | subject_token: credential, | |
| 844 | subject_token_type: AuthEnvironmentBootstrapTokenType, | |
| 845 | requested_token_type: AuthAccessTokenType, | |
| 846 | scope: | |
| 847 | options?.scope ?? | |
| 848 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", | |
| 849 | ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), | |
| 850 | ...(options?.clientMetadata?.deviceType | |
| 851 | ? { client_device_type: options.clientMetadata.deviceType } | |
| 852 | : {}), | |
| 853 | ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}), | |
| 854 | }), | |
| 855 | ), | |
| 828 | 856 | }); |
| 829 | 857 | const body = (yield* response.json) as { |
| 830 | readonly authenticated: boolean; | |
| 831 | readonly sessionMethod: string; | |
| 832 | readonly expiresAt: string; | |
| 833 | readonly sessionToken?: string; | |
| 834 | readonly error?: string; | |
| 858 | readonly access_token?: string; | |
| 859 | readonly issued_token_type?: string; | |
| 860 | readonly token_type?: string; | |
| 861 | readonly expires_in?: number; | |
| 862 | readonly scope?: string; | |
| 863 | readonly _tag?: string; | |
| 864 | readonly message?: string; | |
| 835 | 865 | }; |
| 836 | 866 | return { |
| 837 | 867 | response, |
| ... | ... | @@ -863,20 +893,20 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra |
| 863 | 893 | |
| 864 | 894 | const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => |
| 865 | 895 | Effect.gen(function* () { |
| 866 | const { response, body } = yield* bootstrapBearerSession(credential); | |
| 896 | const { response, body } = yield* exchangeAccessToken(credential); | |
| 867 | 897 | if (response.status !== 200) { |
| 868 | 898 | return yield* new AuthenticationGetterError({ |
| 869 | 899 | message: `Expected bearer bootstrap response to succeed, got ${response.status}`, |
| 870 | 900 | }); |
| 871 | 901 | } |
| 872 | 902 | |
| 873 | if (!body.sessionToken) { | |
| 903 | if (!body.access_token) { | |
| 874 | 904 | return yield* new AuthenticationGetterError({ |
| 875 | message: "Expected bearer bootstrap response to include a session token.", | |
| 905 | message: "Expected token exchange response to include an access token.", | |
| 876 | 906 | }); |
| 877 | 907 | } |
| 878 | 908 | |
| 879 | return body.sessionToken; | |
| 909 | return body.access_token; | |
| 880 | 910 | }); |
| 881 | 911 | |
| 882 | 912 | const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { |
| ... | ... | @@ -895,8 +925,16 @@ const splitHeaderTokens = (value: string | null) => |
| 895 | 925 | .filter((token) => token.length > 0) |
| 896 | 926 | .toSorted(); |
| 897 | 927 | |
| 898 | const assertBrowserApiCorsHeaders = (headers: Readonly<Record<string, string | undefined>>) => { | |
| 928 | const assertBrowserApiCorsResponseHeaders = ( | |
| 929 | headers: Readonly<Record<string, string | undefined>>, | |
| 930 | ) => { | |
| 899 | 931 | assert.equal(headers["access-control-allow-origin"], "*"); |
| 932 | }; | |
| 933 | ||
| 934 | const assertBrowserApiCorsPreflightHeaders = ( | |
| 935 | headers: Readonly<Record<string, string | undefined>>, | |
| 936 | ) => { | |
| 937 | assertBrowserApiCorsResponseHeaders(headers); | |
| 900 | 938 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [ |
| 901 | 939 | "GET", |
| 902 | 940 | "OPTIONS", |
| ... | ... | @@ -1039,7 +1077,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1039 | 1077 | const body = (yield* response.json) as typeof testEnvironmentDescriptor; |
| 1040 | 1078 | |
| 1041 | 1079 | assert.equal(response.status, 200); |
| 1042 | assertBrowserApiCorsHeaders(response.headers); | |
| 1080 | assertBrowserApiCorsResponseHeaders(response.headers); | |
| 1043 | 1081 | assert.deepEqual(body, testEnvironmentDescriptor); |
| 1044 | 1082 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1045 | 1083 | ); |
| ... | ... | @@ -1063,10 +1101,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1063 | 1101 | assert.equal(body.authenticated, false); |
| 1064 | 1102 | assert.equal(body.auth.policy, "desktop-managed-local"); |
| 1065 | 1103 | assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); |
| 1066 | assert.deepEqual(body.auth.sessionMethods, [ | |
| 1067 | "browser-session-cookie", | |
| 1068 | "bearer-session-token", | |
| 1069 | ]); | |
| 1104 | assert.deepEqual(body.auth.sessionMethods, ["browser-session-cookie", "bearer-access-token"]); | |
| 1070 | 1105 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); |
| 1071 | 1106 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1072 | 1107 | ); |
| ... | ... | @@ -1103,56 +1138,134 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1103 | 1138 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1104 | 1139 | ); |
| 1105 | 1140 | |
| 1106 | it.effect( | |
| 1107 | "bootstraps a bearer session and authenticates the session endpoint via authorization header", | |
| 1108 | () => | |
| 1109 | Effect.gen(function* () { | |
| 1110 | yield* buildAppUnderTest(); | |
| 1141 | it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => | |
| 1142 | Effect.gen(function* () { | |
| 1143 | yield* buildAppUnderTest(); | |
| 1111 | 1144 | |
| 1112 | const { response: bootstrapResponse, body: bootstrapBody } = | |
| 1113 | yield* bootstrapBearerSession(); | |
| 1145 | const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken(); | |
| 1114 | 1146 | |
| 1115 | assert.equal(bootstrapResponse.status, 200); | |
| 1116 | assert.equal(bootstrapBody.authenticated, true); | |
| 1117 | assert.equal(bootstrapBody.sessionMethod, "bearer-session-token"); | |
| 1118 | assert.equal(typeof bootstrapBody.sessionToken, "string"); | |
| 1119 | assert.isTrue((bootstrapBody.sessionToken?.length ?? 0) > 0); | |
| 1147 | assert.equal(bootstrapResponse.status, 200); | |
| 1148 | assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); | |
| 1149 | assert.equal(tokenBody.token_type, "Bearer"); | |
| 1150 | assert.equal( | |
| 1151 | tokenBody.scope, | |
| 1152 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", | |
| 1153 | ); | |
| 1154 | assert.equal(typeof tokenBody.access_token, "string"); | |
| 1155 | assert.isTrue((tokenBody.access_token?.length ?? 0) > 0); | |
| 1120 | 1156 | |
| 1121 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | |
| 1122 | headers: { | |
| 1123 | authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, | |
| 1124 | }, | |
| 1125 | }); | |
| 1126 | const sessionBody = (yield* sessionResponse.json) as { | |
| 1127 | readonly authenticated: boolean; | |
| 1128 | readonly sessionMethod?: string; | |
| 1129 | }; | |
| 1157 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | |
| 1158 | headers: { | |
| 1159 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | |
| 1160 | }, | |
| 1161 | }); | |
| 1162 | const sessionBody = (yield* sessionResponse.json) as { | |
| 1163 | readonly authenticated: boolean; | |
| 1164 | readonly sessionMethod?: string; | |
| 1165 | readonly scopes?: ReadonlyArray<string>; | |
| 1166 | }; | |
| 1130 | 1167 | |
| 1131 | assert.equal(sessionResponse.status, 200); | |
| 1132 | assert.equal(sessionBody.authenticated, true); | |
| 1133 | assert.equal(sessionBody.sessionMethod, "bearer-session-token"); | |
| 1134 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1168 | assert.equal(sessionResponse.status, 200); | |
| 1169 | assert.equal(sessionBody.authenticated, true); | |
| 1170 | assert.equal(sessionBody.sessionMethod, "bearer-access-token"); | |
| 1171 | assert.deepEqual(sessionBody.scopes, [ | |
| 1172 | "orchestration:read", | |
| 1173 | "orchestration:operate", | |
| 1174 | "terminal:operate", | |
| 1175 | "review:write", | |
| 1176 | "relay:read", | |
| 1177 | "access:read", | |
| 1178 | "access:write", | |
| 1179 | "relay:write", | |
| 1180 | ]); | |
| 1181 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1135 | 1182 | ); |
| 1136 | 1183 | |
| 1137 | it.effect("issues short-lived websocket tokens for authenticated bearer sessions", () => | |
| 1184 | it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => | |
| 1138 | 1185 | Effect.gen(function* () { |
| 1139 | 1186 | yield* buildAppUnderTest(); |
| 1140 | 1187 | |
| 1141 | 1188 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); |
| 1142 | const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", { | |
| 1189 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | |
| 1143 | 1190 | headers: { |
| 1144 | 1191 | authorization: `Bearer ${bearerToken}`, |
| 1145 | 1192 | }, |
| 1146 | 1193 | }); |
| 1147 | const wsTokenBody = (yield* wsTokenResponse.json) as { | |
| 1148 | readonly token: string; | |
| 1194 | const wsTicketBody = (yield* wsTicketResponse.json) as { | |
| 1195 | readonly ticket: string; | |
| 1149 | 1196 | readonly expiresAt: string; |
| 1150 | 1197 | }; |
| 1151 | 1198 | |
| 1152 | assert.equal(wsTokenResponse.status, 200); | |
| 1153 | assert.equal(typeof wsTokenBody.token, "string"); | |
| 1154 | assert.isTrue(wsTokenBody.token.length > 0); | |
| 1155 | assert.equal(typeof wsTokenBody.expiresAt, "string"); | |
| 1199 | assert.equal(wsTicketResponse.status, 200); | |
| 1200 | assert.equal(typeof wsTicketBody.ticket, "string"); | |
| 1201 | assert.isTrue(wsTicketBody.ticket.length > 0); | |
| 1202 | assert.equal(typeof wsTicketBody.expiresAt, "string"); | |
| 1203 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1204 | ); | |
| 1205 | ||
| 1206 | it.effect("does not allow management-only access tokens to operate the environment", () => | |
| 1207 | Effect.gen(function* () { | |
| 1208 | yield* buildAppUnderTest(); | |
| 1209 | ||
| 1210 | const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken( | |
| 1211 | defaultDesktopBootstrapToken, | |
| 1212 | { scope: "access:write" }, | |
| 1213 | ); | |
| 1214 | assert.equal(exchangeResponse.status, 200); | |
| 1215 | assert.equal(tokenBody.scope, "access:write"); | |
| 1216 | assert.isDefined(tokenBody.access_token); | |
| 1217 | ||
| 1218 | const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1219 | headers: { | |
| 1220 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | |
| 1221 | }, | |
| 1222 | body: yield* HttpBody.json({}), | |
| 1223 | }); | |
| 1224 | const overbroadPairingBody = (yield* overbroadPairingResponse.json) as { | |
| 1225 | readonly requiredScope: string; | |
| 1226 | }; | |
| 1227 | const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1228 | headers: { | |
| 1229 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | |
| 1230 | }, | |
| 1231 | body: yield* HttpBody.json({ scopes: ["access:write"] }), | |
| 1232 | }); | |
| 1233 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | |
| 1234 | headers: { | |
| 1235 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | |
| 1236 | }, | |
| 1237 | }); | |
| 1238 | const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; | |
| 1239 | const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { | |
| 1240 | headers: { | |
| 1241 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | |
| 1242 | }, | |
| 1243 | }); | |
| 1244 | const faviconBody = (yield* faviconResponse.json) as { | |
| 1245 | readonly _tag: string; | |
| 1246 | readonly code: string; | |
| 1247 | readonly requiredScope: string; | |
| 1248 | readonly traceId: string; | |
| 1249 | }; | |
| 1250 | ||
| 1251 | assert.equal(overbroadPairingResponse.status, 403); | |
| 1252 | assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); | |
| 1253 | assert.equal(pairingResponse.status, 200); | |
| 1254 | assert.equal(wsTicketResponse.status, 200); | |
| 1255 | assert.equal(faviconResponse.status, 403); | |
| 1256 | assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); | |
| 1257 | assert.equal(faviconBody.code, "insufficient_scope"); | |
| 1258 | assert.equal(faviconBody.requiredScope, "orchestration:read"); | |
| 1259 | assert.equal(typeof faviconBody.traceId, "string"); | |
| 1260 | ||
| 1261 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | |
| 1262 | const rpcError = yield* Effect.flip( | |
| 1263 | Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), | |
| 1264 | ); | |
| 1265 | assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); | |
| 1266 | if (rpcError._tag === "EnvironmentAuthorizationError") { | |
| 1267 | assert.equal(rpcError.requiredScope, "orchestration:read"); | |
| 1268 | } | |
| 1156 | 1269 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1157 | 1270 | ); |
| 1158 | 1271 | |
| ... | ... | @@ -1161,7 +1274,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1161 | 1274 | yield* buildAppUnderTest(); |
| 1162 | 1275 | |
| 1163 | 1276 | const origin = crossOriginClientOrigin; |
| 1164 | const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBearerSession( | |
| 1277 | const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken( | |
| 1165 | 1278 | defaultDesktopBootstrapToken, |
| 1166 | 1279 | { |
| 1167 | 1280 | headers: { origin }, |
| ... | ... | @@ -1169,13 +1282,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1169 | 1282 | ); |
| 1170 | 1283 | |
| 1171 | 1284 | assert.equal(bootstrapResponse.status, 200); |
| 1172 | assertBrowserApiCorsHeaders(bootstrapResponse.headers); | |
| 1173 | assert.equal(bootstrapBody.authenticated, true); | |
| 1174 | assert.equal(typeof bootstrapBody.sessionToken, "string"); | |
| 1285 | assertBrowserApiCorsResponseHeaders(bootstrapResponse.headers); | |
| 1286 | assert.equal(tokenBody.token_type, "Bearer"); | |
| 1287 | assert.equal(typeof tokenBody.access_token, "string"); | |
| 1175 | 1288 | |
| 1176 | 1289 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { |
| 1177 | 1290 | headers: { |
| 1178 | authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, | |
| 1291 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | |
| 1179 | 1292 | origin, |
| 1180 | 1293 | }, |
| 1181 | 1294 | }); |
| ... | ... | @@ -1185,33 +1298,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1185 | 1298 | }; |
| 1186 | 1299 | |
| 1187 | 1300 | assert.equal(sessionResponse.status, 200); |
| 1188 | assertBrowserApiCorsHeaders(sessionResponse.headers); | |
| 1301 | assertBrowserApiCorsResponseHeaders(sessionResponse.headers); | |
| 1189 | 1302 | assert.equal(sessionBody.authenticated, true); |
| 1190 | assert.equal(sessionBody.sessionMethod, "bearer-session-token"); | |
| 1303 | assert.equal(sessionBody.sessionMethod, "bearer-access-token"); | |
| 1191 | 1304 | |
| 1192 | const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", { | |
| 1305 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | |
| 1193 | 1306 | headers: { |
| 1194 | authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, | |
| 1307 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | |
| 1195 | 1308 | origin, |
| 1196 | 1309 | }, |
| 1197 | 1310 | }); |
| 1198 | const wsTokenBody = (yield* wsTokenResponse.json) as { | |
| 1199 | readonly token: string; | |
| 1311 | const wsTicketBody = (yield* wsTicketResponse.json) as { | |
| 1312 | readonly ticket: string; | |
| 1200 | 1313 | }; |
| 1201 | 1314 | |
| 1202 | assert.equal(wsTokenResponse.status, 200); | |
| 1203 | assertBrowserApiCorsHeaders(wsTokenResponse.headers); | |
| 1204 | assert.equal(typeof wsTokenBody.token, "string"); | |
| 1315 | assert.equal(wsTicketResponse.status, 200); | |
| 1316 | assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers); | |
| 1317 | assert.equal(typeof wsTicketBody.ticket, "string"); | |
| 1205 | 1318 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1206 | 1319 | ); |
| 1207 | 1320 | |
| 1208 | 1321 | it.effect( |
| 1209 | "responds to remote auth websocket-token preflight requests with authorization CORS headers", | |
| 1322 | "responds to remote auth websocket-ticket preflight requests with authorization CORS headers", | |
| 1210 | 1323 | () => |
| 1211 | 1324 | Effect.gen(function* () { |
| 1212 | 1325 | yield* buildAppUnderTest(); |
| 1213 | 1326 | |
| 1214 | const response = yield* HttpClient.options("/api/auth/ws-token", { | |
| 1327 | const response = yield* HttpClient.options("/api/auth/websocket-ticket", { | |
| 1215 | 1328 | headers: { |
| 1216 | 1329 | origin: crossOriginClientOrigin, |
| 1217 | 1330 | "access-control-request-method": "POST", |
| ... | ... | @@ -1220,26 +1333,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1220 | 1333 | }); |
| 1221 | 1334 | |
| 1222 | 1335 | assert.equal(response.status, 204); |
| 1223 | assertBrowserApiCorsHeaders(response.headers); | |
| 1336 | assertBrowserApiCorsPreflightHeaders(response.headers); | |
| 1224 | 1337 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1225 | 1338 | ); |
| 1226 | 1339 | |
| 1227 | it.effect("includes CORS headers on remote websocket-token auth failures", () => | |
| 1340 | it.effect("includes CORS headers on remote websocket-ticket auth failures", () => | |
| 1228 | 1341 | Effect.gen(function* () { |
| 1229 | 1342 | yield* buildAppUnderTest(); |
| 1230 | 1343 | |
| 1231 | const response = yield* HttpClient.post("/api/auth/ws-token", { | |
| 1344 | const response = yield* HttpClient.post("/api/auth/websocket-ticket", { | |
| 1232 | 1345 | headers: { |
| 1233 | 1346 | origin: crossOriginClientOrigin, |
| 1234 | 1347 | }, |
| 1235 | 1348 | }); |
| 1236 | 1349 | const body = (yield* response.json) as { |
| 1237 | readonly error?: string; | |
| 1350 | readonly _tag?: string; | |
| 1351 | readonly code?: string; | |
| 1352 | readonly reason?: string; | |
| 1353 | readonly traceId?: string; | |
| 1238 | 1354 | }; |
| 1239 | 1355 | |
| 1240 | 1356 | assert.equal(response.status, 401); |
| 1241 | assertBrowserApiCorsHeaders(response.headers); | |
| 1242 | assert.equal(body.error, "Authentication required."); | |
| 1357 | assertBrowserApiCorsResponseHeaders(response.headers); | |
| 1358 | assert.equal(body._tag, "EnvironmentAuthInvalidError"); | |
| 1359 | assert.equal(body.code, "auth_invalid"); | |
| 1360 | assert.equal(body.reason, "missing_credential"); | |
| 1361 | assert.equal(typeof body.traceId, "string"); | |
| 1362 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1363 | ); | |
| 1364 | ||
| 1365 | it.effect("persists token exchange client display metadata for authorized-client listings", () => | |
| 1366 | Effect.gen(function* () { | |
| 1367 | yield* buildAppUnderTest({ | |
| 1368 | config: { | |
| 1369 | host: "0.0.0.0", | |
| 1370 | }, | |
| 1371 | }); | |
| 1372 | ||
| 1373 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | |
| 1374 | const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1375 | headers: { | |
| 1376 | cookie: ownerCookie, | |
| 1377 | }, | |
| 1378 | body: yield* HttpBody.json({}), | |
| 1379 | }); | |
| 1380 | const pairingBody = (yield* pairingResponse.json) as { | |
| 1381 | readonly credential: string; | |
| 1382 | }; | |
| 1383 | ||
| 1384 | const { response } = yield* exchangeAccessToken(pairingBody.credential, { | |
| 1385 | headers: { | |
| 1386 | "user-agent": "undici", | |
| 1387 | }, | |
| 1388 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 1389 | clientMetadata: { | |
| 1390 | label: "T3 Code Mobile", | |
| 1391 | deviceType: "mobile", | |
| 1392 | os: "iOS", | |
| 1393 | }, | |
| 1394 | }); | |
| 1395 | ||
| 1396 | const clientsResponse = yield* HttpClient.get("/api/auth/clients", { | |
| 1397 | headers: { | |
| 1398 | cookie: ownerCookie, | |
| 1399 | }, | |
| 1400 | }); | |
| 1401 | const clients = (yield* clientsResponse.json) as ReadonlyArray<{ | |
| 1402 | readonly current: boolean; | |
| 1403 | readonly client: { | |
| 1404 | readonly label?: string; | |
| 1405 | readonly deviceType: string; | |
| 1406 | readonly ipAddress?: string; | |
| 1407 | readonly os?: string; | |
| 1408 | readonly userAgent?: string; | |
| 1409 | }; | |
| 1410 | }>; | |
| 1411 | const mobileClient = clients.find((client) => !client.current); | |
| 1412 | ||
| 1413 | assert.equal(pairingResponse.status, 200); | |
| 1414 | assert.equal(response.status, 200); | |
| 1415 | assert.equal(clientsResponse.status, 200); | |
| 1416 | assert.deepInclude(mobileClient?.client, { | |
| 1417 | label: "T3 Code Mobile", | |
| 1418 | deviceType: "mobile", | |
| 1419 | os: "iOS", | |
| 1420 | ipAddress: "127.0.0.1", | |
| 1421 | userAgent: "undici", | |
| 1422 | }); | |
| 1243 | 1423 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1244 | 1424 | ); |
| 1245 | 1425 | |
| ... | ... | @@ -1251,6 +1431,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1251 | 1431 | headers: { |
| 1252 | 1432 | cookie: yield* getAuthenticatedSessionCookieHeader(), |
| 1253 | 1433 | }, |
| 1434 | body: yield* HttpBody.json({}), | |
| 1254 | 1435 | }); |
| 1255 | 1436 | const body = (yield* response.json) as { |
| 1256 | 1437 | readonly credential: string; |
| ... | ... | @@ -1270,16 +1451,61 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1270 | 1451 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1271 | 1452 | ); |
| 1272 | 1453 | |
| 1454 | it.effect("issues pairing credentials for bearer sessions with access management scope", () => | |
| 1455 | Effect.gen(function* () { | |
| 1456 | yield* buildAppUnderTest(); | |
| 1457 | ||
| 1458 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | |
| 1459 | const response = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1460 | headers: { | |
| 1461 | authorization: `Bearer ${bearerToken}`, | |
| 1462 | }, | |
| 1463 | body: yield* HttpBody.json({ label: "Hosted web" }), | |
| 1464 | }); | |
| 1465 | const body = (yield* response.json) as { | |
| 1466 | readonly credential: string; | |
| 1467 | readonly label?: string; | |
| 1468 | }; | |
| 1469 | ||
| 1470 | assert.equal(response.status, 200); | |
| 1471 | assert.isTrue(body.credential.length > 0); | |
| 1472 | assert.equal(body.label, "Hosted web"); | |
| 1473 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1474 | ); | |
| 1475 | ||
| 1476 | it.effect("rejects pairing credentials with an empty scope grant", () => | |
| 1477 | Effect.gen(function* () { | |
| 1478 | yield* buildAppUnderTest(); | |
| 1479 | ||
| 1480 | const response = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1481 | headers: { | |
| 1482 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 1483 | }, | |
| 1484 | body: yield* HttpBody.json({ scopes: [] }), | |
| 1485 | }); | |
| 1486 | const body = (yield* response.json) as { | |
| 1487 | readonly code: string; | |
| 1488 | readonly reason: string; | |
| 1489 | }; | |
| 1490 | ||
| 1491 | assert.equal(response.status, 400); | |
| 1492 | assert.equal(body.code, "invalid_request"); | |
| 1493 | assert.equal(body.reason, "invalid_scope"); | |
| 1494 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1495 | ); | |
| 1496 | ||
| 1273 | 1497 | it.effect("rejects unauthenticated pairing credential requests", () => |
| 1274 | 1498 | Effect.gen(function* () { |
| 1275 | 1499 | yield* buildAppUnderTest(); |
| 1276 | 1500 | |
| 1277 | const response = yield* HttpClient.post("/api/auth/pairing-token"); | |
| 1501 | const response = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1502 | body: yield* HttpBody.json({}), | |
| 1503 | }); | |
| 1278 | 1504 | assert.equal(response.status, 401); |
| 1279 | 1505 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1280 | 1506 | ); |
| 1281 | 1507 | |
| 1282 | it.effect("lists and revokes pairing links for owner sessions", () => | |
| 1508 | it.effect("lists and revokes pairing links for access management sessions", () => | |
| 1283 | 1509 | Effect.gen(function* () { |
| 1284 | 1510 | yield* buildAppUnderTest({ |
| 1285 | 1511 | config: { |
| ... | ... | @@ -1292,6 +1518,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1292 | 1518 | headers: { |
| 1293 | 1519 | cookie: ownerCookie, |
| 1294 | 1520 | }, |
| 1521 | body: yield* HttpBody.json({}), | |
| 1295 | 1522 | }); |
| 1296 | 1523 | const createdBody = (yield* createdResponse.json) as { |
| 1297 | 1524 | readonly id: string; |
| ... | ... | @@ -1324,7 +1551,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1324 | 1551 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1325 | 1552 | ); |
| 1326 | 1553 | |
| 1327 | it.effect("rejects pairing credential requests from non-owner paired sessions", () => | |
| 1554 | it.effect("rejects pairing credential requests without access management scope", () => | |
| 1328 | 1555 | Effect.gen(function* () { |
| 1329 | 1556 | yield* buildAppUnderTest({ |
| 1330 | 1557 | config: { |
| ... | ... | @@ -1336,6 +1563,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1336 | 1563 | headers: { |
| 1337 | 1564 | cookie: yield* getAuthenticatedSessionCookieHeader(), |
| 1338 | 1565 | }, |
| 1566 | body: yield* HttpBody.json({}), | |
| 1339 | 1567 | }); |
| 1340 | 1568 | const ownerBody = (yield* ownerResponse.json) as { |
| 1341 | 1569 | readonly credential: string; |
| ... | ... | @@ -1347,17 +1575,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1347 | 1575 | headers: { |
| 1348 | 1576 | cookie: pairedSessionCookie, |
| 1349 | 1577 | }, |
| 1578 | body: yield* HttpBody.json({}), | |
| 1350 | 1579 | }); |
| 1351 | 1580 | const pairedBody = (yield* pairedResponse.json) as { |
| 1352 | readonly error: string; | |
| 1581 | readonly _tag: string; | |
| 1582 | readonly code: string; | |
| 1583 | readonly requiredScope: string; | |
| 1584 | readonly traceId: string; | |
| 1353 | 1585 | }; |
| 1354 | 1586 | |
| 1355 | 1587 | assert.equal(pairedResponse.status, 403); |
| 1356 | assert.equal(pairedBody.error, "Only owner sessions can create pairing credentials."); | |
| 1588 | assert.equal(pairedBody._tag, "EnvironmentScopeRequiredError"); | |
| 1589 | assert.equal(pairedBody.code, "insufficient_scope"); | |
| 1590 | assert.equal(pairedBody.requiredScope, "access:write"); | |
| 1591 | assert.equal(typeof pairedBody.traceId, "string"); | |
| 1357 | 1592 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1358 | 1593 | ); |
| 1359 | 1594 | |
| 1360 | it.effect("lists paired clients and revokes other sessions while keeping the owner", () => | |
| 1595 | it.effect("lists paired clients and revokes other sessions while keeping the administrator", () => | |
| 1361 | 1596 | Effect.gen(function* () { |
| 1362 | 1597 | yield* buildAppUnderTest({ |
| 1363 | 1598 | config: { |
| ... | ... | @@ -1429,9 +1664,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1429 | 1664 | headers: { |
| 1430 | 1665 | cookie: pairedSessionCookieHeader, |
| 1431 | 1666 | }, |
| 1667 | body: yield* HttpBody.json({}), | |
| 1432 | 1668 | }); |
| 1433 | 1669 | const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as { |
| 1434 | readonly error: string; | |
| 1670 | readonly _tag: string; | |
| 1671 | readonly code: string; | |
| 1672 | readonly reason: string; | |
| 1673 | readonly traceId: string; | |
| 1435 | 1674 | }; |
| 1436 | 1675 | |
| 1437 | 1676 | assert.equal(listBeforeResponse.status, 200); |
| ... | ... | @@ -1452,7 +1691,69 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1452 | 1691 | assert.lengthOf(clientsAfter, 1); |
| 1453 | 1692 | assert.equal(clientsAfter[0]?.current, true); |
| 1454 | 1693 | assert.equal(pairedClientPairingResponse.status, 401); |
| 1455 | assert.equal(pairedClientPairingBody.error, "Unauthorized request."); | |
| 1694 | assert.equal(pairedClientPairingBody._tag, "EnvironmentAuthInvalidError"); | |
| 1695 | assert.equal(pairedClientPairingBody.code, "auth_invalid"); | |
| 1696 | assert.equal(pairedClientPairingBody.reason, "invalid_credential"); | |
| 1697 | assert.equal(typeof pairedClientPairingBody.traceId, "string"); | |
| 1698 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1699 | ); | |
| 1700 | ||
| 1701 | it.effect("separates access inventory reads from credential management writes", () => | |
| 1702 | Effect.gen(function* () { | |
| 1703 | yield* buildAppUnderTest({ | |
| 1704 | config: { | |
| 1705 | host: "0.0.0.0", | |
| 1706 | }, | |
| 1707 | }); | |
| 1708 | ||
| 1709 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | |
| 1710 | const issueScopedSession = Effect.fnUntraced(function* ( | |
| 1711 | scope: "access:read" | "access:write", | |
| 1712 | ) { | |
| 1713 | const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1714 | headers: { | |
| 1715 | cookie: ownerCookie, | |
| 1716 | }, | |
| 1717 | body: yield* HttpBody.json({ scopes: [scope] }), | |
| 1718 | }); | |
| 1719 | assert.equal(pairingResponse.status, 200); | |
| 1720 | const pairingBody = (yield* pairingResponse.json) as { | |
| 1721 | readonly credential: string; | |
| 1722 | }; | |
| 1723 | return yield* getAuthenticatedSessionCookieHeader(pairingBody.credential); | |
| 1724 | }); | |
| 1725 | ||
| 1726 | const readCookie = yield* issueScopedSession("access:read"); | |
| 1727 | const readListResponse = yield* HttpClient.get("/api/auth/clients", { | |
| 1728 | headers: { | |
| 1729 | cookie: readCookie, | |
| 1730 | }, | |
| 1731 | }); | |
| 1732 | const readWriteResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1733 | headers: { | |
| 1734 | cookie: readCookie, | |
| 1735 | }, | |
| 1736 | body: yield* HttpBody.json({}), | |
| 1737 | }); | |
| 1738 | const readWriteBody = (yield* readWriteResponse.json) as { | |
| 1739 | readonly requiredScope: string; | |
| 1740 | }; | |
| 1741 | ||
| 1742 | const writeCookie = yield* issueScopedSession("access:write"); | |
| 1743 | const writeListResponse = yield* HttpClient.get("/api/auth/clients", { | |
| 1744 | headers: { | |
| 1745 | cookie: writeCookie, | |
| 1746 | }, | |
| 1747 | }); | |
| 1748 | const writeListBody = (yield* writeListResponse.json) as { | |
| 1749 | readonly requiredScope: string; | |
| 1750 | }; | |
| 1751 | ||
| 1752 | assert.equal(readListResponse.status, 200); | |
| 1753 | assert.equal(readWriteResponse.status, 403); | |
| 1754 | assert.equal(readWriteBody.requiredScope, "access:write"); | |
| 1755 | assert.equal(writeListResponse.status, 403); | |
| 1756 | assert.equal(writeListBody.requiredScope, "access:read"); | |
| 1456 | 1757 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1457 | 1758 | ); |
| 1458 | 1759 | |
| ... | ... | @@ -1469,6 +1770,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1469 | 1770 | headers: { |
| 1470 | 1771 | cookie: ownerCookie, |
| 1471 | 1772 | }, |
| 1773 | body: yield* HttpBody.json({}), | |
| 1472 | 1774 | }); |
| 1473 | 1775 | const pairingBody = (yield* pairingResponse.json) as { |
| 1474 | 1776 | readonly credential: string; |
| ... | ... | @@ -1499,6 +1801,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1499 | 1801 | headers: { |
| 1500 | 1802 | cookie: pairedSessionCookie, |
| 1501 | 1803 | }, |
| 1804 | body: yield* HttpBody.json({}), | |
| 1502 | 1805 | }); |
| 1503 | 1806 | |
| 1504 | 1807 | assert.equal(revokeResponse.status, 200); |
| ... | ... | @@ -1515,10 +1818,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1515 | 1818 | |
| 1516 | 1819 | assert.equal(first.response.status, 200); |
| 1517 | 1820 | assert.equal(second.response.status, 401); |
| 1518 | assert.equal( | |
| 1519 | (second.body as { readonly error?: string }).error, | |
| 1520 | "Invalid bootstrap credential.", | |
| 1521 | ); | |
| 1821 | assert.equal((second.body as { readonly _tag?: string })._tag, "EnvironmentAuthInvalidError"); | |
| 1822 | assert.equal((second.body as { readonly code?: string }).code, "auth_invalid"); | |
| 1823 | assert.equal((second.body as { readonly reason?: string }).reason, "invalid_credential"); | |
| 1522 | 1824 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1523 | 1825 | ); |
| 1524 | 1826 | |
| ... | ... | @@ -1594,15 +1896,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1594 | 1896 | yield* buildAppUnderTest(); |
| 1595 | 1897 | |
| 1596 | 1898 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); |
| 1597 | const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", { | |
| 1899 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | |
| 1598 | 1900 | headers: { |
| 1599 | 1901 | authorization: `Bearer ${bearerToken}`, |
| 1600 | 1902 | }, |
| 1601 | 1903 | }); |
| 1602 | const wsTokenBody = (yield* wsTokenResponse.json) as { | |
| 1603 | readonly token: string; | |
| 1904 | const wsTicketBody = (yield* wsTicketResponse.json) as { | |
| 1905 | readonly ticket: string; | |
| 1604 | 1906 | }; |
| 1605 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsToken=${encodeURIComponent(wsTokenBody.token)}`; | |
| 1907 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | |
| 1606 | 1908 | |
| 1607 | 1909 | const response = yield* Effect.scoped( |
| 1608 | 1910 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), |
apps/server/src/server.ts+15-34| ... | ... | @@ -1,13 +1,15 @@ |
| 1 | import { EnvironmentHttpApi } from "@t3tools/contracts"; | |
| 1 | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | import * as Layer from "effect/Layer"; |
| 3 | 4 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; |
| 5 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | |
| 4 | 6 | |
| 5 | 7 | import { ServerConfig } from "./config.ts"; |
| 6 | 8 | import { |
| 7 | 9 | attachmentsRouteLayer, |
| 8 | 10 | otlpTracesProxyRouteLayer, |
| 9 | 11 | projectFaviconRouteLayer, |
| 10 | serverEnvironmentRouteLayer, | |
| 12 | serverEnvironmentHttpApiLayer, | |
| 11 | 13 | staticAndDevRouteLayer, |
| 12 | 14 | browserApiCorsLayer, |
| 13 | 15 | } from "./http.ts"; |
| ... | ... | @@ -62,20 +64,9 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe |
| 62 | 64 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; |
| 63 | 65 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 64 | 66 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; |
| 65 | import { | |
| 66 | authBearerBootstrapRouteLayer, | |
| 67 | authBootstrapRouteLayer, | |
| 68 | authClientsRevokeOthersRouteLayer, | |
| 69 | authClientsRevokeRouteLayer, | |
| 70 | authClientsRouteLayer, | |
| 71 | authPairingLinksRevokeRouteLayer, | |
| 72 | authPairingLinksRouteLayer, | |
| 73 | authPairingCredentialRouteLayer, | |
| 74 | authSessionRouteLayer, | |
| 75 | authWebSocketTokenRouteLayer, | |
| 76 | } from "./auth/http.ts"; | |
| 77 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | |
| 78 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | |
| 67 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | |
| 68 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | |
| 69 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | |
| 79 | 70 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 80 | 71 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 81 | 72 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | ... | @@ -85,10 +76,7 @@ import { |
| 85 | 76 | makePersistedServerRuntimeState, |
| 86 | 77 | persistServerRuntimeState, |
| 87 | 78 | } from "./serverRuntimeState.ts"; |
| 88 | import { | |
| 89 | orchestrationDispatchRouteLayer, | |
| 90 | orchestrationSnapshotRouteLayer, | |
| 91 | } from "./orchestration/http.ts"; | |
| 79 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; | |
| 92 | 80 | import * as NetService from "@t3tools/shared/Net"; |
| 93 | 81 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; |
| 94 | 82 | |
| ... | ... | @@ -238,9 +226,9 @@ const WorkspaceLayerLive = Layer.mergeAll( |
| 238 | 226 | WorkspaceFileSystemLayerLive, |
| 239 | 227 | ); |
| 240 | 228 | |
| 241 | const AuthLayerLive = ServerAuthLive.pipe( | |
| 229 | const AuthLayerLive = EnvironmentAuth.layer.pipe( | |
| 242 | 230 | Layer.provideMerge(PersistenceLayerLive), |
| 243 | Layer.provide(ServerSecretStoreLive), | |
| 231 | Layer.provide(ServerSecretStore.layer), | |
| 244 | 232 | ); |
| 245 | 233 | |
| 246 | 234 | const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( |
| ... | ... | @@ -301,22 +289,15 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( |
| 301 | 289 | ); |
| 302 | 290 | |
| 303 | 291 | export const makeRoutesLayer = Layer.mergeAll( |
| 304 | authBearerBootstrapRouteLayer, | |
| 305 | authBootstrapRouteLayer, | |
| 306 | authClientsRevokeOthersRouteLayer, | |
| 307 | authClientsRevokeRouteLayer, | |
| 308 | authClientsRouteLayer, | |
| 309 | authPairingLinksRevokeRouteLayer, | |
| 310 | authPairingLinksRouteLayer, | |
| 311 | authPairingCredentialRouteLayer, | |
| 312 | authSessionRouteLayer, | |
| 313 | authWebSocketTokenRouteLayer, | |
| 292 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( | |
| 293 | Layer.provide(authHttpApiLayer), | |
| 294 | Layer.provide(orchestrationHttpApiLayer), | |
| 295 | Layer.provide(serverEnvironmentHttpApiLayer), | |
| 296 | Layer.provide(environmentAuthenticatedAuthLayer), | |
| 297 | ), | |
| 314 | 298 | attachmentsRouteLayer, |
| 315 | orchestrationDispatchRouteLayer, | |
| 316 | orchestrationSnapshotRouteLayer, | |
| 317 | 299 | otlpTracesProxyRouteLayer, |
| 318 | 300 | projectFaviconRouteLayer, |
| 319 | serverEnvironmentRouteLayer, | |
| 320 | 301 | staticAndDevRouteLayer, |
| 321 | 302 | websocketRpcRouteLayer, |
| 322 | 303 | ).pipe(Layer.provide(browserApiCorsLayer)); |
apps/server/src/serverRuntimeStartup.ts+2-2| ... | ... | @@ -32,7 +32,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 32 | 32 | import { ServerSettingsService } from "./serverSettings.ts"; |
| 33 | 33 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| 34 | 34 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 35 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | |
| 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | |
| 36 | 36 | import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; |
| 37 | 37 | import { |
| 38 | 38 | formatHeadlessServeOutput, |
| ... | ... | @@ -244,7 +244,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 244 | 244 | |
| 245 | 245 | const resolveStartupBrowserTarget = Effect.gen(function* () { |
| 246 | 246 | const serverConfig = yield* ServerConfig; |
| 247 | const serverAuth = yield* ServerAuth; | |
| 247 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 248 | 248 | const localUrl = `http://localhost:${serverConfig.port}`; |
| 249 | 249 | const bindUrl = |
| 250 | 250 | serverConfig.host && !isWildcardHost(serverConfig.host) |
apps/server/src/serverSettings.ts+3-4| ... | ... | @@ -47,8 +47,7 @@ import { ServerConfig } from "./config.ts"; |
| 47 | 47 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 48 | 48 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 49 | 49 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 50 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | |
| 51 | import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; | |
| 50 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | |
| 52 | 51 | |
| 53 | 52 | const encodeServerSettings = Schema.encodeEffect(ServerSettings); |
| 54 | 53 | const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); |
| ... | ... | @@ -257,7 +256,7 @@ const makeServerSettings = Effect.gen(function* () { |
| 257 | 256 | const { settingsPath } = yield* ServerConfig; |
| 258 | 257 | const fs = yield* FileSystem.FileSystem; |
| 259 | 258 | const pathService = yield* Path.Path; |
| 260 | const secretStore = yield* ServerSecretStore; | |
| 259 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 261 | 260 | const writeSemaphore = yield* Semaphore.make(1); |
| 262 | 261 | const cacheKey = "settings" as const; |
| 263 | 262 | const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); |
| ... | ... | @@ -580,5 +579,5 @@ const makeServerSettings = Effect.gen(function* () { |
| 580 | 579 | }); |
| 581 | 580 | |
| 582 | 581 | export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe( |
| 583 | Layer.provide(ServerSecretStoreLive), | |
| 582 | Layer.provide(ServerSecretStore.layer), | |
| 584 | 583 | ); |
apps/server/src/startupAccess.ts+3-3| ... | ... | @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | 5 | import { HttpServer } from "effect/unstable/http"; |
| 6 | 6 | |
| 7 | 7 | import { ServerConfig } from "./config.ts"; |
| 8 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | |
| 8 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | |
| 9 | 9 | |
| 10 | 10 | export interface HeadlessServeAccessInfo { |
| 11 | 11 | readonly connectionString: string; |
| ... | ... | @@ -133,12 +133,12 @@ export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): |
| 133 | 133 | export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () { |
| 134 | 134 | const serverConfig = yield* ServerConfig; |
| 135 | 135 | const httpServer = yield* HttpServer.HttpServer; |
| 136 | const serverAuth = yield* ServerAuth; | |
| 136 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 137 | 137 | const connectionString = resolveHeadlessConnectionString( |
| 138 | 138 | serverConfig.host, |
| 139 | 139 | resolveListeningPort(httpServer.address, serverConfig.port), |
| 140 | 140 | ); |
| 141 | const issued = yield* serverAuth.issuePairingCredential({ role: "owner" }); | |
| 141 | const issued = yield* serverAuth.issueStartupPairingCredential(); | |
| 142 | 142 | |
| 143 | 143 | return { |
| 144 | 144 | connectionString, |
apps/server/src/ws.ts+163-28| ... | ... | @@ -11,7 +11,14 @@ import * as Schema from "effect/Schema"; |
| 11 | 11 | import * as Stream from "effect/Stream"; |
| 12 | 12 | import { |
| 13 | 13 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, |
| 14 | AuthOrchestrationOperateScope, | |
| 15 | AuthOrchestrationReadScope, | |
| 16 | AuthReviewWriteScope, | |
| 17 | AuthTerminalOperateScope, | |
| 18 | AuthAccessReadScope, | |
| 19 | AuthAccessStreamError, | |
| 14 | 20 | type AuthAccessStreamEvent, |
| 21 | type AuthEnvironmentScope, | |
| 15 | 22 | AuthSessionId, |
| 16 | 23 | CommandId, |
| 17 | 24 | EventId, |
| ... | ... | @@ -29,6 +36,7 @@ import { |
| 29 | 36 | ProjectWriteFileError, |
| 30 | 37 | OrchestrationReplayEventsError, |
| 31 | 38 | FilesystemBrowseError, |
| 39 | EnvironmentAuthorizationError, | |
| 32 | 40 | ThreadId, |
| 33 | 41 | type TerminalAttachStreamEvent, |
| 34 | 42 | type TerminalError, |
| ... | ... | @@ -38,7 +46,7 @@ import { |
| 38 | 46 | WsRpcGroup, |
| 39 | 47 | } from "@t3tools/contracts"; |
| 40 | 48 | import { clamp } from "effect/Number"; |
| 41 | import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; | |
| 49 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; | |
| 42 | 50 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 43 | 51 | |
| 44 | 52 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; |
| ... | ... | @@ -49,9 +57,9 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; |
| 49 | 57 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; |
| 50 | 58 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 51 | 59 | import { |
| 52 | observeRpcEffect, | |
| 53 | observeRpcStream, | |
| 54 | observeRpcStreamEffect, | |
| 60 | observeRpcEffect as instrumentRpcEffect, | |
| 61 | observeRpcStream as instrumentRpcStream, | |
| 62 | observeRpcStreamEffect as instrumentRpcStreamEffect, | |
| 55 | 63 | } from "./observability/RpcInstrumentation.ts"; |
| 56 | 64 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; |
| 57 | 65 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| ... | ... | @@ -69,7 +77,8 @@ import { ReviewService } from "./review/ReviewService.ts"; |
| 69 | 77 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; |
| 70 | 78 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; |
| 71 | 79 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| 72 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | |
| 80 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | |
| 81 | import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts"; | |
| 73 | 82 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 74 | 83 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 75 | 84 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | ... | @@ -84,15 +93,9 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 84 | 93 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 85 | 94 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; |
| 86 | 95 | import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 87 | import { | |
| 88 | BootstrapCredentialService, | |
| 89 | type BootstrapCredentialChange, | |
| 90 | } from "./auth/Services/BootstrapCredentialService.ts"; | |
| 91 | import { | |
| 92 | SessionCredentialService, | |
| 93 | type SessionCredentialChange, | |
| 94 | } from "./auth/Services/SessionCredentialService.ts"; | |
| 95 | import { respondToAuthError } from "./auth/http.ts"; | |
| 96 | import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; | |
| 97 | import * as SessionStore from "./auth/SessionStore.ts"; | |
| 98 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; | |
| 96 | 99 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); |
| 97 | 100 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); |
| 98 | 101 | |
| ... | ... | @@ -122,8 +125,62 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< |
| 122 | 125 | |
| 123 | 126 | const PROVIDER_STATUS_DEBOUNCE_MS = 200; |
| 124 | 127 | |
| 128 | const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ | |
| 129 | [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], | |
| 130 | [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], | |
| 131 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], | |
| 132 | [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], | |
| 133 | [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], | |
| 134 | [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], | |
| 135 | [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], | |
| 136 | [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], | |
| 137 | [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], | |
| 138 | [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], | |
| 139 | [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], | |
| 140 | [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], | |
| 141 | [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], | |
| 142 | [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope], | |
| 143 | [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope], | |
| 144 | [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], | |
| 145 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], | |
| 146 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], | |
| 147 | [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], | |
| 148 | [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], | |
| 149 | [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], | |
| 150 | [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], | |
| 151 | [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], | |
| 152 | [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], | |
| 153 | [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], | |
| 154 | [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], | |
| 155 | [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], | |
| 156 | [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], | |
| 157 | [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], | |
| 158 | [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], | |
| 159 | [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], | |
| 160 | [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], | |
| 161 | [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], | |
| 162 | [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], | |
| 163 | [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], | |
| 164 | [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], | |
| 165 | [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], | |
| 166 | [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], | |
| 167 | [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], | |
| 168 | [WS_METHODS.terminalOpen, AuthTerminalOperateScope], | |
| 169 | [WS_METHODS.terminalAttach, AuthTerminalOperateScope], | |
| 170 | [WS_METHODS.terminalWrite, AuthTerminalOperateScope], | |
| 171 | [WS_METHODS.terminalResize, AuthTerminalOperateScope], | |
| 172 | [WS_METHODS.terminalClear, AuthTerminalOperateScope], | |
| 173 | [WS_METHODS.terminalRestart, AuthTerminalOperateScope], | |
| 174 | [WS_METHODS.terminalClose, AuthTerminalOperateScope], | |
| 175 | [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], | |
| 176 | [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], | |
| 177 | [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], | |
| 178 | [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], | |
| 179 | [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], | |
| 180 | ]); | |
| 181 | ||
| 125 | 182 | function toAuthAccessStreamEvent( |
| 126 | change: BootstrapCredentialChange | SessionCredentialChange, | |
| 183 | change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, | |
| 127 | 184 | revision: number, |
| 128 | 185 | currentSessionId: AuthSessionId, |
| 129 | 186 | ): AuthAccessStreamEvent { |
| ... | ... | @@ -162,9 +219,10 @@ function toAuthAccessStreamEvent( |
| 162 | 219 | } |
| 163 | 220 | } |
| 164 | 221 | |
| 165 | const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | |
| 222 | const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | |
| 166 | 223 | WsRpcGroup.toLayer( |
| 167 | 224 | Effect.gen(function* () { |
| 225 | const currentSessionId = currentSession.sessionId; | |
| 168 | 226 | const crypto = yield* Crypto.Crypto; |
| 169 | 227 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 170 | 228 | const orchestrationEngine = yield* OrchestrationEngineService; |
| ... | ... | @@ -187,7 +245,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 187 | 245 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; |
| 188 | 246 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; |
| 189 | 247 | const serverEnvironment = yield* ServerEnvironment; |
| 190 | const serverAuth = yield* ServerAuth; | |
| 248 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 191 | 249 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; |
| 192 | 250 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( |
| 193 | 251 | Effect.map((settings) => settings.automaticGitFetchInterval), |
| ... | ... | @@ -198,10 +256,70 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 198 | 256 | ), |
| 199 | 257 | ); |
| 200 | 258 | const sourceControlRepositories = yield* SourceControlRepositoryService; |
| 201 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 202 | const sessions = yield* SessionCredentialService; | |
| 259 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | |
| 260 | const sessions = yield* SessionStore.SessionStore; | |
| 203 | 261 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 204 | 262 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; |
| 263 | const authorizationError = (requiredScope: AuthEnvironmentScope) => | |
| 264 | new EnvironmentAuthorizationError({ | |
| 265 | message: `The authenticated token is missing required scope: ${requiredScope}.`, | |
| 266 | requiredScope, | |
| 267 | }); | |
| 268 | const authorizeEffect = <A, E, R>( | |
| 269 | requiredScope: AuthEnvironmentScope, | |
| 270 | effect: Effect.Effect<A, E, R>, | |
| 271 | ): Effect.Effect<A, E | EnvironmentAuthorizationError, R> => | |
| 272 | currentSession.scopes.includes(requiredScope) | |
| 273 | ? effect | |
| 274 | : Effect.fail(authorizationError(requiredScope)); | |
| 275 | const authorizeStream = <A, E, R>( | |
| 276 | requiredScope: AuthEnvironmentScope, | |
| 277 | stream: Stream.Stream<A, E, R>, | |
| 278 | ): Stream.Stream<A, E | EnvironmentAuthorizationError, R> => | |
| 279 | currentSession.scopes.includes(requiredScope) | |
| 280 | ? stream | |
| 281 | : Stream.fail(authorizationError(requiredScope)); | |
| 282 | const requiredScopeForMethod = (method: string): AuthEnvironmentScope => { | |
| 283 | const requiredScope = RPC_REQUIRED_SCOPE.get(method); | |
| 284 | if (requiredScope === undefined) { | |
| 285 | throw new Error(`RPC method ${method} has no declared authorization scope.`); | |
| 286 | } | |
| 287 | return requiredScope; | |
| 288 | }; | |
| 289 | const observeRpcEffect = <A, E, R>( | |
| 290 | method: string, | |
| 291 | effect: Effect.Effect<A, E, R>, | |
| 292 | traceAttributes?: Readonly<Record<string, unknown>>, | |
| 293 | ) => | |
| 294 | instrumentRpcEffect( | |
| 295 | method, | |
| 296 | authorizeEffect(requiredScopeForMethod(method), effect), | |
| 297 | traceAttributes, | |
| 298 | ); | |
| 299 | const observeRpcStream = <A, E, R>( | |
| 300 | method: string, | |
| 301 | stream: Stream.Stream<A, E, R>, | |
| 302 | traceAttributes?: Readonly<Record<string, unknown>>, | |
| 303 | ) => | |
| 304 | instrumentRpcStream( | |
| 305 | method, | |
| 306 | authorizeStream(requiredScopeForMethod(method), stream), | |
| 307 | traceAttributes, | |
| 308 | ); | |
| 309 | const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>( | |
| 310 | method: string, | |
| 311 | effect: Effect.Effect< | |
| 312 | Stream.Stream<A, StreamError, StreamContext>, | |
| 313 | EffectError, | |
| 314 | EffectContext | |
| 315 | >, | |
| 316 | traceAttributes?: Readonly<Record<string, unknown>>, | |
| 317 | ) => | |
| 318 | instrumentRpcStreamEffect( | |
| 319 | method, | |
| 320 | authorizeEffect(requiredScopeForMethod(method), effect), | |
| 321 | traceAttributes, | |
| 322 | ); | |
| 205 | 323 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => |
| 206 | 324 | isOrchestrationDispatchCommandError(cause) |
| 207 | 325 | ? cause |
| ... | ... | @@ -220,9 +338,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 220 | 338 | |
| 221 | 339 | const loadAuthAccessSnapshot = () => |
| 222 | 340 | Effect.all({ |
| 223 | pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie), | |
| 224 | clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie), | |
| 225 | }); | |
| 341 | pairingLinks: serverAuth.listPairingLinks(), | |
| 342 | clientSessions: serverAuth.listClientSessions(currentSessionId), | |
| 343 | }).pipe( | |
| 344 | Effect.mapError( | |
| 345 | (error) => | |
| 346 | new AuthAccessStreamError({ | |
| 347 | message: error.message, | |
| 348 | }), | |
| 349 | ), | |
| 350 | ); | |
| 226 | 351 | |
| 227 | 352 | const appendSetupScriptActivity = (input: { |
| 228 | 353 | readonly threadId: ThreadId; |
| ... | ... | @@ -1258,7 +1383,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 1258 | 1383 | const initialSnapshot = yield* loadAuthAccessSnapshot(); |
| 1259 | 1384 | const revisionRef = yield* Ref.make(1); |
| 1260 | 1385 | const accessChanges: Stream.Stream< |
| 1261 | BootstrapCredentialChange | SessionCredentialChange | |
| 1386 | PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange | |
| 1262 | 1387 | > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); |
| 1263 | 1388 | |
| 1264 | 1389 | const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe( |
| ... | ... | @@ -1294,14 +1419,19 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1294 | 1419 | "/ws", |
| 1295 | 1420 | Effect.gen(function* () { |
| 1296 | 1421 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 1297 | const serverAuth = yield* ServerAuth; | |
| 1298 | const sessions = yield* SessionCredentialService; | |
| 1299 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request); | |
| 1422 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 1423 | const sessions = yield* SessionStore.SessionStore; | |
| 1424 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( | |
| 1425 | Effect.catchTags({ | |
| 1426 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | |
| 1427 | ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), | |
| 1428 | }), | |
| 1429 | ); | |
| 1300 | 1430 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { |
| 1301 | 1431 | disableTracing: true, |
| 1302 | 1432 | }).pipe( |
| 1303 | 1433 | Effect.provide( |
| 1304 | makeWsRpcLayer(session.sessionId).pipe( | |
| 1434 | makeWsRpcLayer(session).pipe( | |
| 1305 | 1435 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1306 | 1436 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 1307 | 1437 | Layer.provide( |
| ... | ... | @@ -1333,7 +1463,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1333 | 1463 | () => rpcWebSocketHttpEffect, |
| 1334 | 1464 | () => sessions.markDisconnected(session.sessionId), |
| 1335 | 1465 | ); |
| 1336 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 1466 | }).pipe( | |
| 1467 | Effect.catchTags({ | |
| 1468 | EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, | |
| 1469 | EnvironmentInternalError: HttpServerRespondable.toResponse, | |
| 1470 | }), | |
| 1471 | ), | |
| 1337 | 1472 | ), |
| 1338 | 1473 | ), |
| 1339 | 1474 | ); |
apps/web/package.json+2-1| ... | ... | @@ -43,6 +43,7 @@ |
| 43 | 43 | "zustand": "^5.0.11" |
| 44 | 44 | }, |
| 45 | 45 | "devDependencies": { |
| 46 | "@effect/platform-node": "catalog:", | |
| 46 | 47 | "@rolldown/plugin-babel": "^0.2.0", |
| 47 | 48 | "@tailwindcss/vite": "^4.0.0", |
| 48 | 49 | "@tanstack/router-plugin": "^1.161.0", |
| ... | ... | @@ -51,7 +52,7 @@ |
| 51 | 52 | "@types/react-dom": "~19.2.3", |
| 52 | 53 | "@vercel/config": "^0.3.0", |
| 53 | 54 | "@vitejs/plugin-react": "^6.0.0", |
| 54 | "@vitest/browser-playwright": "^4.0.18", | |
| 55 | "@vitest/browser-playwright": "catalog:", | |
| 55 | 56 | "babel-plugin-react-compiler": "1.0.0", |
| 56 | 57 | "msw": "2.12.11", |
| 57 | 58 | "playwright": "^1.58.2", |
apps/web/src/authBootstrap.test.ts+174-316| ... | ... | @@ -1,15 +1,17 @@ |
| 1 | import type { DesktopBridge } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | AuthSessionState as AuthSessionStateSchema, | |
| 3 | EnvironmentAuthInvalidError, | |
| 4 | type AuthBrowserSessionResult, | |
| 5 | type AuthCreatePairingCredentialInput, | |
| 6 | type AuthSessionState, | |
| 7 | type DesktopBridge, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as DateTime from "effect/DateTime"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 2 | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 13 | |
| 4 | function jsonResponse(body: unknown, init?: ResponseInit) { | |
| 5 | return new Response(JSON.stringify(body), { | |
| 6 | headers: { | |
| 7 | "content-type": "application/json", | |
| 8 | }, | |
| 9 | status: 200, | |
| 10 | ...init, | |
| 11 | }); | |
| 12 | } | |
| 14 | import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; | |
| 13 | 15 | |
| 14 | 16 | type TestWindow = { |
| 15 | 17 | location: URL; |
| ... | ... | @@ -19,6 +21,42 @@ type TestWindow = { |
| 19 | 21 | desktopBridge?: DesktopBridge; |
| 20 | 22 | }; |
| 21 | 23 | |
| 24 | const LOOPBACK_AUTH = { | |
| 25 | policy: "loopback-browser", | |
| 26 | bootstrapMethods: ["one-time-token"], | |
| 27 | sessionMethods: ["browser-session-cookie"], | |
| 28 | sessionCookieName: "t3_session", | |
| 29 | } as const; | |
| 30 | ||
| 31 | const DESKTOP_AUTH = { | |
| 32 | policy: "desktop-managed-local", | |
| 33 | bootstrapMethods: ["desktop-bootstrap"], | |
| 34 | sessionMethods: ["browser-session-cookie"], | |
| 35 | sessionCookieName: "t3_session", | |
| 36 | } as const; | |
| 37 | ||
| 38 | const SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-04-05T00:00:00.000Z"); | |
| 39 | const encodeAuthSessionState = Schema.encodeSync(AuthSessionStateSchema); | |
| 40 | ||
| 41 | const unauthenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({ | |
| 42 | authenticated: false, | |
| 43 | auth, | |
| 44 | }); | |
| 45 | ||
| 46 | const authenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({ | |
| 47 | authenticated: true, | |
| 48 | auth, | |
| 49 | sessionMethod: "browser-session-cookie", | |
| 50 | expiresAt: SESSION_EXPIRES_AT, | |
| 51 | }); | |
| 52 | ||
| 53 | const browserSession = (scopes: AuthBrowserSessionResult["scopes"]): AuthBrowserSessionResult => ({ | |
| 54 | authenticated: true, | |
| 55 | scopes, | |
| 56 | sessionMethod: "browser-session-cookie", | |
| 57 | expiresAt: SESSION_EXPIRES_AT, | |
| 58 | }); | |
| 59 | ||
| 22 | 60 | function installTestBrowser(url: string) { |
| 23 | 61 | const testWindow: TestWindow = { |
| 24 | 62 | location: new URL(url), |
| ... | ... | @@ -35,8 +73,36 @@ function installTestBrowser(url: string) { |
| 35 | 73 | return testWindow; |
| 36 | 74 | } |
| 37 | 75 | |
| 38 | function sessionResponse(body: unknown, init?: ResponseInit) { | |
| 39 | return jsonResponse(body, init); | |
| 76 | function sequence<A>(...values: ReadonlyArray<A>) { | |
| 77 | let index = 0; | |
| 78 | return () => values[Math.min(index++, values.length - 1)]!; | |
| 79 | } | |
| 80 | ||
| 81 | let disposeHttpTest: (() => Promise<void>) | undefined; | |
| 82 | ||
| 83 | async function installAuthApi(input: { | |
| 84 | readonly session?: () => AuthSessionState; | |
| 85 | readonly browserSession?: ( | |
| 86 | credential: string, | |
| 87 | ) => Effect.Effect<AuthBrowserSessionResult, EnvironmentAuthInvalidError>; | |
| 88 | readonly pairingCredential?: (payload: AuthCreatePairingCredentialInput) => Effect.Effect<{ | |
| 89 | readonly id: string; | |
| 90 | readonly credential: string; | |
| 91 | readonly label?: string; | |
| 92 | readonly expiresAt: DateTime.Utc; | |
| 93 | }>; | |
| 94 | }) { | |
| 95 | const testApi = await installEnvironmentHttpTest({ | |
| 96 | ...(input.session ? { session: () => Effect.succeed(input.session!()) } : {}), | |
| 97 | ...(input.browserSession | |
| 98 | ? { browserSession: (payload) => input.browserSession!(payload.credential) } | |
| 99 | : {}), | |
| 100 | ...(input.pairingCredential | |
| 101 | ? { pairingCredential: (payload) => input.pairingCredential!(payload) } | |
| 102 | : {}), | |
| 103 | }); | |
| 104 | disposeHttpTest = testApi.dispose; | |
| 105 | return testApi; | |
| 40 | 106 | } |
| 41 | 107 | |
| 42 | 108 | describe("resolveInitialServerAuthGateState", () => { |
| ... | ... | @@ -47,6 +113,8 @@ describe("resolveInitialServerAuthGateState", () => { |
| 47 | 113 | }); |
| 48 | 114 | |
| 49 | 115 | afterEach(async () => { |
| 116 | await disposeHttpTest?.(); | |
| 117 | disposeHttpTest = undefined; | |
| 50 | 118 | const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); |
| 51 | 119 | __resetServerAuthBootstrapForTests(); |
| 52 | 120 | vi.unstubAllEnvs(); |
| ... | ... | @@ -55,40 +123,14 @@ describe("resolveInitialServerAuthGateState", () => { |
| 55 | 123 | }); |
| 56 | 124 | |
| 57 | 125 | it("reuses an in-flight silent bootstrap attempt", async () => { |
| 58 | const fetchMock = vi | |
| 59 | .fn<typeof fetch>() | |
| 60 | .mockResolvedValueOnce( | |
| 61 | sessionResponse({ | |
| 62 | authenticated: false, | |
| 63 | auth: { | |
| 64 | policy: "desktop-managed-local", | |
| 65 | bootstrapMethods: ["desktop-bootstrap"], | |
| 66 | sessionMethods: ["browser-session-cookie"], | |
| 67 | sessionCookieName: "t3_session", | |
| 68 | }, | |
| 69 | }), | |
| 70 | ) | |
| 71 | .mockResolvedValueOnce( | |
| 72 | jsonResponse({ | |
| 73 | authenticated: true, | |
| 74 | sessionMethod: "browser-session-cookie", | |
| 75 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 76 | }), | |
| 77 | ) | |
| 78 | .mockResolvedValueOnce( | |
| 79 | sessionResponse({ | |
| 80 | authenticated: true, | |
| 81 | auth: { | |
| 82 | policy: "loopback-browser", | |
| 83 | bootstrapMethods: ["one-time-token"], | |
| 84 | sessionMethods: ["browser-session-cookie"], | |
| 85 | sessionCookieName: "t3_session", | |
| 86 | }, | |
| 87 | sessionMethod: "browser-session-cookie", | |
| 88 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 89 | }), | |
| 90 | ); | |
| 91 | vi.stubGlobal("fetch", fetchMock); | |
| 126 | const nextSession = sequence( | |
| 127 | unauthenticatedSession(DESKTOP_AUTH), | |
| 128 | authenticatedSession(DESKTOP_AUTH), | |
| 129 | ); | |
| 130 | const testApi = await installAuthApi({ | |
| 131 | session: nextSession, | |
| 132 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), | |
| 133 | }); | |
| 92 | 134 | |
| 93 | 135 | const testWindow = installTestBrowser("http://localhost/"); |
| 94 | 136 | testWindow.desktopBridge = { |
| ... | ... | @@ -104,90 +146,45 @@ describe("resolveInitialServerAuthGateState", () => { |
| 104 | 146 | |
| 105 | 147 | await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); |
| 106 | 148 | |
| 107 | expect(fetchMock).toHaveBeenCalledTimes(3); | |
| 108 | expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3773/api/auth/session"); | |
| 109 | expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3773/api/auth/bootstrap"); | |
| 110 | expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session"); | |
| 149 | expect(testApi.calls.session).toBe(2); | |
| 150 | expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); | |
| 111 | 151 | }); |
| 112 | 152 | |
| 113 | it("uses https fetch urls when the primary environment uses wss", async () => { | |
| 114 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 115 | sessionResponse({ | |
| 116 | authenticated: false, | |
| 117 | auth: { | |
| 118 | policy: "loopback-browser", | |
| 119 | bootstrapMethods: ["one-time-token"], | |
| 120 | sessionMethods: ["browser-session-cookie"], | |
| 121 | sessionCookieName: "t3_session", | |
| 122 | }, | |
| 123 | }), | |
| 124 | ); | |
| 125 | vi.stubGlobal("fetch", fetchMock); | |
| 153 | it("uses https urls when the primary environment uses wss", async () => { | |
| 154 | await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); | |
| 126 | 155 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); |
| 127 | 156 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); |
| 128 | 157 | |
| 129 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 158 | const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = | |
| 159 | await import("./environments/primary"); | |
| 130 | 160 | |
| 131 | 161 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 132 | 162 | status: "requires-auth", |
| 133 | auth: { | |
| 134 | policy: "loopback-browser", | |
| 135 | bootstrapMethods: ["one-time-token"], | |
| 136 | sessionMethods: ["browser-session-cookie"], | |
| 137 | sessionCookieName: "t3_session", | |
| 138 | }, | |
| 139 | }); | |
| 140 | ||
| 141 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/session", { | |
| 142 | credentials: "include", | |
| 163 | auth: LOOPBACK_AUTH, | |
| 143 | 164 | }); |
| 165 | expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( | |
| 166 | "https://remote.example.com/api/auth/session", | |
| 167 | ); | |
| 144 | 168 | }); |
| 145 | 169 | |
| 146 | 170 | it("uses the current origin as an auth proxy base for local dev environments", async () => { |
| 147 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 148 | sessionResponse({ | |
| 149 | authenticated: false, | |
| 150 | auth: { | |
| 151 | policy: "loopback-browser", | |
| 152 | bootstrapMethods: ["one-time-token"], | |
| 153 | sessionMethods: ["browser-session-cookie"], | |
| 154 | sessionCookieName: "t3_session", | |
| 155 | }, | |
| 156 | }), | |
| 157 | ); | |
| 158 | vi.stubGlobal("fetch", fetchMock); | |
| 171 | await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); | |
| 159 | 172 | installTestBrowser("http://localhost:5735/"); |
| 160 | 173 | |
| 161 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 174 | const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = | |
| 175 | await import("./environments/primary"); | |
| 162 | 176 | |
| 163 | 177 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 164 | 178 | status: "requires-auth", |
| 165 | auth: { | |
| 166 | policy: "loopback-browser", | |
| 167 | bootstrapMethods: ["one-time-token"], | |
| 168 | sessionMethods: ["browser-session-cookie"], | |
| 169 | sessionCookieName: "t3_session", | |
| 170 | }, | |
| 171 | }); | |
| 172 | ||
| 173 | expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/api/auth/session", { | |
| 174 | credentials: "include", | |
| 179 | auth: LOOPBACK_AUTH, | |
| 175 | 180 | }); |
| 181 | expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( | |
| 182 | "http://localhost:5735/api/auth/session", | |
| 183 | ); | |
| 176 | 184 | }); |
| 177 | 185 | |
| 178 | 186 | it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => { |
| 179 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 180 | sessionResponse({ | |
| 181 | authenticated: false, | |
| 182 | auth: { | |
| 183 | policy: "desktop-managed-local", | |
| 184 | bootstrapMethods: ["desktop-bootstrap"], | |
| 185 | sessionMethods: ["browser-session-cookie"], | |
| 186 | sessionCookieName: "t3_session", | |
| 187 | }, | |
| 188 | }), | |
| 189 | ); | |
| 190 | vi.stubGlobal("fetch", fetchMock); | |
| 187 | await installAuthApi({ session: () => unauthenticatedSession(DESKTOP_AUTH) }); | |
| 191 | 188 | vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); |
| 192 | 189 | |
| 193 | 190 | const testWindow = installTestBrowser("http://127.0.0.1:5733/"); |
| ... | ... | @@ -199,47 +196,25 @@ describe("resolveInitialServerAuthGateState", () => { |
| 199 | 196 | }), |
| 200 | 197 | } as DesktopBridge; |
| 201 | 198 | |
| 202 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 199 | const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = | |
| 200 | await import("./environments/primary"); | |
| 203 | 201 | |
| 204 | 202 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 205 | 203 | status: "requires-auth", |
| 206 | auth: { | |
| 207 | policy: "desktop-managed-local", | |
| 208 | bootstrapMethods: ["desktop-bootstrap"], | |
| 209 | sessionMethods: ["browser-session-cookie"], | |
| 210 | sessionCookieName: "t3_session", | |
| 211 | }, | |
| 212 | }); | |
| 213 | ||
| 214 | expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/api/auth/session", { | |
| 215 | credentials: "include", | |
| 204 | auth: DESKTOP_AUTH, | |
| 216 | 205 | }); |
| 206 | expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( | |
| 207 | "http://127.0.0.1:5733/api/auth/session", | |
| 208 | ); | |
| 217 | 209 | }); |
| 218 | 210 | |
| 219 | 211 | it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { |
| 220 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 221 | sessionResponse({ | |
| 222 | authenticated: false, | |
| 223 | auth: { | |
| 224 | policy: "loopback-browser", | |
| 225 | bootstrapMethods: ["one-time-token"], | |
| 226 | sessionMethods: ["browser-session-cookie"], | |
| 227 | sessionCookieName: "t3_session", | |
| 228 | }, | |
| 229 | }), | |
| 230 | ); | |
| 231 | vi.stubGlobal("fetch", fetchMock); | |
| 232 | ||
| 212 | await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); | |
| 233 | 213 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 234 | 214 | |
| 235 | 215 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 236 | 216 | status: "requires-auth", |
| 237 | auth: { | |
| 238 | policy: "loopback-browser", | |
| 239 | bootstrapMethods: ["one-time-token"], | |
| 240 | sessionMethods: ["browser-session-cookie"], | |
| 241 | sessionCookieName: "t3_session", | |
| 242 | }, | |
| 217 | auth: LOOPBACK_AUTH, | |
| 243 | 218 | }); |
| 244 | 219 | }); |
| 245 | 220 | |
| ... | ... | @@ -251,15 +226,10 @@ describe("resolveInitialServerAuthGateState", () => { |
| 251 | 226 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) |
| 252 | 227 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) |
| 253 | 228 | .mockResolvedValueOnce( |
| 254 | sessionResponse({ | |
| 255 | authenticated: false, | |
| 256 | auth: { | |
| 257 | policy: "loopback-browser", | |
| 258 | bootstrapMethods: ["one-time-token"], | |
| 259 | sessionMethods: ["browser-session-cookie"], | |
| 260 | sessionCookieName: "t3_session", | |
| 261 | }, | |
| 262 | }), | |
| 229 | new Response( | |
| 230 | JSON.stringify(encodeAuthSessionState(unauthenticatedSession(LOOPBACK_AUTH))), | |
| 231 | { status: 200, headers: { "content-type": "application/json" } }, | |
| 232 | ), | |
| 263 | 233 | ); |
| 264 | 234 | vi.stubGlobal("fetch", fetchMock); |
| 265 | 235 | |
| ... | ... | @@ -270,12 +240,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 270 | 240 | |
| 271 | 241 | await expect(gateStatePromise).resolves.toEqual({ |
| 272 | 242 | status: "requires-auth", |
| 273 | auth: { | |
| 274 | policy: "loopback-browser", | |
| 275 | bootstrapMethods: ["one-time-token"], | |
| 276 | sessionMethods: ["browser-session-cookie"], | |
| 277 | sessionCookieName: "t3_session", | |
| 278 | }, | |
| 243 | auth: LOOPBACK_AUTH, | |
| 279 | 244 | }); |
| 280 | 245 | expect(fetchMock).toHaveBeenCalledTimes(4); |
| 281 | 246 | }); |
| ... | ... | @@ -298,136 +263,60 @@ describe("resolveInitialServerAuthGateState", () => { |
| 298 | 263 | }); |
| 299 | 264 | |
| 300 | 265 | it("allows manual token submission after the initial auth check requires pairing", async () => { |
| 301 | const fetchMock = vi | |
| 302 | .fn<typeof fetch>() | |
| 303 | .mockResolvedValueOnce( | |
| 304 | sessionResponse({ | |
| 305 | authenticated: false, | |
| 306 | auth: { | |
| 307 | policy: "loopback-browser", | |
| 308 | bootstrapMethods: ["one-time-token"], | |
| 309 | sessionMethods: ["browser-session-cookie"], | |
| 310 | sessionCookieName: "t3_session", | |
| 311 | }, | |
| 312 | }), | |
| 313 | ) | |
| 314 | .mockResolvedValueOnce( | |
| 315 | jsonResponse({ | |
| 316 | authenticated: true, | |
| 317 | sessionMethod: "browser-session-cookie", | |
| 318 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 319 | }), | |
| 320 | ) | |
| 321 | .mockResolvedValueOnce( | |
| 322 | sessionResponse({ | |
| 323 | authenticated: true, | |
| 324 | auth: { | |
| 325 | policy: "loopback-browser", | |
| 326 | bootstrapMethods: ["one-time-token"], | |
| 327 | sessionMethods: ["browser-session-cookie"], | |
| 328 | sessionCookieName: "t3_session", | |
| 329 | }, | |
| 330 | sessionMethod: "browser-session-cookie", | |
| 331 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 332 | }), | |
| 333 | ); | |
| 334 | vi.stubGlobal("fetch", fetchMock); | |
| 335 | installTestBrowser("http://localhost/"); | |
| 336 | ||
| 266 | const nextSession = sequence( | |
| 267 | unauthenticatedSession(LOOPBACK_AUTH), | |
| 268 | authenticatedSession(LOOPBACK_AUTH), | |
| 269 | ); | |
| 270 | const testApi = await installAuthApi({ | |
| 271 | session: nextSession, | |
| 272 | browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), | |
| 273 | }); | |
| 337 | 274 | const { resolveInitialServerAuthGateState, submitServerAuthCredential } = |
| 338 | 275 | await import("./environments/primary"); |
| 339 | 276 | |
| 340 | 277 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 341 | 278 | status: "requires-auth", |
| 342 | auth: { | |
| 343 | policy: "loopback-browser", | |
| 344 | bootstrapMethods: ["one-time-token"], | |
| 345 | sessionMethods: ["browser-session-cookie"], | |
| 346 | sessionCookieName: "t3_session", | |
| 347 | }, | |
| 279 | auth: LOOPBACK_AUTH, | |
| 348 | 280 | }); |
| 349 | 281 | await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); |
| 350 | 282 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 351 | 283 | status: "authenticated", |
| 352 | 284 | }); |
| 353 | expect(fetchMock).toHaveBeenCalledTimes(3); | |
| 285 | expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); | |
| 286 | expect(testApi.calls.session).toBe(2); | |
| 354 | 287 | }); |
| 355 | 288 | |
| 356 | 289 | it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { |
| 357 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 358 | jsonResponse( | |
| 359 | { | |
| 360 | error: "Invalid bootstrap credential.", | |
| 361 | }, | |
| 362 | { | |
| 363 | status: 401, | |
| 364 | }, | |
| 365 | ), | |
| 366 | ); | |
| 367 | vi.stubGlobal("fetch", fetchMock); | |
| 290 | const testApi = await installAuthApi({ | |
| 291 | browserSession: () => | |
| 292 | Effect.fail( | |
| 293 | new EnvironmentAuthInvalidError({ | |
| 294 | code: "auth_invalid", | |
| 295 | reason: "invalid_credential", | |
| 296 | traceId: "trace-invalid-credential", | |
| 297 | }), | |
| 298 | ), | |
| 299 | }); | |
| 368 | 300 | |
| 369 | 301 | const { submitServerAuthCredential } = await import("./environments/primary"); |
| 370 | 302 | |
| 371 | 303 | await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( |
| 372 | 304 | "Invalid pairing token. Check the token and try again.", |
| 373 | 305 | ); |
| 374 | expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/bootstrap", { | |
| 375 | body: JSON.stringify({ credential: "bad-token" }), | |
| 376 | credentials: "include", | |
| 377 | headers: { | |
| 378 | "content-type": "application/json", | |
| 379 | }, | |
| 380 | method: "POST", | |
| 381 | }); | |
| 306 | expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); | |
| 382 | 307 | }); |
| 383 | 308 | |
| 384 | 309 | it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { |
| 385 | 310 | vi.useFakeTimers(); |
| 386 | const fetchMock = vi | |
| 387 | .fn<typeof fetch>() | |
| 388 | .mockResolvedValueOnce( | |
| 389 | sessionResponse({ | |
| 390 | authenticated: false, | |
| 391 | auth: { | |
| 392 | policy: "desktop-managed-local", | |
| 393 | bootstrapMethods: ["desktop-bootstrap"], | |
| 394 | sessionMethods: ["browser-session-cookie"], | |
| 395 | sessionCookieName: "t3_session", | |
| 396 | }, | |
| 397 | }), | |
| 398 | ) | |
| 399 | .mockResolvedValueOnce( | |
| 400 | jsonResponse({ | |
| 401 | authenticated: true, | |
| 402 | sessionMethod: "browser-session-cookie", | |
| 403 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 404 | }), | |
| 405 | ) | |
| 406 | .mockResolvedValueOnce( | |
| 407 | sessionResponse({ | |
| 408 | authenticated: false, | |
| 409 | auth: { | |
| 410 | policy: "desktop-managed-local", | |
| 411 | bootstrapMethods: ["desktop-bootstrap"], | |
| 412 | sessionMethods: ["browser-session-cookie"], | |
| 413 | sessionCookieName: "t3_session", | |
| 414 | }, | |
| 415 | }), | |
| 416 | ) | |
| 417 | .mockResolvedValueOnce( | |
| 418 | sessionResponse({ | |
| 419 | authenticated: true, | |
| 420 | auth: { | |
| 421 | policy: "desktop-managed-local", | |
| 422 | bootstrapMethods: ["desktop-bootstrap"], | |
| 423 | sessionMethods: ["browser-session-cookie"], | |
| 424 | sessionCookieName: "t3_session", | |
| 425 | }, | |
| 426 | sessionMethod: "browser-session-cookie", | |
| 427 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 428 | }), | |
| 429 | ); | |
| 430 | vi.stubGlobal("fetch", fetchMock); | |
| 311 | const nextSession = sequence( | |
| 312 | unauthenticatedSession(DESKTOP_AUTH), | |
| 313 | unauthenticatedSession(DESKTOP_AUTH), | |
| 314 | authenticatedSession(DESKTOP_AUTH), | |
| 315 | ); | |
| 316 | const testApi = await installAuthApi({ | |
| 317 | session: nextSession, | |
| 318 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), | |
| 319 | }); | |
| 431 | 320 | |
| 432 | 321 | const testWindow = installTestBrowser("http://localhost/"); |
| 433 | 322 | testWindow.desktopBridge = { |
| ... | ... | @@ -444,43 +333,14 @@ describe("resolveInitialServerAuthGateState", () => { |
| 444 | 333 | const gateStatePromise = resolveInitialServerAuthGateState(); |
| 445 | 334 | await vi.advanceTimersByTimeAsync(100); |
| 446 | 335 | |
| 447 | await expect(gateStatePromise).resolves.toEqual({ | |
| 448 | status: "authenticated", | |
| 449 | }); | |
| 450 | expect(fetchMock).toHaveBeenCalledTimes(4); | |
| 451 | expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session"); | |
| 452 | expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); | |
| 336 | await expect(gateStatePromise).resolves.toEqual({ status: "authenticated" }); | |
| 337 | expect(testApi.calls.session).toBe(3); | |
| 453 | 338 | }); |
| 454 | 339 | |
| 455 | 340 | it("memoizes the authenticated gate state after the first successful read", async () => { |
| 456 | const fetchMock = vi | |
| 457 | .fn<typeof fetch>() | |
| 458 | .mockResolvedValueOnce( | |
| 459 | sessionResponse({ | |
| 460 | authenticated: true, | |
| 461 | auth: { | |
| 462 | policy: "loopback-browser", | |
| 463 | bootstrapMethods: ["one-time-token"], | |
| 464 | sessionMethods: ["browser-session-cookie"], | |
| 465 | sessionCookieName: "t3_session", | |
| 466 | }, | |
| 467 | sessionMethod: "browser-session-cookie", | |
| 468 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 469 | }), | |
| 470 | ) | |
| 471 | .mockResolvedValueOnce( | |
| 472 | sessionResponse({ | |
| 473 | authenticated: false, | |
| 474 | auth: { | |
| 475 | policy: "loopback-browser", | |
| 476 | bootstrapMethods: ["one-time-token"], | |
| 477 | sessionMethods: ["browser-session-cookie"], | |
| 478 | sessionCookieName: "t3_session", | |
| 479 | }, | |
| 480 | }), | |
| 481 | ); | |
| 482 | vi.stubGlobal("fetch", fetchMock); | |
| 483 | ||
| 341 | const testApi = await installAuthApi({ | |
| 342 | session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), | |
| 343 | }); | |
| 484 | 344 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 485 | 345 | |
| 486 | 346 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| ... | ... | @@ -489,35 +349,33 @@ describe("resolveInitialServerAuthGateState", () => { |
| 489 | 349 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 490 | 350 | status: "authenticated", |
| 491 | 351 | }); |
| 492 | expect(fetchMock).toHaveBeenCalledTimes(1); | |
| 352 | expect(testApi.calls.session).toBe(1); | |
| 493 | 353 | }); |
| 494 | 354 | |
| 495 | 355 | it("creates a pairing credential from the authenticated auth endpoint", async () => { |
| 496 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 497 | jsonResponse({ | |
| 498 | id: "pairing-link-1", | |
| 499 | credential: "pairing-token", | |
| 500 | label: "Julius iPhone", | |
| 501 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 502 | }), | |
| 503 | ); | |
| 504 | vi.stubGlobal("fetch", fetchMock); | |
| 505 | ||
| 356 | const testApi = await installAuthApi({ | |
| 357 | pairingCredential: (payload) => | |
| 358 | Effect.succeed({ | |
| 359 | id: "pairing-link-1", | |
| 360 | credential: "pairing-token", | |
| 361 | ...(payload.label === undefined ? {} : { label: payload.label }), | |
| 362 | expiresAt: SESSION_EXPIRES_AT, | |
| 363 | }), | |
| 364 | }); | |
| 506 | 365 | const { createServerPairingCredential } = await import("./environments/primary"); |
| 507 | 366 | |
| 508 | await expect(createServerPairingCredential("Julius iPhone")).resolves.toEqual({ | |
| 367 | const credential = await createServerPairingCredential({ | |
| 368 | label: "Julius iPhone", | |
| 369 | scopes: ["orchestration:read"], | |
| 370 | }); | |
| 371 | expect(credential).toMatchObject({ | |
| 509 | 372 | id: "pairing-link-1", |
| 510 | 373 | credential: "pairing-token", |
| 511 | 374 | label: "Julius iPhone", |
| 512 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 513 | }); | |
| 514 | expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/pairing-token", { | |
| 515 | body: JSON.stringify({ label: "Julius iPhone" }), | |
| 516 | credentials: "include", | |
| 517 | headers: { | |
| 518 | "content-type": "application/json", | |
| 519 | }, | |
| 520 | method: "POST", | |
| 521 | 375 | }); |
| 376 | expect(DateTime.formatIso(credential.expiresAt)).toBe("2026-04-05T00:00:00.000Z"); | |
| 377 | expect(testApi.calls.pairingCredential).toEqual([ | |
| 378 | { label: "Julius iPhone", scopes: ["orchestration:read"] }, | |
| 379 | ]); | |
| 522 | 380 | }); |
| 523 | 381 | }); |
apps/web/src/components/ChatView.browser.tsx+1-1| ... | ... | @@ -194,7 +194,7 @@ function createBaseServerConfig(): ServerConfig { |
| 194 | 194 | auth: { |
| 195 | 195 | policy: "loopback-browser", |
| 196 | 196 | bootstrapMethods: ["one-time-token"], |
| 197 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 197 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | |
| 198 | 198 | sessionCookieName: "t3_session", |
| 199 | 199 | }, |
| 200 | 200 | cwd: "/repo/project", |
apps/web/src/components/KeybindingsToast.browser.tsx+1-1| ... | ... | @@ -96,7 +96,7 @@ function createBaseServerConfig(): ServerConfig { |
| 96 | 96 | auth: { |
| 97 | 97 | policy: "loopback-browser", |
| 98 | 98 | bootstrapMethods: ["one-time-token"], |
| 99 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 99 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | |
| 100 | 100 | sessionCookieName: "t3_session", |
| 101 | 101 | }, |
| 102 | 102 | cwd: "/repo/project", |
apps/web/src/components/settings/ConnectionsSettings.tsx+209-52| ... | ... | @@ -9,7 +9,18 @@ import { |
| 9 | 9 | } from "lucide-react"; |
| 10 | 10 | import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; |
| 11 | 11 | import { |
| 12 | AuthAccessReadScope, | |
| 13 | AuthAccessWriteScope, | |
| 14 | AuthAdministrativeScopes, | |
| 15 | AuthOrchestrationOperateScope, | |
| 16 | AuthOrchestrationReadScope, | |
| 17 | AuthRelayReadScope, | |
| 18 | AuthRelayWriteScope, | |
| 19 | AuthReviewWriteScope, | |
| 20 | AuthStandardClientScopes, | |
| 21 | AuthTerminalOperateScope, | |
| 12 | 22 | type AuthClientSession, |
| 23 | type AuthEnvironmentScope, | |
| 13 | 24 | type AuthPairingLink, |
| 14 | 25 | type AdvertisedEndpoint, |
| 15 | 26 | type DesktopDiscoveredSshHost, |
| ... | ... | @@ -31,6 +42,7 @@ import { |
| 31 | 42 | useRelativeTimeTick, |
| 32 | 43 | } from "./settingsLayout"; |
| 33 | 44 | import { Input } from "../ui/input"; |
| 45 | import { Checkbox } from "../ui/checkbox"; | |
| 34 | 46 | import { |
| 35 | 47 | Dialog, |
| 36 | 48 | DialogClose, |
| ... | ... | @@ -75,11 +87,11 @@ import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; |
| 75 | 87 | import { readHostedPairingRequest } from "../../hostedPairing"; |
| 76 | 88 | import { |
| 77 | 89 | createServerPairingCredential, |
| 78 | fetchSessionState, | |
| 79 | 90 | revokeOtherServerClientSessions, |
| 80 | 91 | revokeServerClientSession, |
| 81 | 92 | revokeServerPairingLink, |
| 82 | 93 | isLoopbackHostname, |
| 94 | usePrimarySessionState, | |
| 83 | 95 | type ServerClientSessionRecord, |
| 84 | 96 | type ServerPairingLinkRecord, |
| 85 | 97 | } from "~/environments/primary"; |
| ... | ... | @@ -114,6 +126,97 @@ function formatAccessTimestamp(value: string): string { |
| 114 | 126 | return accessTimestampFormatter.format(parsed); |
| 115 | 127 | } |
| 116 | 128 | |
| 129 | const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ | |
| 130 | readonly scope: AuthEnvironmentScope; | |
| 131 | readonly title: string; | |
| 132 | readonly description: string; | |
| 133 | }> = [ | |
| 134 | { | |
| 135 | scope: AuthOrchestrationReadScope, | |
| 136 | title: "View environment", | |
| 137 | description: "Read threads, status, diffs, and configuration.", | |
| 138 | }, | |
| 139 | { | |
| 140 | scope: AuthOrchestrationOperateScope, | |
| 141 | title: "Operate tasks", | |
| 142 | description: "Start tasks and perform changes in the environment.", | |
| 143 | }, | |
| 144 | { | |
| 145 | scope: AuthTerminalOperateScope, | |
| 146 | title: "Use terminals", | |
| 147 | description: "Create terminals and send input to running shells.", | |
| 148 | }, | |
| 149 | { | |
| 150 | scope: AuthReviewWriteScope, | |
| 151 | title: "Write reviews", | |
| 152 | description: "Create comments while reviewing changes.", | |
| 153 | }, | |
| 154 | { | |
| 155 | scope: AuthAccessReadScope, | |
| 156 | title: "View access", | |
| 157 | description: "Inspect pairing links and authorized clients.", | |
| 158 | }, | |
| 159 | { | |
| 160 | scope: AuthAccessWriteScope, | |
| 161 | title: "Manage access", | |
| 162 | description: "Issue and revoke credentials for other clients.", | |
| 163 | }, | |
| 164 | { | |
| 165 | scope: AuthRelayReadScope, | |
| 166 | title: "View relay", | |
| 167 | description: "Inspect managed relay connectivity.", | |
| 168 | }, | |
| 169 | { | |
| 170 | scope: AuthRelayWriteScope, | |
| 171 | title: "Manage relay", | |
| 172 | description: "Change managed tunnel connectivity.", | |
| 173 | }, | |
| 174 | ]; | |
| 175 | ||
| 176 | function AccessScopeSummary({ | |
| 177 | scopes, | |
| 178 | label, | |
| 179 | }: { | |
| 180 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 181 | readonly label: string; | |
| 182 | }) { | |
| 183 | const scopeCountLabel = `${scopes.length} ${scopes.length === 1 ? "scope" : "scopes"}`; | |
| 184 | ||
| 185 | return ( | |
| 186 | <Popover> | |
| 187 | <PopoverTrigger | |
| 188 | openOnHover | |
| 189 | delay={250} | |
| 190 | closeDelay={100} | |
| 191 | render={ | |
| 192 | <button | |
| 193 | type="button" | |
| 194 | aria-label={`${label}: show ${scopeCountLabel}`} | |
| 195 | className="cursor-help underline decoration-border underline-offset-2 outline-hidden hover:text-foreground focus-visible:text-foreground" | |
| 196 | /> | |
| 197 | } | |
| 198 | > | |
| 199 | {scopeCountLabel} | |
| 200 | </PopoverTrigger> | |
| 201 | <PopoverPopup | |
| 202 | side="top" | |
| 203 | align="start" | |
| 204 | tooltipStyle | |
| 205 | className="w-max max-w-80 whitespace-normal" | |
| 206 | > | |
| 207 | <p className="mb-1 font-medium">Granted scopes</p> | |
| 208 | <div className="flex flex-col gap-0.5"> | |
| 209 | {scopes.map((scope) => ( | |
| 210 | <code key={scope} className="font-mono text-foreground/85"> | |
| 211 | {scope} | |
| 212 | </code> | |
| 213 | ))} | |
| 214 | </div> | |
| 215 | </PopoverPopup> | |
| 216 | </Popover> | |
| 217 | ); | |
| 218 | } | |
| 219 | ||
| 117 | 220 | type ConnectionStatusDotProps = { |
| 118 | 221 | tooltipText?: string | null; |
| 119 | 222 | dotClassName: string; |
| ... | ... | @@ -629,8 +732,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 629 | 732 | |
| 630 | 733 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); |
| 631 | 734 | |
| 632 | const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; | |
| 633 | const primaryLabel = pairingLink.label ?? `${roleLabel} link`; | |
| 735 | const primaryLabel = pairingLink.label ?? "Pairing link"; | |
| 634 | 736 | const defaultEndpointCopyOption = |
| 635 | 737 | endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? |
| 636 | 738 | endpointCopyOptions[0] ?? |
| ... | ... | @@ -759,7 +861,9 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 759 | 861 | </Popover> |
| 760 | 862 | </div> |
| 761 | 863 | <p className="text-xs text-muted-foreground" title={expiresAbsolute}> |
| 762 | {[roleLabel, formatExpiresInLabel(pairingLink.expiresAt, nowMs)].join(" · ")} | |
| 864 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} | |
| 865 | <span aria-hidden> · </span> | |
| 866 | <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" /> | |
| 763 | 867 | </p> |
| 764 | 868 | {shareablePairingUrl === null ? ( |
| 765 | 869 | <p className="text-[11px] text-muted-foreground/70"> |
| ... | ... | @@ -900,7 +1004,6 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 900 | 1004 | : lastConnectedAt |
| 901 | 1005 | ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` |
| 902 | 1006 | : "Not connected yet."; |
| 903 | const roleLabel = clientSession.role === "owner" ? "Owner" : "Client"; | |
| 904 | 1007 | const deviceInfoBits = [ |
| 905 | 1008 | clientSession.client.deviceType !== "unknown" |
| 906 | 1009 | ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1) |
| ... | ... | @@ -932,7 +1035,13 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 932 | 1035 | ) : null} |
| 933 | 1036 | </div> |
| 934 | 1037 | <p className="text-xs text-muted-foreground"> |
| 935 | {[roleLabel, ...deviceInfoBits].join(" · ")} | |
| 1038 | {deviceInfoBits.length > 0 ? ( | |
| 1039 | <> | |
| 1040 | {deviceInfoBits.join(" · ")} | |
| 1041 | <span aria-hidden> · </span> | |
| 1042 | </> | |
| 1043 | ) : null} | |
| 1044 | <AccessScopeSummary scopes={clientSession.scopes} label="Client scopes" /> | |
| 936 | 1045 | </p> |
| 937 | 1046 | </div> |
| 938 | 1047 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| ... | ... | @@ -965,13 +1074,17 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 965 | 1074 | }: AuthorizedClientsHeaderActionProps) { |
| 966 | 1075 | const [dialogOpen, setDialogOpen] = useState(false); |
| 967 | 1076 | const [pairingLabel, setPairingLabel] = useState(""); |
| 1077 | const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([ | |
| 1078 | ...AuthStandardClientScopes, | |
| 1079 | ]); | |
| 968 | 1080 | const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false); |
| 969 | 1081 | |
| 970 | 1082 | const handleCreatePairingLink = useCallback(async () => { |
| 971 | 1083 | setIsCreatingPairingLink(true); |
| 972 | 1084 | try { |
| 973 | await createServerPairingCredential(pairingLabel); | |
| 1085 | await createServerPairingCredential({ label: pairingLabel, scopes: pairingScopes }); | |
| 974 | 1086 | setPairingLabel(""); |
| 1087 | setPairingScopes([...AuthStandardClientScopes]); | |
| 975 | 1088 | setDialogOpen(false); |
| 976 | 1089 | } catch (error) { |
| 977 | 1090 | const message = error instanceof Error ? error.message : "Failed to create pairing URL."; |
| ... | ... | @@ -985,7 +1098,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 985 | 1098 | } finally { |
| 986 | 1099 | setIsCreatingPairingLink(false); |
| 987 | 1100 | } |
| 988 | }, [pairingLabel]); | |
| 1101 | }, [pairingLabel, pairingScopes]); | |
| 1102 | ||
| 1103 | const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => { | |
| 1104 | setPairingScopes((current) => | |
| 1105 | checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope), | |
| 1106 | ); | |
| 1107 | }, []); | |
| 989 | 1108 | |
| 990 | 1109 | return ( |
| 991 | 1110 | <div className="flex items-center gap-2"> |
| ... | ... | @@ -1005,6 +1124,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1005 | 1124 | setDialogOpen(open); |
| 1006 | 1125 | if (!open) { |
| 1007 | 1126 | setPairingLabel(""); |
| 1127 | setPairingScopes([...AuthStandardClientScopes]); | |
| 1008 | 1128 | } |
| 1009 | 1129 | }} |
| 1010 | 1130 | > |
| ... | ... | @@ -1016,7 +1136,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1016 | 1136 | </Button> |
| 1017 | 1137 | } |
| 1018 | 1138 | /> |
| 1019 | <DialogPopup className="max-w-sm"> | |
| 1139 | <DialogPopup className="max-w-md"> | |
| 1020 | 1140 | <DialogHeader> |
| 1021 | 1141 | <DialogTitle>Create pairing link</DialogTitle> |
| 1022 | 1142 | <DialogDescription> |
| ... | ... | @@ -1024,7 +1144,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1024 | 1144 | authorized client. |
| 1025 | 1145 | </DialogDescription> |
| 1026 | 1146 | </DialogHeader> |
| 1027 | <DialogPanel> | |
| 1147 | <DialogPanel className="space-y-5"> | |
| 1028 | 1148 | <label className="block"> |
| 1029 | 1149 | <span className="mb-1.5 block text-xs font-medium text-foreground"> |
| 1030 | 1150 | Client label (optional) |
| ... | ... | @@ -1037,6 +1157,62 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1037 | 1157 | autoFocus |
| 1038 | 1158 | /> |
| 1039 | 1159 | </label> |
| 1160 | <section className="space-y-3"> | |
| 1161 | <div className="flex items-center justify-between gap-3"> | |
| 1162 | <div> | |
| 1163 | <h3 className="text-xs font-medium text-foreground">Permissions</h3> | |
| 1164 | <p className="text-xs text-muted-foreground"> | |
| 1165 | Limit what the paired client can do. | |
| 1166 | </p> | |
| 1167 | </div> | |
| 1168 | <div className="flex gap-1"> | |
| 1169 | <Button | |
| 1170 | size="xs" | |
| 1171 | variant="outline" | |
| 1172 | disabled={isCreatingPairingLink} | |
| 1173 | onClick={() => setPairingScopes([AuthOrchestrationReadScope])} | |
| 1174 | > | |
| 1175 | Read only | |
| 1176 | </Button> | |
| 1177 | <Button | |
| 1178 | size="xs" | |
| 1179 | variant="outline" | |
| 1180 | disabled={isCreatingPairingLink} | |
| 1181 | onClick={() => setPairingScopes([...AuthStandardClientScopes])} | |
| 1182 | > | |
| 1183 | Standard | |
| 1184 | </Button> | |
| 1185 | </div> | |
| 1186 | </div> | |
| 1187 | <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25"> | |
| 1188 | {PAIRING_SCOPE_OPTIONS.map(({ scope, title, description }) => ( | |
| 1189 | <label | |
| 1190 | key={scope} | |
| 1191 | className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40" | |
| 1192 | > | |
| 1193 | <Checkbox | |
| 1194 | className="mt-0.5" | |
| 1195 | checked={pairingScopes.includes(scope)} | |
| 1196 | disabled={isCreatingPairingLink} | |
| 1197 | onCheckedChange={(checked) => togglePairingScope(scope, checked === true)} | |
| 1198 | /> | |
| 1199 | <span className="min-w-0"> | |
| 1200 | <span className="block text-xs font-medium text-foreground">{title}</span> | |
| 1201 | <span className="block text-xs leading-snug text-muted-foreground"> | |
| 1202 | {description} | |
| 1203 | </span> | |
| 1204 | </span> | |
| 1205 | </label> | |
| 1206 | ))} | |
| 1207 | </div> | |
| 1208 | {pairingScopes.length === 0 ? ( | |
| 1209 | <p className="text-xs text-destructive">Select at least one permission.</p> | |
| 1210 | ) : pairingScopes.includes(AuthAccessWriteScope) ? ( | |
| 1211 | <p className="text-xs text-warning"> | |
| 1212 | This client can create or revoke access for other devices. | |
| 1213 | </p> | |
| 1214 | ) : null} | |
| 1215 | </section> | |
| 1040 | 1216 | </DialogPanel> |
| 1041 | 1217 | <DialogFooter variant="bare"> |
| 1042 | 1218 | <Button |
| ... | ... | @@ -1046,7 +1222,10 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1046 | 1222 | > |
| 1047 | 1223 | Cancel |
| 1048 | 1224 | </Button> |
| 1049 | <Button disabled={isCreatingPairingLink} onClick={() => void handleCreatePairingLink()}> | |
| 1225 | <Button | |
| 1226 | disabled={isCreatingPairingLink || pairingScopes.length === 0} | |
| 1227 | onClick={() => void handleCreatePairingLink()} | |
| 1228 | > | |
| 1050 | 1229 | {isCreatingPairingLink ? "Creating…" : "Create link"} |
| 1051 | 1230 | </Button> |
| 1052 | 1231 | </DialogFooter> |
| ... | ... | @@ -1290,14 +1469,12 @@ function SavedBackendListRow({ |
| 1290 | 1469 | : connectionState === "error" |
| 1291 | 1470 | ? "bg-destructive" |
| 1292 | 1471 | : "bg-muted-foreground/40"; |
| 1293 | const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null; | |
| 1294 | 1472 | const descriptorLabel = runtime?.descriptor?.label ?? null; |
| 1295 | 1473 | const displayLabel = descriptorLabel ?? record.label; |
| 1296 | 1474 | const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); |
| 1297 | 1475 | const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); |
| 1298 | 1476 | const metadataBits = [ |
| 1299 | 1477 | record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, |
| 1300 | roleLabel, | |
| 1301 | 1478 | record.lastConnectedAt |
| 1302 | 1479 | ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` |
| 1303 | 1480 | : null, |
| ... | ... | @@ -1317,8 +1494,14 @@ function SavedBackendListRow({ |
| 1317 | 1494 | /> |
| 1318 | 1495 | <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3> |
| 1319 | 1496 | </div> |
| 1320 | {metadataBits.length > 0 ? ( | |
| 1321 | <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> | |
| 1497 | {metadataBits.length > 0 || runtime?.scopes ? ( | |
| 1498 | <p className="text-xs text-muted-foreground"> | |
| 1499 | {metadataBits.length > 0 ? metadataBits.join(" · ") : null} | |
| 1500 | {metadataBits.length > 0 && runtime?.scopes ? <span aria-hidden> · </span> : null} | |
| 1501 | {runtime?.scopes ? ( | |
| 1502 | <AccessScopeSummary scopes={runtime.scopes} label="Granted scopes" /> | |
| 1503 | ) : null} | |
| 1504 | </p> | |
| 1322 | 1505 | ) : null} |
| 1323 | 1506 | {versionMismatch ? ( |
| 1324 | 1507 | <p className="flex items-center gap-1 text-warning text-xs"> |
| ... | ... | @@ -1401,12 +1584,13 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({ |
| 1401 | 1584 | |
| 1402 | 1585 | export function ConnectionsSettings() { |
| 1403 | 1586 | const desktopBridge = window.desktopBridge; |
| 1404 | const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>( | |
| 1405 | desktopBridge ? "owner" : null, | |
| 1406 | ); | |
| 1407 | const [currentAuthPolicy, setCurrentAuthPolicy] = useState< | |
| 1408 | "desktop-managed-local" | "loopback-browser" | "remote-reachable" | "unsafe-no-auth" | null | |
| 1409 | >(desktopBridge ? null : null); | |
| 1587 | const primarySessionState = usePrimarySessionState(); | |
| 1588 | const currentSessionScopes = desktopBridge | |
| 1589 | ? AuthAdministrativeScopes | |
| 1590 | : primarySessionState.data?.authenticated | |
| 1591 | ? (primarySessionState.data.scopes ?? null) | |
| 1592 | : null; | |
| 1593 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); | |
| 1410 | 1594 | const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId); |
| 1411 | 1595 | const savedEnvironmentIds = useMemo( |
| 1412 | 1596 | () => |
| ... | ... | @@ -1516,7 +1700,7 @@ export function ConnectionsSettings() { |
| 1516 | 1700 | const setDefaultAdvertisedEndpointKey = useUiStateStore( |
| 1517 | 1701 | (state) => state.setDefaultAdvertisedEndpointKey, |
| 1518 | 1702 | ); |
| 1519 | const canManageLocalBackend = currentSessionRole === "owner"; | |
| 1703 | const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; | |
| 1520 | 1704 | const isLocalBackendNetworkAccessible = desktopBridge |
| 1521 | 1705 | ? desktopServerExposureState?.mode === "network-accessible" |
| 1522 | 1706 | : currentAuthPolicy === "remote-reachable"; |
| ... | ... | @@ -1929,30 +2113,6 @@ export function ConnectionsSettings() { |
| 1929 | 2113 | savedBackendMode, |
| 1930 | 2114 | ]); |
| 1931 | 2115 | |
| 1932 | useEffect(() => { | |
| 1933 | if (desktopBridge) { | |
| 1934 | setCurrentSessionRole("owner"); | |
| 1935 | return; | |
| 1936 | } | |
| 1937 | ||
| 1938 | let cancelled = false; | |
| 1939 | void fetchSessionState() | |
| 1940 | .then((session) => { | |
| 1941 | if (cancelled) return; | |
| 1942 | setCurrentSessionRole(session.authenticated ? (session.role ?? null) : null); | |
| 1943 | setCurrentAuthPolicy(session.auth.policy); | |
| 1944 | }) | |
| 1945 | .catch(() => { | |
| 1946 | if (cancelled) return; | |
| 1947 | setCurrentSessionRole(null); | |
| 1948 | setCurrentAuthPolicy(null); | |
| 1949 | }); | |
| 1950 | ||
| 1951 | return () => { | |
| 1952 | cancelled = true; | |
| 1953 | }; | |
| 1954 | }, [desktopBridge]); | |
| 1955 | ||
| 1956 | 2116 | useEffect(() => { |
| 1957 | 2117 | if (!canManageLocalBackend) return; |
| 1958 | 2118 | |
| ... | ... | @@ -2065,10 +2225,7 @@ export function ConnectionsSettings() { |
| 2065 | 2225 | setDesktopAdvertisedEndpoints([]); |
| 2066 | 2226 | setDesktopServerExposureError(null); |
| 2067 | 2227 | }, [canManageLocalBackend]); |
| 2068 | const visibleDesktopPairingLinks = useMemo( | |
| 2069 | () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"), | |
| 2070 | [desktopPairingLinks], | |
| 2071 | ); | |
| 2228 | const visibleDesktopPairingLinks = desktopPairingLinks; | |
| 2072 | 2229 | const tailscaleHttpsEndpoint = useMemo( |
| 2073 | 2230 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, |
| 2074 | 2231 | [desktopAdvertisedEndpoints], |
| ... | ... | @@ -2650,8 +2807,8 @@ export function ConnectionsSettings() { |
| 2650 | 2807 | ) : ( |
| 2651 | 2808 | <SettingsSection title="Local backend access"> |
| 2652 | 2809 | <SettingsRow |
| 2653 | title="Owner tools" | |
| 2654 | description="Pairing links and client-session management are only available to owner sessions for this backend." | |
| 2810 | title="Administrative access" | |
| 2811 | description="Pairing links and client-session management require the access:write scope for this backend." | |
| 2655 | 2812 | /> |
| 2656 | 2813 | </SettingsSection> |
| 2657 | 2814 | )} |
apps/web/src/components/settings/SettingsPanels.browser.tsx+32-21| ... | ... | @@ -3,6 +3,7 @@ import "../../index.css"; |
| 3 | 3 | import { |
| 4 | 4 | type AuthAccessStreamEvent, |
| 5 | 5 | type AuthAccessSnapshot, |
| 6 | type AuthEnvironmentScope, | |
| 6 | 7 | AuthSessionId, |
| 7 | 8 | DEFAULT_SERVER_SETTINGS, |
| 8 | 9 | EnvironmentId, |
| ... | ... | @@ -217,7 +218,7 @@ function createBaseServerConfig(): ServerConfig { |
| 217 | 218 | auth: { |
| 218 | 219 | policy: "loopback-browser", |
| 219 | 220 | bootstrapMethods: ["one-time-token"], |
| 220 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 221 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | |
| 221 | 222 | sessionCookieName: "t3_session", |
| 222 | 223 | }, |
| 223 | 224 | cwd: "/repo/project", |
| ... | ... | @@ -286,7 +287,7 @@ function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistory |
| 286 | 287 | function makePairingLink(input: { |
| 287 | 288 | readonly id: string; |
| 288 | 289 | readonly credential: string; |
| 289 | readonly role: "owner" | "client"; | |
| 290 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 290 | 291 | readonly subject: string; |
| 291 | 292 | readonly label?: string; |
| 292 | 293 | readonly createdAt: string; |
| ... | ... | @@ -302,7 +303,7 @@ function makePairingLink(input: { |
| 302 | 303 | function makeClientSession(input: { |
| 303 | 304 | readonly sessionId: string; |
| 304 | 305 | readonly subject: string; |
| 305 | readonly role: "owner" | "client"; | |
| 306 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 306 | 307 | readonly method: "browser-session-cookie"; |
| 307 | 308 | readonly client?: { |
| 308 | 309 | readonly label?: string; |
| ... | ... | @@ -394,26 +395,26 @@ const createDesktopBridgeStub = (overrides?: { |
| 394 | 395 | }, |
| 395 | 396 | }), |
| 396 | 397 | bootstrapSshBearerSession: vi.fn().mockResolvedValue({ |
| 397 | authenticated: true, | |
| 398 | role: "owner", | |
| 399 | sessionMethod: "bearer-session-token", | |
| 400 | expiresAt: "2026-05-01T12:00:00.000Z", | |
| 401 | sessionToken: "ssh-bearer-token", | |
| 398 | access_token: "ssh-bearer-token", | |
| 399 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | |
| 400 | token_type: "Bearer", | |
| 401 | expires_in: 3_600, | |
| 402 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 402 | 403 | }), |
| 403 | 404 | fetchSshSessionState: vi.fn().mockResolvedValue({ |
| 404 | 405 | authenticated: true, |
| 405 | 406 | auth: { |
| 406 | 407 | policy: "remote-reachable", |
| 407 | 408 | bootstrapMethods: ["one-time-token"], |
| 408 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 409 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | |
| 409 | 410 | sessionCookieName: "t3_session", |
| 410 | 411 | }, |
| 411 | role: "owner", | |
| 412 | sessionMethod: "bearer-session-token", | |
| 412 | scopes: ["orchestration:read", "access:write"], | |
| 413 | sessionMethod: "bearer-access-token", | |
| 413 | 414 | expiresAt: "2026-05-01T12:00:00.000Z", |
| 414 | 415 | }), |
| 415 | issueSshWebSocketToken: vi.fn().mockResolvedValue({ | |
| 416 | token: "ssh-ws-token", | |
| 416 | issueSshWebSocketTicket: vi.fn().mockResolvedValue({ | |
| 417 | ticket: "ssh-ws-ticket", | |
| 417 | 418 | expiresAt: "2026-05-01T12:05:00.000Z", |
| 418 | 419 | }), |
| 419 | 420 | onSshPasswordPrompt: vi.fn(() => () => {}), |
| ... | ... | @@ -508,7 +509,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 508 | 509 | makeClientSession({ |
| 509 | 510 | sessionId: "session-owner", |
| 510 | 511 | subject: "browser-owner", |
| 511 | role: "owner", | |
| 512 | scopes: ["orchestration:read", "access:write"], | |
| 512 | 513 | method: "browser-session-cookie", |
| 513 | 514 | client: { |
| 514 | 515 | label: "Chrome on Mac", |
| ... | ... | @@ -531,7 +532,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 531 | 532 | JSON.stringify({ |
| 532 | 533 | authenticated: true, |
| 533 | 534 | auth: createBaseServerConfig().auth, |
| 534 | role: "owner", | |
| 535 | scopes: ["orchestration:read", "access:write"], | |
| 535 | 536 | sessionMethod: "browser-session-cookie", |
| 536 | 537 | expiresAt: "2036-05-07T00:00:00.000Z", |
| 537 | 538 | }), |
| ... | ... | @@ -776,7 +777,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 776 | 777 | makeClientSession({ |
| 777 | 778 | sessionId: "session-owner", |
| 778 | 779 | subject: "desktop-bootstrap", |
| 779 | role: "owner", | |
| 780 | scopes: ["orchestration:read", "access:write"], | |
| 780 | 781 | method: "browser-session-cookie", |
| 781 | 782 | client: { |
| 782 | 783 | label: "This Mac", |
| ... | ... | @@ -805,7 +806,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 805 | 806 | makePairingLink({ |
| 806 | 807 | id: "pairing-link-1", |
| 807 | 808 | credential: "pairing-token", |
| 808 | role: "client", | |
| 809 | scopes: ["orchestration:read"], | |
| 809 | 810 | subject: "one-time-token", |
| 810 | 811 | label: "Julius iPhone", |
| 811 | 812 | createdAt: "2036-04-07T00:00:00.000Z", |
| ... | ... | @@ -817,7 +818,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 817 | 818 | makeClientSession({ |
| 818 | 819 | sessionId: "session-client", |
| 819 | 820 | subject: "one-time-token", |
| 820 | role: "client", | |
| 821 | scopes: ["orchestration:read"], | |
| 821 | 822 | method: "browser-session-cookie", |
| 822 | 823 | client: { |
| 823 | 824 | label: "Julius iPhone", |
| ... | ... | @@ -867,12 +868,22 @@ describe("GeneralSettingsPanel observability", () => { |
| 867 | 868 | await expect.element(page.getByText("This Mac")).toBeInTheDocument(); |
| 868 | 869 | await page.getByRole("button", { name: "Create link", exact: true }).click(); |
| 869 | 870 | await expect.element(page.getByText("Create pairing link")).toBeInTheDocument(); |
| 871 | await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked(); | |
| 872 | await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).toBeChecked(); | |
| 873 | await page.getByRole("button", { name: "Read only", exact: true }).click(); | |
| 874 | await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked(); | |
| 875 | await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).not.toBeChecked(); | |
| 870 | 876 | await page.getByRole("button", { name: "Create link", exact: true }).click(); |
| 871 | 877 | authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!); |
| 872 | 878 | authAccessHarness.emitClientUpserted(clientSessions[1]!); |
| 873 | 879 | await expect |
| 874 | .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88")) | |
| 880 | .element(page.getByRole("button", { name: "Pairing link scopes: show 1 scope" })) | |
| 881 | .toBeInTheDocument(); | |
| 882 | await expect | |
| 883 | .element(page.getByText("Mobile · iOS · Safari · 192.168.1.88")) | |
| 875 | 884 | .toBeInTheDocument(); |
| 885 | await page.getByRole("button", { name: "Client scopes: show 1 scope" }).click(); | |
| 886 | await expect.element(page.getByText("orchestration:read", { exact: true })).toBeInTheDocument(); | |
| 876 | 887 | await expect |
| 877 | 888 | .element(page.getByRole("button", { name: /^Copy pairing URL for:/ })) |
| 878 | 889 | .toBeInTheDocument(); |
| ... | ... | @@ -893,7 +904,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 893 | 904 | makeClientSession({ |
| 894 | 905 | sessionId: "session-owner", |
| 895 | 906 | subject: "desktop-bootstrap", |
| 896 | role: "owner", | |
| 907 | scopes: ["orchestration:read", "access:write"], | |
| 897 | 908 | method: "browser-session-cookie", |
| 898 | 909 | client: { |
| 899 | 910 | label: "This Mac", |
| ... | ... | @@ -909,7 +920,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 909 | 920 | makeClientSession({ |
| 910 | 921 | sessionId: "session-client", |
| 911 | 922 | subject: "one-time-token", |
| 912 | role: "client", | |
| 923 | scopes: ["orchestration:read"], | |
| 913 | 924 | method: "browser-session-cookie", |
| 914 | 925 | client: { |
| 915 | 926 | label: "Julius iPhone", |
apps/web/src/environments/primary/auth.ts+209-132| ... | ... | @@ -1,21 +1,25 @@ |
| 1 | 1 | import type { |
| 2 | AuthBootstrapInput, | |
| 3 | AuthBootstrapResult, | |
| 2 | AuthBrowserSessionResult, | |
| 4 | 3 | AuthClientMetadata, |
| 5 | AuthCreatePairingCredentialInput, | |
| 4 | AuthEnvironmentScope, | |
| 6 | 5 | AuthPairingCredentialResult, |
| 7 | AuthRevokeClientSessionInput, | |
| 8 | AuthRevokePairingLinkInput, | |
| 9 | 6 | AuthSessionId, |
| 10 | 7 | AuthSessionState, |
| 11 | 8 | } from "@t3tools/contracts"; |
| 9 | import { EnvironmentHttpCommonError } from "@t3tools/contracts"; | |
| 10 | import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts"; | |
| 11 | import * as DateTime from "effect/DateTime"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Schema from "effect/Schema"; | |
| 14 | import { HttpClientError } from "effect/unstable/http"; | |
| 12 | 15 | |
| 13 | 16 | import { |
| 14 | 17 | getPairingTokenFromUrl, |
| 15 | 18 | stripPairingTokenFromUrl as stripPairingTokenUrl, |
| 16 | 19 | } from "../../pairingUrl"; |
| 17 | 20 | |
| 18 | import { resolvePrimaryEnvironmentHttpUrl } from "./target"; | |
| 21 | import { PrimaryEnvironmentHttpClient } from "./httpClient"; | |
| 22 | import { runPrimaryHttp } from "../../lib/runtime"; | |
| 19 | 23 | import * as Data from "effect/Data"; |
| 20 | 24 | import * as Predicate from "effect/Predicate"; |
| 21 | 25 | |
| ... | ... | @@ -25,11 +29,12 @@ export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ |
| 25 | 29 | }> {} |
| 26 | 30 | const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => |
| 27 | 31 | Predicate.isTagged(u, "BootstrapHttpError"); |
| 32 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); | |
| 28 | 33 | |
| 29 | 34 | export interface ServerPairingLinkRecord { |
| 30 | 35 | readonly id: string; |
| 31 | 36 | readonly credential: string; |
| 32 | readonly role: "owner" | "client"; | |
| 37 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 33 | 38 | readonly subject: string; |
| 34 | 39 | readonly label?: string; |
| 35 | 40 | readonly createdAt: string; |
| ... | ... | @@ -39,8 +44,8 @@ export interface ServerPairingLinkRecord { |
| 39 | 44 | export interface ServerClientSessionRecord { |
| 40 | 45 | readonly sessionId: AuthSessionId; |
| 41 | 46 | readonly subject: string; |
| 42 | readonly role: "owner" | "client"; | |
| 43 | readonly method: "browser-session-cookie" | "bearer-session-token"; | |
| 47 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 48 | readonly method: "browser-session-cookie" | "bearer-access-token"; | |
| 44 | 49 | readonly client: AuthClientMetadata; |
| 45 | 50 | readonly issuedAt: string; |
| 46 | 51 | readonly expiresAt: string; |
| ... | ... | @@ -93,79 +98,97 @@ function getDesktopBootstrapCredential(): string | null { |
| 93 | 98 | |
| 94 | 99 | export async function fetchSessionState(): Promise<AuthSessionState> { |
| 95 | 100 | return retryTransientBootstrap(async () => { |
| 96 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/session"), { | |
| 97 | credentials: "include", | |
| 98 | }); | |
| 99 | if (!response.ok) { | |
| 101 | try { | |
| 102 | return await runPrimaryHttp( | |
| 103 | PrimaryEnvironmentHttpClient.pipe( | |
| 104 | Effect.flatMap((client) => client.auth.session({ headers: {} })), | |
| 105 | ), | |
| 106 | ); | |
| 107 | } catch (error) { | |
| 108 | const status = readHttpApiStatus(error); | |
| 100 | 109 | throw new BootstrapHttpError({ |
| 101 | message: `Failed to load server auth session state (${response.status}).`, | |
| 102 | status: response.status, | |
| 110 | message: `Failed to load server auth session state (${status ?? "unknown"}).`, | |
| 111 | status: status ?? 500, | |
| 103 | 112 | }); |
| 104 | 113 | } |
| 105 | return (await response.json()) as AuthSessionState; | |
| 106 | 114 | }); |
| 107 | 115 | } |
| 108 | 116 | |
| 109 | async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> { | |
| 110 | const text = await response.text(); | |
| 111 | return text || fallbackMessage; | |
| 117 | function readHttpApiStatus(error: unknown): number | null { | |
| 118 | if (isEnvironmentHttpCommonError(error)) { | |
| 119 | return readEnvironmentHttpErrorStatus(error); | |
| 120 | } | |
| 121 | return HttpClientError.isHttpClientError(error) && error.response !== undefined | |
| 122 | ? error.response.status | |
| 123 | : null; | |
| 112 | 124 | } |
| 113 | 125 | |
| 114 | const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([ | |
| 115 | "Invalid bootstrap credential.", | |
| 116 | "Unknown bootstrap credential.", | |
| 117 | ]); | |
| 118 | ||
| 119 | function parseBootstrapErrorMessage(message: string): string { | |
| 120 | const trimmed = message.trim(); | |
| 121 | if (trimmed.length === 0) { | |
| 122 | return ""; | |
| 126 | function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): number { | |
| 127 | switch (error._tag) { | |
| 128 | case "EnvironmentRequestInvalidError": | |
| 129 | return 400; | |
| 130 | case "EnvironmentAuthInvalidError": | |
| 131 | return 401; | |
| 132 | case "EnvironmentScopeRequiredError": | |
| 133 | case "EnvironmentOperationForbiddenError": | |
| 134 | return 403; | |
| 135 | case "EnvironmentInternalError": | |
| 136 | return 500; | |
| 123 | 137 | } |
| 138 | } | |
| 124 | 139 | |
| 125 | try { | |
| 126 | const parsed = JSON.parse(trimmed) as { | |
| 127 | error?: unknown; | |
| 128 | }; | |
| 129 | if (typeof parsed.error === "string" && parsed.error.trim().length > 0) { | |
| 130 | return parsed.error.trim(); | |
| 131 | } | |
| 132 | } catch { | |
| 133 | // Not JSON; fall back to plain text. | |
| 140 | function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string { | |
| 141 | if (!isEnvironmentHttpCommonError(error)) { | |
| 142 | return fallbackMessage; | |
| 143 | } | |
| 144 | switch (error._tag) { | |
| 145 | case "EnvironmentAuthInvalidError": | |
| 146 | return error.reason === "missing_credential" | |
| 147 | ? "Authentication required." | |
| 148 | : "Invalid bootstrap credential."; | |
| 149 | case "EnvironmentRequestInvalidError": | |
| 150 | return error.reason === "invalid_scope" | |
| 151 | ? "Requested token scope is invalid." | |
| 152 | : "Requested scope exceeds the bootstrap credential grant."; | |
| 153 | case "EnvironmentScopeRequiredError": | |
| 154 | return `The authenticated token is missing required scope: ${error.requiredScope}.`; | |
| 155 | case "EnvironmentOperationForbiddenError": | |
| 156 | return "This operation is not allowed for the current session."; | |
| 157 | case "EnvironmentInternalError": | |
| 158 | return fallbackMessage; | |
| 134 | 159 | } |
| 135 | ||
| 136 | return trimmed; | |
| 137 | 160 | } |
| 138 | 161 | |
| 162 | const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([ | |
| 163 | "Invalid bootstrap credential.", | |
| 164 | "Unknown bootstrap credential.", | |
| 165 | ]); | |
| 166 | ||
| 139 | 167 | function toFriendlyBootstrapErrorMessage(status: number, message: string): string { |
| 140 | const parsedMessage = parseBootstrapErrorMessage(message); | |
| 141 | if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(parsedMessage)) { | |
| 168 | const trimmedMessage = message.trim(); | |
| 169 | if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) { | |
| 142 | 170 | return "Invalid pairing token. Check the token and try again."; |
| 143 | 171 | } |
| 144 | 172 | |
| 145 | return parsedMessage; | |
| 173 | return trimmedMessage; | |
| 146 | 174 | } |
| 147 | 175 | |
| 148 | async function exchangeBootstrapCredential(credential: string): Promise<AuthBootstrapResult> { | |
| 176 | async function exchangeBootstrapCredential(credential: string): Promise<AuthBrowserSessionResult> { | |
| 149 | 177 | return retryTransientBootstrap(async () => { |
| 150 | const payload: AuthBootstrapInput = { credential }; | |
| 151 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/bootstrap"), { | |
| 152 | body: JSON.stringify(payload), | |
| 153 | credentials: "include", | |
| 154 | headers: { | |
| 155 | "content-type": "application/json", | |
| 156 | }, | |
| 157 | method: "POST", | |
| 158 | }); | |
| 159 | ||
| 160 | if (!response.ok) { | |
| 161 | const message = toFriendlyBootstrapErrorMessage(response.status, await response.text()); | |
| 178 | try { | |
| 179 | return await runPrimaryHttp( | |
| 180 | PrimaryEnvironmentHttpClient.pipe( | |
| 181 | Effect.flatMap((client) => client.auth.browserSession({ payload: { credential } })), | |
| 182 | ), | |
| 183 | ); | |
| 184 | } catch (error) { | |
| 185 | const status = readHttpApiStatus(error) ?? 500; | |
| 186 | const message = toFriendlyBootstrapErrorMessage(status, readHttpApiErrorMessage(error, "")); | |
| 162 | 187 | throw new BootstrapHttpError({ |
| 163 | message: message || `Failed to bootstrap auth session (${response.status}).`, | |
| 164 | status: response.status, | |
| 188 | message: message || `Failed to bootstrap auth session (${status}).`, | |
| 189 | status, | |
| 165 | 190 | }); |
| 166 | 191 | } |
| 167 | ||
| 168 | return (await response.json()) as AuthBootstrapResult; | |
| 169 | 192 | }); |
| 170 | 193 | } |
| 171 | 194 | |
| ... | ... | @@ -266,57 +289,93 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 266 | 289 | stripPairingTokenFromUrl(); |
| 267 | 290 | } |
| 268 | 291 | |
| 269 | export async function createServerPairingCredential( | |
| 270 | label?: string, | |
| 271 | ): Promise<AuthPairingCredentialResult> { | |
| 272 | const trimmedLabel = label?.trim(); | |
| 273 | const payload: AuthCreatePairingCredentialInput = trimmedLabel ? { label: trimmedLabel } : {}; | |
| 274 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-token"), { | |
| 275 | body: JSON.stringify(payload), | |
| 276 | credentials: "include", | |
| 277 | headers: { | |
| 278 | "content-type": "application/json", | |
| 279 | }, | |
| 280 | method: "POST", | |
| 281 | }); | |
| 282 | ||
| 283 | if (!response.ok) { | |
| 292 | export async function createServerPairingCredential(input?: { | |
| 293 | readonly label?: string; | |
| 294 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 295 | }): Promise<AuthPairingCredentialResult> { | |
| 296 | const trimmedLabel = input?.label?.trim(); | |
| 297 | try { | |
| 298 | return await runPrimaryHttp( | |
| 299 | PrimaryEnvironmentHttpClient.pipe( | |
| 300 | Effect.flatMap((client) => | |
| 301 | client.auth.pairingCredential({ | |
| 302 | headers: {}, | |
| 303 | payload: { | |
| 304 | ...(trimmedLabel ? { label: trimmedLabel } : {}), | |
| 305 | ...(input?.scopes ? { scopes: input.scopes } : {}), | |
| 306 | }, | |
| 307 | }), | |
| 308 | ), | |
| 309 | ), | |
| 310 | ); | |
| 311 | } catch (error) { | |
| 284 | 312 | throw new Error( |
| 285 | await readErrorMessage(response, `Failed to create pairing credential (${response.status}).`), | |
| 313 | readHttpApiErrorMessage( | |
| 314 | error, | |
| 315 | `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`, | |
| 316 | ), | |
| 317 | { cause: error }, | |
| 286 | 318 | ); |
| 287 | 319 | } |
| 288 | ||
| 289 | return (await response.json()) as AuthPairingCredentialResult; | |
| 290 | 320 | } |
| 291 | 321 | |
| 292 | 322 | export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPairingLinkRecord>> { |
| 293 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links"), { | |
| 294 | credentials: "include", | |
| 295 | }); | |
| 296 | ||
| 297 | if (!response.ok) { | |
| 323 | try { | |
| 324 | const pairingLinks = await runPrimaryHttp( | |
| 325 | PrimaryEnvironmentHttpClient.pipe( | |
| 326 | Effect.flatMap((client) => client.auth.pairingLinks({ headers: {} })), | |
| 327 | ), | |
| 328 | ); | |
| 329 | return pairingLinks.map((pairingLink) => { | |
| 330 | const timestamps = { | |
| 331 | createdAt: DateTime.formatIso(pairingLink.createdAt), | |
| 332 | expiresAt: DateTime.formatIso(pairingLink.expiresAt), | |
| 333 | }; | |
| 334 | if (pairingLink.label === undefined) { | |
| 335 | return { | |
| 336 | id: pairingLink.id, | |
| 337 | credential: pairingLink.credential, | |
| 338 | scopes: pairingLink.scopes, | |
| 339 | subject: pairingLink.subject, | |
| 340 | createdAt: timestamps.createdAt, | |
| 341 | expiresAt: timestamps.expiresAt, | |
| 342 | }; | |
| 343 | } | |
| 344 | return { | |
| 345 | id: pairingLink.id, | |
| 346 | credential: pairingLink.credential, | |
| 347 | scopes: pairingLink.scopes, | |
| 348 | subject: pairingLink.subject, | |
| 349 | label: pairingLink.label, | |
| 350 | createdAt: timestamps.createdAt, | |
| 351 | expiresAt: timestamps.expiresAt, | |
| 352 | }; | |
| 353 | }); | |
| 354 | } catch (error) { | |
| 298 | 355 | throw new Error( |
| 299 | await readErrorMessage(response, `Failed to load pairing links (${response.status}).`), | |
| 356 | readHttpApiErrorMessage( | |
| 357 | error, | |
| 358 | `Failed to load pairing links (${readHttpApiStatus(error) ?? "unknown"}).`, | |
| 359 | ), | |
| 360 | { cause: error }, | |
| 300 | 361 | ); |
| 301 | 362 | } |
| 302 | ||
| 303 | return (await response.json()) as ReadonlyArray<ServerPairingLinkRecord>; | |
| 304 | 363 | } |
| 305 | 364 | |
| 306 | 365 | export async function revokeServerPairingLink(id: string): Promise<void> { |
| 307 | const payload: AuthRevokePairingLinkInput = { id }; | |
| 308 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links/revoke"), { | |
| 309 | body: JSON.stringify(payload), | |
| 310 | credentials: "include", | |
| 311 | headers: { | |
| 312 | "content-type": "application/json", | |
| 313 | }, | |
| 314 | method: "POST", | |
| 315 | }); | |
| 316 | ||
| 317 | if (!response.ok) { | |
| 366 | try { | |
| 367 | await runPrimaryHttp( | |
| 368 | PrimaryEnvironmentHttpClient.pipe( | |
| 369 | Effect.flatMap((client) => client.auth.revokePairingLink({ headers: {}, payload: { id } })), | |
| 370 | ), | |
| 371 | ); | |
| 372 | } catch (error) { | |
| 318 | 373 | throw new Error( |
| 319 | await readErrorMessage(response, `Failed to revoke pairing link (${response.status}).`), | |
| 374 | readHttpApiErrorMessage( | |
| 375 | error, | |
| 376 | `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`, | |
| 377 | ), | |
| 378 | { cause: error }, | |
| 320 | 379 | ); |
| 321 | 380 | } |
| 322 | 381 | } |
| ... | ... | @@ -324,57 +383,75 @@ export async function revokeServerPairingLink(id: string): Promise<void> { |
| 324 | 383 | export async function listServerClientSessions(): Promise< |
| 325 | 384 | ReadonlyArray<ServerClientSessionRecord> |
| 326 | 385 | > { |
| 327 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients"), { | |
| 328 | credentials: "include", | |
| 329 | }); | |
| 330 | ||
| 331 | if (!response.ok) { | |
| 386 | try { | |
| 387 | const clientSessions = await runPrimaryHttp( | |
| 388 | PrimaryEnvironmentHttpClient.pipe( | |
| 389 | Effect.flatMap((client) => client.auth.clients({ headers: {} })), | |
| 390 | ), | |
| 391 | ); | |
| 392 | return clientSessions.map((clientSession) => ({ | |
| 393 | sessionId: clientSession.sessionId, | |
| 394 | subject: clientSession.subject, | |
| 395 | scopes: clientSession.scopes, | |
| 396 | method: clientSession.method, | |
| 397 | client: clientSession.client, | |
| 398 | issuedAt: DateTime.formatIso(clientSession.issuedAt), | |
| 399 | expiresAt: DateTime.formatIso(clientSession.expiresAt), | |
| 400 | lastConnectedAt: | |
| 401 | clientSession.lastConnectedAt === null | |
| 402 | ? null | |
| 403 | : DateTime.formatIso(clientSession.lastConnectedAt), | |
| 404 | connected: clientSession.connected, | |
| 405 | current: clientSession.current, | |
| 406 | })); | |
| 407 | } catch (error) { | |
| 332 | 408 | throw new Error( |
| 333 | await readErrorMessage(response, `Failed to load paired clients (${response.status}).`), | |
| 409 | readHttpApiErrorMessage( | |
| 410 | error, | |
| 411 | `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`, | |
| 412 | ), | |
| 413 | { cause: error }, | |
| 334 | 414 | ); |
| 335 | 415 | } |
| 336 | ||
| 337 | return (await response.json()) as ReadonlyArray<ServerClientSessionRecord>; | |
| 338 | 416 | } |
| 339 | 417 | |
| 340 | 418 | export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> { |
| 341 | const payload: AuthRevokeClientSessionInput = { sessionId }; | |
| 342 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke"), { | |
| 343 | body: JSON.stringify(payload), | |
| 344 | credentials: "include", | |
| 345 | headers: { | |
| 346 | "content-type": "application/json", | |
| 347 | }, | |
| 348 | method: "POST", | |
| 349 | }); | |
| 350 | ||
| 351 | if (!response.ok) { | |
| 419 | try { | |
| 420 | await runPrimaryHttp( | |
| 421 | PrimaryEnvironmentHttpClient.pipe( | |
| 422 | Effect.flatMap((client) => | |
| 423 | client.auth.revokeClient({ headers: {}, payload: { sessionId } }), | |
| 424 | ), | |
| 425 | ), | |
| 426 | ); | |
| 427 | } catch (error) { | |
| 352 | 428 | throw new Error( |
| 353 | await readErrorMessage(response, `Failed to revoke client session (${response.status}).`), | |
| 429 | readHttpApiErrorMessage( | |
| 430 | error, | |
| 431 | `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`, | |
| 432 | ), | |
| 433 | { cause: error }, | |
| 354 | 434 | ); |
| 355 | 435 | } |
| 356 | 436 | } |
| 357 | 437 | |
| 358 | 438 | export async function revokeOtherServerClientSessions(): Promise<number> { |
| 359 | const response = await fetch( | |
| 360 | resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke-others"), | |
| 361 | { | |
| 362 | credentials: "include", | |
| 363 | method: "POST", | |
| 364 | }, | |
| 365 | ); | |
| 366 | ||
| 367 | if (!response.ok) { | |
| 439 | try { | |
| 440 | const result = await runPrimaryHttp( | |
| 441 | PrimaryEnvironmentHttpClient.pipe( | |
| 442 | Effect.flatMap((client) => client.auth.revokeOtherClients({ headers: {} })), | |
| 443 | ), | |
| 444 | ); | |
| 445 | return result.revokedCount; | |
| 446 | } catch (error) { | |
| 368 | 447 | throw new Error( |
| 369 | await readErrorMessage( | |
| 370 | response, | |
| 371 | `Failed to revoke other client sessions (${response.status}).`, | |
| 448 | readHttpApiErrorMessage( | |
| 449 | error, | |
| 450 | `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`, | |
| 372 | 451 | ), |
| 452 | { cause: error }, | |
| 373 | 453 | ); |
| 374 | 454 | } |
| 375 | ||
| 376 | const result = (await response.json()) as { revokedCount?: number }; | |
| 377 | return result.revokedCount ?? 0; | |
| 378 | 455 | } |
| 379 | 456 | |
| 380 | 457 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { |
apps/web/src/environments/primary/bootstrap.test.ts+41-33| ... | ... | @@ -1,25 +1,18 @@ |
| 1 | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 3 | 4 | |
| 4 | 5 | import { |
| 5 | 6 | getPrimaryKnownEnvironment, |
| 7 | resolvePrimaryEnvironmentHttpUrl, | |
| 6 | 8 | resolveInitialPrimaryEnvironmentDescriptor, |
| 7 | 9 | resetPrimaryEnvironmentDescriptorForTests, |
| 8 | 10 | writePrimaryEnvironmentDescriptor, |
| 9 | 11 | } from "."; |
| 10 | ||
| 11 | function jsonResponse(body: unknown, init?: ResponseInit) { | |
| 12 | return new Response(JSON.stringify(body), { | |
| 13 | headers: { | |
| 14 | "content-type": "application/json", | |
| 15 | }, | |
| 16 | status: 200, | |
| 17 | ...init, | |
| 18 | }); | |
| 19 | } | |
| 12 | import { installEnvironmentHttpTest } from "../../../test/environmentHttpTest"; | |
| 20 | 13 | |
| 21 | 14 | const BASE_ENVIRONMENT = { |
| 22 | environmentId: "environment-local", | |
| 15 | environmentId: EnvironmentId.make("environment-local"), | |
| 23 | 16 | label: "Local environment", |
| 24 | 17 | platform: { |
| 25 | 18 | os: "darwin", |
| ... | ... | @@ -29,7 +22,17 @@ const BASE_ENVIRONMENT = { |
| 29 | 22 | capabilities: { |
| 30 | 23 | repositoryIdentity: true, |
| 31 | 24 | }, |
| 32 | }; | |
| 25 | } satisfies ExecutionEnvironmentDescriptor; | |
| 26 | ||
| 27 | let disposeHttpTest: (() => Promise<void>) | undefined; | |
| 28 | ||
| 29 | async function installDescriptorApi() { | |
| 30 | const testApi = await installEnvironmentHttpTest({ | |
| 31 | descriptor: () => Effect.succeed(BASE_ENVIRONMENT), | |
| 32 | }); | |
| 33 | disposeHttpTest = testApi.dispose; | |
| 34 | return testApi; | |
| 35 | } | |
| 33 | 36 | |
| 34 | 37 | function installTestBrowser(url: string) { |
| 35 | 38 | vi.stubGlobal("window", { |
| ... | ... | @@ -47,7 +50,9 @@ describe("environmentBootstrap", () => { |
| 47 | 50 | installTestBrowser("http://localhost/"); |
| 48 | 51 | }); |
| 49 | 52 | |
| 50 | afterEach(() => { | |
| 53 | afterEach(async () => { | |
| 54 | await disposeHttpTest?.(); | |
| 55 | disposeHttpTest = undefined; | |
| 51 | 56 | resetPrimaryEnvironmentDescriptorForTests(); |
| 52 | 57 | vi.unstubAllEnvs(); |
| 53 | 58 | vi.useRealTimers(); |
| ... | ... | @@ -88,35 +93,35 @@ describe("environmentBootstrap", () => { |
| 88 | 93 | }); |
| 89 | 94 | |
| 90 | 95 | it("reuses an in-flight descriptor bootstrap request", async () => { |
| 91 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 92 | vi.stubGlobal("fetch", fetchMock); | |
| 96 | const testApi = await installDescriptorApi(); | |
| 93 | 97 | |
| 94 | 98 | await Promise.all([ |
| 95 | 99 | resolveInitialPrimaryEnvironmentDescriptor(), |
| 96 | 100 | resolveInitialPrimaryEnvironmentDescriptor(), |
| 97 | 101 | ]); |
| 98 | 102 | |
| 99 | expect(fetchMock).toHaveBeenCalledTimes(1); | |
| 100 | expect(fetchMock).toHaveBeenCalledWith("http://localhost/.well-known/t3/environment"); | |
| 103 | expect(testApi.calls.descriptor).toBe(1); | |
| 101 | 104 | }); |
| 102 | 105 | |
| 103 | 106 | it("uses https descriptor urls when the primary environment uses wss", async () => { |
| 104 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 105 | vi.stubGlobal("fetch", fetchMock); | |
| 106 | 107 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); |
| 107 | 108 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); |
| 109 | await installDescriptorApi(); | |
| 108 | 110 | |
| 109 | 111 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); |
| 110 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); | |
| 112 | expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( | |
| 113 | "https://remote.example.com/.well-known/t3/environment", | |
| 114 | ); | |
| 111 | 115 | }); |
| 112 | 116 | |
| 113 | 117 | it("derives the websocket url when only VITE_HTTP_URL is configured", async () => { |
| 114 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 115 | vi.stubGlobal("fetch", fetchMock); | |
| 116 | 118 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); |
| 119 | await installDescriptorApi(); | |
| 117 | 120 | |
| 118 | 121 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); |
| 119 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); | |
| 122 | expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( | |
| 123 | "https://remote.example.com/.well-known/t3/environment", | |
| 124 | ); | |
| 120 | 125 | expect(getPrimaryKnownEnvironment()?.target).toEqual({ |
| 121 | 126 | httpBaseUrl: "https://remote.example.com/", |
| 122 | 127 | wsBaseUrl: "wss://remote.example.com/", |
| ... | ... | @@ -124,12 +129,13 @@ describe("environmentBootstrap", () => { |
| 124 | 129 | }); |
| 125 | 130 | |
| 126 | 131 | it("derives the http url when only VITE_WS_URL is configured", async () => { |
| 127 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 128 | vi.stubGlobal("fetch", fetchMock); | |
| 129 | 132 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); |
| 133 | await installDescriptorApi(); | |
| 130 | 134 | |
| 131 | 135 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); |
| 132 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); | |
| 136 | expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( | |
| 137 | "https://remote.example.com/.well-known/t3/environment", | |
| 138 | ); | |
| 133 | 139 | expect(getPrimaryKnownEnvironment()?.target).toEqual({ |
| 134 | 140 | httpBaseUrl: "https://remote.example.com/", |
| 135 | 141 | wsBaseUrl: "wss://remote.example.com/", |
| ... | ... | @@ -137,17 +143,16 @@ describe("environmentBootstrap", () => { |
| 137 | 143 | }); |
| 138 | 144 | |
| 139 | 145 | it("uses the current origin as the descriptor base for local dev environments", async () => { |
| 140 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 141 | vi.stubGlobal("fetch", fetchMock); | |
| 142 | 146 | installTestBrowser("http://localhost:5735/"); |
| 147 | await installDescriptorApi(); | |
| 143 | 148 | |
| 144 | 149 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); |
| 145 | expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/.well-known/t3/environment"); | |
| 150 | expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( | |
| 151 | "http://localhost:5735/.well-known/t3/environment", | |
| 152 | ); | |
| 146 | 153 | }); |
| 147 | 154 | |
| 148 | 155 | it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => { |
| 149 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 150 | vi.stubGlobal("fetch", fetchMock); | |
| 151 | 156 | vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); |
| 152 | 157 | vi.stubGlobal("window", { |
| 153 | 158 | location: new URL("http://127.0.0.1:5733/"), |
| ... | ... | @@ -163,8 +168,11 @@ describe("environmentBootstrap", () => { |
| 163 | 168 | }), |
| 164 | 169 | }, |
| 165 | 170 | }); |
| 171 | await installDescriptorApi(); | |
| 166 | 172 | |
| 167 | 173 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); |
| 168 | expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/.well-known/t3/environment"); | |
| 174 | expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( | |
| 175 | "http://127.0.0.1:5733/.well-known/t3/environment", | |
| 176 | ); | |
| 169 | 177 | }); |
| 170 | 178 | }); |
apps/web/src/environments/primary/context.ts+17-10| ... | ... | @@ -4,13 +4,15 @@ import { |
| 4 | 4 | type KnownEnvironment, |
| 5 | 5 | } from "@t3tools/client-runtime"; |
| 6 | 6 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import { HttpClientError } from "effect/unstable/http"; | |
| 7 | 9 | import { create } from "zustand"; |
| 8 | 10 | |
| 9 | 11 | import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; |
| 12 | import { PrimaryEnvironmentHttpClient } from "./httpClient"; | |
| 10 | 13 | |
| 11 | import { readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl } from "./target"; | |
| 12 | ||
| 13 | const SERVER_ENVIRONMENT_DESCRIPTOR_PATH = "/.well-known/t3/environment"; | |
| 14 | import { runPrimaryHttp } from "../../lib/runtime"; | |
| 15 | import { readPrimaryEnvironmentTarget } from "./target"; | |
| 14 | 16 | |
| 15 | 17 | interface PrimaryEnvironmentBootstrapState { |
| 16 | 18 | readonly descriptor: ExecutionEnvironmentDescriptor | null; |
| ... | ... | @@ -48,17 +50,22 @@ function createPrimaryKnownEnvironment(input: { |
| 48 | 50 | |
| 49 | 51 | async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> { |
| 50 | 52 | return retryTransientBootstrap(async () => { |
| 51 | const response = await fetch( | |
| 52 | resolvePrimaryEnvironmentHttpUrl(SERVER_ENVIRONMENT_DESCRIPTOR_PATH), | |
| 53 | ); | |
| 54 | if (!response.ok) { | |
| 53 | let descriptor: ExecutionEnvironmentDescriptor; | |
| 54 | try { | |
| 55 | descriptor = await runPrimaryHttp( | |
| 56 | PrimaryEnvironmentHttpClient.pipe(Effect.flatMap((client) => client.metadata.descriptor())), | |
| 57 | ); | |
| 58 | } catch (error) { | |
| 59 | const status = | |
| 60 | HttpClientError.isHttpClientError(error) && error.response !== undefined | |
| 61 | ? error.response.status | |
| 62 | : 500; | |
| 55 | 63 | throw new BootstrapHttpError({ |
| 56 | message: `Failed to load server environment descriptor (${response.status}).`, | |
| 57 | status: response.status, | |
| 64 | message: `Failed to load server environment descriptor (${status}).`, | |
| 65 | status, | |
| 58 | 66 | }); |
| 59 | 67 | } |
| 60 | 68 | |
| 61 | const descriptor = (await response.json()) as ExecutionEnvironmentDescriptor; | |
| 62 | 69 | writePrimaryEnvironmentDescriptor(descriptor); |
| 63 | 70 | return descriptor; |
| 64 | 71 | }); |
apps/web/src/environments/primary/httpClient.ts created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import { resolvePrimaryEnvironmentHttpUrl } from "./target"; | |
| 7 | ||
| 8 | export type PrimaryEnvironmentHttpClientShape = Effect.Success< | |
| 9 | ReturnType<typeof makeEnvironmentHttpApiClient> | |
| 10 | >; | |
| 11 | ||
| 12 | export class PrimaryEnvironmentHttpClient extends Context.Service< | |
| 13 | PrimaryEnvironmentHttpClient, | |
| 14 | PrimaryEnvironmentHttpClientShape | |
| 15 | >()("@t3tools/web/environments/primary/httpClient/PrimaryEnvironmentHttpClient") {} | |
| 16 | ||
| 17 | export const primaryEnvironmentHttpClientLive = Layer.effect( | |
| 18 | PrimaryEnvironmentHttpClient, | |
| 19 | Effect.suspend(() => makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"))), | |
| 20 | ); |
apps/web/src/environments/primary/index.ts+2| ... | ... | @@ -32,4 +32,6 @@ export { |
| 32 | 32 | __resetServerAuthBootstrapForTests, |
| 33 | 33 | } from "./auth"; |
| 34 | 34 | |
| 35 | export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; | |
| 36 | ||
| 35 | 37 | export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target"; |
apps/web/src/environments/primary/sessionState.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 6 | import { useCallback } from "react"; | |
| 7 | ||
| 8 | import { appAtomRegistry } from "../../rpc/atomRegistry"; | |
| 9 | import { fetchSessionState } from "./auth"; | |
| 10 | ||
| 11 | const primarySessionStateAtom = Atom.make(Effect.promise(fetchSessionState)).pipe( | |
| 12 | Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), | |
| 13 | Atom.setIdleTTL(5 * 60_000), | |
| 14 | Atom.withLabel("primary-environment:session"), | |
| 15 | ); | |
| 16 | ||
| 17 | export function refreshPrimarySessionState(): void { | |
| 18 | appAtomRegistry.refresh(primarySessionStateAtom); | |
| 19 | } | |
| 20 | ||
| 21 | export function usePrimarySessionState() { | |
| 22 | const result = useAtomValue(primarySessionStateAtom); | |
| 23 | const refresh = useCallback(() => { | |
| 24 | refreshPrimarySessionState(); | |
| 25 | }, []); | |
| 26 | let error: string | null = null; | |
| 27 | if (result._tag === "Failure") { | |
| 28 | const cause = Cause.squash(result.cause); | |
| 29 | error = cause instanceof Error ? cause.message : "Could not read environment session."; | |
| 30 | } | |
| 31 | return { | |
| 32 | data: Option.getOrNull(AsyncResult.value(result)), | |
| 33 | error, | |
| 34 | isPending: result.waiting, | |
| 35 | refresh, | |
| 36 | }; | |
| 37 | } |
apps/web/src/environments/runtime/catalog.ts+3-3| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; |
| 2 | 2 | import type { |
| 3 | AuthSessionRole, | |
| 3 | AuthEnvironmentScope, | |
| 4 | 4 | EnvironmentId, |
| 5 | 5 | ExecutionEnvironmentDescriptor, |
| 6 | 6 | PersistedSavedEnvironmentRecord, |
| ... | ... | @@ -272,7 +272,7 @@ export interface SavedEnvironmentRuntimeState { |
| 272 | 272 | readonly authState: SavedEnvironmentAuthState; |
| 273 | 273 | readonly lastError: string | null; |
| 274 | 274 | readonly lastErrorAt: string | null; |
| 275 | readonly role: AuthSessionRole | null; | |
| 275 | readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null; | |
| 276 | 276 | readonly descriptor: ExecutionEnvironmentDescriptor | null; |
| 277 | 277 | readonly serverConfig: ServerConfig | null; |
| 278 | 278 | readonly connectedAt: string | null; |
| ... | ... | @@ -295,7 +295,7 @@ const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Ob |
| 295 | 295 | authState: "unknown", |
| 296 | 296 | lastError: null, |
| 297 | 297 | lastErrorAt: null, |
| 298 | role: null, | |
| 298 | scopes: null, | |
| 299 | 299 | descriptor: null, |
| 300 | 300 | serverConfig: null, |
| 301 | 301 | connectedAt: null, |
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+30-31| ... | ... | @@ -1,13 +1,15 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 2 | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 4 | |
| 5 | const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError); | |
| 6 | ||
| 4 | 7 | let mockSavedRecords: Array<Record<string, unknown>> = []; |
| 5 | 8 | |
| 6 | 9 | const mockResolveRemotePairingTarget = vi.fn(); |
| 7 | 10 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); |
| 8 | 11 | const mockBootstrapRemoteBearerSession = vi.fn(); |
| 9 | 12 | const mockFetchRemoteSessionState = vi.fn(); |
| 10 | const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); | |
| 11 | 13 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); |
| 12 | 14 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); |
| 13 | 15 | const mockBootstrapSshBearerSession = vi.fn(); |
| ... | ... | @@ -128,7 +130,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 128 | 130 | })), |
| 129 | 131 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, |
| 130 | 132 | fetchRemoteSessionState: mockFetchRemoteSessionState, |
| 131 | isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, | |
| 132 | 133 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, |
| 133 | 134 | }; |
| 134 | 135 | }); |
| ... | ... | @@ -153,7 +154,7 @@ describe("addSavedEnvironment", () => { |
| 153 | 154 | fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, |
| 154 | 155 | bootstrapSshBearerSession: mockBootstrapSshBearerSession, |
| 155 | 156 | fetchSshSessionState: mockFetchSshSessionState, |
| 156 | issueSshWebSocketToken: vi.fn(), | |
| 157 | issueSshWebSocketTicket: vi.fn(), | |
| 157 | 158 | }, |
| 158 | 159 | }); |
| 159 | 160 | mockResolveRemotePairingTarget.mockImplementation( |
| ... | ... | @@ -176,24 +177,23 @@ describe("addSavedEnvironment", () => { |
| 176 | 177 | label: "Remote environment", |
| 177 | 178 | }); |
| 178 | 179 | mockBootstrapRemoteBearerSession.mockResolvedValue({ |
| 179 | sessionToken: "bearer-token", | |
| 180 | role: "owner", | |
| 180 | access_token: "bearer-token", | |
| 181 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 181 | 182 | }); |
| 182 | 183 | mockFetchRemoteSessionState.mockResolvedValue({ |
| 183 | 184 | authenticated: true, |
| 184 | role: "owner", | |
| 185 | scopes: ["orchestration:read", "access:write"], | |
| 185 | 186 | }); |
| 186 | mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false); | |
| 187 | 187 | mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( |
| 188 | "wss://remote.example.com/?wsToken=remote-token", | |
| 188 | "wss://remote.example.com/?wsTicket=remote-token", | |
| 189 | 189 | ); |
| 190 | 190 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ |
| 191 | 191 | environmentId: EnvironmentId.make("environment-1"), |
| 192 | 192 | label: "Remote environment", |
| 193 | 193 | }); |
| 194 | 194 | mockBootstrapSshBearerSession.mockResolvedValue({ |
| 195 | sessionToken: "ssh-bearer-token", | |
| 196 | role: "owner", | |
| 195 | access_token: "ssh-bearer-token", | |
| 196 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 197 | 197 | }); |
| 198 | 198 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); |
| 199 | 199 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); |
| ... | ... | @@ -202,7 +202,7 @@ describe("addSavedEnvironment", () => { |
| 202 | 202 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); |
| 203 | 203 | mockFetchSshSessionState.mockResolvedValue({ |
| 204 | 204 | authenticated: true, |
| 205 | role: "owner", | |
| 205 | scopes: ["orchestration:read", "access:write"], | |
| 206 | 206 | }); |
| 207 | 207 | mockCreateEnvironmentConnection.mockImplementation( |
| 208 | 208 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ |
| ... | ... | @@ -381,18 +381,18 @@ describe("addSavedEnvironment", () => { |
| 381 | 381 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); |
| 382 | 382 | mockBootstrapSshBearerSession |
| 383 | 383 | .mockResolvedValueOnce({ |
| 384 | sessionToken: "ssh-bearer-token", | |
| 385 | role: "owner", | |
| 384 | access_token: "ssh-bearer-token", | |
| 385 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 386 | 386 | }) |
| 387 | 387 | .mockResolvedValueOnce({ |
| 388 | sessionToken: "ssh-bearer-token-2", | |
| 389 | role: "owner", | |
| 388 | access_token: "ssh-bearer-token-2", | |
| 389 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 390 | 390 | }); |
| 391 | 391 | mockFetchSshSessionState |
| 392 | 392 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) |
| 393 | 393 | .mockResolvedValueOnce({ |
| 394 | 394 | authenticated: true, |
| 395 | role: "owner", | |
| 395 | scopes: ["orchestration:read", "access:write"], | |
| 396 | 396 | }); |
| 397 | 397 | |
| 398 | 398 | const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = |
| ... | ... | @@ -418,14 +418,13 @@ describe("addSavedEnvironment", () => { |
| 418 | 418 | |
| 419 | 419 | it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { |
| 420 | 420 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); |
| 421 | const authError = { | |
| 422 | status: 401, | |
| 423 | message: "Unauthorized", | |
| 424 | }; | |
| 421 | const authError = decodeEnvironmentAuthInvalidError({ | |
| 422 | _tag: "EnvironmentAuthInvalidError", | |
| 423 | code: "auth_invalid", | |
| 424 | reason: "invalid_credential", | |
| 425 | traceId: "trace-auth-test", | |
| 426 | }); | |
| 425 | 427 | mockFetchRemoteSessionState.mockRejectedValueOnce(authError); |
| 426 | mockIsRemoteEnvironmentAuthHttpError.mockImplementation( | |
| 427 | (error: unknown) => error === authError, | |
| 428 | ); | |
| 429 | 428 | |
| 430 | 429 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); |
| 431 | 430 | |
| ... | ... | @@ -450,18 +449,18 @@ describe("addSavedEnvironment", () => { |
| 450 | 449 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); |
| 451 | 450 | mockBootstrapSshBearerSession |
| 452 | 451 | .mockResolvedValueOnce({ |
| 453 | sessionToken: "ssh-bearer-token", | |
| 454 | role: "owner", | |
| 452 | access_token: "ssh-bearer-token", | |
| 453 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 455 | 454 | }) |
| 456 | 455 | .mockResolvedValueOnce({ |
| 457 | sessionToken: "ssh-bearer-token-2", | |
| 458 | role: "owner", | |
| 456 | access_token: "ssh-bearer-token-2", | |
| 457 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 459 | 458 | }); |
| 460 | 459 | mockFetchSshSessionState |
| 461 | 460 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) |
| 462 | 461 | .mockResolvedValueOnce({ |
| 463 | 462 | authenticated: true, |
| 464 | role: "owner", | |
| 463 | scopes: ["orchestration:read", "access:write"], | |
| 465 | 464 | }); |
| 466 | 465 | |
| 467 | 466 | const createdConnections: Array<{ |
| ... | ... | @@ -745,7 +744,7 @@ describe("addSavedEnvironment", () => { |
| 745 | 744 | ); |
| 746 | 745 | let resolveSessionState!: (value: { |
| 747 | 746 | readonly authenticated: true; |
| 748 | readonly role: "owner"; | |
| 747 | readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; | |
| 749 | 748 | }) => void; |
| 750 | 749 | mockFetchRemoteSessionState.mockReturnValue( |
| 751 | 750 | new Promise((resolve) => { |
| ... | ... | @@ -768,7 +767,7 @@ describe("addSavedEnvironment", () => { |
| 768 | 767 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); |
| 769 | 768 | resolveSessionState({ |
| 770 | 769 | authenticated: true, |
| 771 | role: "owner", | |
| 770 | scopes: ["orchestration:read", "access:write"], | |
| 772 | 771 | }); |
| 773 | 772 | await expect(reconnectPromise).resolves.toBeUndefined(); |
| 774 | 773 |
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+1-1| ... | ... | @@ -222,7 +222,7 @@ describe("saved environment startup", () => { |
| 222 | 222 | |
| 223 | 223 | mockFetchRemoteSessionState.mockResolvedValue({ |
| 224 | 224 | authenticated: true, |
| 225 | role: "owner", | |
| 225 | scopes: ["orchestration:read", "access:write"], | |
| 226 | 226 | }); |
| 227 | 227 | mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) => |
| 228 | 228 | environmentId === savedRecord.environmentId ? savedRecord : null, |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+1-2| ... | ... | @@ -153,7 +153,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 153 | 153 | ...actual, |
| 154 | 154 | createWsRpcClient: vi.fn(() => stubWsClient), |
| 155 | 155 | fetchRemoteSessionState: mockFetchRemoteSessionState, |
| 156 | isRemoteEnvironmentAuthHttpError: vi.fn(() => false), | |
| 157 | 156 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, |
| 158 | 157 | }; |
| 159 | 158 | }); |
| ... | ... | @@ -304,7 +303,7 @@ describe("retainThreadDetailSubscription", () => { |
| 304 | 303 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); |
| 305 | 304 | mockFetchRemoteSessionState.mockResolvedValue({ |
| 306 | 305 | authenticated: true, |
| 307 | role: "client", | |
| 306 | scopes: ["orchestration:read"], | |
| 308 | 307 | }); |
| 309 | 308 | mockConnectionReconnects.length = 0; |
| 310 | 309 | }); |
apps/web/src/environments/runtime/service.ts+31-22| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | type AuthSessionRole, | |
| 2 | AuthEnvironmentScope, | |
| 3 | AuthStandardClientScopes, | |
| 3 | 4 | type DesktopSshEnvironmentBootstrap, |
| 4 | 5 | type DesktopSshEnvironmentTarget, |
| 5 | 6 | type EnvironmentId, |
| ... | ... | @@ -7,6 +8,7 @@ import { |
| 7 | 8 | type OrchestrationShellSnapshot, |
| 8 | 9 | type OrchestrationShellStreamEvent, |
| 9 | 10 | type ServerConfig, |
| 11 | EnvironmentAuthInvalidError, | |
| 10 | 12 | ThreadId, |
| 11 | 13 | } from "@t3tools/contracts"; |
| 12 | 14 | import { |
| ... | ... | @@ -15,12 +17,12 @@ import { |
| 15 | 17 | bootstrapRemoteBearerSession, |
| 16 | 18 | fetchRemoteEnvironmentDescriptor, |
| 17 | 19 | fetchRemoteSessionState, |
| 18 | isRemoteEnvironmentAuthHttpError, | |
| 19 | 20 | resolveRemoteWebSocketConnectionUrl, |
| 20 | 21 | } from "@t3tools/client-runtime"; |
| 21 | 22 | |
| 22 | 23 | import { type QueryClient } from "@tanstack/react-query"; |
| 23 | 24 | import { Throttler } from "@tanstack/react-pacer"; |
| 25 | import * as Schema from "effect/Schema"; | |
| 24 | 26 | import { |
| 25 | 27 | createKnownEnvironment, |
| 26 | 28 | getKnownEnvironmentWsBaseUrl, |
| ... | ... | @@ -76,6 +78,8 @@ import { |
| 76 | 78 | deriveLogicalProjectKeyFromSettings, |
| 77 | 79 | derivePhysicalProjectKey, |
| 78 | 80 | } from "../../logicalProject"; |
| 81 | ||
| 82 | const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); | |
| 79 | 83 | import { getClientSettings } from "~/hooks/useSettings"; |
| 80 | 84 | import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; |
| 81 | 85 | import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; |
| ... | ... | @@ -99,6 +103,7 @@ type ThreadDetailSubscriptionEntry = { |
| 99 | 103 | }; |
| 100 | 104 | |
| 101 | 105 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); |
| 106 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); | |
| 102 | 107 | |
| 103 | 108 | function isSavedEnvironmentConnectionCancelledError( |
| 104 | 109 | error: unknown, |
| ... | ... | @@ -738,6 +743,10 @@ async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: |
| 738 | 743 | return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); |
| 739 | 744 | } |
| 740 | 745 | |
| 746 | function readIssuedBearerScopes(scope: string): ReadonlyArray<AuthEnvironmentScope> { | |
| 747 | return decodeIssuedBearerScopes(scope.split(" ")); | |
| 748 | } | |
| 749 | ||
| 741 | 750 | async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { |
| 742 | 751 | return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); |
| 743 | 752 | } |
| ... | ... | @@ -747,9 +756,9 @@ async function resolveDesktopSshWebSocketConnectionUrl( |
| 747 | 756 | httpBaseUrl: string, |
| 748 | 757 | bearerToken: string, |
| 749 | 758 | ) { |
| 750 | const issued = await getDesktopSshBridge().issueSshWebSocketToken(httpBaseUrl, bearerToken); | |
| 759 | const issued = await getDesktopSshBridge().issueSshWebSocketTicket(httpBaseUrl, bearerToken); | |
| 751 | 760 | const url = new URL(wsBaseUrl, window.location.origin); |
| 752 | url.searchParams.set("wsToken", issued.token); | |
| 761 | url.searchParams.set("wsTicket", issued.ticket); | |
| 753 | 762 | return url.toString(); |
| 754 | 763 | } |
| 755 | 764 | |
| ... | ... | @@ -799,7 +808,7 @@ async function prepareSavedEnvironmentRecordForConnection( |
| 799 | 808 | async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ |
| 800 | 809 | readonly record: SavedEnvironmentRecord; |
| 801 | 810 | readonly bearerToken: string; |
| 802 | readonly role: AuthSessionRole | null; | |
| 811 | readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null; | |
| 803 | 812 | }> { |
| 804 | 813 | const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); |
| 805 | 814 | const prepared = await prepareSavedEnvironmentRecordForConnection(record, { |
| ... | ... | @@ -827,7 +836,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro |
| 827 | 836 | }); |
| 828 | 837 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( |
| 829 | 838 | prepared.record.environmentId, |
| 830 | bearerSession.sessionToken, | |
| 839 | bearerSession.access_token, | |
| 831 | 840 | ); |
| 832 | 841 | if (!didPersistBearerToken) { |
| 833 | 842 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); |
| ... | ... | @@ -836,8 +845,8 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro |
| 836 | 845 | |
| 837 | 846 | return { |
| 838 | 847 | record: prepared.record, |
| 839 | bearerToken: bearerSession.sessionToken, | |
| 840 | role: bearerSession.role ?? null, | |
| 848 | bearerToken: bearerSession.access_token, | |
| 849 | scopes: readIssuedBearerScopes(bearerSession.scope), | |
| 841 | 850 | }; |
| 842 | 851 | } |
| 843 | 852 | |
| ... | ... | @@ -1214,7 +1223,7 @@ async function refreshSavedEnvironmentMetadata( |
| 1214 | 1223 | environmentId: EnvironmentId, |
| 1215 | 1224 | bearerToken: string, |
| 1216 | 1225 | client: WsRpcClient, |
| 1217 | roleHint?: AuthSessionRole | null, | |
| 1226 | scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null, | |
| 1218 | 1227 | configHint?: ServerConfig | null, |
| 1219 | 1228 | ): Promise<void> { |
| 1220 | 1229 | const record = getSavedEnvironmentRecord(environmentId); |
| ... | ... | @@ -1238,7 +1247,7 @@ async function refreshSavedEnvironmentMetadata( |
| 1238 | 1247 | authState: sessionState.authenticated ? "authenticated" : "requires-auth", |
| 1239 | 1248 | descriptor: serverConfig.environment, |
| 1240 | 1249 | serverConfig, |
| 1241 | role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null, | |
| 1250 | scopes: sessionState.authenticated ? (sessionState.scopes ?? scopeHint ?? null) : null, | |
| 1242 | 1251 | }); |
| 1243 | 1252 | useSavedEnvironmentRegistryStore |
| 1244 | 1253 | .getState() |
| ... | ... | @@ -1311,7 +1320,7 @@ async function ensureSavedEnvironmentConnection( |
| 1311 | 1320 | options?: { |
| 1312 | 1321 | readonly client?: WsRpcClient; |
| 1313 | 1322 | readonly bearerToken?: string; |
| 1314 | readonly role?: AuthSessionRole | null; | |
| 1323 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null; | |
| 1315 | 1324 | readonly serverConfig?: ServerConfig | null; |
| 1316 | 1325 | }, |
| 1317 | 1326 | ): Promise<EnvironmentConnection> { |
| ... | ... | @@ -1330,7 +1339,7 @@ async function ensureSavedEnvironmentConnection( |
| 1330 | 1339 | isCurrent: attempt.isCurrent, |
| 1331 | 1340 | promise: Promise.resolve().then(async () => { |
| 1332 | 1341 | let activeRecord = record; |
| 1333 | let roleHint = options?.role ?? null; | |
| 1342 | let scopeHint = options?.scopes ?? null; | |
| 1334 | 1343 | let bearerToken = |
| 1335 | 1344 | options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); |
| 1336 | 1345 | if (!bearerToken) { |
| ... | ... | @@ -1338,11 +1347,11 @@ async function ensureSavedEnvironmentConnection( |
| 1338 | 1347 | const issued = await issueDesktopSshBearerSession(record); |
| 1339 | 1348 | activeRecord = issued.record; |
| 1340 | 1349 | bearerToken = issued.bearerToken; |
| 1341 | roleHint = issued.role; | |
| 1350 | scopeHint = [...AuthStandardClientScopes]; | |
| 1342 | 1351 | } else { |
| 1343 | 1352 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| 1344 | 1353 | authState: "requires-auth", |
| 1345 | role: null, | |
| 1354 | scopes: null, | |
| 1346 | 1355 | connectionState: "disconnected", |
| 1347 | 1356 | lastError: "Saved environment is missing its saved credential. Pair it again.", |
| 1348 | 1357 | lastErrorAt: isoNow(), |
| ... | ... | @@ -1409,13 +1418,13 @@ async function ensureSavedEnvironmentConnection( |
| 1409 | 1418 | activeRecord.environmentId, |
| 1410 | 1419 | activeBearerToken, |
| 1411 | 1420 | client, |
| 1412 | roleHint, | |
| 1421 | scopeHint, | |
| 1413 | 1422 | initialServerConfig, |
| 1414 | 1423 | ); |
| 1415 | 1424 | } catch (error) { |
| 1416 | 1425 | const isAuthError = activeRecord.desktopSsh |
| 1417 | 1426 | ? isSshHttpAuthError(error, 401) |
| 1418 | : isRemoteEnvironmentAuthHttpError(error) && error.status === 401; | |
| 1427 | : isEnvironmentAuthInvalidError(error); | |
| 1419 | 1428 | if (!isAuthError) { |
| 1420 | 1429 | throw error; |
| 1421 | 1430 | } |
| ... | ... | @@ -1429,12 +1438,12 @@ async function ensureSavedEnvironmentConnection( |
| 1429 | 1438 | const issued = await issueDesktopSshBearerSession(activeRecord); |
| 1430 | 1439 | activeRecord = issued.record; |
| 1431 | 1440 | bearerToken = issued.bearerToken; |
| 1432 | roleHint = issued.role; | |
| 1441 | scopeHint = [...AuthStandardClientScopes]; | |
| 1433 | 1442 | await connection.dispose().catch(() => undefined); |
| 1434 | 1443 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); |
| 1435 | 1444 | return await ensureSavedEnvironmentConnection(activeRecord, { |
| 1436 | 1445 | bearerToken, |
| 1437 | role: roleHint, | |
| 1446 | scopes: scopeHint, | |
| 1438 | 1447 | serverConfig: options?.serverConfig ?? null, |
| 1439 | 1448 | }); |
| 1440 | 1449 | } |
| ... | ... | @@ -1638,7 +1647,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P |
| 1638 | 1647 | await removeConnection(environmentId).catch(() => false); |
| 1639 | 1648 | await ensureSavedEnvironmentConnection(issued.record, { |
| 1640 | 1649 | bearerToken: issued.bearerToken, |
| 1641 | role: issued.role, | |
| 1650 | scopes: [...AuthStandardClientScopes], | |
| 1642 | 1651 | }); |
| 1643 | 1652 | return; |
| 1644 | 1653 | } catch (recoveryError) { |
| ... | ... | @@ -1714,7 +1723,7 @@ export async function addSavedEnvironment(input: { |
| 1714 | 1723 | await persistSavedEnvironmentRecord(record); |
| 1715 | 1724 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( |
| 1716 | 1725 | environmentId, |
| 1717 | bearerSession.sessionToken, | |
| 1726 | bearerSession.access_token, | |
| 1718 | 1727 | ); |
| 1719 | 1728 | if (!didPersistBearerToken) { |
| 1720 | 1729 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); |
| ... | ... | @@ -1726,8 +1735,8 @@ export async function addSavedEnvironment(input: { |
| 1726 | 1735 | } |
| 1727 | 1736 | await removeConnection(environmentId).catch(() => false); |
| 1728 | 1737 | await ensureSavedEnvironmentConnection(record, { |
| 1729 | bearerToken: bearerSession.sessionToken, | |
| 1730 | role: bearerSession.role, | |
| 1738 | bearerToken: bearerSession.access_token, | |
| 1739 | scopes: readIssuedBearerScopes(bearerSession.scope), | |
| 1731 | 1740 | }); |
| 1732 | 1741 | return record; |
| 1733 | 1742 | } |
apps/web/src/lib/runtime.ts+34| ... | ... | @@ -1,5 +1,39 @@ |
| 1 | 1 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 2 | import type * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import { FetchHttpClient } from "effect/unstable/http"; | |
| 2 | 5 | |
| 3 | 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; |
| 7 | import { | |
| 8 | PrimaryEnvironmentHttpClient, | |
| 9 | primaryEnvironmentHttpClientLive, | |
| 10 | } from "../environments/primary/httpClient"; | |
| 4 | 11 | |
| 5 | 12 | export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch)); |
| 13 | ||
| 14 | const primaryHttpRuntime = ManagedRuntime.make( | |
| 15 | primaryEnvironmentHttpClientLive.pipe( | |
| 16 | Layer.provide( | |
| 17 | Layer.mergeAll( | |
| 18 | remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), | |
| 19 | Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }), | |
| 20 | ), | |
| 21 | ), | |
| 22 | ), | |
| 23 | ); | |
| 24 | ||
| 25 | export type PrimaryHttpEffectRunner = <A, E>( | |
| 26 | effect: Effect.Effect<A, E, PrimaryEnvironmentHttpClient>, | |
| 27 | ) => Promise<A>; | |
| 28 | ||
| 29 | const livePrimaryHttpRunner: PrimaryHttpEffectRunner = (effect) => | |
| 30 | primaryHttpRuntime.runPromise(effect); | |
| 31 | ||
| 32 | let primaryHttpRunner = livePrimaryHttpRunner; | |
| 33 | ||
| 34 | export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironmentHttpClient>) => | |
| 35 | primaryHttpRunner(effect); | |
| 36 | ||
| 37 | export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void { | |
| 38 | primaryHttpRunner = runner ?? livePrimaryHttpRunner; | |
| 39 | } |
apps/web/src/localApi.test.ts+3-3| ... | ... | @@ -202,8 +202,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg |
| 202 | 202 | fetchSshSessionState: async () => { |
| 203 | 203 | throw new Error("fetchSshSessionState not implemented in test"); |
| 204 | 204 | }, |
| 205 | issueSshWebSocketToken: async () => { | |
| 206 | throw new Error("issueSshWebSocketToken not implemented in test"); | |
| 205 | issueSshWebSocketTicket: async () => { | |
| 206 | throw new Error("issueSshWebSocketTicket not implemented in test"); | |
| 207 | 207 | }, |
| 208 | 208 | onSshPasswordPrompt: () => () => undefined, |
| 209 | 209 | resolveSshPasswordPrompt: async () => undefined, |
| ... | ... | @@ -289,7 +289,7 @@ const baseServerConfig: ServerConfig = { |
| 289 | 289 | auth: { |
| 290 | 290 | policy: "loopback-browser", |
| 291 | 291 | bootstrapMethods: ["one-time-token"], |
| 292 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 292 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | |
| 293 | 293 | sessionCookieName: "t3_session", |
| 294 | 294 | }, |
| 295 | 295 | cwd: "/tmp/workspace", |
apps/web/src/rpc/serverState.test.ts+1-1| ... | ... | @@ -76,7 +76,7 @@ const baseServerConfig: ServerConfig = { |
| 76 | 76 | auth: { |
| 77 | 77 | policy: "loopback-browser", |
| 78 | 78 | bootstrapMethods: ["one-time-token"], |
| 79 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 79 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | |
| 80 | 80 | sessionCookieName: "t3_session", |
| 81 | 81 | }, |
| 82 | 82 | cwd: "/tmp/workspace", |
apps/web/test/authHttpHandlers.ts+77-15| ... | ... | @@ -1,11 +1,27 @@ |
| 1 | 1 | import { |
| 2 | AuthSessionId, | |
| 3 | EnvironmentAuthenticatedAuth, | |
| 4 | EnvironmentAuthenticatedPrincipal, | |
| 5 | EnvironmentAuthHttpApi, | |
| 2 | 6 | EnvironmentId, |
| 7 | EnvironmentMetadataHttpApi, | |
| 8 | type AuthEnvironmentScope, | |
| 3 | 9 | type ExecutionEnvironmentDescriptor, |
| 4 | 10 | type ServerAuthDescriptor, |
| 5 | 11 | } from "@t3tools/contracts"; |
| 6 | import { HttpResponse, http } from "msw"; | |
| 12 | import * as DateTime from "effect/DateTime"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import { HttpRouter, HttpServer } from "effect/unstable/http"; | |
| 16 | import * as HttpApi from "effect/unstable/httpapi/HttpApi"; | |
| 17 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | |
| 18 | import { http } from "msw"; | |
| 7 | 19 | |
| 8 | const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z"; | |
| 20 | const BrowserEnvironmentHttpApi = HttpApi.make("browserEnvironment") | |
| 21 | .add(EnvironmentMetadataHttpApi) | |
| 22 | .add(EnvironmentAuthHttpApi); | |
| 23 | ||
| 24 | const TEST_SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-05-01T12:00:00.000Z"); | |
| 9 | 25 | const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { |
| 10 | 26 | environmentId: EnvironmentId.make("environment-local"), |
| 11 | 27 | label: "Local environment", |
| ... | ... | @@ -19,23 +35,69 @@ const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { |
| 19 | 35 | }, |
| 20 | 36 | }; |
| 21 | 37 | |
| 38 | const unexpectedEndpoint = (endpoint: string) => | |
| 39 | Effect.die(new Error(`Unexpected browser environment HTTP endpoint: ${endpoint}`)); | |
| 40 | ||
| 22 | 41 | export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { |
| 23 | return [ | |
| 24 | http.get("*/.well-known/t3/environment", () => HttpResponse.json(TEST_ENVIRONMENT_DESCRIPTOR)), | |
| 25 | http.get("*/api/auth/session", () => | |
| 26 | HttpResponse.json({ | |
| 27 | authenticated: true, | |
| 28 | auth: getAuthDescriptor(), | |
| 29 | sessionMethod: "browser-session-cookie", | |
| 42 | const authenticatedAuthLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => | |
| 43 | httpEffect.pipe( | |
| 44 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { | |
| 45 | sessionId: AuthSessionId.make("browser-session"), | |
| 46 | subject: "browser-client", | |
| 47 | method: "browser-session-cookie", | |
| 48 | scopes: new Set<AuthEnvironmentScope>(), | |
| 30 | 49 | expiresAt: TEST_SESSION_EXPIRES_AT, |
| 31 | 50 | }), |
| 32 | 51 | ), |
| 33 | http.post("*/api/auth/bootstrap", () => | |
| 34 | HttpResponse.json({ | |
| 35 | authenticated: true, | |
| 36 | sessionMethod: "browser-session-cookie", | |
| 37 | expiresAt: TEST_SESSION_EXPIRES_AT, | |
| 38 | }), | |
| 52 | ); | |
| 53 | const metadataLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "metadata", (handlers) => | |
| 54 | handlers.handle("descriptor", () => Effect.succeed(TEST_ENVIRONMENT_DESCRIPTOR)), | |
| 55 | ); | |
| 56 | const authLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "auth", (handlers) => | |
| 57 | handlers | |
| 58 | .handle("session", () => | |
| 59 | Effect.succeed({ | |
| 60 | authenticated: true, | |
| 61 | auth: getAuthDescriptor(), | |
| 62 | sessionMethod: "browser-session-cookie", | |
| 63 | expiresAt: TEST_SESSION_EXPIRES_AT, | |
| 64 | }), | |
| 65 | ) | |
| 66 | .handle("browserSession", () => | |
| 67 | Effect.succeed({ | |
| 68 | authenticated: true, | |
| 69 | scopes: [ | |
| 70 | "orchestration:read", | |
| 71 | "orchestration:operate", | |
| 72 | "terminal:operate", | |
| 73 | "review:write", | |
| 74 | "relay:read", | |
| 75 | ], | |
| 76 | sessionMethod: "browser-session-cookie", | |
| 77 | expiresAt: TEST_SESSION_EXPIRES_AT, | |
| 78 | }), | |
| 79 | ) | |
| 80 | .handle("token", () => unexpectedEndpoint("auth.token")) | |
| 81 | .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) | |
| 82 | .handle("pairingCredential", () => unexpectedEndpoint("auth.pairingCredential")) | |
| 83 | .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks")) | |
| 84 | .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink")) | |
| 85 | .handle("clients", () => unexpectedEndpoint("auth.clients")) | |
| 86 | .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient")) | |
| 87 | .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")), | |
| 88 | ).pipe(Layer.provide(authenticatedAuthLayer)); | |
| 89 | const { handler } = HttpRouter.toWebHandler( | |
| 90 | HttpApiBuilder.layer(BrowserEnvironmentHttpApi).pipe( | |
| 91 | Layer.provide(metadataLayer), | |
| 92 | Layer.provide(authLayer), | |
| 93 | Layer.provide(authenticatedAuthLayer), | |
| 94 | Layer.provide(HttpServer.layerServices), | |
| 39 | 95 | ), |
| 96 | { disableLogger: true }, | |
| 97 | ); | |
| 98 | ||
| 99 | return [ | |
| 100 | http.all("*/.well-known/t3/environment", ({ request }) => handler(request)), | |
| 101 | http.all("*/api/auth/*", ({ request }) => handler(request)), | |
| 40 | 102 | ] as const; |
| 41 | 103 | } |
apps/web/test/environmentHttpTest.ts created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | |
| 2 | import { | |
| 3 | AuthSessionId, | |
| 4 | EnvironmentAuthenticatedAuth, | |
| 5 | EnvironmentAuthenticatedPrincipal, | |
| 6 | EnvironmentHttpApi, | |
| 7 | type AuthBrowserSessionRequest, | |
| 8 | type AuthBrowserSessionResult, | |
| 9 | type AuthCreatePairingCredentialInput, | |
| 10 | type AuthEnvironmentScope, | |
| 11 | type AuthPairingCredentialResult, | |
| 12 | type AuthSessionState, | |
| 13 | type ExecutionEnvironmentDescriptor, | |
| 14 | type EnvironmentAuthInvalidError, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import * as DateTime from "effect/DateTime"; | |
| 17 | import type * as Context from "effect/Context"; | |
| 18 | import * as Effect from "effect/Effect"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | import * as ManagedRuntime from "effect/ManagedRuntime"; | |
| 21 | import { HttpApiTest } from "effect/unstable/httpapi"; | |
| 22 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | |
| 23 | ||
| 24 | import { PrimaryEnvironmentHttpClient } from "../src/environments/primary/httpClient"; | |
| 25 | import { __setPrimaryHttpRunnerForTests } from "../src/lib/runtime"; | |
| 26 | ||
| 27 | type BrowserSessionHandler = ( | |
| 28 | payload: AuthBrowserSessionRequest, | |
| 29 | ) => Effect.Effect<AuthBrowserSessionResult, EnvironmentAuthInvalidError>; | |
| 30 | ||
| 31 | interface EnvironmentHttpTestScenario { | |
| 32 | readonly descriptor?: () => Effect.Effect<ExecutionEnvironmentDescriptor>; | |
| 33 | readonly session?: () => Effect.Effect<AuthSessionState>; | |
| 34 | readonly browserSession?: BrowserSessionHandler; | |
| 35 | readonly pairingCredential?: ( | |
| 36 | payload: AuthCreatePairingCredentialInput, | |
| 37 | ) => Effect.Effect<AuthPairingCredentialResult>; | |
| 38 | } | |
| 39 | ||
| 40 | export interface EnvironmentHttpTestCalls { | |
| 41 | descriptor: number; | |
| 42 | session: number; | |
| 43 | browserSession: Array<AuthBrowserSessionRequest>; | |
| 44 | pairingCredential: Array<AuthCreatePairingCredentialInput>; | |
| 45 | } | |
| 46 | ||
| 47 | const unexpectedEndpoint = (endpoint: string) => | |
| 48 | Effect.die(new Error(`Unexpected environment HTTP endpoint: ${endpoint}`)); | |
| 49 | ||
| 50 | const authenticatedAuth: Context.Service.Shape<typeof EnvironmentAuthenticatedAuth> = ( | |
| 51 | httpEffect, | |
| 52 | ) => | |
| 53 | httpEffect.pipe( | |
| 54 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { | |
| 55 | sessionId: AuthSessionId.make("test-session"), | |
| 56 | subject: "test-client", | |
| 57 | method: "browser-session-cookie", | |
| 58 | scopes: new Set<AuthEnvironmentScope>(), | |
| 59 | expiresAt: DateTime.makeUnsafe("2026-05-01T12:00:00.000Z"), | |
| 60 | }), | |
| 61 | ); | |
| 62 | ||
| 63 | export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestScenario) { | |
| 64 | const calls: EnvironmentHttpTestCalls = { | |
| 65 | descriptor: 0, | |
| 66 | session: 0, | |
| 67 | browserSession: [], | |
| 68 | pairingCredential: [], | |
| 69 | }; | |
| 70 | ||
| 71 | const client = await Effect.runPromise( | |
| 72 | HttpApiTest.groups(EnvironmentHttpApi, ["metadata", "auth"]).pipe( | |
| 73 | Effect.provide([ | |
| 74 | NodeHttpServer.layerHttpServices, | |
| 75 | HttpApiBuilder.group(EnvironmentHttpApi, "metadata", (handlers) => | |
| 76 | handlers.handle( | |
| 77 | "descriptor", | |
| 78 | Effect.fn("test.environment.metadata.descriptor")(function* () { | |
| 79 | calls.descriptor += 1; | |
| 80 | return yield* scenario.descriptor?.() ?? unexpectedEndpoint("metadata.descriptor"); | |
| 81 | }), | |
| 82 | ), | |
| 83 | ), | |
| 84 | HttpApiBuilder.group(EnvironmentHttpApi, "auth", (handlers) => | |
| 85 | handlers | |
| 86 | .handle( | |
| 87 | "session", | |
| 88 | Effect.fn("test.environment.auth.session")(function* () { | |
| 89 | calls.session += 1; | |
| 90 | return yield* scenario.session?.() ?? unexpectedEndpoint("auth.session"); | |
| 91 | }), | |
| 92 | ) | |
| 93 | .handle( | |
| 94 | "browserSession", | |
| 95 | Effect.fn("test.environment.auth.browserSession")(function* ({ payload }) { | |
| 96 | calls.browserSession.push(payload); | |
| 97 | return yield* ( | |
| 98 | scenario.browserSession?.(payload) ?? unexpectedEndpoint("auth.browserSession") | |
| 99 | ); | |
| 100 | }), | |
| 101 | ) | |
| 102 | .handle("token", () => unexpectedEndpoint("auth.token")) | |
| 103 | .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) | |
| 104 | .handle( | |
| 105 | "pairingCredential", | |
| 106 | Effect.fn("test.environment.auth.pairingCredential")(function* ({ payload }) { | |
| 107 | calls.pairingCredential.push(payload); | |
| 108 | return yield* ( | |
| 109 | scenario.pairingCredential?.(payload) ?? | |
| 110 | unexpectedEndpoint("auth.pairingCredential") | |
| 111 | ); | |
| 112 | }), | |
| 113 | ) | |
| 114 | .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks")) | |
| 115 | .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink")) | |
| 116 | .handle("clients", () => unexpectedEndpoint("auth.clients")) | |
| 117 | .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient")) | |
| 118 | .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")), | |
| 119 | ), | |
| 120 | ]), | |
| 121 | Effect.provideService(EnvironmentAuthenticatedAuth, authenticatedAuth), | |
| 122 | Effect.scoped, | |
| 123 | ), | |
| 124 | ); | |
| 125 | ||
| 126 | const runtime = ManagedRuntime.make(Layer.succeed(PrimaryEnvironmentHttpClient, client)); | |
| 127 | __setPrimaryHttpRunnerForTests((effect) => runtime.runPromise(effect)); | |
| 128 | ||
| 129 | return { | |
| 130 | calls, | |
| 131 | async dispose() { | |
| 132 | __setPrimaryHttpRunnerForTests(); | |
| 133 | await runtime.dispose(); | |
| 134 | }, | |
| 135 | }; | |
| 136 | } |
bun.lock+5-2| ... | ... | @@ -20,6 +20,7 @@ |
| 20 | 20 | "version": "0.0.24", |
| 21 | 21 | "dependencies": { |
| 22 | 22 | "@effect/platform-node": "catalog:", |
| 23 | "@t3tools/client-runtime": "workspace:*", | |
| 23 | 24 | "@t3tools/contracts": "workspace:*", |
| 24 | 25 | "@t3tools/shared": "workspace:*", |
| 25 | 26 | "@t3tools/ssh": "workspace:*", |
| ... | ... | @@ -179,6 +180,7 @@ |
| 179 | 180 | "zustand": "^5.0.11", |
| 180 | 181 | }, |
| 181 | 182 | "devDependencies": { |
| 183 | "@effect/platform-node": "catalog:", | |
| 182 | 184 | "@rolldown/plugin-babel": "^0.2.0", |
| 183 | 185 | "@tailwindcss/vite": "^4.0.0", |
| 184 | 186 | "@tanstack/router-plugin": "^1.161.0", |
| ... | ... | @@ -187,7 +189,7 @@ |
| 187 | 189 | "@types/react-dom": "~19.2.3", |
| 188 | 190 | "@vercel/config": "^0.3.0", |
| 189 | 191 | "@vitejs/plugin-react": "^6.0.0", |
| 190 | "@vitest/browser-playwright": "^4.0.18", | |
| 192 | "@vitest/browser-playwright": "catalog:", | |
| 191 | 193 | "babel-plugin-react-compiler": "1.0.0", |
| 192 | 194 | "msw": "2.12.11", |
| 193 | 195 | "playwright": "^1.58.2", |
| ... | ... | @@ -345,10 +347,11 @@ |
| 345 | 347 | "@types/bun": "^1.3.11", |
| 346 | 348 | "@types/node": "^24.10.13", |
| 347 | 349 | "@typescript/native-preview": "7.0.0-dev.20260527.2", |
| 350 | "@vitest/browser-playwright": "4.1.0", | |
| 348 | 351 | "effect": "4.0.0-beta.73", |
| 349 | 352 | "tsdown": "^0.20.3", |
| 350 | 353 | "typescript": "~6.0.3", |
| 351 | "vitest": "^4.0.0", | |
| 354 | "vitest": "4.1.0", | |
| 352 | 355 | }, |
| 353 | 356 | "packages": { |
| 354 | 357 | "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], |
docs/environment-auth.md created+113| ... | ... | @@ -0,0 +1,113 @@ |
| 1 | # Environment Authentication Profile | |
| 2 | ||
| 3 | The environment server and the relay use separate credentials, issuers, and trust | |
| 4 | boundaries. They intentionally use a similar OAuth-shaped model so that permission | |
| 5 | checks and token exchange behavior can be audited against established concepts. | |
| 6 | ||
| 7 | ## Authorization Model | |
| 8 | ||
| 9 | Environment authorization is capability-based. A session carries zero or more | |
| 10 | OAuth-style scope strings: | |
| 11 | ||
| 12 | | Scope | Permission | | |
| 13 | | ----------------------- | ------------------------------------------------------------------------ | | |
| 14 | | `orchestration:read` | Read snapshots, status, events, configuration, and filesystem/VCS state. | | |
| 15 | | `orchestration:operate` | Dispatch user operations and mutate environment-side workspace state. | | |
| 16 | | `terminal:operate` | Create, attach, input, resize, clear, restart, and terminate terminals. | | |
| 17 | | `review:write` | Read review diff previews used to compose review feedback. | | |
| 18 | | `access:read` | Inspect pairing links and client sessions. | | |
| 19 | | `access:write` | Create or revoke pairing links and client sessions. | | |
| 20 | | `relay:read` | Inspect managed relay connectivity. | | |
| 21 | | `relay:write` | Link, configure, or unlink managed relay connectivity. | | |
| 22 | ||
| 23 | Ordinary pairing links grant the four client-operation scopes and read access to | |
| 24 | managed relay connectivity: | |
| 25 | `orchestration:read orchestration:operate terminal:operate review:write relay:read`. | |
| 26 | The desktop bootstrap credential and command-line administrative bootstrap | |
| 27 | credentials additionally grant `access:read access:write relay:write`. | |
| 28 | ||
| 29 | ## Authentication Flows | |
| 30 | ||
| 31 | ### Browser Session | |
| 32 | ||
| 33 | `POST /api/auth/browser-session` consumes a one-time bootstrap credential and creates a | |
| 34 | browser session cookie. The cookie is an HTTP transport adapter for the same | |
| 35 | scoped session model; the response never exposes the session secret to browser | |
| 36 | JavaScript. | |
| 37 | ||
| 38 | ### Bearer Access Token | |
| 39 | ||
| 40 | Non-browser clients use `POST /oauth/token` with an | |
| 41 | `application/x-www-form-urlencoded` body: | |
| 42 | ||
| 43 | ```text | |
| 44 | grant_type=urn:ietf:params:oauth:grant-type:token-exchange | |
| 45 | subject_token=<bootstrap credential> | |
| 46 | subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap | |
| 47 | requested_token_type=urn:ietf:params:oauth:token-type:access_token | |
| 48 | scope=orchestration:read orchestration:operate terminal:operate review:write relay:read | |
| 49 | ``` | |
| 50 | ||
| 51 | Clients may additionally submit `client_label`, `client_device_type`, and | |
| 52 | `client_os` extension parameters so the authorized-clients UI can identify the | |
| 53 | device that established the session. These are presentation hints only; the | |
| 54 | environment derives transport metadata such as IP address and user agent from | |
| 55 | the request and does not use these fields for authorization. | |
| 56 | ||
| 57 | The response has the token-exchange shape: | |
| 58 | ||
| 59 | ```json | |
| 60 | { | |
| 61 | "access_token": "<opaque session token>", | |
| 62 | "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", | |
| 63 | "token_type": "Bearer", | |
| 64 | "expires_in": 3600, | |
| 65 | "scope": "orchestration:read orchestration:operate terminal:operate review:write relay:read" | |
| 66 | } | |
| 67 | ``` | |
| 68 | ||
| 69 | Requested scopes must be a subset of the one-time bootstrap credential grant. | |
| 70 | An ordinary paired client therefore cannot exchange its grant for | |
| 71 | `access:read`, `access:write`, or `relay:write`. | |
| 72 | ||
| 73 | ### WebSocket Ticket | |
| 74 | ||
| 75 | `POST /api/auth/websocket-ticket` accepts any authenticated session and returns | |
| 76 | a short-lived, single-purpose WebSocket ticket. This keeps bearer tokens and | |
| 77 | browser cookies out of WebSocket URLs while allowing the socket handshake to | |
| 78 | authenticate. The ticket carries its session's scopes; each RPC method then | |
| 79 | enforces `orchestration:read`, `orchestration:operate`, `terminal:operate`, | |
| 80 | `review:write`, or `access:read` as appropriate. Review feedback submission | |
| 81 | currently dispatches an orchestration operation, so clients performing it also | |
| 82 | need `orchestration:operate`. Creating a ticket is not | |
| 83 | authorization to call every RPC method. | |
| 84 | ||
| 85 | ## Standards Alignment | |
| 86 | ||
| 87 | - Bearer access tokens are used through the `Authorization: Bearer` scheme from | |
| 88 | RFC 6750. | |
| 89 | - The token endpoint profiles the request and response vocabulary from OAuth 2.0 | |
| 90 | Token Exchange (RFC 8693), including `subject_token`, `requested_token_type`, | |
| 91 | `access_token`, `issued_token_type`, and `token_type`. | |
| 92 | - Scope values follow the OAuth 2.0 scope model from RFC 6749: space-delimited, | |
| 93 | unordered capabilities with subset checking during exchange. | |
| 94 | ||
| 95 | This is intentionally not a general-purpose OAuth authorization server. The | |
| 96 | environment bootstrap token type is private, the bootstrap cookie and WebSocket | |
| 97 | connection-token routes are product-specific adapters, and the API returns its | |
| 98 | typed `HttpApi` errors rather than implementing every OAuth error response | |
| 99 | surface. | |
| 100 | ||
| 101 | ## Upgrade Behavior | |
| 102 | ||
| 103 | Migration `031_AuthAuthorizationScopes` is a hard cutover from role-bearing auth | |
| 104 | records to scoped records. It deletes existing pairing links and sessions while | |
| 105 | leaving non-authentication environment state unchanged. Upgraded clients must | |
| 106 | pair again; old `owner` or `client` credentials are never silently mapped to new | |
| 107 | capabilities. | |
| 108 | ||
| 109 | ## Relay Boundary | |
| 110 | ||
| 111 | Relay-managed tunnels use their own tokens and keys. The relay can reuse scope | |
| 112 | parsing and token-exchange conventions, but an environment access token is not a | |
| 113 | relay token and cannot be presented to the relay. |
package.json+2-1| ... | ... | @@ -24,7 +24,8 @@ |
| 24 | 24 | "@typescript/native-preview": "7.0.0-dev.20260527.2", |
| 25 | 25 | "tsdown": "^0.20.3", |
| 26 | 26 | "typescript": "~6.0.3", |
| 27 | "vitest": "^4.0.0" | |
| 27 | "@vitest/browser-playwright": "4.1.0", | |
| 28 | "vitest": "4.1.0" | |
| 28 | 29 | } |
| 29 | 30 | }, |
| 30 | 31 | "type": "module", |
packages/client-runtime/src/remote.test.ts+167-32| ... | ... | @@ -2,19 +2,23 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import * as Duration from "effect/Duration"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Schema from "effect/Schema"; | |
| 5 | 6 | import * as TestClock from "effect/testing/TestClock"; |
| 6 | import { HttpClient } from "effect/unstable/http"; | |
| 7 | 7 | |
| 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; | |
| 8 | 9 | import { |
| 9 | 10 | bootstrapRemoteBearerSession, |
| 10 | 11 | fetchRemoteEnvironmentDescriptor, |
| 11 | 12 | fetchRemoteSessionState, |
| 12 | issueRemoteWebSocketToken, | |
| 13 | issueRemoteWebSocketTicket, | |
| 13 | 14 | remoteHttpClientLayer, |
| 15 | RemoteEnvironmentAuthInvalidJsonError, | |
| 14 | 16 | RemoteEnvironmentAuthTimeoutError, |
| 15 | 17 | resolveRemoteWebSocketConnectionUrl, |
| 16 | 18 | } from "./remote.ts"; |
| 17 | 19 | |
| 20 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); | |
| 21 | ||
| 18 | 22 | type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; |
| 19 | 23 | |
| 20 | 24 | const recordedFetch = (...responses: ReadonlyArray<Response>) => { |
| ... | ... | @@ -42,10 +46,7 @@ const hangingFetch = () => { |
| 42 | 46 | return { fetchFn, calls }; |
| 43 | 47 | }; |
| 44 | 48 | |
| 45 | const provideRemoteHttp = | |
| 46 | (fetchFn: typeof fetch) => | |
| 47 | <A, E, R>(effect: Effect.Effect<A, E, R | HttpClient.HttpClient>) => | |
| 48 | effect.pipe(Effect.provide(remoteHttpClientLayer(fetchFn))); | |
| 49 | const provideRemoteHttp = (fetchFn: typeof fetch) => Effect.provide(remoteHttpClientLayer(fetchFn)); | |
| 49 | 50 | |
| 50 | 51 | const expectFetchCall = ( |
| 51 | 52 | calls: ReadonlyArray<FetchCall>, |
| ... | ... | @@ -90,11 +91,12 @@ describe("remote", () => { |
| 90 | 91 | const fetch = recordedFetch( |
| 91 | 92 | Response.json( |
| 92 | 93 | { |
| 93 | authenticated: true, | |
| 94 | role: "client", | |
| 95 | sessionMethod: "bearer-session-token", | |
| 96 | expiresAt: "2026-05-01T12:00:00.000Z", | |
| 97 | sessionToken: "bearer-token", | |
| 94 | access_token: "bearer-token", | |
| 95 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | |
| 96 | token_type: "Bearer", | |
| 97 | expires_in: 3600, | |
| 98 | scope: | |
| 99 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 98 | 100 | }, |
| 99 | 101 | { status: 200 }, |
| 100 | 102 | ), |
| ... | ... | @@ -106,16 +108,80 @@ describe("remote", () => { |
| 106 | 108 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 107 | 109 | |
| 108 | 110 | expect(result).toMatchObject({ |
| 109 | sessionMethod: "bearer-session-token", | |
| 110 | sessionToken: "bearer-token", | |
| 111 | token_type: "Bearer", | |
| 112 | access_token: "bearer-token", | |
| 113 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 111 | 114 | }); |
| 112 | 115 | expectFetchCall(fetch.calls, 1, { |
| 113 | url: "https://remote.example.com/api/auth/bootstrap/bearer", | |
| 116 | url: "https://remote.example.com/oauth/token", | |
| 114 | 117 | method: "POST", |
| 115 | 118 | headers: { |
| 116 | "content-type": "application/json", | |
| 119 | "content-type": "application/x-www-form-urlencoded", | |
| 117 | 120 | }, |
| 118 | body: `{"credential":"pairing-token"}`, | |
| 121 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token", | |
| 122 | }); | |
| 123 | }), | |
| 124 | ); | |
| 125 | ||
| 126 | it.effect("submits optional client display metadata during bearer token exchange", () => | |
| 127 | Effect.gen(function* () { | |
| 128 | const fetch = recordedFetch( | |
| 129 | Response.json( | |
| 130 | { | |
| 131 | access_token: "bearer-token", | |
| 132 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | |
| 133 | token_type: "Bearer", | |
| 134 | expires_in: 3600, | |
| 135 | scope: | |
| 136 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 137 | }, | |
| 138 | { status: 200 }, | |
| 139 | ), | |
| 140 | ); | |
| 141 | ||
| 142 | yield* bootstrapRemoteBearerSession({ | |
| 143 | httpBaseUrl: "https://remote.example.com/", | |
| 144 | credential: "pairing-token", | |
| 145 | clientMetadata: { | |
| 146 | label: "T3 Code Mobile", | |
| 147 | deviceType: "mobile", | |
| 148 | os: "iOS", | |
| 149 | }, | |
| 150 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | |
| 151 | ||
| 152 | expectFetchCall(fetch.calls, 1, { | |
| 153 | url: "https://remote.example.com/oauth/token", | |
| 154 | method: "POST", | |
| 155 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS", | |
| 156 | }); | |
| 157 | }), | |
| 158 | ); | |
| 159 | ||
| 160 | it.effect("allows a client to explicitly narrow a pairing grant", () => | |
| 161 | Effect.gen(function* () { | |
| 162 | const fetch = recordedFetch( | |
| 163 | Response.json( | |
| 164 | { | |
| 165 | access_token: "read-only-token", | |
| 166 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | |
| 167 | token_type: "Bearer", | |
| 168 | expires_in: 3600, | |
| 169 | scope: "orchestration:read", | |
| 170 | }, | |
| 171 | { status: 200 }, | |
| 172 | ), | |
| 173 | ); | |
| 174 | ||
| 175 | yield* bootstrapRemoteBearerSession({ | |
| 176 | httpBaseUrl: "https://remote.example.com/", | |
| 177 | credential: "pairing-token", | |
| 178 | scopes: ["orchestration:read"], | |
| 179 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | |
| 180 | ||
| 181 | expectFetchCall(fetch.calls, 1, { | |
| 182 | url: "https://remote.example.com/oauth/token", | |
| 183 | method: "POST", | |
| 184 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=orchestration%3Aread", | |
| 119 | 185 | }); |
| 120 | 186 | }), |
| 121 | 187 | ); |
| ... | ... | @@ -144,18 +210,24 @@ describe("remote", () => { |
| 144 | 210 | auth: { |
| 145 | 211 | policy: "remote-reachable", |
| 146 | 212 | bootstrapMethods: ["one-time-token"], |
| 147 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 213 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | |
| 148 | 214 | sessionCookieName: "t3_session", |
| 149 | 215 | }, |
| 150 | role: "client", | |
| 151 | sessionMethod: "bearer-session-token", | |
| 216 | scopes: [ | |
| 217 | "orchestration:read", | |
| 218 | "orchestration:operate", | |
| 219 | "terminal:operate", | |
| 220 | "review:write", | |
| 221 | "relay:read", | |
| 222 | ], | |
| 223 | sessionMethod: "bearer-access-token", | |
| 152 | 224 | expiresAt: "2026-05-01T12:00:00.000Z", |
| 153 | 225 | }, |
| 154 | 226 | { status: 200 }, |
| 155 | 227 | ), |
| 156 | 228 | Response.json( |
| 157 | 229 | { |
| 158 | token: "ws-token", | |
| 230 | ticket: "ws-ticket", | |
| 159 | 231 | expiresAt: "2026-05-01T12:05:00.000Z", |
| 160 | 232 | }, |
| 161 | 233 | { status: 200 }, |
| ... | ... | @@ -176,15 +248,21 @@ describe("remote", () => { |
| 176 | 248 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 177 | 249 | expect(session).toMatchObject({ |
| 178 | 250 | authenticated: true, |
| 179 | role: "client", | |
| 251 | scopes: [ | |
| 252 | "orchestration:read", | |
| 253 | "orchestration:operate", | |
| 254 | "terminal:operate", | |
| 255 | "review:write", | |
| 256 | "relay:read", | |
| 257 | ], | |
| 180 | 258 | }); |
| 181 | 259 | |
| 182 | const token = yield* issueRemoteWebSocketToken({ | |
| 260 | const token = yield* issueRemoteWebSocketTicket({ | |
| 183 | 261 | httpBaseUrl: "https://remote.example.com/", |
| 184 | 262 | bearerToken: "bearer-token", |
| 185 | 263 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 186 | 264 | expect(token).toMatchObject({ |
| 187 | token: "ws-token", | |
| 265 | ticket: "ws-ticket", | |
| 188 | 266 | }); |
| 189 | 267 | |
| 190 | 268 | expectFetchCall(fetch.calls, 1, { |
| ... | ... | @@ -199,7 +277,7 @@ describe("remote", () => { |
| 199 | 277 | }, |
| 200 | 278 | }); |
| 201 | 279 | expectFetchCall(fetch.calls, 3, { |
| 202 | url: "https://remote.example.com/api/auth/ws-token", | |
| 280 | url: "https://remote.example.com/api/auth/websocket-ticket", | |
| 203 | 281 | method: "POST", |
| 204 | 282 | headers: { |
| 205 | 283 | authorization: "Bearer bearer-token", |
| ... | ... | @@ -227,25 +305,82 @@ describe("remote", () => { |
| 227 | 305 | }).pipe(Effect.provide(TestClock.layer())), |
| 228 | 306 | ); |
| 229 | 307 | |
| 230 | it.effect("mints a websocket url that targets the rpc route with a short-lived ws token", () => | |
| 308 | it.effect("revives declared typed errors from remote auth failures", () => | |
| 231 | 309 | Effect.gen(function* () { |
| 232 | 310 | const fetch = recordedFetch( |
| 233 | 311 | Response.json( |
| 234 | 312 | { |
| 235 | token: "ws-token", | |
| 236 | expiresAt: "2026-05-01T12:05:00.000Z", | |
| 313 | _tag: "EnvironmentAuthInvalidError", | |
| 314 | code: "auth_invalid", | |
| 315 | reason: "missing_credential", | |
| 316 | traceId: "trace-auth-test", | |
| 317 | }, | |
| 318 | { status: 401 }, | |
| 319 | ), | |
| 320 | ); | |
| 321 | ||
| 322 | const error = yield* issueRemoteWebSocketTicket({ | |
| 323 | httpBaseUrl: "https://remote.example.com/", | |
| 324 | bearerToken: "expired-token", | |
| 325 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip); | |
| 326 | ||
| 327 | expect(isEnvironmentAuthInvalidError(error)).toBe(true); | |
| 328 | if (isEnvironmentAuthInvalidError(error)) { | |
| 329 | expect(error.reason).toBe("missing_credential"); | |
| 330 | expect(error.traceId).toBe("trace-auth-test"); | |
| 331 | } | |
| 332 | }), | |
| 333 | ); | |
| 334 | ||
| 335 | it.effect("classifies malformed successful remote auth responses as invalid responses", () => | |
| 336 | Effect.gen(function* () { | |
| 337 | const fetch = recordedFetch( | |
| 338 | Response.json( | |
| 339 | { | |
| 340 | access_token: "", | |
| 341 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | |
| 342 | token_type: "Bearer", | |
| 343 | expires_in: 3600, | |
| 344 | scope: | |
| 345 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | |
| 237 | 346 | }, |
| 238 | 347 | { status: 200 }, |
| 239 | 348 | ), |
| 240 | 349 | ); |
| 241 | 350 | |
| 242 | const url = yield* resolveRemoteWebSocketConnectionUrl({ | |
| 243 | wsBaseUrl: "wss://remote.example.com/", | |
| 351 | const error = yield* bootstrapRemoteBearerSession({ | |
| 244 | 352 | httpBaseUrl: "https://remote.example.com/", |
| 245 | bearerToken: "bearer-token", | |
| 246 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | |
| 353 | credential: "pairing-token", | |
| 354 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip); | |
| 247 | 355 | |
| 248 | expect(url).toBe("wss://remote.example.com/ws?wsToken=ws-token"); | |
| 356 | expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError); | |
| 357 | expect(error.message).toBe( | |
| 358 | "Remote auth endpoint returned an invalid response from https://remote.example.com/oauth/token.", | |
| 359 | ); | |
| 249 | 360 | }), |
| 250 | 361 | ); |
| 362 | ||
| 363 | it.effect( | |
| 364 | "mints a websocket url that targets the rpc route with a short-lived websocket ticket", | |
| 365 | () => | |
| 366 | Effect.gen(function* () { | |
| 367 | const fetch = recordedFetch( | |
| 368 | Response.json( | |
| 369 | { | |
| 370 | ticket: "ws-ticket", | |
| 371 | expiresAt: "2026-05-01T12:05:00.000Z", | |
| 372 | }, | |
| 373 | { status: 200 }, | |
| 374 | ), | |
| 375 | ); | |
| 376 | ||
| 377 | const url = yield* resolveRemoteWebSocketConnectionUrl({ | |
| 378 | wsBaseUrl: "wss://remote.example.com/", | |
| 379 | httpBaseUrl: "https://remote.example.com/", | |
| 380 | bearerToken: "bearer-token", | |
| 381 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | |
| 382 | ||
| 383 | expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); | |
| 384 | }), | |
| 385 | ); | |
| 251 | 386 | }); |
packages/client-runtime/src/remote.ts+145-142| ... | ... | @@ -1,30 +1,31 @@ |
| 1 | import { | |
| 2 | AuthAccessTokenType, | |
| 3 | type AuthClientPresentationMetadata, | |
| 4 | AuthEnvironmentBootstrapTokenType, | |
| 5 | AuthTokenExchangeGrantType, | |
| 6 | type AuthEnvironmentScope, | |
| 7 | EnvironmentHttpApi, | |
| 8 | EnvironmentHttpCommonError, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | |
| 1 | 11 | import type { |
| 2 | AuthBearerBootstrapResult, | |
| 3 | AuthSessionState, | |
| 4 | AuthWebSocketTokenResult, | |
| 5 | ExecutionEnvironmentDescriptor, | |
| 12 | EnvironmentAuthInvalidError, | |
| 13 | EnvironmentInternalError, | |
| 14 | EnvironmentOperationForbiddenError, | |
| 15 | EnvironmentRequestInvalidError, | |
| 16 | EnvironmentScopeRequiredError, | |
| 6 | 17 | } from "@t3tools/contracts"; |
| 7 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 8 | 18 | import * as Data from "effect/Data"; |
| 9 | 19 | import * as Duration from "effect/Duration"; |
| 10 | 20 | import * as Effect from "effect/Effect"; |
| 11 | 21 | import * as Layer from "effect/Layer"; |
| 12 | 22 | import * as Option from "effect/Option"; |
| 13 | import * as Result from "effect/Result"; | |
| 14 | 23 | import * as Schema from "effect/Schema"; |
| 15 | import { identity } from "effect/Function"; | |
| 16 | import { | |
| 17 | FetchHttpClient, | |
| 18 | HttpClient, | |
| 19 | HttpClientRequest, | |
| 20 | HttpClientResponse, | |
| 21 | } from "effect/unstable/http"; | |
| 24 | import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; | |
| 25 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | |
| 22 | 26 | |
| 23 | 27 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; |
| 24 | const RemoteAuthErrorBody = Schema.Struct({ | |
| 25 | error: Schema.optional(Schema.String), | |
| 26 | }); | |
| 27 | const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody); | |
| 28 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); | |
| 28 | 29 | |
| 29 | 30 | const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { |
| 30 | 31 | const url = new URL(httpBaseUrl); |
| ... | ... | @@ -34,6 +35,14 @@ const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { |
| 34 | 35 | return url.toString(); |
| 35 | 36 | }; |
| 36 | 37 | |
| 38 | const remoteApiBaseUrl = (httpBaseUrl: string): string => { | |
| 39 | const url = new URL(httpBaseUrl); | |
| 40 | url.pathname = "/"; | |
| 41 | url.search = ""; | |
| 42 | url.hash = ""; | |
| 43 | return url.toString(); | |
| 44 | }; | |
| 45 | ||
| 37 | 46 | export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( |
| 38 | 47 | "RemoteEnvironmentAuthFetchError", |
| 39 | 48 | )<{ |
| ... | ... | @@ -41,13 +50,6 @@ export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( |
| 41 | 50 | readonly cause: unknown; |
| 42 | 51 | }> {} |
| 43 | 52 | |
| 44 | export class RemoteEnvironmentAuthResponseReadError extends Data.TaggedError( | |
| 45 | "RemoteEnvironmentAuthResponseReadError", | |
| 46 | )<{ | |
| 47 | readonly message: string; | |
| 48 | readonly cause: unknown; | |
| 49 | }> {} | |
| 50 | ||
| 51 | 53 | export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( |
| 52 | 54 | "RemoteEnvironmentAuthInvalidJsonError", |
| 53 | 55 | )<{ |
| ... | ... | @@ -55,14 +57,19 @@ export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( |
| 55 | 57 | readonly cause: unknown; |
| 56 | 58 | }> {} |
| 57 | 59 | |
| 58 | export class RemoteEnvironmentAuthHttpError extends Data.TaggedError( | |
| 59 | "RemoteEnvironmentAuthHttpError", | |
| 60 | export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( | |
| 61 | "RemoteEnvironmentAuthUndeclaredStatusError", | |
| 60 | 62 | )<{ |
| 61 | 63 | readonly message: string; |
| 62 | 64 | readonly status: number; |
| 65 | readonly requestUrl: string; | |
| 63 | 66 | }> { |
| 64 | constructor(message: string, status: number) { | |
| 65 | super({ message, status }); | |
| 67 | constructor(requestUrl: string, status: number) { | |
| 68 | super({ | |
| 69 | message: `Remote auth endpoint ${requestUrl} returned undeclared status ${status}.`, | |
| 70 | requestUrl, | |
| 71 | status, | |
| 72 | }); | |
| 66 | 73 | } |
| 67 | 74 | } |
| 68 | 75 | |
| ... | ... | @@ -83,87 +90,67 @@ export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( |
| 83 | 90 | } |
| 84 | 91 | |
| 85 | 92 | export type RemoteEnvironmentAuthError = |
| 93 | | EnvironmentRequestInvalidError | |
| 94 | | EnvironmentAuthInvalidError | |
| 95 | | EnvironmentScopeRequiredError | |
| 96 | | EnvironmentOperationForbiddenError | |
| 97 | | EnvironmentInternalError | |
| 86 | 98 | | RemoteEnvironmentAuthFetchError |
| 87 | | RemoteEnvironmentAuthResponseReadError | |
| 88 | 99 | | RemoteEnvironmentAuthInvalidJsonError |
| 89 | | RemoteEnvironmentAuthHttpError | |
| 100 | | RemoteEnvironmentAuthUndeclaredStatusError | |
| 90 | 101 | | RemoteEnvironmentAuthTimeoutError; |
| 91 | 102 | |
| 92 | export const isRemoteEnvironmentAuthHttpError = ( | |
| 93 | error: unknown, | |
| 94 | ): error is RemoteEnvironmentAuthHttpError => error instanceof RemoteEnvironmentAuthHttpError; | |
| 95 | ||
| 96 | const readRemoteAuthErrorMessage = ( | |
| 97 | response: HttpClientResponse.HttpClientResponse, | |
| 98 | fallbackMessage: string, | |
| 99 | ): Effect.Effect<string, RemoteEnvironmentAuthResponseReadError> => | |
| 100 | response.text.pipe( | |
| 101 | Effect.mapError( | |
| 102 | (cause) => | |
| 103 | new RemoteEnvironmentAuthResponseReadError({ | |
| 104 | message: "Remote auth endpoint returned an unreadable error response.", | |
| 105 | cause, | |
| 106 | }), | |
| 107 | ), | |
| 108 | Effect.map((text) => { | |
| 109 | if (!text) { | |
| 110 | return fallbackMessage; | |
| 111 | } | |
| 112 | ||
| 113 | const decoded = decodeRemoteAuthErrorBody(text); | |
| 114 | if (Result.isSuccess(decoded) && decoded.success.error) { | |
| 115 | return decoded.success.error; | |
| 116 | } | |
| 117 | ||
| 118 | return text; | |
| 119 | }), | |
| 120 | ); | |
| 121 | ||
| 122 | const readRemoteJson = <T>( | |
| 123 | response: HttpClientResponse.HttpClientResponse, | |
| 124 | requestUrl: string, | |
| 125 | ): Effect.Effect<T, RemoteEnvironmentAuthInvalidJsonError> => | |
| 126 | response.json.pipe( | |
| 127 | Effect.mapError( | |
| 128 | (cause) => | |
| 129 | new RemoteEnvironmentAuthInvalidJsonError({ | |
| 130 | message: `Remote auth endpoint returned invalid JSON from ${requestUrl}.`, | |
| 131 | cause, | |
| 132 | }), | |
| 133 | ), | |
| 134 | Effect.map((value) => value as T), | |
| 135 | ); | |
| 136 | ||
| 137 | 103 | export const remoteHttpClientLayer = ( |
| 138 | 104 | fetchFn: typeof globalThis.fetch, |
| 139 | 105 | ): Layer.Layer<HttpClient.HttpClient> => |
| 140 | 106 | FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))); |
| 141 | 107 | |
| 142 | const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(function* <T>(input: { | |
| 143 | readonly httpBaseUrl: string; | |
| 144 | readonly pathname: string; | |
| 145 | readonly method?: "GET" | "POST"; | |
| 146 | readonly bearerToken?: string; | |
| 147 | readonly body?: unknown; | |
| 148 | readonly timeoutMs?: number; | |
| 149 | }) { | |
| 150 | const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname); | |
| 151 | const method = input.method ?? "GET"; | |
| 152 | const timeoutMs = input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS; | |
| 153 | const request = HttpClientRequest.make(method)(requestUrl).pipe( | |
| 154 | input.bearerToken ? HttpClientRequest.bearerToken(input.bearerToken) : identity, | |
| 155 | input.body !== undefined ? HttpClientRequest.bodyJsonUnsafe(input.body) : identity, | |
| 108 | const failRemoteRequest = ( | |
| 109 | requestUrl: string, | |
| 110 | cause: unknown, | |
| 111 | ): Effect.Effect<never, RemoteEnvironmentAuthError> => { | |
| 112 | if (cause instanceof RemoteEnvironmentAuthTimeoutError) { | |
| 113 | return Effect.fail(cause); | |
| 114 | } | |
| 115 | if (isEnvironmentHttpCommonError(cause)) { | |
| 116 | return Effect.fail(cause); | |
| 117 | } | |
| 118 | if (Schema.isSchemaError(cause)) { | |
| 119 | return Effect.fail( | |
| 120 | new RemoteEnvironmentAuthInvalidJsonError({ | |
| 121 | message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, | |
| 122 | cause, | |
| 123 | }), | |
| 124 | ); | |
| 125 | } | |
| 126 | if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { | |
| 127 | const response = cause.response; | |
| 128 | if (response.status < 200 || response.status >= 300) { | |
| 129 | return Effect.fail( | |
| 130 | new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), | |
| 131 | ); | |
| 132 | } | |
| 133 | return Effect.fail( | |
| 134 | new RemoteEnvironmentAuthInvalidJsonError({ | |
| 135 | message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, | |
| 136 | cause, | |
| 137 | }), | |
| 138 | ); | |
| 139 | } | |
| 140 | return Effect.fail( | |
| 141 | new RemoteEnvironmentAuthFetchError({ | |
| 142 | message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`, | |
| 143 | cause, | |
| 144 | }), | |
| 156 | 145 | ); |
| 146 | }; | |
| 157 | 147 | |
| 158 | const client = yield* HttpClient.HttpClient; | |
| 159 | const response = yield* client.execute(request).pipe( | |
| 160 | Effect.mapError( | |
| 161 | (cause) => | |
| 162 | new RemoteEnvironmentAuthFetchError({ | |
| 163 | message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`, | |
| 164 | cause, | |
| 165 | }), | |
| 166 | ), | |
| 148 | const executeRemoteRequest = <A, E, R>( | |
| 149 | requestUrl: string, | |
| 150 | timeoutMs: number, | |
| 151 | request: Effect.Effect<A, E, R>, | |
| 152 | ): Effect.Effect<A, RemoteEnvironmentAuthError, R> => | |
| 153 | request.pipe( | |
| 167 | 154 | Effect.timeoutOption(Duration.millis(timeoutMs)), |
| 168 | 155 | Effect.flatMap( |
| 169 | 156 | Option.match({ |
| ... | ... | @@ -171,38 +158,46 @@ const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(functi |
| 171 | 158 | onSome: Effect.succeed, |
| 172 | 159 | }), |
| 173 | 160 | ), |
| 161 | Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), | |
| 174 | 162 | ); |
| 175 | 163 | |
| 176 | if (response.status < 200 || response.status >= 300) { | |
| 177 | return yield* readRemoteAuthErrorMessage( | |
| 178 | response, | |
| 179 | `Remote auth request failed (${response.status}).`, | |
| 180 | ).pipe( | |
| 181 | Effect.flatMap((message) => | |
| 182 | Effect.fail(new RemoteEnvironmentAuthHttpError(message, response.status)), | |
| 183 | ), | |
| 184 | ); | |
| 185 | } | |
| 186 | ||
| 187 | return yield* readRemoteJson<T>(response, requestUrl); | |
| 188 | }); | |
| 164 | export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => | |
| 165 | Effect.gen(function* () { | |
| 166 | const httpClient = yield* HttpClient.HttpClient; | |
| 167 | return yield* HttpApiClient.makeWith(EnvironmentHttpApi, { | |
| 168 | httpClient, | |
| 169 | baseUrl: remoteApiBaseUrl(httpBaseUrl), | |
| 170 | }); | |
| 171 | }); | |
| 189 | 172 | |
| 190 | 173 | export const bootstrapRemoteBearerSession = Effect.fn( |
| 191 | 174 | "clientRuntime.remote.bootstrapRemoteBearerSession", |
| 192 | 175 | )(function* (input: { |
| 193 | 176 | readonly httpBaseUrl: string; |
| 194 | 177 | readonly credential: string; |
| 178 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | |
| 179 | readonly clientMetadata?: AuthClientPresentationMetadata; | |
| 195 | 180 | readonly timeoutMs?: number; |
| 196 | 181 | }) { |
| 197 | return yield* fetchRemoteJson<AuthBearerBootstrapResult>({ | |
| 198 | httpBaseUrl: input.httpBaseUrl, | |
| 199 | pathname: "/api/auth/bootstrap/bearer", | |
| 200 | method: "POST", | |
| 201 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | |
| 202 | body: { | |
| 203 | credential: input.credential, | |
| 204 | }, | |
| 205 | }); | |
| 182 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 183 | return yield* executeRemoteRequest( | |
| 184 | remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), | |
| 185 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | |
| 186 | client.auth.token({ | |
| 187 | payload: { | |
| 188 | grant_type: AuthTokenExchangeGrantType, | |
| 189 | subject_token: input.credential, | |
| 190 | subject_token_type: AuthEnvironmentBootstrapTokenType, | |
| 191 | requested_token_type: AuthAccessTokenType, | |
| 192 | ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), | |
| 193 | ...(input.clientMetadata?.label ? { client_label: input.clientMetadata.label } : {}), | |
| 194 | ...(input.clientMetadata?.deviceType | |
| 195 | ? { client_device_type: input.clientMetadata.deviceType } | |
| 196 | : {}), | |
| 197 | ...(input.clientMetadata?.os ? { client_os: input.clientMetadata.os } : {}), | |
| 198 | }, | |
| 199 | }), | |
| 200 | ); | |
| 206 | 201 | }); |
| 207 | 202 | |
| 208 | 203 | export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")( |
| ... | ... | @@ -211,39 +206,47 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo |
| 211 | 206 | readonly bearerToken: string; |
| 212 | 207 | readonly timeoutMs?: number; |
| 213 | 208 | }) { |
| 214 | return yield* fetchRemoteJson<AuthSessionState>({ | |
| 215 | httpBaseUrl: input.httpBaseUrl, | |
| 216 | pathname: "/api/auth/session", | |
| 217 | bearerToken: input.bearerToken, | |
| 218 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | |
| 219 | }); | |
| 209 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 210 | return yield* executeRemoteRequest( | |
| 211 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), | |
| 212 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | |
| 213 | client.auth.session({ | |
| 214 | headers: { | |
| 215 | authorization: `Bearer ${input.bearerToken}`, | |
| 216 | }, | |
| 217 | }), | |
| 218 | ); | |
| 220 | 219 | }, |
| 221 | 220 | ); |
| 222 | 221 | |
| 223 | 222 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( |
| 224 | 223 | "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", |
| 225 | 224 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { |
| 226 | return yield* fetchRemoteJson<ExecutionEnvironmentDescriptor>({ | |
| 227 | httpBaseUrl: input.httpBaseUrl, | |
| 228 | pathname: "/.well-known/t3/environment", | |
| 229 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | |
| 230 | }); | |
| 225 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 226 | return yield* executeRemoteRequest( | |
| 227 | remoteEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), | |
| 228 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | |
| 229 | client.metadata.descriptor(), | |
| 230 | ); | |
| 231 | 231 | }); |
| 232 | 232 | |
| 233 | export const issueRemoteWebSocketToken = Effect.fn( | |
| 234 | "clientRuntime.remote.issueRemoteWebSocketToken", | |
| 233 | export const issueRemoteWebSocketTicket = Effect.fn( | |
| 234 | "clientRuntime.remote.issueRemoteWebSocketTicket", | |
| 235 | 235 | )(function* (input: { |
| 236 | 236 | readonly httpBaseUrl: string; |
| 237 | 237 | readonly bearerToken: string; |
| 238 | 238 | readonly timeoutMs?: number; |
| 239 | 239 | }) { |
| 240 | return yield* fetchRemoteJson<AuthWebSocketTokenResult>({ | |
| 241 | httpBaseUrl: input.httpBaseUrl, | |
| 242 | pathname: "/api/auth/ws-token", | |
| 243 | method: "POST", | |
| 244 | bearerToken: input.bearerToken, | |
| 245 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | |
| 246 | }); | |
| 240 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | |
| 241 | return yield* executeRemoteRequest( | |
| 242 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), | |
| 243 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | |
| 244 | client.auth.webSocketTicket({ | |
| 245 | headers: { | |
| 246 | authorization: `Bearer ${input.bearerToken}`, | |
| 247 | }, | |
| 248 | }), | |
| 249 | ); | |
| 247 | 250 | }); |
| 248 | 251 | |
| 249 | 252 | export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| ... | ... | @@ -254,7 +257,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 254 | 257 | readonly bearerToken: string; |
| 255 | 258 | readonly timeoutMs?: number; |
| 256 | 259 | }) { |
| 257 | const issued = yield* issueRemoteWebSocketToken({ | |
| 260 | const issued = yield* issueRemoteWebSocketTicket({ | |
| 258 | 261 | httpBaseUrl: input.httpBaseUrl, |
| 259 | 262 | bearerToken: input.bearerToken, |
| 260 | 263 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), |
| ... | ... | @@ -264,6 +267,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 264 | 267 | if (url.pathname === "" || url.pathname === "/") { |
| 265 | 268 | url.pathname = "/ws"; |
| 266 | 269 | } |
| 267 | url.searchParams.set("wsToken", issued.token); | |
| 270 | url.searchParams.set("wsTicket", issued.ticket); | |
| 268 | 271 | return url.toString(); |
| 269 | 272 | }); |
packages/client-runtime/src/wsTransport.test.ts+2-2| ... | ... | @@ -170,13 +170,13 @@ describe("WsTransport", () => { |
| 170 | 170 | }); |
| 171 | 171 | |
| 172 | 172 | it("supports async websocket url providers", async () => { |
| 173 | const transport = createTransport(async () => "wss://remote.example.com/?wsToken=dynamic"); | |
| 173 | const transport = createTransport(async () => "wss://remote.example.com/?wsTicket=dynamic"); | |
| 174 | 174 | |
| 175 | 175 | await waitFor(() => { |
| 176 | 176 | expect(sockets).toHaveLength(1); |
| 177 | 177 | }); |
| 178 | 178 | |
| 179 | expect(getSocket().url).toBe("wss://remote.example.com/ws?wsToken=dynamic"); | |
| 179 | expect(getSocket().url).toBe("wss://remote.example.com/ws?wsTicket=dynamic"); | |
| 180 | 180 | await transport.dispose(); |
| 181 | 181 | }); |
| 182 | 182 |
packages/contracts/src/auth.ts+106-31| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; | |
| 2 | 3 | |
| 3 | 4 | import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | 5 | |
| ... | ... | @@ -60,17 +61,56 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; |
| 60 | 61 | * Current methods: |
| 61 | 62 | * - `browser-session-cookie`: cookie-backed browser session, used by the web |
| 62 | 63 | * app after bootstrap/pairing |
| 63 | * - `bearer-session-token`: token-based session suitable for non-cookie or | |
| 64 | * - `bearer-access-token`: scoped token suitable for non-cookie or | |
| 64 | 65 | * non-browser clients |
| 65 | 66 | */ |
| 66 | 67 | export const ServerAuthSessionMethod = Schema.Literals([ |
| 67 | 68 | "browser-session-cookie", |
| 68 | "bearer-session-token", | |
| 69 | "bearer-access-token", | |
| 69 | 70 | ]); |
| 70 | 71 | export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; |
| 71 | 72 | |
| 72 | export const AuthSessionRole = Schema.Literals(["owner", "client"]); | |
| 73 | export type AuthSessionRole = typeof AuthSessionRole.Type; | |
| 73 | export const AuthOrchestrationReadScope = "orchestration:read" as const; | |
| 74 | export const AuthOrchestrationOperateScope = "orchestration:operate" as const; | |
| 75 | export const AuthTerminalOperateScope = "terminal:operate" as const; | |
| 76 | export const AuthReviewWriteScope = "review:write" as const; | |
| 77 | export const AuthAccessReadScope = "access:read" as const; | |
| 78 | export const AuthAccessWriteScope = "access:write" as const; | |
| 79 | export const AuthRelayReadScope = "relay:read" as const; | |
| 80 | export const AuthRelayWriteScope = "relay:write" as const; | |
| 81 | export const AuthEnvironmentScope = Schema.Literals([ | |
| 82 | AuthOrchestrationReadScope, | |
| 83 | AuthOrchestrationOperateScope, | |
| 84 | AuthTerminalOperateScope, | |
| 85 | AuthReviewWriteScope, | |
| 86 | AuthAccessReadScope, | |
| 87 | AuthAccessWriteScope, | |
| 88 | AuthRelayReadScope, | |
| 89 | AuthRelayWriteScope, | |
| 90 | ]); | |
| 91 | export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type; | |
| 92 | export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope); | |
| 93 | export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; | |
| 94 | ||
| 95 | export const AuthStandardClientScopes = [ | |
| 96 | AuthOrchestrationReadScope, | |
| 97 | AuthOrchestrationOperateScope, | |
| 98 | AuthTerminalOperateScope, | |
| 99 | AuthReviewWriteScope, | |
| 100 | AuthRelayReadScope, | |
| 101 | ] as const; | |
| 102 | export const AuthAdministrativeScopes = [ | |
| 103 | ...AuthStandardClientScopes, | |
| 104 | AuthAccessReadScope, | |
| 105 | AuthAccessWriteScope, | |
| 106 | AuthRelayWriteScope, | |
| 107 | ] as const; | |
| 108 | ||
| 109 | export const AuthTokenExchangeGrantType = | |
| 110 | "urn:ietf:params:oauth:grant-type:token-exchange" as const; | |
| 111 | export const AuthAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" as const; | |
| 112 | export const AuthEnvironmentBootstrapTokenType = | |
| 113 | "urn:t3:params:oauth:token-type:environment-bootstrap" as const; | |
| 74 | 114 | |
| 75 | 115 | /** |
| 76 | 116 | * Server-advertised auth capabilities for a specific execution environment. |
| ... | ... | @@ -100,33 +140,61 @@ export const ServerAuthDescriptor = Schema.Struct({ |
| 100 | 140 | }); |
| 101 | 141 | export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type; |
| 102 | 142 | |
| 103 | export const AuthBootstrapInput = Schema.Struct({ | |
| 143 | export const AuthBrowserSessionRequest = Schema.Struct({ | |
| 104 | 144 | credential: TrimmedNonEmptyString, |
| 105 | 145 | }); |
| 106 | export type AuthBootstrapInput = typeof AuthBootstrapInput.Type; | |
| 146 | export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type; | |
| 107 | 147 | |
| 108 | export const AuthBootstrapResult = Schema.Struct({ | |
| 148 | export const AuthBrowserSessionResult = Schema.Struct({ | |
| 109 | 149 | authenticated: Schema.Literal(true), |
| 110 | role: AuthSessionRole, | |
| 150 | scopes: AuthEnvironmentScopes, | |
| 111 | 151 | sessionMethod: ServerAuthSessionMethod, |
| 112 | 152 | expiresAt: Schema.DateTimeUtc, |
| 113 | 153 | }); |
| 114 | export type AuthBootstrapResult = typeof AuthBootstrapResult.Type; | |
| 154 | export type AuthBrowserSessionResult = typeof AuthBrowserSessionResult.Type; | |
| 115 | 155 | |
| 116 | export const AuthBearerBootstrapResult = Schema.Struct({ | |
| 117 | authenticated: Schema.Literal(true), | |
| 118 | role: AuthSessionRole, | |
| 119 | sessionMethod: Schema.Literal("bearer-session-token"), | |
| 120 | expiresAt: Schema.DateTimeUtc, | |
| 121 | sessionToken: TrimmedNonEmptyString, | |
| 156 | export const AuthClientMetadataDeviceType = Schema.Literals([ | |
| 157 | "desktop", | |
| 158 | "mobile", | |
| 159 | "tablet", | |
| 160 | "bot", | |
| 161 | "unknown", | |
| 162 | ]); | |
| 163 | export type AuthClientMetadataDeviceType = typeof AuthClientMetadataDeviceType.Type; | |
| 164 | ||
| 165 | export const AuthClientPresentationMetadata = Schema.Struct({ | |
| 166 | label: Schema.optionalKey(TrimmedNonEmptyString), | |
| 167 | deviceType: Schema.optionalKey(AuthClientMetadataDeviceType), | |
| 168 | os: Schema.optionalKey(TrimmedNonEmptyString), | |
| 169 | }); | |
| 170 | export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; | |
| 171 | ||
| 172 | export const AuthTokenExchangeRequest = Schema.Struct({ | |
| 173 | grant_type: Schema.Literal(AuthTokenExchangeGrantType), | |
| 174 | subject_token: TrimmedNonEmptyString, | |
| 175 | subject_token_type: Schema.Literal(AuthEnvironmentBootstrapTokenType), | |
| 176 | requested_token_type: Schema.Literal(AuthAccessTokenType), | |
| 177 | scope: Schema.optionalKey(TrimmedNonEmptyString), | |
| 178 | client_label: Schema.optionalKey(TrimmedNonEmptyString), | |
| 179 | client_device_type: Schema.optionalKey(AuthClientMetadataDeviceType), | |
| 180 | client_os: Schema.optionalKey(TrimmedNonEmptyString), | |
| 181 | }).pipe(HttpApiSchema.asFormUrlEncoded()); | |
| 182 | export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type; | |
| 183 | ||
| 184 | export const AuthAccessTokenResult = Schema.Struct({ | |
| 185 | access_token: TrimmedNonEmptyString, | |
| 186 | issued_token_type: Schema.Literal(AuthAccessTokenType), | |
| 187 | token_type: Schema.Literal("Bearer"), | |
| 188 | expires_in: Schema.Number, | |
| 189 | scope: TrimmedNonEmptyString, | |
| 122 | 190 | }); |
| 123 | export type AuthBearerBootstrapResult = typeof AuthBearerBootstrapResult.Type; | |
| 191 | export type AuthAccessTokenResult = typeof AuthAccessTokenResult.Type; | |
| 124 | 192 | |
| 125 | export const AuthWebSocketTokenResult = Schema.Struct({ | |
| 126 | token: TrimmedNonEmptyString, | |
| 193 | export const AuthWebSocketTicketResult = Schema.Struct({ | |
| 194 | ticket: TrimmedNonEmptyString, | |
| 127 | 195 | expiresAt: Schema.DateTimeUtc, |
| 128 | 196 | }); |
| 129 | export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type; | |
| 197 | export type AuthWebSocketTicketResult = typeof AuthWebSocketTicketResult.Type; | |
| 130 | 198 | |
| 131 | 199 | export const AuthPairingCredentialResult = Schema.Struct({ |
| 132 | 200 | id: TrimmedNonEmptyString, |
| ... | ... | @@ -139,7 +207,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ |
| 139 | 207 | export const AuthPairingLink = Schema.Struct({ |
| 140 | 208 | id: TrimmedNonEmptyString, |
| 141 | 209 | credential: TrimmedNonEmptyString, |
| 142 | role: AuthSessionRole, | |
| 210 | scopes: AuthEnvironmentScopes, | |
| 143 | 211 | subject: TrimmedNonEmptyString, |
| 144 | 212 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 145 | 213 | createdAt: Schema.DateTimeUtc, |
| ... | ... | @@ -147,15 +215,6 @@ export const AuthPairingLink = Schema.Struct({ |
| 147 | 215 | }); |
| 148 | 216 | export type AuthPairingLink = typeof AuthPairingLink.Type; |
| 149 | 217 | |
| 150 | export const AuthClientMetadataDeviceType = Schema.Literals([ | |
| 151 | "desktop", | |
| 152 | "mobile", | |
| 153 | "tablet", | |
| 154 | "bot", | |
| 155 | "unknown", | |
| 156 | ]); | |
| 157 | export type AuthClientMetadataDeviceType = typeof AuthClientMetadataDeviceType.Type; | |
| 158 | ||
| 159 | 218 | export const AuthClientMetadata = Schema.Struct({ |
| 160 | 219 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 161 | 220 | ipAddress: Schema.optionalKey(TrimmedNonEmptyString), |
| ... | ... | @@ -169,7 +228,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type; |
| 169 | 228 | export const AuthClientSession = Schema.Struct({ |
| 170 | 229 | sessionId: AuthSessionId, |
| 171 | 230 | subject: TrimmedNonEmptyString, |
| 172 | role: AuthSessionRole, | |
| 231 | scopes: AuthEnvironmentScopes, | |
| 173 | 232 | method: ServerAuthSessionMethod, |
| 174 | 233 | client: AuthClientMetadata, |
| 175 | 234 | issuedAt: Schema.DateTimeUtc, |
| ... | ... | @@ -214,6 +273,21 @@ export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({ |
| 214 | 273 | export type AuthAccessStreamPairingLinkRemovedEvent = |
| 215 | 274 | typeof AuthAccessStreamPairingLinkRemovedEvent.Type; |
| 216 | 275 | |
| 276 | export class AuthAccessStreamError extends Schema.TaggedErrorClass<AuthAccessStreamError>()( | |
| 277 | "AuthAccessStreamError", | |
| 278 | { | |
| 279 | message: Schema.String, | |
| 280 | }, | |
| 281 | ) {} | |
| 282 | ||
| 283 | export class EnvironmentAuthorizationError extends Schema.TaggedErrorClass<EnvironmentAuthorizationError>()( | |
| 284 | "EnvironmentAuthorizationError", | |
| 285 | { | |
| 286 | message: Schema.String, | |
| 287 | requiredScope: AuthEnvironmentScope, | |
| 288 | }, | |
| 289 | ) {} | |
| 290 | ||
| 217 | 291 | export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({ |
| 218 | 292 | version: Schema.Literal(1), |
| 219 | 293 | revision: Schema.Number, |
| ... | ... | @@ -253,13 +327,14 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T |
| 253 | 327 | |
| 254 | 328 | export const AuthCreatePairingCredentialInput = Schema.Struct({ |
| 255 | 329 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 330 | scopes: Schema.optionalKey(AuthEnvironmentScopes), | |
| 256 | 331 | }); |
| 257 | 332 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; |
| 258 | 333 | |
| 259 | 334 | export const AuthSessionState = Schema.Struct({ |
| 260 | 335 | authenticated: Schema.Boolean, |
| 261 | 336 | auth: ServerAuthDescriptor, |
| 262 | role: Schema.optionalKey(AuthSessionRole), | |
| 337 | scopes: Schema.optionalKey(AuthEnvironmentScopes), | |
| 263 | 338 | sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), |
| 264 | 339 | expiresAt: Schema.optionalKey(Schema.DateTimeUtc), |
| 265 | 340 | }); |
packages/contracts/src/environmentHttp.ts created+325| ... | ... | @@ -0,0 +1,325 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import type * as DateTime from "effect/DateTime"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import * as HttpApi from "effect/unstable/httpapi/HttpApi"; | |
| 5 | import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; | |
| 6 | import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; | |
| 7 | import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware"; | |
| 8 | import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable"; | |
| 9 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; | |
| 10 | ||
| 11 | import { | |
| 12 | AuthAccessTokenResult, | |
| 13 | AuthBrowserSessionRequest, | |
| 14 | AuthBrowserSessionResult, | |
| 15 | AuthClientSession, | |
| 16 | AuthCreatePairingCredentialInput, | |
| 17 | AuthPairingCredentialResult, | |
| 18 | AuthPairingLink, | |
| 19 | AuthRevokeClientSessionInput, | |
| 20 | AuthRevokePairingLinkInput, | |
| 21 | AuthEnvironmentScope, | |
| 22 | AuthTokenExchangeRequest, | |
| 23 | AuthSessionState, | |
| 24 | AuthWebSocketTicketResult, | |
| 25 | ServerAuthSessionMethod, | |
| 26 | } from "./auth.ts"; | |
| 27 | import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 28 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | |
| 29 | import { | |
| 30 | ClientOrchestrationCommand, | |
| 31 | DispatchResult, | |
| 32 | OrchestrationReadModel, | |
| 33 | } from "./orchestration.ts"; | |
| 34 | ||
| 35 | const OptionalBearerHeaders = Schema.Struct({ | |
| 36 | authorization: Schema.optionalKey(Schema.String), | |
| 37 | }); | |
| 38 | ||
| 39 | export const EnvironmentRequestInvalidReason = Schema.Literals([ | |
| 40 | "invalid_scope", | |
| 41 | "scope_not_granted", | |
| 42 | "invalid_command", | |
| 43 | ]); | |
| 44 | export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type; | |
| 45 | ||
| 46 | export const EnvironmentAuthInvalidReason = Schema.Literals([ | |
| 47 | "missing_credential", | |
| 48 | "invalid_credential", | |
| 49 | ]); | |
| 50 | export type EnvironmentAuthInvalidReason = typeof EnvironmentAuthInvalidReason.Type; | |
| 51 | ||
| 52 | export const EnvironmentOperationForbiddenReason = Schema.Literals([ | |
| 53 | "current_session_revoke_not_allowed", | |
| 54 | ]); | |
| 55 | export type EnvironmentOperationForbiddenReason = typeof EnvironmentOperationForbiddenReason.Type; | |
| 56 | ||
| 57 | export const EnvironmentInternalErrorReason = Schema.Literals([ | |
| 58 | "bootstrap_validation_failed", | |
| 59 | "browser_session_issuance_failed", | |
| 60 | "browser_session_cookie_failed", | |
| 61 | "access_token_issuance_failed", | |
| 62 | "websocket_ticket_issuance_failed", | |
| 63 | "pairing_credential_issuance_failed", | |
| 64 | "pairing_links_load_failed", | |
| 65 | "pairing_link_revoke_failed", | |
| 66 | "client_sessions_load_failed", | |
| 67 | "client_session_revoke_failed", | |
| 68 | "orchestration_snapshot_failed", | |
| 69 | "orchestration_dispatch_failed", | |
| 70 | "internal_error", | |
| 71 | ]); | |
| 72 | export type EnvironmentInternalErrorReason = typeof EnvironmentInternalErrorReason.Type; | |
| 73 | ||
| 74 | export class EnvironmentRequestInvalidError extends Schema.TaggedErrorClass<EnvironmentRequestInvalidError>()( | |
| 75 | "EnvironmentRequestInvalidError", | |
| 76 | { | |
| 77 | code: Schema.Literal("invalid_request"), | |
| 78 | reason: EnvironmentRequestInvalidReason, | |
| 79 | traceId: TrimmedNonEmptyString, | |
| 80 | }, | |
| 81 | { httpApiStatus: 400 }, | |
| 82 | ) { | |
| 83 | [HttpServerRespondable.symbol]() { | |
| 84 | return HttpServerResponse.schemaJson(EnvironmentRequestInvalidError)(this, { status: 400 }); | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<EnvironmentAuthInvalidError>()( | |
| 89 | "EnvironmentAuthInvalidError", | |
| 90 | { | |
| 91 | code: Schema.Literal("auth_invalid"), | |
| 92 | reason: EnvironmentAuthInvalidReason, | |
| 93 | traceId: TrimmedNonEmptyString, | |
| 94 | }, | |
| 95 | { httpApiStatus: 401 }, | |
| 96 | ) { | |
| 97 | [HttpServerRespondable.symbol]() { | |
| 98 | return HttpServerResponse.schemaJson(EnvironmentAuthInvalidError)(this, { status: 401 }); | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass<EnvironmentScopeRequiredError>()( | |
| 103 | "EnvironmentScopeRequiredError", | |
| 104 | { | |
| 105 | code: Schema.Literal("insufficient_scope"), | |
| 106 | requiredScope: AuthEnvironmentScope, | |
| 107 | traceId: TrimmedNonEmptyString, | |
| 108 | }, | |
| 109 | { httpApiStatus: 403 }, | |
| 110 | ) { | |
| 111 | [HttpServerRespondable.symbol]() { | |
| 112 | return HttpServerResponse.schemaJson(EnvironmentScopeRequiredError)(this, { status: 403 }); | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | export class EnvironmentOperationForbiddenError extends Schema.TaggedErrorClass<EnvironmentOperationForbiddenError>()( | |
| 117 | "EnvironmentOperationForbiddenError", | |
| 118 | { | |
| 119 | code: Schema.Literal("operation_forbidden"), | |
| 120 | reason: EnvironmentOperationForbiddenReason, | |
| 121 | traceId: TrimmedNonEmptyString, | |
| 122 | }, | |
| 123 | { httpApiStatus: 403 }, | |
| 124 | ) { | |
| 125 | [HttpServerRespondable.symbol]() { | |
| 126 | return HttpServerResponse.schemaJson(EnvironmentOperationForbiddenError)(this, { status: 403 }); | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | export class EnvironmentInternalError extends Schema.TaggedErrorClass<EnvironmentInternalError>()( | |
| 131 | "EnvironmentInternalError", | |
| 132 | { | |
| 133 | code: Schema.Literal("internal_error"), | |
| 134 | reason: EnvironmentInternalErrorReason, | |
| 135 | traceId: TrimmedNonEmptyString, | |
| 136 | }, | |
| 137 | { httpApiStatus: 500 }, | |
| 138 | ) { | |
| 139 | [HttpServerRespondable.symbol]() { | |
| 140 | return HttpServerResponse.schemaJson(EnvironmentInternalError)(this, { status: 500 }); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | export const EnvironmentHttpCommonError = Schema.Union([ | |
| 145 | EnvironmentRequestInvalidError, | |
| 146 | EnvironmentAuthInvalidError, | |
| 147 | EnvironmentScopeRequiredError, | |
| 148 | EnvironmentOperationForbiddenError, | |
| 149 | EnvironmentInternalError, | |
| 150 | ]); | |
| 151 | export type EnvironmentHttpCommonError = typeof EnvironmentHttpCommonError.Type; | |
| 152 | ||
| 153 | const EnvironmentAuthenticationErrors = [ | |
| 154 | EnvironmentAuthInvalidError, | |
| 155 | EnvironmentInternalError, | |
| 156 | ] as const; | |
| 157 | const EnvironmentSessionCreationErrors = [ | |
| 158 | EnvironmentAuthInvalidError, | |
| 159 | EnvironmentInternalError, | |
| 160 | ] as const; | |
| 161 | const EnvironmentTokenExchangeErrors = [ | |
| 162 | EnvironmentRequestInvalidError, | |
| 163 | EnvironmentAuthInvalidError, | |
| 164 | EnvironmentInternalError, | |
| 165 | ] as const; | |
| 166 | const EnvironmentScopedOperationErrors = [ | |
| 167 | EnvironmentScopeRequiredError, | |
| 168 | EnvironmentInternalError, | |
| 169 | ] as const; | |
| 170 | const EnvironmentPairingCredentialErrors = [ | |
| 171 | EnvironmentRequestInvalidError, | |
| 172 | ...EnvironmentScopedOperationErrors, | |
| 173 | ] as const; | |
| 174 | const EnvironmentSessionRevokeErrors = [ | |
| 175 | EnvironmentScopeRequiredError, | |
| 176 | EnvironmentOperationForbiddenError, | |
| 177 | EnvironmentInternalError, | |
| 178 | ] as const; | |
| 179 | const EnvironmentOrchestrationSnapshotErrors = [ | |
| 180 | EnvironmentScopeRequiredError, | |
| 181 | EnvironmentInternalError, | |
| 182 | ] as const; | |
| 183 | const EnvironmentOrchestrationDispatchErrors = [ | |
| 184 | EnvironmentRequestInvalidError, | |
| 185 | EnvironmentScopeRequiredError, | |
| 186 | EnvironmentInternalError, | |
| 187 | ] as const; | |
| 188 | ||
| 189 | export interface EnvironmentSessionPrincipalShape { | |
| 190 | readonly sessionId: AuthSessionId; | |
| 191 | readonly subject: string; | |
| 192 | readonly method: ServerAuthSessionMethod; | |
| 193 | readonly scopes: ReadonlySet<AuthEnvironmentScope>; | |
| 194 | readonly expiresAt?: DateTime.DateTime; | |
| 195 | } | |
| 196 | ||
| 197 | export class EnvironmentAuthenticatedPrincipal extends Context.Service< | |
| 198 | EnvironmentAuthenticatedPrincipal, | |
| 199 | EnvironmentSessionPrincipalShape | |
| 200 | >()("@t3tools/contracts/environmentHttp/EnvironmentAuthenticatedPrincipal") {} | |
| 201 | ||
| 202 | export class EnvironmentAuthenticatedAuth extends HttpApiMiddleware.Service< | |
| 203 | EnvironmentAuthenticatedAuth, | |
| 204 | { provides: EnvironmentAuthenticatedPrincipal } | |
| 205 | >()("EnvironmentAuthenticatedAuth", { | |
| 206 | error: EnvironmentAuthenticationErrors, | |
| 207 | }) {} | |
| 208 | ||
| 209 | export const AuthPairingLinkRevokeResult = Schema.Struct({ | |
| 210 | revoked: Schema.Boolean, | |
| 211 | }); | |
| 212 | export type AuthPairingLinkRevokeResult = typeof AuthPairingLinkRevokeResult.Type; | |
| 213 | ||
| 214 | export const AuthClientSessionRevokeResult = Schema.Struct({ | |
| 215 | revoked: Schema.Boolean, | |
| 216 | }); | |
| 217 | export type AuthClientSessionRevokeResult = typeof AuthClientSessionRevokeResult.Type; | |
| 218 | ||
| 219 | export const AuthOtherClientSessionsRevokeResult = Schema.Struct({ | |
| 220 | revokedCount: Schema.Number, | |
| 221 | }); | |
| 222 | export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type; | |
| 223 | ||
| 224 | export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( | |
| 225 | HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", { | |
| 226 | success: ExecutionEnvironmentDescriptor, | |
| 227 | }), | |
| 228 | ) {} | |
| 229 | ||
| 230 | export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") | |
| 231 | .add( | |
| 232 | HttpApiEndpoint.get("session", "/api/auth/session", { | |
| 233 | headers: OptionalBearerHeaders, | |
| 234 | success: AuthSessionState, | |
| 235 | error: [EnvironmentInternalError], | |
| 236 | }), | |
| 237 | ) | |
| 238 | .add( | |
| 239 | HttpApiEndpoint.post("browserSession", "/api/auth/browser-session", { | |
| 240 | payload: AuthBrowserSessionRequest, | |
| 241 | success: AuthBrowserSessionResult, | |
| 242 | error: EnvironmentSessionCreationErrors, | |
| 243 | }), | |
| 244 | ) | |
| 245 | .add( | |
| 246 | HttpApiEndpoint.post("token", "/oauth/token", { | |
| 247 | payload: AuthTokenExchangeRequest, | |
| 248 | success: AuthAccessTokenResult, | |
| 249 | error: EnvironmentTokenExchangeErrors, | |
| 250 | }), | |
| 251 | ) | |
| 252 | .add( | |
| 253 | HttpApiEndpoint.post("webSocketTicket", "/api/auth/websocket-ticket", { | |
| 254 | headers: OptionalBearerHeaders, | |
| 255 | success: AuthWebSocketTicketResult, | |
| 256 | error: [EnvironmentInternalError], | |
| 257 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 258 | ) | |
| 259 | .add( | |
| 260 | HttpApiEndpoint.post("pairingCredential", "/api/auth/pairing-token", { | |
| 261 | headers: OptionalBearerHeaders, | |
| 262 | payload: AuthCreatePairingCredentialInput, | |
| 263 | success: AuthPairingCredentialResult, | |
| 264 | error: EnvironmentPairingCredentialErrors, | |
| 265 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 266 | ) | |
| 267 | .add( | |
| 268 | HttpApiEndpoint.get("pairingLinks", "/api/auth/pairing-links", { | |
| 269 | headers: OptionalBearerHeaders, | |
| 270 | success: Schema.Array(AuthPairingLink), | |
| 271 | error: EnvironmentScopedOperationErrors, | |
| 272 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 273 | ) | |
| 274 | .add( | |
| 275 | HttpApiEndpoint.post("revokePairingLink", "/api/auth/pairing-links/revoke", { | |
| 276 | headers: OptionalBearerHeaders, | |
| 277 | payload: AuthRevokePairingLinkInput, | |
| 278 | success: AuthPairingLinkRevokeResult, | |
| 279 | error: EnvironmentScopedOperationErrors, | |
| 280 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 281 | ) | |
| 282 | .add( | |
| 283 | HttpApiEndpoint.get("clients", "/api/auth/clients", { | |
| 284 | headers: OptionalBearerHeaders, | |
| 285 | success: Schema.Array(AuthClientSession), | |
| 286 | error: EnvironmentScopedOperationErrors, | |
| 287 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 288 | ) | |
| 289 | .add( | |
| 290 | HttpApiEndpoint.post("revokeClient", "/api/auth/clients/revoke", { | |
| 291 | headers: OptionalBearerHeaders, | |
| 292 | payload: AuthRevokeClientSessionInput, | |
| 293 | success: AuthClientSessionRevokeResult, | |
| 294 | error: EnvironmentSessionRevokeErrors, | |
| 295 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 296 | ) | |
| 297 | .add( | |
| 298 | HttpApiEndpoint.post("revokeOtherClients", "/api/auth/clients/revoke-others", { | |
| 299 | headers: OptionalBearerHeaders, | |
| 300 | success: AuthOtherClientSessionsRevokeResult, | |
| 301 | error: EnvironmentScopedOperationErrors, | |
| 302 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 303 | ) {} | |
| 304 | ||
| 305 | export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") | |
| 306 | .add( | |
| 307 | HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { | |
| 308 | headers: OptionalBearerHeaders, | |
| 309 | success: OrchestrationReadModel, | |
| 310 | error: EnvironmentOrchestrationSnapshotErrors, | |
| 311 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 312 | ) | |
| 313 | .add( | |
| 314 | HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", { | |
| 315 | headers: OptionalBearerHeaders, | |
| 316 | payload: ClientOrchestrationCommand, | |
| 317 | success: DispatchResult, | |
| 318 | error: EnvironmentOrchestrationDispatchErrors, | |
| 319 | }).middleware(EnvironmentAuthenticatedAuth), | |
| 320 | ) {} | |
| 321 | ||
| 322 | export class EnvironmentHttpApi extends HttpApi.make("environment") | |
| 323 | .add(EnvironmentMetadataHttpApi) | |
| 324 | .add(EnvironmentAuthHttpApi) | |
| 325 | .add(EnvironmentOrchestrationHttpApi) {} |
packages/contracts/src/index.ts+1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | export * from "./baseSchemas.ts"; |
| 2 | 2 | export * from "./auth.ts"; |
| 3 | 3 | export * from "./environment.ts"; |
| 4 | export * from "./environmentHttp.ts"; | |
| 4 | 5 | export * from "./desktopBootstrap.ts"; |
| 5 | 6 | export * from "./remoteAccess.ts"; |
| 6 | 7 | export * from "./ipc.ts"; |
packages/contracts/src/ipc.ts+4-4| ... | ... | @@ -66,7 +66,7 @@ import type { |
| 66 | 66 | OrchestrationThreadStreamItem, |
| 67 | 67 | } from "./orchestration.ts"; |
| 68 | 68 | import { EnvironmentId } from "./baseSchemas.ts"; |
| 69 | import { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts"; | |
| 69 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; | |
| 70 | 70 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 71 | 71 | import { EditorId } from "./editor.ts"; |
| 72 | 72 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| ... | ... | @@ -394,12 +394,12 @@ export interface DesktopBridge { |
| 394 | 394 | bootstrapSshBearerSession: ( |
| 395 | 395 | httpBaseUrl: string, |
| 396 | 396 | credential: string, |
| 397 | ) => Promise<AuthBearerBootstrapResult>; | |
| 397 | ) => Promise<AuthAccessTokenResult>; | |
| 398 | 398 | fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise<AuthSessionState>; |
| 399 | issueSshWebSocketToken: ( | |
| 399 | issueSshWebSocketTicket: ( | |
| 400 | 400 | httpBaseUrl: string, |
| 401 | 401 | bearerToken: string, |
| 402 | ) => Promise<AuthWebSocketTokenResult>; | |
| 402 | ) => Promise<AuthWebSocketTicketResult>; | |
| 403 | 403 | onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; |
| 404 | 404 | resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise<void>; |
| 405 | 405 | getServerExposureState: () => Promise<DesktopServerExposureState>; |
packages/contracts/src/rpc.ts+56-42| ... | ... | @@ -3,7 +3,11 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | 4 | |
| 5 | 5 | import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; |
| 6 | import { AuthAccessStreamEvent } from "./auth.ts"; | |
| 6 | import { | |
| 7 | AuthAccessStreamError, | |
| 8 | AuthAccessStreamEvent, | |
| 9 | EnvironmentAuthorizationError, | |
| 10 | } from "./auth.ts"; | |
| 7 | 11 | import { |
| 8 | 12 | FilesystemBrowseInput, |
| 9 | 13 | FilesystemBrowseResult, |
| ... | ... | @@ -179,19 +183,19 @@ export const WS_METHODS = { |
| 179 | 183 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { |
| 180 | 184 | payload: ServerUpsertKeybindingInput, |
| 181 | 185 | success: ServerUpsertKeybindingResult, |
| 182 | error: KeybindingsConfigError, | |
| 186 | error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), | |
| 183 | 187 | }); |
| 184 | 188 | |
| 185 | 189 | export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { |
| 186 | 190 | payload: ServerRemoveKeybindingInput, |
| 187 | 191 | success: ServerRemoveKeybindingResult, |
| 188 | error: KeybindingsConfigError, | |
| 192 | error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), | |
| 189 | 193 | }); |
| 190 | 194 | |
| 191 | 195 | export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { |
| 192 | 196 | payload: Schema.Struct({}), |
| 193 | 197 | success: ServerConfig, |
| 194 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), | |
| 198 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), | |
| 195 | 199 | }); |
| 196 | 200 | |
| 197 | 201 | export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { |
| ... | ... | @@ -205,39 +209,43 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv |
| 205 | 209 | instanceId: Schema.optional(ProviderInstanceId), |
| 206 | 210 | }), |
| 207 | 211 | success: ServerProviderUpdatedPayload, |
| 212 | error: EnvironmentAuthorizationError, | |
| 208 | 213 | }); |
| 209 | 214 | |
| 210 | 215 | export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { |
| 211 | 216 | payload: ServerProviderUpdateInput, |
| 212 | 217 | success: ServerProviderUpdatedPayload, |
| 213 | error: ServerProviderUpdateError, | |
| 218 | error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), | |
| 214 | 219 | }); |
| 215 | 220 | |
| 216 | 221 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { |
| 217 | 222 | payload: Schema.Struct({}), |
| 218 | 223 | success: ServerSettings, |
| 219 | error: ServerSettingsError, | |
| 224 | error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), | |
| 220 | 225 | }); |
| 221 | 226 | |
| 222 | 227 | export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { |
| 223 | 228 | payload: Schema.Struct({ patch: ServerSettingsPatch }), |
| 224 | 229 | success: ServerSettings, |
| 225 | error: ServerSettingsError, | |
| 230 | error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), | |
| 226 | 231 | }); |
| 227 | 232 | |
| 228 | 233 | export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { |
| 229 | 234 | payload: Schema.Struct({}), |
| 230 | 235 | success: SourceControlDiscoveryResult, |
| 236 | error: EnvironmentAuthorizationError, | |
| 231 | 237 | }); |
| 232 | 238 | |
| 233 | 239 | export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { |
| 234 | 240 | payload: Schema.Struct({}), |
| 235 | 241 | success: ServerTraceDiagnosticsResult, |
| 242 | error: EnvironmentAuthorizationError, | |
| 236 | 243 | }); |
| 237 | 244 | |
| 238 | 245 | export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { |
| 239 | 246 | payload: Schema.Struct({}), |
| 240 | 247 | success: ServerProcessDiagnosticsResult, |
| 248 | error: EnvironmentAuthorizationError, | |
| 241 | 249 | }); |
| 242 | 250 | |
| 243 | 251 | export const WsServerGetProcessResourceHistoryRpc = Rpc.make( |
| ... | ... | @@ -245,12 +253,14 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( |
| 245 | 253 | { |
| 246 | 254 | payload: ServerProcessResourceHistoryInput, |
| 247 | 255 | success: ServerProcessResourceHistoryResult, |
| 256 | error: EnvironmentAuthorizationError, | |
| 248 | 257 | }, |
| 249 | 258 | ); |
| 250 | 259 | |
| 251 | 260 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { |
| 252 | 261 | payload: ServerSignalProcessInput, |
| 253 | 262 | success: ServerSignalProcessResult, |
| 263 | error: EnvironmentAuthorizationError, | |
| 254 | 264 | }); |
| 255 | 265 | |
| 256 | 266 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( |
| ... | ... | @@ -258,14 +268,14 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make( |
| 258 | 268 | { |
| 259 | 269 | payload: SourceControlRepositoryLookupInput, |
| 260 | 270 | success: SourceControlRepositoryInfo, |
| 261 | error: SourceControlRepositoryError, | |
| 271 | error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), | |
| 262 | 272 | }, |
| 263 | 273 | ); |
| 264 | 274 | |
| 265 | 275 | export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { |
| 266 | 276 | payload: SourceControlCloneRepositoryInput, |
| 267 | 277 | success: SourceControlCloneRepositoryResult, |
| 268 | error: SourceControlRepositoryError, | |
| 278 | error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), | |
| 269 | 279 | }); |
| 270 | 280 | |
| 271 | 281 | export const WsSourceControlPublishRepositoryRpc = Rpc.make( |
| ... | ... | @@ -273,103 +283,103 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make( |
| 273 | 283 | { |
| 274 | 284 | payload: SourceControlPublishRepositoryInput, |
| 275 | 285 | success: SourceControlPublishRepositoryResult, |
| 276 | error: SourceControlRepositoryError, | |
| 286 | error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), | |
| 277 | 287 | }, |
| 278 | 288 | ); |
| 279 | 289 | |
| 280 | 290 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { |
| 281 | 291 | payload: ProjectSearchEntriesInput, |
| 282 | 292 | success: ProjectSearchEntriesResult, |
| 283 | error: ProjectSearchEntriesError, | |
| 293 | error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), | |
| 284 | 294 | }); |
| 285 | 295 | |
| 286 | 296 | export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { |
| 287 | 297 | payload: ProjectWriteFileInput, |
| 288 | 298 | success: ProjectWriteFileResult, |
| 289 | error: ProjectWriteFileError, | |
| 299 | error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), | |
| 290 | 300 | }); |
| 291 | 301 | |
| 292 | 302 | export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { |
| 293 | 303 | payload: LaunchEditorInput, |
| 294 | error: ExternalLauncherError, | |
| 304 | error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), | |
| 295 | 305 | }); |
| 296 | 306 | |
| 297 | 307 | export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { |
| 298 | 308 | payload: FilesystemBrowseInput, |
| 299 | 309 | success: FilesystemBrowseResult, |
| 300 | error: FilesystemBrowseError, | |
| 310 | error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), | |
| 301 | 311 | }); |
| 302 | 312 | |
| 303 | 313 | export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { |
| 304 | 314 | payload: VcsStatusInput, |
| 305 | 315 | success: VcsStatusStreamEvent, |
| 306 | error: GitManagerServiceError, | |
| 316 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), | |
| 307 | 317 | stream: true, |
| 308 | 318 | }); |
| 309 | 319 | |
| 310 | 320 | export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { |
| 311 | 321 | payload: VcsPullInput, |
| 312 | 322 | success: VcsPullResult, |
| 313 | error: GitCommandError, | |
| 323 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), | |
| 314 | 324 | }); |
| 315 | 325 | |
| 316 | 326 | export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { |
| 317 | 327 | payload: VcsStatusInput, |
| 318 | 328 | success: VcsStatusResult, |
| 319 | error: GitManagerServiceError, | |
| 329 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), | |
| 320 | 330 | }); |
| 321 | 331 | |
| 322 | 332 | export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { |
| 323 | 333 | payload: GitRunStackedActionInput, |
| 324 | 334 | success: GitActionProgressEvent, |
| 325 | error: GitManagerServiceError, | |
| 335 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), | |
| 326 | 336 | stream: true, |
| 327 | 337 | }); |
| 328 | 338 | |
| 329 | 339 | export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { |
| 330 | 340 | payload: GitPullRequestRefInput, |
| 331 | 341 | success: GitResolvePullRequestResult, |
| 332 | error: GitManagerServiceError, | |
| 342 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), | |
| 333 | 343 | }); |
| 334 | 344 | |
| 335 | 345 | export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { |
| 336 | 346 | payload: GitPreparePullRequestThreadInput, |
| 337 | 347 | success: GitPreparePullRequestThreadResult, |
| 338 | error: GitManagerServiceError, | |
| 348 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), | |
| 339 | 349 | }); |
| 340 | 350 | |
| 341 | 351 | export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { |
| 342 | 352 | payload: VcsListRefsInput, |
| 343 | 353 | success: VcsListRefsResult, |
| 344 | error: GitCommandError, | |
| 354 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), | |
| 345 | 355 | }); |
| 346 | 356 | |
| 347 | 357 | export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { |
| 348 | 358 | payload: VcsCreateWorktreeInput, |
| 349 | 359 | success: VcsCreateWorktreeResult, |
| 350 | error: GitCommandError, | |
| 360 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), | |
| 351 | 361 | }); |
| 352 | 362 | |
| 353 | 363 | export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { |
| 354 | 364 | payload: VcsRemoveWorktreeInput, |
| 355 | error: GitCommandError, | |
| 365 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), | |
| 356 | 366 | }); |
| 357 | 367 | |
| 358 | 368 | export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { |
| 359 | 369 | payload: VcsCreateRefInput, |
| 360 | 370 | success: VcsCreateRefResult, |
| 361 | error: GitCommandError, | |
| 371 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), | |
| 362 | 372 | }); |
| 363 | 373 | |
| 364 | 374 | export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { |
| 365 | 375 | payload: VcsSwitchRefInput, |
| 366 | 376 | success: VcsSwitchRefResult, |
| 367 | error: GitCommandError, | |
| 377 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), | |
| 368 | 378 | }); |
| 369 | 379 | |
| 370 | 380 | export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { |
| 371 | 381 | payload: VcsInitInput, |
| 372 | error: VcsError, | |
| 382 | error: Schema.Union([VcsError, EnvironmentAuthorizationError]), | |
| 373 | 383 | }); |
| 374 | 384 | |
| 375 | 385 | /** |
| ... | ... | @@ -380,46 +390,46 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { |
| 380 | 390 | export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { |
| 381 | 391 | payload: ReviewDiffPreviewInput, |
| 382 | 392 | success: ReviewDiffPreviewResult, |
| 383 | error: ReviewDiffPreviewError, | |
| 393 | error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), | |
| 384 | 394 | }); |
| 385 | 395 | |
| 386 | 396 | export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { |
| 387 | 397 | payload: TerminalOpenInput, |
| 388 | 398 | success: TerminalSessionSnapshot, |
| 389 | error: TerminalError, | |
| 399 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), | |
| 390 | 400 | }); |
| 391 | 401 | |
| 392 | 402 | export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { |
| 393 | 403 | payload: TerminalAttachInput, |
| 394 | 404 | success: TerminalAttachStreamEvent, |
| 395 | error: TerminalError, | |
| 405 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), | |
| 396 | 406 | stream: true, |
| 397 | 407 | }); |
| 398 | 408 | |
| 399 | 409 | export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { |
| 400 | 410 | payload: TerminalWriteInput, |
| 401 | error: TerminalError, | |
| 411 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), | |
| 402 | 412 | }); |
| 403 | 413 | |
| 404 | 414 | export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { |
| 405 | 415 | payload: TerminalResizeInput, |
| 406 | error: TerminalError, | |
| 416 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), | |
| 407 | 417 | }); |
| 408 | 418 | |
| 409 | 419 | export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { |
| 410 | 420 | payload: TerminalClearInput, |
| 411 | error: TerminalError, | |
| 421 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), | |
| 412 | 422 | }); |
| 413 | 423 | |
| 414 | 424 | export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { |
| 415 | 425 | payload: TerminalRestartInput, |
| 416 | 426 | success: TerminalSessionSnapshot, |
| 417 | error: TerminalError, | |
| 427 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), | |
| 418 | 428 | }); |
| 419 | 429 | |
| 420 | 430 | export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { |
| 421 | 431 | payload: TerminalCloseInput, |
| 422 | error: TerminalError, | |
| 432 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), | |
| 423 | 433 | }); |
| 424 | 434 | |
| 425 | 435 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| ... | ... | @@ -427,14 +437,14 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| 427 | 437 | { |
| 428 | 438 | payload: ClientOrchestrationCommand, |
| 429 | 439 | success: OrchestrationRpcSchemas.dispatchCommand.output, |
| 430 | error: OrchestrationDispatchCommandError, | |
| 440 | error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), | |
| 431 | 441 | }, |
| 432 | 442 | ); |
| 433 | 443 | |
| 434 | 444 | export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { |
| 435 | 445 | payload: OrchestrationGetTurnDiffInput, |
| 436 | 446 | success: OrchestrationRpcSchemas.getTurnDiff.output, |
| 437 | error: OrchestrationGetTurnDiffError, | |
| 447 | error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]), | |
| 438 | 448 | }); |
| 439 | 449 | |
| 440 | 450 | export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( |
| ... | ... | @@ -442,14 +452,14 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( |
| 442 | 452 | { |
| 443 | 453 | payload: OrchestrationGetFullThreadDiffInput, |
| 444 | 454 | success: OrchestrationRpcSchemas.getFullThreadDiff.output, |
| 445 | error: OrchestrationGetFullThreadDiffError, | |
| 455 | error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]), | |
| 446 | 456 | }, |
| 447 | 457 | ); |
| 448 | 458 | |
| 449 | 459 | export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { |
| 450 | 460 | payload: OrchestrationReplayEventsInput, |
| 451 | 461 | success: OrchestrationRpcSchemas.replayEvents.output, |
| 452 | error: OrchestrationReplayEventsError, | |
| 462 | error: Schema.Union([OrchestrationReplayEventsError, EnvironmentAuthorizationError]), | |
| 453 | 463 | }); |
| 454 | 464 | |
| 455 | 465 | export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( |
| ... | ... | @@ -457,14 +467,14 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( |
| 457 | 467 | { |
| 458 | 468 | payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input, |
| 459 | 469 | success: OrchestrationRpcSchemas.getArchivedShellSnapshot.output, |
| 460 | error: OrchestrationGetSnapshotError, | |
| 470 | error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), | |
| 461 | 471 | }, |
| 462 | 472 | ); |
| 463 | 473 | |
| 464 | 474 | export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { |
| 465 | 475 | payload: OrchestrationRpcSchemas.subscribeShell.input, |
| 466 | 476 | success: OrchestrationRpcSchemas.subscribeShell.output, |
| 467 | error: OrchestrationGetSnapshotError, | |
| 477 | error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), | |
| 468 | 478 | stream: true, |
| 469 | 479 | }); |
| 470 | 480 | |
| ... | ... | @@ -473,7 +483,7 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( |
| 473 | 483 | { |
| 474 | 484 | payload: OrchestrationRpcSchemas.subscribeThread.input, |
| 475 | 485 | success: OrchestrationRpcSchemas.subscribeThread.output, |
| 476 | error: OrchestrationGetSnapshotError, | |
| 486 | error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), | |
| 477 | 487 | stream: true, |
| 478 | 488 | }, |
| 479 | 489 | ); |
| ... | ... | @@ -481,31 +491,35 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( |
| 481 | 491 | export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { |
| 482 | 492 | payload: Schema.Struct({}), |
| 483 | 493 | success: TerminalEvent, |
| 494 | error: EnvironmentAuthorizationError, | |
| 484 | 495 | stream: true, |
| 485 | 496 | }); |
| 486 | 497 | |
| 487 | 498 | export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { |
| 488 | 499 | payload: Schema.Struct({}), |
| 489 | 500 | success: TerminalMetadataStreamEvent, |
| 501 | error: EnvironmentAuthorizationError, | |
| 490 | 502 | stream: true, |
| 491 | 503 | }); |
| 492 | 504 | |
| 493 | 505 | export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { |
| 494 | 506 | payload: Schema.Struct({}), |
| 495 | 507 | success: ServerConfigStreamEvent, |
| 496 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), | |
| 508 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), | |
| 497 | 509 | stream: true, |
| 498 | 510 | }); |
| 499 | 511 | |
| 500 | 512 | export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { |
| 501 | 513 | payload: Schema.Struct({}), |
| 502 | 514 | success: ServerLifecycleStreamEvent, |
| 515 | error: EnvironmentAuthorizationError, | |
| 503 | 516 | stream: true, |
| 504 | 517 | }); |
| 505 | 518 | |
| 506 | 519 | export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { |
| 507 | 520 | payload: Schema.Struct({}), |
| 508 | 521 | success: AuthAccessStreamEvent, |
| 522 | error: Schema.Union([AuthAccessStreamError, EnvironmentAuthorizationError]), | |
| 509 | 523 | stream: true, |
| 510 | 524 | }); |
| 511 | 525 |
packages/shared/package.json+4| ... | ... | @@ -79,6 +79,10 @@ |
| 79 | 79 | "types": "./src/remote.ts", |
| 80 | 80 | "import": "./src/remote.ts" |
| 81 | 81 | }, |
| 82 | "./oauthScope": { | |
| 83 | "types": "./src/oauthScope.ts", | |
| 84 | "import": "./src/oauthScope.ts" | |
| 85 | }, | |
| 82 | 86 | "./searchRanking": { |
| 83 | 87 | "types": "./src/searchRanking.ts", |
| 84 | 88 | "import": "./src/searchRanking.ts" |
packages/shared/src/oauthScope.test.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts"; | |
| 4 | ||
| 5 | describe("OAuth scopes", () => { | |
| 6 | it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => { | |
| 7 | expect(parseOAuthScope("orchestration:read access:write orchestration:read")).toEqual([ | |
| 8 | "orchestration:read", | |
| 9 | "access:write", | |
| 10 | ]); | |
| 11 | }); | |
| 12 | ||
| 13 | it("rejects whitespace that is not the SP delimiter or introduces empty tokens", () => { | |
| 14 | expect(parseOAuthScope("orchestration:read\taccess:write")).toBeNull(); | |
| 15 | expect(parseOAuthScope("orchestration:read access:write")).toBeNull(); | |
| 16 | }); | |
| 17 | ||
| 18 | it("encodes and restricts requested scopes to the allowed capability set", () => { | |
| 19 | expect(encodeOAuthScope(["orchestration:read", "access:write"])).toBe( | |
| 20 | "orchestration:read access:write", | |
| 21 | ); | |
| 22 | expect( | |
| 23 | parseAllowedOAuthScope({ | |
| 24 | value: "orchestration:read access:write", | |
| 25 | allowedScopes: new Set(["orchestration:read", "access:write"] as const), | |
| 26 | }), | |
| 27 | ).toEqual(["orchestration:read", "access:write"]); | |
| 28 | expect( | |
| 29 | parseAllowedOAuthScope({ | |
| 30 | value: "orchestration:read relay:write", | |
| 31 | allowedScopes: new Set(["orchestration:read", "access:write"] as const), | |
| 32 | }), | |
| 33 | ).toBeNull(); | |
| 34 | }); | |
| 35 | }); |
packages/shared/src/oauthScope.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; | |
| 2 | ||
| 3 | /** | |
| 4 | * Decodes an RFC 6749 `scope` value as a set while preserving first-seen order. | |
| 5 | */ | |
| 6 | export function parseOAuthScope(value: string): ReadonlyArray<string> | null { | |
| 7 | if (value.length === 0) { | |
| 8 | return null; | |
| 9 | } | |
| 10 | ||
| 11 | const scopes = value.split(" "); | |
| 12 | if (scopes.some((scope) => !OAUTH_SCOPE_TOKEN.test(scope))) { | |
| 13 | return null; | |
| 14 | } | |
| 15 | ||
| 16 | return [...new Set(scopes)]; | |
| 17 | } | |
| 18 | ||
| 19 | export function encodeOAuthScope(scopes: ReadonlyArray<string>): string { | |
| 20 | const encoded = scopes.join(" "); | |
| 21 | const parsed = parseOAuthScope(encoded); | |
| 22 | if (parsed === null || parsed.length !== scopes.length) { | |
| 23 | throw new Error("OAuth scopes must be non-empty, valid, and unique."); | |
| 24 | } | |
| 25 | return encoded; | |
| 26 | } | |
| 27 | ||
| 28 | export function parseAllowedOAuthScope<Scope extends string>(input: { | |
| 29 | readonly value: string; | |
| 30 | readonly allowedScopes: ReadonlySet<Scope>; | |
| 31 | }): ReadonlyArray<Scope> | null { | |
| 32 | const scopes = parseOAuthScope(input.value); | |
| 33 | if ( | |
| 34 | scopes === null || | |
| 35 | !scopes.every((scope): scope is Scope => input.allowedScopes.has(scope as Scope)) | |
| 36 | ) { | |
| 37 | return null; | |
| 38 | } | |
| 39 | return scopes; | |
| 40 | } |
packages/shared/src/observability.test.ts+16| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import * as Arr from "effect/Array"; |
| 4 | import * as Cause from "effect/Cause"; | |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 6 | 7 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -12,12 +13,27 @@ import * as Schema from "effect/Schema"; |
| 12 | 13 | import * as Tracer from "effect/Tracer"; |
| 13 | 14 | |
| 14 | 15 | import { |
| 16 | causeErrorTag, | |
| 15 | 17 | compactTraceAttributes, |
| 16 | 18 | makeLocalFileTracer, |
| 17 | 19 | makeTraceSink, |
| 18 | 20 | type TraceRecord, |
| 19 | 21 | } from "./observability.ts"; |
| 20 | 22 | |
| 23 | describe("causeErrorTag", () => { | |
| 24 | it("reports the tagged failure value instead of the Cause reason wrapper", () => { | |
| 25 | assert.equal( | |
| 26 | causeErrorTag(Cause.fail({ _tag: "ServerAuthInvalidCredentialError" })), | |
| 27 | "ServerAuthInvalidCredentialError", | |
| 28 | ); | |
| 29 | }); | |
| 30 | ||
| 31 | it("reports structural cause kinds when no typed failure exists", () => { | |
| 32 | assert.equal(causeErrorTag(Cause.die(new Error("unexpected"))), "Die"); | |
| 33 | assert.equal(causeErrorTag(Cause.interrupt()), "Interrupt"); | |
| 34 | }); | |
| 35 | }); | |
| 36 | ||
| 21 | 37 | const TraceRecordLine = Schema.Struct({ |
| 22 | 38 | name: Schema.String, |
| 23 | 39 | spanId: Schema.String, |
packages/shared/src/observability.ts+16| ... | ... | @@ -73,6 +73,22 @@ export interface OtlpTraceRecord extends BaseTraceRecord { |
| 73 | 73 | |
| 74 | 74 | export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; |
| 75 | 75 | |
| 76 | function taggedErrorName(error: unknown): string { | |
| 77 | return typeof error === "object" && error !== null && "_tag" in error | |
| 78 | ? String(error._tag) | |
| 79 | : error instanceof Error | |
| 80 | ? error.name | |
| 81 | : typeof error; | |
| 82 | } | |
| 83 | ||
| 84 | export function causeErrorTag(cause: Cause.Cause<unknown>): string { | |
| 85 | const failure = Cause.findErrorOption(cause); | |
| 86 | if (Option.isSome(failure)) { | |
| 87 | return taggedErrorName(failure.value); | |
| 88 | } | |
| 89 | return cause.reasons[0]?._tag ?? "Empty"; | |
| 90 | } | |
| 91 | ||
| 76 | 92 | export interface TraceSinkOptions { |
| 77 | 93 | readonly filePath: string; |
| 78 | 94 | readonly maxBytes: number; |
packages/ssh/src/tunnel.test.ts+2-2| ... | ... | @@ -293,7 +293,7 @@ describe("ssh tunnel scripts", () => { |
| 293 | 293 | makeSuccessfulProcess(`{ |
| 294 | 294 | "id": "88941235-6ed5-4184-a2ff-5339e2075958", |
| 295 | 295 | "credential": "LCL4R2TPHDKQ", |
| 296 | "role": "client", | |
| 296 | "scopes": ["orchestration:read"], | |
| 297 | 297 | "expiresAt": "2026-04-29T01:01:20.994Z" |
| 298 | 298 | } |
| 299 | 299 | |
| ... | ... | @@ -321,7 +321,7 @@ describe("ssh tunnel scripts", () => { |
| 321 | 321 | { |
| 322 | 322 | "id": "88941235-6ed5-4184-a2ff-5339e2075958", |
| 323 | 323 | "credential": "LCL4R2TPHDKQ", |
| 324 | "role": "client", | |
| 324 | "scopes": ["orchestration:read"], | |
| 325 | 325 | "expiresAt": "2026-04-29T01:01:20.994Z" |
| 326 | 326 | } |
| 327 | 327 |
packages/ssh/src/tunnel.ts+17-87| ... | ... | @@ -160,13 +160,8 @@ const RemotePairingResult = Schema.Struct({ |
| 160 | 160 | credential: Schema.String, |
| 161 | 161 | }); |
| 162 | 162 | |
| 163 | const RemoteHttpError = Schema.Struct({ | |
| 164 | error: Schema.optional(Schema.String), | |
| 165 | }); | |
| 166 | ||
| 167 | 163 | const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult)); |
| 168 | 164 | const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult)); |
| 169 | const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError)); | |
| 170 | 165 | |
| 171 | 166 | const decodeRemoteJsonOutput = <A, E>( |
| 172 | 167 | stdout: string, |
| ... | ... | @@ -991,92 +986,27 @@ function isLoopbackHostname(hostname: string): boolean { |
| 991 | 986 | return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; |
| 992 | 987 | } |
| 993 | 988 | |
| 994 | function resolveLoopbackSshHttpUrl( | |
| 995 | rawHttpBaseUrl: unknown, | |
| 996 | pathname: string, | |
| 997 | ): Effect.Effect<URL, SshHttpBridgeError> { | |
| 998 | return Effect.try({ | |
| 999 | try: () => { | |
| 1000 | if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { | |
| 1001 | throw new Error("Invalid SSH forwarded http base URL."); | |
| 1002 | } | |
| 1003 | const baseUrl = new URL(rawHttpBaseUrl); | |
| 1004 | if (!isLoopbackHostname(baseUrl.hostname)) { | |
| 1005 | throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); | |
| 1006 | } | |
| 1007 | const url = new URL(baseUrl.toString()); | |
| 1008 | url.pathname = pathname; | |
| 1009 | url.search = ""; | |
| 1010 | url.hash = ""; | |
| 1011 | return url; | |
| 1012 | }, | |
| 1013 | catch: (cause) => | |
| 1014 | new SshHttpBridgeError({ | |
| 1015 | message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.", | |
| 1016 | cause, | |
| 1017 | }), | |
| 1018 | }); | |
| 1019 | } | |
| 1020 | ||
| 1021 | export const fetchLoopbackSshJson = Effect.fn("ssh/tunnel.fetchLoopbackSshJson")(function* < | |
| 1022 | T, | |
| 1023 | >(input: { | |
| 1024 | readonly httpBaseUrl: unknown; | |
| 1025 | readonly pathname: string; | |
| 1026 | readonly method?: "GET" | "POST"; | |
| 1027 | readonly bearerToken?: unknown; | |
| 1028 | readonly body?: unknown; | |
| 1029 | }): Effect.fn.Return<T, SshHttpBridgeError, HttpClient.HttpClient> { | |
| 1030 | const requestUrl = yield* resolveLoopbackSshHttpUrl(input.httpBaseUrl, input.pathname); | |
| 1031 | const bearerToken = | |
| 1032 | typeof input.bearerToken === "string" && input.bearerToken.trim().length > 0 | |
| 1033 | ? input.bearerToken | |
| 1034 | : null; | |
| 1035 | ||
| 1036 | const request = ( | |
| 1037 | input.method === "POST" | |
| 1038 | ? HttpClientRequest.post(requestUrl.toString()) | |
| 1039 | : HttpClientRequest.get(requestUrl.toString()) | |
| 1040 | ).pipe( | |
| 1041 | input.body === undefined ? (req) => req : HttpClientRequest.bodyJsonUnsafe(input.body), | |
| 1042 | bearerToken | |
| 1043 | ? HttpClientRequest.setHeader("authorization", `Bearer ${bearerToken}`) | |
| 1044 | : (req) => req, | |
| 1045 | ); | |
| 1046 | const client = yield* HttpClient.HttpClient; | |
| 1047 | const response = yield* client.execute(request).pipe( | |
| 1048 | Effect.mapError( | |
| 1049 | (cause) => | |
| 989 | export const resolveLoopbackSshHttpBaseUrl = Effect.fn("ssh/tunnel.resolveLoopbackSshHttpBaseUrl")( | |
| 990 | function* (rawHttpBaseUrl: unknown): Effect.fn.Return<string, SshHttpBridgeError> { | |
| 991 | return yield* Effect.try({ | |
| 992 | try: () => { | |
| 993 | if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { | |
| 994 | throw new Error("Invalid SSH forwarded http base URL."); | |
| 995 | } | |
| 996 | const baseUrl = new URL(rawHttpBaseUrl); | |
| 997 | if (!isLoopbackHostname(baseUrl.hostname)) { | |
| 998 | throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); | |
| 999 | } | |
| 1000 | return baseUrl.toString(); | |
| 1001 | }, | |
| 1002 | catch: (cause) => | |
| 1050 | 1003 | new SshHttpBridgeError({ |
| 1051 | message: `Failed to reach SSH forwarded endpoint ${requestUrl.toString()}.`, | |
| 1004 | message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.", | |
| 1052 | 1005 | cause, |
| 1053 | 1006 | }), |
| 1054 | ), | |
| 1055 | ); | |
| 1056 | if (response.status < 200 || response.status >= 300) { | |
| 1057 | const text = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))); | |
| 1058 | const parsedError = yield* decodeRemoteHttpError(text).pipe( | |
| 1059 | Effect.catch(() => Effect.succeed(null)), | |
| 1060 | ); | |
| 1061 | const message = | |
| 1062 | parsedError?.error && parsedError.error.trim().length > 0 | |
| 1063 | ? parsedError.error | |
| 1064 | : text || `SSH forwarded request failed (${response.status}).`; | |
| 1065 | return yield* new SshHttpBridgeError({ | |
| 1066 | status: response.status, | |
| 1067 | message: `[ssh_http:${response.status}] ${message} (${input.method ?? "GET"} ${requestUrl.toString()})`, | |
| 1068 | 1007 | }); |
| 1069 | } | |
| 1070 | return (yield* response.json.pipe( | |
| 1071 | Effect.mapError( | |
| 1072 | (cause) => | |
| 1073 | new SshHttpBridgeError({ | |
| 1074 | message: `SSH forwarded endpoint ${requestUrl.toString()} returned invalid JSON.`, | |
| 1075 | cause, | |
| 1076 | }), | |
| 1077 | ), | |
| 1078 | )) as T; | |
| 1079 | }); | |
| 1008 | }, | |
| 1009 | ); | |
| 1080 | 1010 | |
| 1081 | 1011 | const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () { |
| 1082 | 1012 | const net = yield* NetService.NetService; |