| 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,6 +16,7 @@ |
| 16 | }, | 16 | }, |
| 17 | "dependencies": { | 17 | "dependencies": { |
| 18 | "@effect/platform-node": "catalog:", | 18 | "@effect/platform-node": "catalog:", |
| 19 | "@t3tools/client-runtime": "workspace:*", | ||
| 19 | "@t3tools/contracts": "workspace:*", | 20 | "@t3tools/contracts": "workspace:*", |
| 20 | "@t3tools/shared": "workspace:*", | 21 | "@t3tools/shared": "workspace:*", |
| 21 | "@t3tools/ssh": "workspace:*", | 22 | "@t3tools/ssh": "workspace:*", |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2-2| ... | @@ -22,7 +22,7 @@ import { | ... | @@ -22,7 +22,7 @@ import { |
| 22 | ensureSshEnvironment, | 22 | ensureSshEnvironment, |
| 23 | fetchSshEnvironmentDescriptor, | 23 | fetchSshEnvironmentDescriptor, |
| 24 | fetchSshSessionState, | 24 | fetchSshSessionState, |
| 25 | issueSshWebSocketToken, | 25 | issueSshWebSocketTicket, |
| 26 | resolveSshPasswordPrompt, | 26 | resolveSshPasswordPrompt, |
| 27 | } from "./methods/sshEnvironment.ts"; | 27 | } from "./methods/sshEnvironment.ts"; |
| 28 | import { | 28 | import { |
| ... | @@ -62,7 +62,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { | ... | @@ -62,7 +62,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 62 | yield* ipc.handle(fetchSshEnvironmentDescriptor); | 62 | yield* ipc.handle(fetchSshEnvironmentDescriptor); |
| 63 | yield* ipc.handle(bootstrapSshBearerSession); | 63 | yield* ipc.handle(bootstrapSshBearerSession); |
| 64 | yield* ipc.handle(fetchSshSessionState); | 64 | yield* ipc.handle(fetchSshSessionState); |
| 65 | yield* ipc.handle(issueSshWebSocketToken); | 65 | yield* ipc.handle(issueSshWebSocketTicket); |
| 66 | yield* ipc.handle(resolveSshPasswordPrompt); | 66 | yield* ipc.handle(resolveSshPasswordPrompt); |
| 67 | 67 | ||
| 68 | yield* ipc.handle(getServerExposureState); | 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,3 +1,9 @@ |
| 1 | import { | ||
| 2 | bootstrapRemoteBearerSession, | ||
| 3 | fetchRemoteEnvironmentDescriptor, | ||
| 4 | fetchRemoteSessionState, | ||
| 5 | issueRemoteWebSocketTicket, | ||
| 6 | } from "@t3tools/client-runtime"; | ||
| 1 | import { | 7 | import { |
| 2 | DesktopDiscoveredSshHostSchema, | 8 | DesktopDiscoveredSshHostSchema, |
| 3 | DesktopSshBearerBootstrapInputSchema, | 9 | DesktopSshBearerBootstrapInputSchema, |
| ... | @@ -9,10 +15,12 @@ import { | ... | @@ -9,10 +15,12 @@ import { |
| 9 | DesktopSshPasswordPromptCancelledType, | 15 | DesktopSshPasswordPromptCancelledType, |
| 10 | DesktopSshPasswordPromptResolutionInputSchema, | 16 | DesktopSshPasswordPromptResolutionInputSchema, |
| 11 | ExecutionEnvironmentDescriptor, | 17 | ExecutionEnvironmentDescriptor, |
| 12 | AuthBearerBootstrapResult, | 18 | AuthAccessTokenResult, |
| 13 | AuthSessionState, | 19 | AuthSessionState, |
| 14 | AuthWebSocketTokenResult, | 20 | AuthWebSocketTicketResult, |
| 15 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 22 | import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; | ||
| 23 | import * as Data from "effect/Data"; | ||
| 16 | import * as Effect from "effect/Effect"; | 24 | import * as Effect from "effect/Effect"; |
| 17 | import * as Schema from "effect/Schema"; | 25 | import * as Schema from "effect/Schema"; |
| 18 | 26 | ||
| ... | @@ -20,7 +28,34 @@ import * as IpcChannels from "../channels.ts"; | ... | @@ -20,7 +28,34 @@ import * as IpcChannels from "../channels.ts"; |
| 20 | import { makeIpcMethod } from "../DesktopIpc.ts"; | 28 | import { makeIpcMethod } from "../DesktopIpc.ts"; |
| 21 | import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; | 29 | import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; |
| 22 | import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; | 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 | export const discoverSshHosts = makeIpcMethod({ | 60 | export const discoverSshHosts = makeIpcMethod({ |
| 26 | channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, | 61 | channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, |
| ... | @@ -69,21 +104,26 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({ | ... | @@ -69,21 +104,26 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({ |
| 69 | payload: DesktopSshHttpBaseUrlInputSchema, | 104 | payload: DesktopSshHttpBaseUrlInputSchema, |
| 70 | result: ExecutionEnvironmentDescriptor, | 105 | result: ExecutionEnvironmentDescriptor, |
| 71 | handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) { | 106 | handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) { |
| 72 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | 107 | return yield* withLoopbackSshApi("fetch-environment-descriptor", (resolvedHttpBaseUrl) => |
| 73 | return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl }); | 108 | fetchRemoteEnvironmentDescriptor({ httpBaseUrl: resolvedHttpBaseUrl }), |
| 109 | )(httpBaseUrl); | ||
| 74 | }), | 110 | }), |
| 75 | }); | 111 | }); |
| 76 | 112 | ||
| 77 | export const bootstrapSshBearerSession = makeIpcMethod({ | 113 | export const bootstrapSshBearerSession = makeIpcMethod({ |
| 78 | channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, | 114 | channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, |
| 79 | payload: DesktopSshBearerBootstrapInputSchema, | 115 | payload: DesktopSshBearerBootstrapInputSchema, |
| 80 | result: AuthBearerBootstrapResult, | 116 | result: AuthAccessTokenResult, |
| 81 | handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({ | 117 | handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({ |
| 82 | httpBaseUrl, | 118 | httpBaseUrl, |
| 83 | credential, | 119 | credential, |
| 84 | }) { | 120 | }) { |
| 85 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | 121 | return yield* withLoopbackSshApi("bootstrap-bearer-session", (resolvedHttpBaseUrl) => |
| 86 | return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential }); | 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,21 +135,29 @@ export const fetchSshSessionState = makeIpcMethod({ |
| 95 | httpBaseUrl, | 135 | httpBaseUrl, |
| 96 | bearerToken, | 136 | bearerToken, |
| 97 | }) { | 137 | }) { |
| 98 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | 138 | return yield* withLoopbackSshApi("fetch-session-state", (resolvedHttpBaseUrl) => |
| 99 | return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken }); | 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 | channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, | 148 | channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, |
| 105 | payload: DesktopSshBearerRequestInputSchema, | 149 | payload: DesktopSshBearerRequestInputSchema, |
| 106 | result: AuthWebSocketTokenResult, | 150 | result: AuthWebSocketTicketResult, |
| 107 | handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({ | 151 | handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({ |
| 108 | httpBaseUrl, | 152 | httpBaseUrl, |
| 109 | bearerToken, | 153 | bearerToken, |
| 110 | }) { | 154 | }) { |
| 111 | const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | 155 | return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) => |
| 112 | return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); | 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,7 +40,6 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; |
| 40 | import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; | 40 | import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; |
| 41 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; | 41 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 42 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; | 42 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 43 | import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; | ||
| 44 | import * as DesktopState from "./app/DesktopState.ts"; | 43 | import * as DesktopState from "./app/DesktopState.ts"; |
| 45 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | 44 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 46 | import * as DesktopWindow from "./window/DesktopWindow.ts"; | 45 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| ... | @@ -116,7 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll( | ... | @@ -116,7 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll( |
| 116 | DesktopObservability.layer, | 115 | DesktopObservability.layer, |
| 117 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); | 116 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); |
| 118 | 117 | ||
| 119 | const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( | 118 | const desktopSshLayer = desktopSshEnvironmentLayer.pipe( |
| 120 | Layer.provideMerge(DesktopSshPasswordPrompts.layer()), | 119 | Layer.provideMerge(DesktopSshPasswordPrompts.layer()), |
| 121 | ); | 120 | ); |
| 122 | 121 |
apps/desktop/src/preload.ts+1-1| ... | @@ -66,7 +66,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -66,7 +66,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 66 | }), | 66 | }), |
| 67 | fetchSshSessionState: (httpBaseUrl, bearerToken) => | 67 | fetchSshSessionState: (httpBaseUrl, bearerToken) => |
| 68 | ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), | 68 | ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), |
| 69 | issueSshWebSocketToken: (httpBaseUrl, bearerToken) => | 69 | issueSshWebSocketTicket: (httpBaseUrl, bearerToken) => |
| 70 | ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), | 70 | ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), |
| 71 | onSshPasswordPrompt: (listener) => { | 71 | onSshPasswordPrompt: (listener) => { |
| 72 | const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { | 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,6 +1,6 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | 2 | ||
| 3 | import { redactPairingCredential } from "./connection"; | 3 | import { mobileAuthClientMetadata, redactPairingCredential } from "./connection"; |
| 4 | 4 | ||
| 5 | vi.mock("./runtime", () => ({ | 5 | vi.mock("./runtime", () => ({ |
| 6 | mobileRemoteHttpRuntime: { | 6 | mobileRemoteHttpRuntime: { |
| ... | @@ -8,7 +8,21 @@ vi.mock("./runtime", () => ({ | ... | @@ -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 | describe("mobile remote connection records", () => { | 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 | it("removes one-time bootstrap credentials before persisting pairing URLs", () => { | 26 | it("removes one-time bootstrap credentials before persisting pairing URLs", () => { |
| 13 | expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe( | 27 | expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe( |
| 14 | "https://desktop.example/", | 28 | "https://desktop.example/", |
apps/mobile/src/lib/connection.ts+12-2| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { |
| 3 | bootstrapRemoteBearerSession, | 3 | bootstrapRemoteBearerSession, |
| 4 | fetchRemoteEnvironmentDescriptor, | 4 | fetchRemoteEnvironmentDescriptor, |
| 5 | } from "@t3tools/client-runtime"; | 5 | } from "@t3tools/client-runtime"; |
| 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; | 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; |
| 7 | import { Platform } from "react-native"; | ||
| 7 | import { mobileRemoteHttpRuntime } from "./runtime"; | 8 | import { mobileRemoteHttpRuntime } from "./runtime"; |
| 8 | 9 | ||
| 9 | export interface RemoteConnectionInput { | 10 | export interface RemoteConnectionInput { |
| ... | @@ -36,6 +37,14 @@ export function redactPairingCredential(pairingUrl: string): string { | ... | @@ -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 | export async function bootstrapRemoteConnection( | 48 | export async function bootstrapRemoteConnection( |
| 40 | input: RemoteConnectionInput, | 49 | input: RemoteConnectionInput, |
| 41 | ): Promise<SavedRemoteConnection> { | 50 | ): Promise<SavedRemoteConnection> { |
| ... | @@ -53,6 +62,7 @@ export async function bootstrapRemoteConnection( | ... | @@ -53,6 +62,7 @@ export async function bootstrapRemoteConnection( |
| 53 | bootstrapRemoteBearerSession({ | 62 | bootstrapRemoteBearerSession({ |
| 54 | httpBaseUrl: target.httpBaseUrl, | 63 | httpBaseUrl: target.httpBaseUrl, |
| 55 | credential: target.credential, | 64 | credential: target.credential, |
| 65 | clientMetadata: mobileAuthClientMetadata(), | ||
| 56 | }), | 66 | }), |
| 57 | ); | 67 | ); |
| 58 | 68 | ||
| ... | @@ -63,6 +73,6 @@ export async function bootstrapRemoteConnection( | ... | @@ -63,6 +73,6 @@ export async function bootstrapRemoteConnection( |
| 63 | displayUrl: target.httpBaseUrl, | 73 | displayUrl: target.httpBaseUrl, |
| 64 | httpBaseUrl: target.httpBaseUrl, | 74 | httpBaseUrl: target.httpBaseUrl, |
| 65 | wsBaseUrl: target.wsBaseUrl, | 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,265 +1,371 @@ |
| 1 | import { | 1 | import { |
| 2 | type AuthBearerBootstrapResult, | 2 | AuthAccessReadScope, |
| 3 | AuthBootstrapInput, | 3 | AuthAccessWriteScope, |
| 4 | AuthCreatePairingCredentialInput, | 4 | AuthStandardClientScopes, |
| 5 | AuthRevokeClientSessionInput, | 5 | AuthOrchestrationOperateScope, |
| 6 | AuthRevokePairingLinkInput, | 6 | AuthOrchestrationReadScope, |
| 7 | type AuthWebSocketTokenResult, | 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 | } from "@t3tools/contracts"; | 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 | import * as DateTime from "effect/DateTime"; | 26 | import * as DateTime from "effect/DateTime"; |
| 10 | import * as Effect from "effect/Effect"; | 27 | import * as Effect from "effect/Effect"; |
| 11 | import * as Schema from "effect/Schema"; | 28 | import * as Layer from "effect/Layer"; |
| 12 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 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"; | 34 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 15 | import { SessionCredentialService } from "./Services/SessionCredentialService.ts"; | 35 | import * as SessionStore from "./SessionStore.ts"; |
| 16 | import { deriveAuthClientMetadata } from "./utils.ts"; | 36 | import { deriveAuthClientMetadata } from "./utils.ts"; |
| 17 | import { browserApiCorsHeaders } from "../httpCors.ts"; | ||
| 18 | 37 | ||
| 19 | export const respondToAuthError = (error: AuthError) => | 38 | export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( |
| 20 | Effect.gen(function* () { | 39 | Effect.map((span) => span.traceId), |
| 21 | if ((error.status ?? 500) >= 500) { | 40 | Effect.orElseSucceed(() => "unavailable"), |
| 22 | yield* Effect.logError("auth route failed", { | 41 | ); |
| 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 | }); | ||
| 34 | 42 | ||
| 35 | export const authSessionRouteLayer = HttpRouter.add( | 43 | export function annotateEnvironmentRequest(endpoint: string) { |
| 36 | "GET", | 44 | return Effect.gen(function* () { |
| 37 | "/api/auth/session", | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const request = yield* HttpServerRequest.HttpServerRequest; | 45 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 40 | const serverAuth = yield* ServerAuth; | 46 | const url = HttpServerRequest.toURL(request); |
| 41 | const session = yield* serverAuth.getSessionState(request); | 47 | const traceId = yield* currentEnvironmentTraceId; |
| 42 | return HttpServerResponse.jsonUnsafe(session, { | 48 | |
| 43 | status: 200, | 49 | yield* Effect.addFinalizer((exit) => |
| 44 | headers: browserApiCorsHeaders, | 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 | }), | 65 | }); |
| 47 | ); | 66 | } |
| 48 | 67 | ||
| 49 | const PairingCredentialRequestHeaders = Schema.Struct({ | 68 | export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) { |
| 50 | "content-length": Schema.optionalKey(Schema.String), | 69 | return currentEnvironmentTraceId.pipe( |
| 51 | "content-type": Schema.optionalKey(Schema.String), | 70 | Effect.flatMap((traceId) => |
| 52 | "transfer-encoding": Schema.optionalKey(Schema.String), | 71 | Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })), |
| 53 | }); | 72 | ), |
| 73 | ); | ||
| 74 | } | ||
| 54 | 75 | ||
| 55 | function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) { | 76 | export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidReason) { |
| 56 | const contentLengthHeader = headers["content-length"]; | 77 | return currentEnvironmentTraceId.pipe( |
| 57 | if (typeof contentLengthHeader === "string") { | 78 | Effect.flatMap((traceId) => |
| 58 | const contentLength = Number.parseInt(contentLengthHeader, 10); | 79 | Effect.fail(new EnvironmentRequestInvalidError({ code: "invalid_request", reason, traceId })), |
| 59 | if (Number.isFinite(contentLength)) { | 80 | ), |
| 60 | return contentLength > 0; | 81 | ); |
| 61 | } | ||
| 62 | } | ||
| 63 | return typeof headers["transfer-encoding"] === "string"; | ||
| 64 | } | 82 | } |
| 65 | 83 | ||
| 66 | export const authBootstrapRouteLayer = HttpRouter.add( | 84 | export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) { |
| 67 | "POST", | 85 | return currentEnvironmentTraceId.pipe( |
| 68 | "/api/auth/bootstrap", | 86 | Effect.flatMap((traceId) => |
| 69 | Effect.gen(function* () { | 87 | Effect.fail( |
| 70 | const request = yield* HttpServerRequest.HttpServerRequest; | 88 | new EnvironmentScopeRequiredError({ |
| 71 | const serverAuth = yield* ServerAuth; | 89 | code: "insufficient_scope", |
| 72 | const sessions = yield* SessionCredentialService; | 90 | requiredScope, |
| 73 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( | 91 | traceId, |
| 74 | Effect.mapError( | 92 | }), |
| 75 | (cause) => | ||
| 76 | new AuthError({ | ||
| 77 | message: "Invalid bootstrap payload.", | ||
| 78 | status: 400, | ||
| 79 | cause, | ||
| 80 | }), | ||
| 81 | ), | 93 | ), |
| 82 | ); | 94 | ), |
| 83 | const result = yield* serverAuth.exchangeBootstrapCredential( | 95 | ); |
| 84 | payload.credential, | 96 | } |
| 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 | ); | ||
| 101 | 97 | ||
| 102 | export const authBearerBootstrapRouteLayer = HttpRouter.add( | 98 | function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") { |
| 103 | "POST", | 99 | return currentEnvironmentTraceId.pipe( |
| 104 | "/api/auth/bootstrap/bearer", | 100 | Effect.flatMap((traceId) => |
| 105 | Effect.gen(function* () { | 101 | Effect.fail( |
| 106 | const request = yield* HttpServerRequest.HttpServerRequest; | 102 | new EnvironmentOperationForbiddenError({ |
| 107 | const serverAuth = yield* ServerAuth; | 103 | code: "operation_forbidden", |
| 108 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( | 104 | reason, |
| 109 | Effect.mapError( | 105 | traceId, |
| 110 | (cause) => | 106 | }), |
| 111 | new AuthError({ | ||
| 112 | message: "Invalid bootstrap payload.", | ||
| 113 | status: 400, | ||
| 114 | cause, | ||
| 115 | }), | ||
| 116 | ), | 107 | ), |
| 117 | ); | 108 | ), |
| 118 | const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession( | 109 | ); |
| 119 | payload.credential, | 110 | } |
| 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 | ); | ||
| 143 | 111 | ||
| 144 | export const authPairingCredentialRouteLayer = HttpRouter.add( | 112 | export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) { |
| 145 | "POST", | 113 | return Effect.gen(function* () { |
| 146 | "/api/auth/pairing-token", | 114 | const traceId = yield* currentEnvironmentTraceId; |
| 147 | Effect.gen(function* () { | 115 | if (error !== undefined) { |
| 148 | const serverAuth = yield* ServerAuth; | 116 | yield* Effect.logError("environment api operation failed", { |
| 149 | const request = yield* HttpServerRequest.HttpServerRequest; | 117 | reason, |
| 150 | const session = yield* serverAuth.authenticateHttpRequest(request); | 118 | traceId, |
| 151 | if (session.role !== "owner") { | 119 | cause: error, |
| 152 | return yield* new AuthError({ | ||
| 153 | message: "Only owner sessions can create pairing credentials.", | ||
| 154 | status: 403, | ||
| 155 | }); | 120 | }); |
| 156 | } | 121 | } |
| 157 | const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe( | 122 | return yield* new EnvironmentInternalError({ code: "internal_error", reason, traceId }); |
| 158 | Effect.mapError( | 123 | }); |
| 159 | (cause) => | 124 | } |
| 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 | ); | ||
| 183 | 125 | ||
| 184 | const authenticateOwnerSession = Effect.gen(function* () { | 126 | export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( |
| 185 | const request = yield* HttpServerRequest.HttpServerRequest; | 127 | scope: AuthEnvironmentScope, |
| 186 | const serverAuth = yield* ServerAuth; | 128 | ) { |
| 187 | const session = yield* serverAuth.authenticateHttpRequest(request); | 129 | const session = yield* EnvironmentAuthenticatedPrincipal; |
| 188 | if (session.role !== "owner") { | 130 | if (!session.scopes.has(scope)) { |
| 189 | return yield* new AuthError({ | 131 | return yield* failEnvironmentScopeRequired(scope); |
| 190 | message: "Only owner sessions can manage network access.", | ||
| 191 | status: 403, | ||
| 192 | }); | ||
| 193 | } | 132 | } |
| 194 | return { serverAuth, session } as const; | 133 | return session; |
| 195 | }); | 134 | }); |
| 196 | 135 | ||
| 197 | export const authPairingLinksRouteLayer = HttpRouter.add( | 136 | export const environmentAuthenticatedAuthLayer = Layer.effect( |
| 198 | "GET", | 137 | EnvironmentAuthenticatedAuth, |
| 199 | "/api/auth/pairing-links", | ||
| 200 | Effect.gen(function* () { | 138 | Effect.gen(function* () { |
| 201 | const { serverAuth } = yield* authenticateOwnerSession; | 139 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 202 | const pairingLinks = yield* serverAuth.listPairingLinks(); | 140 | return (httpEffect) => |
| 203 | return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 }); | 141 | Effect.gen(function* () { |
| 204 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | 142 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 205 | ); | 143 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( |
| 206 | 144 | Effect.catchTags({ | |
| 207 | export const authPairingLinksRevokeRouteLayer = HttpRouter.add( | 145 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), |
| 208 | "POST", | 146 | ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), |
| 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, | ||
| 219 | }), | 147 | }), |
| 220 | ), | 148 | ); |
| 221 | ); | 149 | return yield* httpEffect.pipe( |
| 222 | const revoked = yield* serverAuth.revokePairingLink(payload.id); | 150 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { |
| 223 | return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); | 151 | ...session, |
| 224 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | 152 | scopes: new Set(session.scopes), |
| 153 | }), | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | }), | ||
| 225 | ); | 157 | ); |
| 226 | 158 | ||
| 227 | export const authClientsRouteLayer = HttpRouter.add( | 159 | export const authHttpApiLayer = HttpApiBuilder.group( |
| 228 | "GET", | 160 | EnvironmentHttpApi, |
| 229 | "/api/auth/clients", | 161 | "auth", |
| 230 | Effect.gen(function* () { | 162 | Effect.fnUntraced(function* (handlers) { |
| 231 | const { serverAuth, session } = yield* authenticateOwnerSession; | 163 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 232 | const clients = yield* serverAuth.listClientSessions(session.sessionId); | 164 | const sessions = yield* SessionStore.SessionStore; |
| 233 | return HttpServerResponse.jsonUnsafe(clients, { status: 200 }); | ||
| 234 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | ||
| 235 | ); | ||
| 236 | 165 | ||
| 237 | export const authClientsRevokeRouteLayer = HttpRouter.add( | 166 | return handlers |
| 238 | "POST", | 167 | .handle( |
| 239 | "/api/auth/clients/revoke", | 168 | "session", |
| 240 | Effect.gen(function* () { | 169 | Effect.fn("environment.auth.session")( |
| 241 | const { serverAuth, session } = yield* authenticateOwnerSession; | 170 | function* (args) { |
| 242 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe( | 171 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 243 | Effect.mapError( | 172 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 244 | (cause) => | 173 | return yield* serverAuth.getSessionState(request); |
| 245 | new AuthError({ | 174 | }, |
| 246 | message: "Invalid revoke client payload.", | 175 | Effect.catchTag("ServerAuthInternalError", (error) => |
| 247 | status: 400, | 176 | failEnvironmentInternal("internal_error", error), |
| 248 | cause, | 177 | ), |
| 249 | }), | 178 | ), |
| 250 | ), | 179 | ) |
| 251 | ); | 180 | .handle( |
| 252 | const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId); | 181 | "browserSession", |
| 253 | return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); | 182 | Effect.fn("environment.auth.browserSession")( |
| 254 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | 183 | function* (args) { |
| 255 | ); | 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( | 199 | yield* HttpEffect.appendPreResponseHandler((_request, response) => |
| 258 | "POST", | 200 | Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), |
| 259 | "/api/auth/clients/revoke-others", | 201 | ); |
| 260 | Effect.gen(function* () { | 202 | return result.response; |
| 261 | const { serverAuth, session } = yield* authenticateOwnerSession; | 203 | }, |
| 262 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); | 204 | Effect.catchTags({ |
| 263 | return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 }); | 205 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), |
| 264 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | 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,4 +23,32 @@ describe("deriveAuthClientMetadata", () => { |
| 23 | os: "macOS", | 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,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 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 3 | import * as Crypto from "node:crypto"; | 7 | import * as Crypto from "node:crypto"; |
| 4 | 8 | ||
| ... | @@ -115,17 +119,17 @@ function readRemoteAddressFromSource(source: unknown): string | undefined { | ... | @@ -115,17 +119,17 @@ function readRemoteAddressFromSource(source: unknown): string | undefined { |
| 115 | 119 | ||
| 116 | export function deriveAuthClientMetadata(input: { | 120 | export function deriveAuthClientMetadata(input: { |
| 117 | readonly request: HttpServerRequest.HttpServerRequest; | 121 | readonly request: HttpServerRequest.HttpServerRequest; |
| 118 | readonly label?: string; | 122 | readonly presented?: AuthClientPresentationMetadata; |
| 119 | }): AuthClientMetadata { | 123 | }): AuthClientMetadata { |
| 120 | const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); | 124 | const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); |
| 121 | const ipAddress = readRemoteAddressFromSource(input.request.source); | 125 | const ipAddress = readRemoteAddressFromSource(input.request.source); |
| 122 | const os = inferOs(userAgent); | 126 | const os = input.presented?.os ?? inferOs(userAgent); |
| 123 | const browser = inferBrowser(userAgent); | 127 | const browser = inferBrowser(userAgent); |
| 124 | return { | 128 | return { |
| 125 | ...(input.label ? { label: input.label } : {}), | 129 | ...(input.presented?.label ? { label: input.presented.label } : {}), |
| 126 | ...(ipAddress ? { ipAddress } : {}), | 130 | ...(ipAddress ? { ipAddress } : {}), |
| 127 | ...(userAgent ? { userAgent } : {}), | 131 | ...(userAgent ? { userAgent } : {}), |
| 128 | deviceType: inferDeviceType(userAgent), | 132 | deviceType: input.presented?.deviceType ?? inferDeviceType(userAgent), |
| 129 | ...(os ? { os } : {}), | 133 | ...(os ? { os } : {}), |
| 130 | ...(browser ? { browser } : {}), | 134 | ...(browser ? { browser } : {}), |
| 131 | }; | 135 | }; |
apps/server/src/bin.test.ts+35-15| ... | @@ -6,12 +6,15 @@ import { join } from "node:path"; | ... | @@ -6,12 +6,15 @@ import { join } from "node:path"; |
| 6 | 6 | ||
| 7 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 7 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; | ||
| 9 | import * as NetService from "@t3tools/shared/Net"; | 10 | import * as NetService from "@t3tools/shared/Net"; |
| 10 | import { assert, it } from "@effect/vitest"; | 11 | import { assert, it } from "@effect/vitest"; |
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 13 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | 14 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; |
| 14 | import * as HttpServer from "effect/unstable/http/HttpServer"; | 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 | import * as CliError from "effect/unstable/cli/CliError"; | 18 | import * as CliError from "effect/unstable/cli/CliError"; |
| 16 | import * as TestConsole from "effect/testing/TestConsole"; | 19 | import * as TestConsole from "effect/testing/TestConsole"; |
| 17 | import { Command } from "effect/unstable/cli"; | 20 | import { Command } from "effect/unstable/cli"; |
| ... | @@ -20,10 +23,7 @@ import { cli } from "./bin.ts"; | ... | @@ -20,10 +23,7 @@ import { cli } from "./bin.ts"; |
| 20 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; | 23 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; |
| 21 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 22 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | 25 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| 23 | import { | 26 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; |
| 24 | orchestrationDispatchRouteLayer, | ||
| 25 | orchestrationSnapshotRouteLayer, | ||
| 26 | } from "./orchestration/http.ts"; | ||
| 27 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; | 27 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; |
| 28 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | 28 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; |
| 29 | import { | 29 | import { |
| ... | @@ -31,10 +31,12 @@ import { | ... | @@ -31,10 +31,12 @@ import { |
| 31 | persistServerRuntimeState, | 31 | persistServerRuntimeState, |
| 32 | } from "./serverRuntimeState.ts"; | 32 | } from "./serverRuntimeState.ts"; |
| 33 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 33 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 34 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | 34 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 35 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 36 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | ||
| 36 | 37 | ||
| 37 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 38 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 39 | class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} | ||
| 38 | 40 | ||
| 39 | const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args); | 41 | const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args); |
| 40 | const runCliWithRuntime = (args: ReadonlyArray<string>) => | 42 | const runCliWithRuntime = (args: ReadonlyArray<string>) => |
| ... | @@ -105,18 +107,18 @@ const readPersistedSnapshot = (baseDir: string) => | ... | @@ -105,18 +107,18 @@ const readPersistedSnapshot = (baseDir: string) => |
| 105 | const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => | 107 | const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => |
| 106 | Effect.gen(function* () { | 108 | Effect.gen(function* () { |
| 107 | const config = yield* makeCliTestServerConfig(baseDir); | 109 | const config = yield* makeCliTestServerConfig(baseDir); |
| 108 | const routesLayer = Layer.mergeAll( | 110 | const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( |
| 109 | orchestrationSnapshotRouteLayer, | 111 | Layer.provide(orchestrationHttpApiLayer), |
| 110 | orchestrationDispatchRouteLayer, | 112 | Layer.provide(environmentAuthenticatedAuthLayer), |
| 111 | ); | 113 | ); |
| 112 | const appLayer = HttpRouter.serve(routesLayer, { | 114 | const appLayer = HttpRouter.serve(routesLayer, { |
| 113 | disableListenLog: true, | 115 | disableListenLog: true, |
| 114 | disableLogger: true, | 116 | disableLogger: true, |
| 115 | }).pipe( | 117 | }).pipe( |
| 116 | Layer.provideMerge( | 118 | Layer.provideMerge( |
| 117 | ServerAuthLive.pipe( | 119 | EnvironmentAuth.layer.pipe( |
| 118 | Layer.provideMerge(SqlitePersistenceLayerLive), | 120 | Layer.provideMerge(SqlitePersistenceLayerLive), |
| 119 | Layer.provide(ServerSecretStoreLive), | 121 | Layer.provide(ServerSecretStore.layer), |
| 120 | ), | 122 | ), |
| 121 | ), | 123 | ), |
| 122 | Layer.provideMerge(makeProjectPersistenceLayer(config)), | 124 | Layer.provideMerge(makeProjectPersistenceLayer(config)), |
| ... | @@ -214,7 +216,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { | ... | @@ -214,7 +216,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { |
| 214 | const issued = JSON.parse(issuedOutput.output) as { | 216 | const issued = JSON.parse(issuedOutput.output) as { |
| 215 | readonly sessionId: string; | 217 | readonly sessionId: string; |
| 216 | readonly token: string; | 218 | readonly token: string; |
| 217 | readonly role: string; | 219 | readonly scopes: ReadonlyArray<string>; |
| 218 | }; | 220 | }; |
| 219 | const listedOutput = yield* captureStdout( | 221 | const listedOutput = yield* captureStdout( |
| 220 | runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), | 222 | runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), |
| ... | @@ -223,15 +225,33 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { | ... | @@ -223,15 +225,33 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { |
| 223 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | 225 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ |
| 224 | readonly sessionId: string; | 226 | readonly sessionId: string; |
| 225 | readonly token?: string; | 227 | readonly token?: string; |
| 226 | readonly role: string; | 228 | readonly scopes: ReadonlyArray<string>; |
| 227 | }>; | 229 | }>; |
| 228 | 230 | ||
| 229 | assert.equal(typeof issued.sessionId, "string"); | 231 | assert.equal(typeof issued.sessionId, "string"); |
| 230 | assert.equal(typeof issued.token, "string"); | 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 | assert.equal(listed.length, 1); | 243 | assert.equal(listed.length, 1); |
| 233 | assert.equal(listed[0]?.sessionId, issued.sessionId); | 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 | assert.equal("token" in (listed[0] ?? {}), false); | 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,4 +1,8 @@ |
| 1 | import { AuthSessionId } from "@t3tools/contracts"; | 1 | import { |
| 2 | AuthAdministrativeScopes, | ||
| 3 | AuthSessionId, | ||
| 4 | AuthStandardClientScopes, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import * as Console from "effect/Console"; | 6 | import * as Console from "effect/Console"; |
| 3 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| ... | @@ -6,9 +10,8 @@ import * as Option from "effect/Option"; | ... | @@ -6,9 +10,8 @@ import * as Option from "effect/Option"; |
| 6 | import * as References from "effect/References"; | 10 | import * as References from "effect/References"; |
| 7 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; | 11 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; |
| 8 | 12 | ||
| 9 | import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; | 13 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 10 | import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; | 14 | |
| 11 | import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; | ||
| 12 | import { | 15 | import { |
| 13 | formatIssuedPairingCredential, | 16 | formatIssuedPairingCredential, |
| 14 | formatIssuedSession, | 17 | formatIssuedSession, |
| ... | @@ -23,9 +26,9 @@ import { | ... | @@ -23,9 +26,9 @@ import { |
| 23 | resolveCliAuthConfig, | 26 | resolveCliAuthConfig, |
| 24 | } from "./config.ts"; | 27 | } from "./config.ts"; |
| 25 | 28 | ||
| 26 | const runWithAuthControlPlane = <A, E>( | 29 | const runWithEnvironmentAuth = <A, E>( |
| 27 | flags: CliAuthLocationFlags, | 30 | flags: CliAuthLocationFlags, |
| 28 | run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>, | 31 | run: (environmentAuth: EnvironmentAuth.EnvironmentAuthShape) => Effect.Effect<A, E>, |
| 29 | options?: { | 32 | options?: { |
| 30 | readonly quietLogs?: boolean; | 33 | readonly quietLogs?: boolean; |
| 31 | }, | 34 | }, |
| ... | @@ -35,11 +38,11 @@ const runWithAuthControlPlane = <A, E>( | ... | @@ -35,11 +38,11 @@ const runWithAuthControlPlane = <A, E>( |
| 35 | const config = yield* resolveCliAuthConfig(flags, logLevel); | 38 | const config = yield* resolveCliAuthConfig(flags, logLevel); |
| 36 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | 39 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; |
| 37 | return yield* Effect.gen(function* () { | 40 | return yield* Effect.gen(function* () { |
| 38 | const authControlPlane = yield* AuthControlPlane; | 41 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 39 | return yield* run(authControlPlane); | 42 | return yield* run(environmentAuth); |
| 40 | }).pipe( | 43 | }).pipe( |
| 41 | Effect.provide( | 44 | Effect.provide( |
| 42 | Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( | 45 | Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe( |
| 43 | Layer.provide(Layer.succeed(ServerConfig, config)), | 46 | Layer.provide(Layer.succeed(ServerConfig, config)), |
| 44 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | 47 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), |
| 45 | ), | 48 | ), |
| ... | @@ -58,11 +61,6 @@ const jsonFlag = Flag.boolean("json").pipe( | ... | @@ -58,11 +61,6 @@ const jsonFlag = Flag.boolean("json").pipe( |
| 58 | Flag.withDefault(false), | 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 | const labelFlag = Flag.string("label").pipe( | 64 | const labelFlag = Flag.string("label").pipe( |
| 67 | Flag.withDescription("Optional human-readable label."), | 65 | Flag.withDescription("Optional human-readable label."), |
| 68 | Flag.optional, | 66 | Flag.optional, |
| ... | @@ -92,12 +90,12 @@ const pairingCreateCommand = Command.make("create", { | ... | @@ -92,12 +90,12 @@ const pairingCreateCommand = Command.make("create", { |
| 92 | }).pipe( | 90 | }).pipe( |
| 93 | Command.withDescription("Issue a new client pairing token."), | 91 | Command.withDescription("Issue a new client pairing token."), |
| 94 | Command.withHandler((flags) => | 92 | Command.withHandler((flags) => |
| 95 | runWithAuthControlPlane( | 93 | runWithEnvironmentAuth( |
| 96 | flags, | 94 | flags, |
| 97 | (authControlPlane) => | 95 | (environmentAuth) => |
| 98 | Effect.gen(function* () { | 96 | Effect.gen(function* () { |
| 99 | const issued = yield* authControlPlane.createPairingLink({ | 97 | const issued = yield* environmentAuth.createPairingLink({ |
| 100 | role: "client", | 98 | scopes: AuthStandardClientScopes, |
| 101 | subject: "one-time-token", | 99 | subject: "one-time-token", |
| 102 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | 100 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), |
| 103 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | 101 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), |
| ... | @@ -121,11 +119,13 @@ const pairingListCommand = Command.make("list", { | ... | @@ -121,11 +119,13 @@ const pairingListCommand = Command.make("list", { |
| 121 | }).pipe( | 119 | }).pipe( |
| 122 | Command.withDescription("List active client pairing tokens without revealing their secrets."), | 120 | Command.withDescription("List active client pairing tokens without revealing their secrets."), |
| 123 | Command.withHandler((flags) => | 121 | Command.withHandler((flags) => |
| 124 | runWithAuthControlPlane( | 122 | runWithEnvironmentAuth( |
| 125 | flags, | 123 | flags, |
| 126 | (authControlPlane) => | 124 | (environmentAuth) => |
| 127 | Effect.gen(function* () { | 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 | yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); | 129 | yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); |
| 130 | }), | 130 | }), |
| 131 | { | 131 | { |
| ... | @@ -141,9 +141,9 @@ const pairingRevokeCommand = Command.make("revoke", { | ... | @@ -141,9 +141,9 @@ const pairingRevokeCommand = Command.make("revoke", { |
| 141 | }).pipe( | 141 | }).pipe( |
| 142 | Command.withDescription("Revoke an active client pairing token."), | 142 | Command.withDescription("Revoke an active client pairing token."), |
| 143 | Command.withHandler((flags) => | 143 | Command.withHandler((flags) => |
| 144 | runWithAuthControlPlane(flags, (authControlPlane) => | 144 | runWithEnvironmentAuth(flags, (environmentAuth) => |
| 145 | Effect.gen(function* () { | 145 | Effect.gen(function* () { |
| 146 | const revoked = yield* authControlPlane.revokePairingLink(flags.id); | 146 | const revoked = yield* environmentAuth.revokePairingLink(flags.id); |
| 147 | yield* Console.log( | 147 | yield* Console.log( |
| 148 | revoked | 148 | revoked |
| 149 | ? `Revoked pairing credential ${flags.id}.\n` | 149 | ? `Revoked pairing credential ${flags.id}.\n` |
| ... | @@ -162,20 +162,19 @@ const pairingCommand = Command.make("pairing").pipe( | ... | @@ -162,20 +162,19 @@ const pairingCommand = Command.make("pairing").pipe( |
| 162 | const sessionIssueCommand = Command.make("issue", { | 162 | const sessionIssueCommand = Command.make("issue", { |
| 163 | ...authLocationFlags, | 163 | ...authLocationFlags, |
| 164 | ttl: ttlFlag, | 164 | ttl: ttlFlag, |
| 165 | role: sessionRoleFlag, | ||
| 166 | label: labelFlag, | 165 | label: labelFlag, |
| 167 | subject: subjectFlag, | 166 | subject: subjectFlag, |
| 168 | tokenOnly: tokenOnlyFlag, | 167 | tokenOnly: tokenOnlyFlag, |
| 169 | json: jsonFlag, | 168 | json: jsonFlag, |
| 170 | }).pipe( | 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 | Command.withHandler((flags) => | 171 | Command.withHandler((flags) => |
| 173 | runWithAuthControlPlane( | 172 | runWithEnvironmentAuth( |
| 174 | flags, | 173 | flags, |
| 175 | (authControlPlane) => | 174 | (environmentAuth) => |
| 176 | Effect.gen(function* () { | 175 | Effect.gen(function* () { |
| 177 | const issued = yield* authControlPlane.issueSession({ | 176 | const issued = yield* environmentAuth.issueSession({ |
| 178 | role: flags.role, | 177 | scopes: AuthAdministrativeScopes, |
| 179 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | 178 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), |
| 180 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | 179 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), |
| 181 | ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), | 180 | ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), |
| ... | @@ -200,11 +199,11 @@ const sessionListCommand = Command.make("list", { | ... | @@ -200,11 +199,11 @@ const sessionListCommand = Command.make("list", { |
| 200 | }).pipe( | 199 | }).pipe( |
| 201 | Command.withDescription("List active sessions without revealing bearer tokens."), | 200 | Command.withDescription("List active sessions without revealing bearer tokens."), |
| 202 | Command.withHandler((flags) => | 201 | Command.withHandler((flags) => |
| 203 | runWithAuthControlPlane( | 202 | runWithEnvironmentAuth( |
| 204 | flags, | 203 | flags, |
| 205 | (authControlPlane) => | 204 | (environmentAuth) => |
| 206 | Effect.gen(function* () { | 205 | Effect.gen(function* () { |
| 207 | const sessions = yield* authControlPlane.listSessions(); | 206 | const sessions = yield* environmentAuth.listSessions(); |
| 208 | yield* Console.log(formatSessionList(sessions, { json: flags.json })); | 207 | yield* Console.log(formatSessionList(sessions, { json: flags.json })); |
| 209 | }), | 208 | }), |
| 210 | { | 209 | { |
| ... | @@ -223,9 +222,9 @@ const sessionRevokeCommand = Command.make("revoke", { | ... | @@ -223,9 +222,9 @@ const sessionRevokeCommand = Command.make("revoke", { |
| 223 | }).pipe( | 222 | }).pipe( |
| 224 | Command.withDescription("Revoke an active session."), | 223 | Command.withDescription("Revoke an active session."), |
| 225 | Command.withHandler((flags) => | 224 | Command.withHandler((flags) => |
| 226 | runWithAuthControlPlane(flags, (authControlPlane) => | 225 | runWithEnvironmentAuth(flags, (environmentAuth) => |
| 227 | Effect.gen(function* () { | 226 | Effect.gen(function* () { |
| 228 | const revoked = yield* authControlPlane.revokeSession(flags.sessionId); | 227 | const revoked = yield* environmentAuth.revokeSession(flags.sessionId); |
| 229 | yield* Console.log( | 228 | yield* Console.log( |
| 230 | revoked | 229 | revoked |
| 231 | ? `Revoked session ${flags.sessionId}.\n` | 230 | ? `Revoked session ${flags.sessionId}.\n` |
apps/server/src/cli/project.ts+61-77| ... | @@ -1,6 +1,9 @@ | ... | @@ -1,6 +1,9 @@ |
| 1 | import { | 1 | import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | OrchestrationReadModel, | 3 | AuthAdministrativeScopes, |
| 4 | EnvironmentHttpApi, | ||
| 5 | EnvironmentHttpCommonError, | ||
| 6 | type OrchestrationReadModel, | ||
| 4 | ProjectId, | 7 | ProjectId, |
| 5 | type ClientOrchestrationCommand, | 8 | type ClientOrchestrationCommand, |
| 6 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| ... | @@ -18,16 +21,11 @@ import * as Path from "effect/Path"; | ... | @@ -18,16 +21,11 @@ import * as Path from "effect/Path"; |
| 18 | import * as References from "effect/References"; | 21 | import * as References from "effect/References"; |
| 19 | import * as Schema from "effect/Schema"; | 22 | import * as Schema from "effect/Schema"; |
| 20 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; | 23 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; |
| 21 | import { | 24 | import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; |
| 22 | FetchHttpClient, | 25 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; |
| 23 | HttpClient, | 26 | |
| 24 | HttpClientRequest, | 27 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 25 | HttpClientResponse, | 28 | |
| 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"; | ||
| 31 | import { ServerConfig, type ServerConfigShape } from "../config.ts"; | 29 | import { ServerConfig, type ServerConfigShape } from "../config.ts"; |
| 32 | import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; | 30 | import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; |
| 33 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | 31 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| ... | @@ -78,50 +76,53 @@ const ProjectCliRuntimeLive = Layer.mergeAll( | ... | @@ -78,50 +76,53 @@ const ProjectCliRuntimeLive = Layer.mergeAll( |
| 78 | ); | 76 | ); |
| 79 | 77 | ||
| 80 | const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); | 78 | const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); |
| 81 | const OrchestrationHttpErrorResponse = Schema.Struct({ | 79 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); |
| 82 | error: Schema.String, | ||
| 83 | }); | ||
| 84 | |||
| 85 | const withProjectCliSessionToken = <A, E, R>( | 80 | const withProjectCliSessionToken = <A, E, R>( |
| 86 | authControlPlane: AuthControlPlaneShape, | 81 | environmentAuth: EnvironmentAuth.EnvironmentAuthShape, |
| 87 | run: (token: string) => Effect.Effect<A, E, R>, | 82 | run: (token: string) => Effect.Effect<A, E, R>, |
| 88 | ) => | 83 | ) => |
| 89 | Effect.acquireUseRelease( | 84 | Effect.acquireUseRelease( |
| 90 | authControlPlane.issueSession({ | 85 | environmentAuth.issueSession({ |
| 91 | role: "owner", | 86 | scopes: AuthAdministrativeScopes, |
| 92 | label: "t3 project cli", | 87 | label: "t3 project cli", |
| 93 | }), | 88 | }), |
| 94 | (issued) => run(issued.token), | 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 | const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) => | 93 | const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 99 | effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); | 94 | effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); |
| 100 | 95 | ||
| 101 | const runLiveServerRequest = <A, E extends Error, R>( | 96 | const failLiveServerRequest = (cause: unknown) => { |
| 102 | request: HttpClientRequest.HttpClientRequest, | 97 | if (isEnvironmentHttpCommonError(cause)) { |
| 103 | handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>, | 98 | return Effect.fail( |
| 104 | ) => | 99 | new ProjectCommandError({ |
| 105 | Effect.gen(function* () { | 100 | message: `Server request failed (${cause.code}, trace ${cause.traceId}).`, |
| 106 | const httpClient = yield* HttpClient.HttpClient; | 101 | }), |
| 107 | const response = yield* httpClient.execute(request); | 102 | ); |
| 108 | return yield* handle(response); | 103 | } |
| 109 | }).pipe(withProjectCliLiveServerTimeout); | 104 | if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { |
| 110 | 105 | return Effect.fail( | |
| 111 | const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => | 106 | new ProjectCommandError({ |
| 112 | HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); | 107 | message: `Server request failed with undeclared status ${cause.response.status}.`, |
| 113 | 108 | }), | |
| 114 | const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => | 109 | ); |
| 115 | HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( | 110 | } |
| 116 | Effect.map((body) => body.error), | 111 | return Effect.fail( |
| 117 | Effect.catch(() => Effect.succeed(null)), | 112 | new ProjectCommandError({ |
| 118 | Effect.map((body) => { | 113 | message: `Failed to call running server: ${String(cause)}.`, |
| 119 | if (typeof body === "string" && body.trim().length > 0) { | ||
| 120 | return body; | ||
| 121 | } | ||
| 122 | return `Server request failed with status ${response.status}.`; | ||
| 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 | const normalizeWorkspaceRootForProjectCommand = Effect.fn( | 127 | const normalizeWorkspaceRootForProjectCommand = Effect.fn( |
| 127 | "normalizeWorkspaceRootForProjectCommand", | 128 | "normalizeWorkspaceRootForProjectCommand", |
| ... | @@ -193,42 +194,25 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( | ... | @@ -193,42 +194,25 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( |
| 193 | }); | 194 | }); |
| 194 | 195 | ||
| 195 | const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => | 196 | const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => |
| 196 | runLiveServerRequest( | 197 | Effect.gen(function* () { |
| 197 | HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( | 198 | const client = yield* makeLiveServerClient(origin); |
| 198 | HttpClientRequest.acceptJson, | 199 | return yield* client.orchestration.snapshot({ |
| 199 | HttpClientRequest.bearerToken(bearerToken), | 200 | headers: { authorization: `Bearer ${bearerToken}` }, |
| 200 | ), | 201 | }); |
| 201 | HttpClientResponse.matchStatus({ | 202 | }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); |
| 202 | "2xx": decodeOrchestrationReadModelResponse, | ||
| 203 | orElse: (response) => | ||
| 204 | readErrorMessageFromResponse(response).pipe( | ||
| 205 | Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))), | ||
| 206 | ), | ||
| 207 | }), | ||
| 208 | ); | ||
| 209 | 203 | ||
| 210 | const dispatchLiveOrchestrationCommand = ( | 204 | const dispatchLiveOrchestrationCommand = ( |
| 211 | origin: string, | 205 | origin: string, |
| 212 | bearerToken: string, | 206 | bearerToken: string, |
| 213 | command: ProjectCliDispatchCommand, | 207 | command: ProjectCliDispatchCommand, |
| 214 | ) => | 208 | ) => |
| 215 | HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( | 209 | Effect.gen(function* () { |
| 216 | HttpClientRequest.acceptJson, | 210 | const client = yield* makeLiveServerClient(origin); |
| 217 | HttpClientRequest.bearerToken(bearerToken), | 211 | yield* client.orchestration.dispatch({ |
| 218 | HttpClientRequest.bodyJson(command), | 212 | headers: { authorization: `Bearer ${bearerToken}` }, |
| 219 | Effect.flatMap((request) => | 213 | payload: command, |
| 220 | runLiveServerRequest( | 214 | } as Parameters<typeof client.orchestration.dispatch>[0]); |
| 221 | request, | 215 | }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); |
| 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 | ); | ||
| 232 | 216 | ||
| 233 | const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { | 217 | const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { |
| 234 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 218 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| ... | @@ -236,13 +220,13 @@ const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { | ... | @@ -236,13 +220,13 @@ const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { |
| 236 | }); | 220 | }); |
| 237 | 221 | ||
| 238 | const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( | 222 | const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( |
| 239 | function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { | 223 | function* (environmentAuth: EnvironmentAuth.EnvironmentAuthShape, config: ServerConfigShape) { |
| 240 | const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); | 224 | const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); |
| 241 | if (Option.isNone(runtimeState)) { | 225 | if (Option.isNone(runtimeState)) { |
| 242 | return Option.none<{ readonly origin: string }>(); | 226 | return Option.none<{ readonly origin: string }>(); |
| 243 | } | 227 | } |
| 244 | 228 | ||
| 245 | const attempt = withProjectCliSessionToken(authControlPlane, (token) => | 229 | const attempt = withProjectCliSessionToken(environmentAuth, (token) => |
| 246 | fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( | 230 | fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( |
| 247 | Effect.as({ | 231 | Effect.as({ |
| 248 | origin: runtimeState.value.origin, | 232 | origin: runtimeState.value.origin, |
| ... | @@ -279,11 +263,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( | ... | @@ -279,11 +263,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( |
| 279 | const minimumLogLevel = config.logLevel; | 263 | const minimumLogLevel = config.logLevel; |
| 280 | 264 | ||
| 281 | return yield* Effect.gen(function* () { | 265 | return yield* Effect.gen(function* () { |
| 282 | const authControlPlane = yield* AuthControlPlane; | 266 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 283 | const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); | 267 | const liveMode = yield* tryResolveLiveProjectExecutionMode(environmentAuth, config); |
| 284 | 268 | ||
| 285 | if (Option.isSome(liveMode)) { | 269 | if (Option.isSome(liveMode)) { |
| 286 | return yield* withProjectCliSessionToken(authControlPlane, (token) => | 270 | return yield* withProjectCliSessionToken(environmentAuth, (token) => |
| 287 | Effect.gen(function* () { | 271 | Effect.gen(function* () { |
| 288 | const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); | 272 | const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); |
| 289 | const output = yield* run({ | 273 | const output = yield* run({ |
| ... | @@ -314,7 +298,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( | ... | @@ -314,7 +298,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( |
| 314 | }).pipe(Effect.provide(offlineRuntimeLayer)); | 298 | }).pipe(Effect.provide(offlineRuntimeLayer)); |
| 315 | }).pipe( | 299 | }).pipe( |
| 316 | Effect.provide( | 300 | Effect.provide( |
| 317 | Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( | 301 | Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).pipe( |
| 318 | Layer.provideMerge(FetchHttpClient.layer), | 302 | Layer.provideMerge(FetchHttpClient.layer), |
| 319 | Layer.provide(Layer.succeed(ServerConfig, config)), | 303 | Layer.provide(Layer.succeed(ServerConfig, config)), |
| 320 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | 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,7 +13,7 @@ it("formats issued pairing credentials with the secret and optional pair URL", ( |
| 13 | { | 13 | { |
| 14 | id: "pairing-1", | 14 | id: "pairing-1", |
| 15 | credential: "secret-pairing-token", | 15 | credential: "secret-pairing-token", |
| 16 | role: "client", | 16 | scopes: ["orchestration:read"], |
| 17 | subject: "one-time-token", | 17 | subject: "one-time-token", |
| 18 | createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), | 18 | createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), |
| 19 | expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), | 19 | expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), |
| ... | @@ -33,7 +33,7 @@ it("formats pairing listings without exposing the secret token", () => { | ... | @@ -33,7 +33,7 @@ it("formats pairing listings without exposing the secret token", () => { |
| 33 | credential: "secret-pairing-token", | 33 | credential: "secret-pairing-token", |
| 34 | subject: "one-time-token", | 34 | subject: "one-time-token", |
| 35 | label: "Phone", | 35 | label: "Phone", |
| 36 | role: "client", | 36 | scopes: ["orchestration:read"], |
| 37 | createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), | 37 | createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), |
| 38 | expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), | 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,8 +50,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings |
| 50 | { | 50 | { |
| 51 | sessionId: "session-1" as never, | 51 | sessionId: "session-1" as never, |
| 52 | token: "secret-session-token", | 52 | token: "secret-session-token", |
| 53 | method: "bearer-session-token", | 53 | method: "bearer-access-token", |
| 54 | role: "owner", | 54 | scopes: ["orchestration:read", "access:write"], |
| 55 | subject: "cli-issued-session", | 55 | subject: "cli-issued-session", |
| 56 | client: { | 56 | client: { |
| 57 | label: "deploy-bot", | 57 | label: "deploy-bot", |
| ... | @@ -66,8 +66,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings | ... | @@ -66,8 +66,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings |
| 66 | [ | 66 | [ |
| 67 | { | 67 | { |
| 68 | sessionId: "session-1" as never, | 68 | sessionId: "session-1" as never, |
| 69 | method: "bearer-session-token", | 69 | method: "bearer-access-token", |
| 70 | role: "owner", | 70 | scopes: ["orchestration:read", "access:write"], |
| 71 | subject: "cli-issued-session", | 71 | subject: "cli-issued-session", |
| 72 | client: { | 72 | client: { |
| 73 | label: "deploy-bot", | 73 | label: "deploy-bot", |
apps/server/src/cliAuthFormat.ts+10-8| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; | 1 | import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; |
| 2 | import * as DateTime from "effect/DateTime"; | 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 | const newline = "\n"; | 6 | const newline = "\n"; |
| 7 | 7 | ||
| ... | @@ -47,7 +47,7 @@ export function formatIssuedPairingCredential( | ... | @@ -47,7 +47,7 @@ export function formatIssuedPairingCredential( |
| 47 | id: credential.id, | 47 | id: credential.id, |
| 48 | credential: credential.credential, | 48 | credential: credential.credential, |
| 49 | ...(credential.label ? { label: credential.label } : {}), | 49 | ...(credential.label ? { label: credential.label } : {}), |
| 50 | role: credential.role, | 50 | scopes: credential.scopes, |
| 51 | expiresAt: toIsoString(credential.expiresAt), | 51 | expiresAt: toIsoString(credential.expiresAt), |
| 52 | ...(pairUrl ? { pairUrl } : {}), | 52 | ...(pairUrl ? { pairUrl } : {}), |
| 53 | }, | 53 | }, |
| ... | @@ -77,7 +77,7 @@ export function formatPairingCredentialList( | ... | @@ -77,7 +77,7 @@ export function formatPairingCredentialList( |
| 77 | credentials.map((credential) => ({ | 77 | credentials.map((credential) => ({ |
| 78 | id: credential.id, | 78 | id: credential.id, |
| 79 | ...(credential.label ? { label: credential.label } : {}), | 79 | ...(credential.label ? { label: credential.label } : {}), |
| 80 | role: credential.role, | 80 | scopes: credential.scopes, |
| 81 | createdAt: toIsoString(credential.createdAt), | 81 | createdAt: toIsoString(credential.createdAt), |
| 82 | expiresAt: toIsoString(credential.expiresAt), | 82 | expiresAt: toIsoString(credential.expiresAt), |
| 83 | })), | 83 | })), |
| ... | @@ -95,7 +95,7 @@ export function formatPairingCredentialList( | ... | @@ -95,7 +95,7 @@ export function formatPairingCredentialList( |
| 95 | .map((credential) => | 95 | .map((credential) => |
| 96 | [ | 96 | [ |
| 97 | `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, | 97 | `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, |
| 98 | ` role: ${credential.role}`, | 98 | ` scopes: ${credential.scopes.join(" ")}`, |
| 99 | ` created: ${toIsoString(credential.createdAt)}`, | 99 | ` created: ${toIsoString(credential.createdAt)}`, |
| 100 | ` expires: ${toIsoString(credential.expiresAt)}`, | 100 | ` expires: ${toIsoString(credential.expiresAt)}`, |
| 101 | ].join(newline), | 101 | ].join(newline), |
| ... | @@ -121,7 +121,7 @@ export function formatIssuedSession( | ... | @@ -121,7 +121,7 @@ export function formatIssuedSession( |
| 121 | sessionId: session.sessionId, | 121 | sessionId: session.sessionId, |
| 122 | token: session.token, | 122 | token: session.token, |
| 123 | method: session.method, | 123 | method: session.method, |
| 124 | role: session.role, | 124 | scopes: session.scopes, |
| 125 | subject: session.subject, | 125 | subject: session.subject, |
| 126 | client: session.client, | 126 | client: session.client, |
| 127 | expiresAt: toIsoString(session.expiresAt), | 127 | expiresAt: toIsoString(session.expiresAt), |
| ... | @@ -133,7 +133,8 @@ export function formatIssuedSession( | ... | @@ -133,7 +133,8 @@ export function formatIssuedSession( |
| 133 | 133 | ||
| 134 | return ( | 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 | `Token: ${session.token}`, | 138 | `Token: ${session.token}`, |
| 138 | `Subject: ${session.subject}`, | 139 | `Subject: ${session.subject}`, |
| 139 | `Client: ${formatClientMetadata(session.client)}`, | 140 | `Client: ${formatClientMetadata(session.client)}`, |
| ... | @@ -153,7 +154,7 @@ export function formatSessionList( | ... | @@ -153,7 +154,7 @@ export function formatSessionList( |
| 153 | sessions.map((session) => ({ | 154 | sessions.map((session) => ({ |
| 154 | sessionId: session.sessionId, | 155 | sessionId: session.sessionId, |
| 155 | method: session.method, | 156 | method: session.method, |
| 156 | role: session.role, | 157 | scopes: session.scopes, |
| 157 | subject: session.subject, | 158 | subject: session.subject, |
| 158 | client: session.client, | 159 | client: session.client, |
| 159 | connected: session.connected, | 160 | connected: session.connected, |
| ... | @@ -174,7 +175,8 @@ export function formatSessionList( | ... | @@ -174,7 +175,8 @@ export function formatSessionList( |
| 174 | sessions | 175 | sessions |
| 175 | .map((session) => | 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 | ` method: ${session.method}`, | 180 | ` method: ${session.method}`, |
| 179 | ` subject: ${session.subject}`, | 181 | ` subject: ${session.subject}`, |
| 180 | ` client: ${formatClientMetadata(session.client)}`, | 182 | ` client: ${formatClientMetadata(session.client)}`, |
apps/server/src/http.ts+68-30| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import Mime from "@effect/platform-node/Mime"; | 1 | import Mime from "@effect/platform-node/Mime"; |
| 2 | import { | ||
| 3 | AuthOrchestrationOperateScope, | ||
| 4 | AuthOrchestrationReadScope, | ||
| 5 | EnvironmentHttpApi, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; | 7 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 3 | import * as Data from "effect/Data"; | 8 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| ... | @@ -13,7 +18,9 @@ import { | ... | @@ -13,7 +18,9 @@ import { |
| 13 | HttpRouter, | 18 | HttpRouter, |
| 14 | HttpServerResponse, | 19 | HttpServerResponse, |
| 15 | HttpServerRequest, | 20 | HttpServerRequest, |
| 21 | HttpServerRespondable, | ||
| 16 | } from "effect/unstable/http"; | 22 | } from "effect/unstable/http"; |
| 23 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ||
| 17 | import { OtlpTracer } from "effect/unstable/observability"; | 24 | import { OtlpTracer } from "effect/unstable/observability"; |
| 18 | 25 | ||
| 19 | import { | 26 | import { |
| ... | @@ -25,14 +32,15 @@ import { resolveAttachmentPathById } from "./attachmentStore.ts"; | ... | @@ -25,14 +32,15 @@ import { resolveAttachmentPathById } from "./attachmentStore.ts"; |
| 25 | import { resolveStaticDir, ServerConfig } from "./config.ts"; | 32 | import { resolveStaticDir, ServerConfig } from "./config.ts"; |
| 26 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; | 33 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; |
| 27 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; | 34 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; |
| 28 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 29 | import { respondToAuthError } from "./auth/http.ts"; | ||
| 30 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | ||
| 31 | import { | 36 | import { |
| 32 | browserApiCorsAllowedHeaders, | 37 | annotateEnvironmentRequest, |
| 33 | browserApiCorsAllowedMethods, | 38 | failEnvironmentScopeRequired, |
| 34 | browserApiCorsHeaders, | 39 | failEnvironmentAuthInvalid, |
| 35 | } from "./httpCors.ts"; | 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 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; | 45 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; |
| 38 | 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>`; | 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,8 +48,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 40 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 48 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 41 | 49 | ||
| 42 | export const browserApiCorsLayer = HttpRouter.cors({ | 50 | export const browserApiCorsLayer = HttpRouter.cors({ |
| 43 | allowedMethods: [...browserApiCorsAllowedMethods], | 51 | allowedMethods: browserApiCorsAllowedMethods, |
| 44 | allowedHeaders: [...browserApiCorsAllowedHeaders], | 52 | allowedHeaders: browserApiCorsAllowedHeaders, |
| 45 | maxAge: 600, | 53 | maxAge: 600, |
| 46 | }); | 54 | }); |
| 47 | 55 | ||
| ... | @@ -61,23 +69,35 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { | ... | @@ -61,23 +69,35 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { |
| 61 | return redirectUrl.toString(); | 69 | return redirectUrl.toString(); |
| 62 | } | 70 | } |
| 63 | 71 | ||
| 64 | const requireAuthenticatedRequest = Effect.gen(function* () { | 72 | const authenticateRawRouteWithScope = ( |
| 65 | const request = yield* HttpServerRequest.HttpServerRequest; | 73 | scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, |
| 66 | const serverAuth = yield* ServerAuth; | 74 | ) => |
| 67 | yield* serverAuth.authenticateHttpRequest(request); | ||
| 68 | }); | ||
| 69 | |||
| 70 | export const serverEnvironmentRouteLayer = HttpRouter.add( | ||
| 71 | "GET", | ||
| 72 | "/.well-known/t3/environment", | ||
| 73 | Effect.gen(function* () { | 75 | Effect.gen(function* () { |
| 74 | const descriptor = yield* Effect.service(ServerEnvironment).pipe( | 76 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 75 | Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor), | 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,7 +110,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 90 | "POST", | 110 | "POST", |
| 91 | OTLP_TRACES_PROXY_PATH, | 111 | OTLP_TRACES_PROXY_PATH, |
| 92 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 93 | yield* requireAuthenticatedRequest; | 113 | yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope); |
| 94 | const request = yield* HttpServerRequest.HttpServerRequest; | 114 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 95 | const config = yield* ServerConfig; | 115 | const config = yield* ServerConfig; |
| 96 | const otlpTracesUrl = config.otlpTracesUrl; | 116 | const otlpTracesUrl = config.otlpTracesUrl; |
| ... | @@ -132,14 +152,20 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( | ... | @@ -132,14 +152,20 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 132 | Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), | 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 | export const attachmentsRouteLayer = HttpRouter.add( | 164 | export const attachmentsRouteLayer = HttpRouter.add( |
| 139 | "GET", | 165 | "GET", |
| 140 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, | 166 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, |
| 141 | Effect.gen(function* () { | 167 | Effect.gen(function* () { |
| 142 | yield* requireAuthenticatedRequest; | 168 | yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); |
| 143 | const request = yield* HttpServerRequest.HttpServerRequest; | 169 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 144 | const url = HttpServerRequest.toURL(request); | 170 | const url = HttpServerRequest.toURL(request); |
| 145 | if (Option.isNone(url)) { | 171 | if (Option.isNone(url)) { |
| ... | @@ -188,14 +214,20 @@ export const attachmentsRouteLayer = HttpRouter.add( | ... | @@ -188,14 +214,20 @@ export const attachmentsRouteLayer = HttpRouter.add( |
| 188 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | 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 | export const projectFaviconRouteLayer = HttpRouter.add( | 226 | export const projectFaviconRouteLayer = HttpRouter.add( |
| 195 | "GET", | 227 | "GET", |
| 196 | "/api/project-favicon", | 228 | "/api/project-favicon", |
| 197 | Effect.gen(function* () { | 229 | Effect.gen(function* () { |
| 198 | yield* requireAuthenticatedRequest; | 230 | yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); |
| 199 | const request = yield* HttpServerRequest.HttpServerRequest; | 231 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 200 | const url = HttpServerRequest.toURL(request); | 232 | const url = HttpServerRequest.toURL(request); |
| 201 | if (Option.isNone(url)) { | 233 | if (Option.isNone(url)) { |
| ... | @@ -229,7 +261,13 @@ export const projectFaviconRouteLayer = HttpRouter.add( | ... | @@ -229,7 +261,13 @@ export const projectFaviconRouteLayer = HttpRouter.add( |
| 229 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | 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 | export const staticAndDevRouteLayer = HttpRouter.add( | 273 | export const staticAndDevRouteLayer = HttpRouter.add( |
apps/server/src/orchestration/http.ts+48-84| ... | @@ -1,95 +1,59 @@ | ... | @@ -1,95 +1,59 @@ |
| 1 | import { | 1 | import { |
| 2 | ClientOrchestrationCommand, | 2 | AuthOrchestrationOperateScope, |
| 3 | OrchestrationDispatchCommandError, | 3 | AuthOrchestrationReadScope, |
| 4 | OrchestrationGetSnapshotError, | 4 | EnvironmentHttpApi, |
| 5 | type OrchestrationReadModel, | ||
| 6 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; | 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 | import { normalizeDispatchCommand } from "./Normalizer.ts"; | 9 | import { normalizeDispatchCommand } from "./Normalizer.ts"; |
| 10 | import { | ||
| 11 | annotateEnvironmentRequest, | ||
| 12 | failEnvironmentInternal, | ||
| 13 | failEnvironmentInvalidRequest, | ||
| 14 | requireEnvironmentScope, | ||
| 15 | } from "../auth/http.ts"; | ||
| 12 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; | 16 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 13 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | 17 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; |
| 14 | 18 | ||
| 15 | const respondToOrchestrationHttpError = ( | 19 | export const orchestrationHttpApiLayer = HttpApiBuilder.group( |
| 16 | error: OrchestrationDispatchCommandError | OrchestrationGetSnapshotError, | 20 | EnvironmentHttpApi, |
| 17 | ) => | 21 | "orchestration", |
| 18 | Effect.gen(function* () { | 22 | Effect.fnUntraced(function* (handlers) { |
| 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; | ||
| 47 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 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 | const orchestrationEngine = yield* OrchestrationEngineService; | 24 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 74 | const command = yield* HttpServerRequest.schemaBodyJson(ClientOrchestrationCommand).pipe( | 25 | |
| 75 | Effect.mapError( | 26 | return handlers |
| 76 | (cause) => | 27 | .handle( |
| 77 | new OrchestrationDispatchCommandError({ | 28 | "snapshot", |
| 78 | message: "Invalid orchestration command payload.", | 29 | Effect.fn("environment.orchestration.snapshot")(function* (args) { |
| 79 | cause, | 30 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 80 | }), | 31 | yield* requireEnvironmentScope(AuthOrchestrationReadScope); |
| 81 | ), | 32 | return yield* projectionSnapshotQuery |
| 82 | ); | 33 | .getSnapshot() |
| 83 | const normalizedCommand = yield* normalizeDispatchCommand(command); | 34 | .pipe( |
| 84 | const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe( | 35 | Effect.catch((cause) => |
| 85 | Effect.mapError( | 36 | failEnvironmentInternal("orchestration_snapshot_failed", cause), |
| 86 | (cause) => | 37 | ), |
| 87 | new OrchestrationDispatchCommandError({ | 38 | ); |
| 88 | message: "Failed to dispatch orchestration command.", | 39 | }), |
| 89 | cause, | 40 | ) |
| 90 | }), | 41 | .handle( |
| 91 | ), | 42 | "dispatch", |
| 92 | ); | 43 | Effect.fn("environment.orchestration.dispatch")(function* (args) { |
| 93 | return HttpServerResponse.jsonUnsafe(result, { status: 200 }); | 44 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 94 | }).pipe(Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError)), | 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,7 +38,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 38 | id, | 38 | id, |
| 39 | credential, | 39 | credential, |
| 40 | method, | 40 | method, |
| 41 | role, | 41 | scopes, |
| 42 | subject, | 42 | subject, |
| 43 | label, | 43 | label, |
| 44 | created_at, | 44 | created_at, |
| ... | @@ -50,7 +50,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -50,7 +50,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 50 | ${input.id}, | 50 | ${input.id}, |
| 51 | ${input.credential}, | 51 | ${input.credential}, |
| 52 | ${input.method}, | 52 | ${input.method}, |
| 53 | ${input.role}, | 53 | ${JSON.stringify(input.scopes)}, |
| 54 | ${input.subject}, | 54 | ${input.subject}, |
| 55 | ${input.label}, | 55 | ${input.label}, |
| 56 | ${input.createdAt}, | 56 | ${input.createdAt}, |
| ... | @@ -76,7 +76,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -76,7 +76,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 76 | id AS "id", | 76 | id AS "id", |
| 77 | credential AS "credential", | 77 | credential AS "credential", |
| 78 | method AS "method", | 78 | method AS "method", |
| 79 | role AS "role", | 79 | scopes AS "scopes", |
| 80 | subject AS "subject", | 80 | subject AS "subject", |
| 81 | label AS "label", | 81 | label AS "label", |
| 82 | created_at AS "createdAt", | 82 | created_at AS "createdAt", |
| ... | @@ -95,7 +95,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -95,7 +95,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 95 | id AS "id", | 95 | id AS "id", |
| 96 | credential AS "credential", | 96 | credential AS "credential", |
| 97 | method AS "method", | 97 | method AS "method", |
| 98 | role AS "role", | 98 | scopes AS "scopes", |
| 99 | subject AS "subject", | 99 | subject AS "subject", |
| 100 | label AS "label", | 100 | label AS "label", |
| 101 | created_at AS "createdAt", | 101 | created_at AS "createdAt", |
| ... | @@ -133,7 +133,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -133,7 +133,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 133 | id AS "id", | 133 | id AS "id", |
| 134 | credential AS "credential", | 134 | credential AS "credential", |
| 135 | method AS "method", | 135 | method AS "method", |
| 136 | role AS "role", | 136 | scopes AS "scopes", |
| 137 | subject AS "subject", | 137 | subject AS "subject", |
| 138 | label AS "label", | 138 | label AS "label", |
| 139 | created_at AS "createdAt", | 139 | created_at AS "createdAt", |
apps/server/src/persistence/Layers/AuthSessions.ts+8-8| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { AuthSessionId } from "@t3tools/contracts"; | 1 | import { AuthEnvironmentScopes, AuthSessionId, ServerAuthSessionMethod } from "@t3tools/contracts"; |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| ... | @@ -26,8 +26,8 @@ import { | ... | @@ -26,8 +26,8 @@ import { |
| 26 | const AuthSessionDbRow = Schema.Struct({ | 26 | const AuthSessionDbRow = Schema.Struct({ |
| 27 | sessionId: AuthSessionId, | 27 | sessionId: AuthSessionId, |
| 28 | subject: Schema.String, | 28 | subject: Schema.String, |
| 29 | role: Schema.Literals(["owner", "client"]), | 29 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), |
| 30 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | 30 | method: ServerAuthSessionMethod, |
| 31 | clientLabel: Schema.NullOr(Schema.String), | 31 | clientLabel: Schema.NullOr(Schema.String), |
| 32 | clientIpAddress: Schema.NullOr(Schema.String), | 32 | clientIpAddress: Schema.NullOr(Schema.String), |
| 33 | clientUserAgent: Schema.NullOr(Schema.String), | 33 | clientUserAgent: Schema.NullOr(Schema.String), |
| ... | @@ -44,7 +44,7 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco | ... | @@ -44,7 +44,7 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco |
| 44 | return { | 44 | return { |
| 45 | sessionId: row.sessionId, | 45 | sessionId: row.sessionId, |
| 46 | subject: row.subject, | 46 | subject: row.subject, |
| 47 | role: row.role, | 47 | scopes: row.scopes, |
| 48 | method: row.method, | 48 | method: row.method, |
| 49 | client: { | 49 | client: { |
| 50 | label: row.clientLabel, | 50 | label: row.clientLabel, |
| ... | @@ -78,7 +78,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { | ... | @@ -78,7 +78,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { |
| 78 | INSERT INTO auth_sessions ( | 78 | INSERT INTO auth_sessions ( |
| 79 | session_id, | 79 | session_id, |
| 80 | subject, | 80 | subject, |
| 81 | role, | 81 | scopes, |
| 82 | method, | 82 | method, |
| 83 | client_label, | 83 | client_label, |
| 84 | client_ip_address, | 84 | client_ip_address, |
| ... | @@ -93,7 +93,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { | ... | @@ -93,7 +93,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { |
| 93 | VALUES ( | 93 | VALUES ( |
| 94 | ${input.sessionId}, | 94 | ${input.sessionId}, |
| 95 | ${input.subject}, | 95 | ${input.subject}, |
| 96 | ${input.role}, | 96 | ${JSON.stringify(input.scopes)}, |
| 97 | ${input.method}, | 97 | ${input.method}, |
| 98 | ${input.client.label}, | 98 | ${input.client.label}, |
| 99 | ${input.client.ipAddress}, | 99 | ${input.client.ipAddress}, |
| ... | @@ -116,7 +116,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { | ... | @@ -116,7 +116,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { |
| 116 | SELECT | 116 | SELECT |
| 117 | session_id AS "sessionId", | 117 | session_id AS "sessionId", |
| 118 | subject AS "subject", | 118 | subject AS "subject", |
| 119 | role AS "role", | 119 | scopes AS "scopes", |
| 120 | method AS "method", | 120 | method AS "method", |
| 121 | client_label AS "clientLabel", | 121 | client_label AS "clientLabel", |
| 122 | client_ip_address AS "clientIpAddress", | 122 | client_ip_address AS "clientIpAddress", |
| ... | @@ -141,7 +141,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { | ... | @@ -141,7 +141,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { |
| 141 | SELECT | 141 | SELECT |
| 142 | session_id AS "sessionId", | 142 | session_id AS "sessionId", |
| 143 | subject AS "subject", | 143 | subject AS "subject", |
| 144 | role AS "role", | 144 | scopes AS "scopes", |
| 145 | method AS "method", | 145 | method AS "method", |
| 146 | client_label AS "clientLabel", | 146 | client_label AS "clientLabel", |
| 147 | client_ip_address AS "clientIpAddress", | 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,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts" |
| 43 | import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; | 43 | import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; |
| 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; | 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; |
| 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; | 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; |
| 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; | ||
| 46 | 47 | ||
| 47 | /** | 48 | /** |
| 48 | * Migration loader with all migrations defined inline. | 49 | * Migration loader with all migrations defined inline. |
| ... | @@ -85,6 +86,7 @@ export const migrationEntries = [ | ... | @@ -85,6 +86,7 @@ export const migrationEntries = [ |
| 85 | [28, "ProjectionThreadSessionInstanceId", Migration0028], | 86 | [28, "ProjectionThreadSessionInstanceId", Migration0028], |
| 86 | [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], | 87 | [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], |
| 87 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], | 88 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], |
| 89 | [31, "AuthAuthorizationScopes", Migration0031], | ||
| 88 | ] as const; | 90 | ] as const; |
| 89 | 91 | ||
| 90 | export const makeMigrationLoader = (throughId?: number) => | 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,6 +2,7 @@ import * as Option from "effect/Option"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 4 | import type * as Effect from "effect/Effect"; | 4 | import type * as Effect from "effect/Effect"; |
| 5 | import { AuthEnvironmentScopes } from "@t3tools/contracts"; | ||
| 5 | 6 | ||
| 6 | import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; | 7 | import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; |
| 7 | 8 | ||
| ... | @@ -9,7 +10,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ | ... | @@ -9,7 +10,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ |
| 9 | id: Schema.String, | 10 | id: Schema.String, |
| 10 | credential: Schema.String, | 11 | credential: Schema.String, |
| 11 | method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), | 12 | method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), |
| 12 | role: Schema.Literals(["owner", "client"]), | 13 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), |
| 13 | subject: Schema.String, | 14 | subject: Schema.String, |
| 14 | label: Schema.NullOr(Schema.String), | 15 | label: Schema.NullOr(Schema.String), |
| 15 | createdAt: Schema.DateTimeUtcFromString, | 16 | createdAt: Schema.DateTimeUtcFromString, |
| ... | @@ -23,7 +24,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ | ... | @@ -23,7 +24,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ |
| 23 | id: Schema.String, | 24 | id: Schema.String, |
| 24 | credential: Schema.String, | 25 | credential: Schema.String, |
| 25 | method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), | 26 | method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), |
| 26 | role: Schema.Literals(["owner", "client"]), | 27 | scopes: AuthEnvironmentScopes, |
| 27 | subject: Schema.String, | 28 | subject: Schema.String, |
| 28 | label: Schema.NullOr(Schema.String), | 29 | label: Schema.NullOr(Schema.String), |
| 29 | createdAt: Schema.DateTimeUtcFromString, | 30 | createdAt: Schema.DateTimeUtcFromString, |
apps/server/src/persistence/Services/AuthSessions.ts+10-5| ... | @@ -1,4 +1,9 @@ | ... | @@ -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 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 3 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 4 | import * as Context from "effect/Context"; | 9 | import * as Context from "effect/Context"; |
| ... | @@ -19,8 +24,8 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe | ... | @@ -19,8 +24,8 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe |
| 19 | export const AuthSessionRecord = Schema.Struct({ | 24 | export const AuthSessionRecord = Schema.Struct({ |
| 20 | sessionId: AuthSessionId, | 25 | sessionId: AuthSessionId, |
| 21 | subject: Schema.String, | 26 | subject: Schema.String, |
| 22 | role: Schema.Literals(["owner", "client"]), | 27 | scopes: AuthEnvironmentScopes, |
| 23 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | 28 | method: ServerAuthSessionMethod, |
| 24 | client: AuthSessionClientMetadataRecord, | 29 | client: AuthSessionClientMetadataRecord, |
| 25 | issuedAt: Schema.DateTimeUtcFromString, | 30 | issuedAt: Schema.DateTimeUtcFromString, |
| 26 | expiresAt: Schema.DateTimeUtcFromString, | 31 | expiresAt: Schema.DateTimeUtcFromString, |
| ... | @@ -32,8 +37,8 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type; | ... | @@ -32,8 +37,8 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type; |
| 32 | export const CreateAuthSessionInput = Schema.Struct({ | 37 | export const CreateAuthSessionInput = Schema.Struct({ |
| 33 | sessionId: AuthSessionId, | 38 | sessionId: AuthSessionId, |
| 34 | subject: Schema.String, | 39 | subject: Schema.String, |
| 35 | role: Schema.Literals(["owner", "client"]), | 40 | scopes: AuthEnvironmentScopes, |
| 36 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | 41 | method: ServerAuthSessionMethod, |
| 37 | client: AuthSessionClientMetadataRecord, | 42 | client: AuthSessionClientMetadataRecord, |
| 38 | issuedAt: Schema.DateTimeUtcFromString, | 43 | issuedAt: Schema.DateTimeUtcFromString, |
| 39 | expiresAt: Schema.DateTimeUtcFromString, | 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,6 +3,9 @@ import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | AuthAccessTokenType, | ||
| 7 | AuthEnvironmentBootstrapTokenType, | ||
| 8 | AuthTokenExchangeGrantType, | ||
| 6 | CommandId, | 9 | CommandId, |
| 7 | DEFAULT_SERVER_SETTINGS, | 10 | DEFAULT_SERVER_SETTINGS, |
| 8 | EnvironmentId, | 11 | EnvironmentId, |
| ... | @@ -46,6 +49,7 @@ import { | ... | @@ -46,6 +49,7 @@ import { |
| 46 | HttpClient, | 49 | HttpClient, |
| 47 | HttpRouter, | 50 | HttpRouter, |
| 48 | HttpServer, | 51 | HttpServer, |
| 52 | UrlParams, | ||
| 49 | } from "effect/unstable/http"; | 53 | } from "effect/unstable/http"; |
| 50 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 54 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 51 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | 55 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| ... | @@ -114,8 +118,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; | ... | @@ -114,8 +118,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 114 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | 118 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 115 | import * as ReviewService from "./review/ReviewService.ts"; | 119 | import * as ReviewService from "./review/ReviewService.ts"; |
| 116 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 120 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 117 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | 121 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 118 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | 122 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 119 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 123 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 120 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 124 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 121 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 125 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | @@ -215,7 +219,10 @@ const browserOtlpTracingLayer = Layer.mergeAll( | ... | @@ -215,7 +219,10 @@ const browserOtlpTracingLayer = Layer.mergeAll( |
| 215 | ); | 219 | ); |
| 216 | 220 | ||
| 217 | const makeAuthTestLayer = () => | 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 | const makeBrowserOtlpPayload = (spanName: string) => | 227 | const makeBrowserOtlpPayload = (spanName: string) => |
| 221 | Effect.gen(function* () { | 228 | Effect.gen(function* () { |
| ... | @@ -799,7 +806,7 @@ const bootstrapBrowserSession = ( | ... | @@ -799,7 +806,7 @@ const bootstrapBrowserSession = ( |
| 799 | }, | 806 | }, |
| 800 | ) => | 807 | ) => |
| 801 | Effect.gen(function* () { | 808 | Effect.gen(function* () { |
| 802 | const response = yield* HttpClient.post("/api/auth/bootstrap", { | 809 | const response = yield* HttpClient.post("/api/auth/browser-session", { |
| 803 | headers: options?.headers, | 810 | headers: options?.headers, |
| 804 | body: yield* HttpBody.json({ credential }), | 811 | body: yield* HttpBody.json({ credential }), |
| 805 | }); | 812 | }); |
| ... | @@ -815,23 +822,46 @@ const bootstrapBrowserSession = ( | ... | @@ -815,23 +822,46 @@ const bootstrapBrowserSession = ( |
| 815 | }; | 822 | }; |
| 816 | }); | 823 | }); |
| 817 | 824 | ||
| 818 | const bootstrapBearerSession = ( | 825 | const exchangeAccessToken = ( |
| 819 | credential = defaultDesktopBootstrapToken, | 826 | credential = defaultDesktopBootstrapToken, |
| 820 | options?: { | 827 | options?: { |
| 821 | readonly headers?: Record<string, string>; | 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 | Effect.gen(function* () { | 837 | Effect.gen(function* () { |
| 825 | const response = yield* HttpClient.post("/api/auth/bootstrap/bearer", { | 838 | const response = yield* HttpClient.post("/oauth/token", { |
| 826 | headers: options?.headers, | 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 | const body = (yield* response.json) as { | 857 | const body = (yield* response.json) as { |
| 830 | readonly authenticated: boolean; | 858 | readonly access_token?: string; |
| 831 | readonly sessionMethod: string; | 859 | readonly issued_token_type?: string; |
| 832 | readonly expiresAt: string; | 860 | readonly token_type?: string; |
| 833 | readonly sessionToken?: string; | 861 | readonly expires_in?: number; |
| 834 | readonly error?: string; | 862 | readonly scope?: string; |
| 863 | readonly _tag?: string; | ||
| 864 | readonly message?: string; | ||
| 835 | }; | 865 | }; |
| 836 | return { | 866 | return { |
| 837 | response, | 867 | response, |
| ... | @@ -863,20 +893,20 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra | ... | @@ -863,20 +893,20 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra |
| 863 | 893 | ||
| 864 | const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => | 894 | const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => |
| 865 | Effect.gen(function* () { | 895 | Effect.gen(function* () { |
| 866 | const { response, body } = yield* bootstrapBearerSession(credential); | 896 | const { response, body } = yield* exchangeAccessToken(credential); |
| 867 | if (response.status !== 200) { | 897 | if (response.status !== 200) { |
| 868 | return yield* new AuthenticationGetterError({ | 898 | return yield* new AuthenticationGetterError({ |
| 869 | message: `Expected bearer bootstrap response to succeed, got ${response.status}`, | 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 | return yield* new AuthenticationGetterError({ | 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 | const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { | 912 | const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { |
| ... | @@ -895,8 +925,16 @@ const splitHeaderTokens = (value: string | null) => | ... | @@ -895,8 +925,16 @@ const splitHeaderTokens = (value: string | null) => |
| 895 | .filter((token) => token.length > 0) | 925 | .filter((token) => token.length > 0) |
| 896 | .toSorted(); | 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 | assert.equal(headers["access-control-allow-origin"], "*"); | 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 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [ | 938 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [ |
| 901 | "GET", | 939 | "GET", |
| 902 | "OPTIONS", | 940 | "OPTIONS", |
| ... | @@ -1039,7 +1077,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1039,7 +1077,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1039 | const body = (yield* response.json) as typeof testEnvironmentDescriptor; | 1077 | const body = (yield* response.json) as typeof testEnvironmentDescriptor; |
| 1040 | 1078 | ||
| 1041 | assert.equal(response.status, 200); | 1079 | assert.equal(response.status, 200); |
| 1042 | assertBrowserApiCorsHeaders(response.headers); | 1080 | assertBrowserApiCorsResponseHeaders(response.headers); |
| 1043 | assert.deepEqual(body, testEnvironmentDescriptor); | 1081 | assert.deepEqual(body, testEnvironmentDescriptor); |
| 1044 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1082 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1045 | ); | 1083 | ); |
| ... | @@ -1063,10 +1101,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1063,10 +1101,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1063 | assert.equal(body.authenticated, false); | 1101 | assert.equal(body.authenticated, false); |
| 1064 | assert.equal(body.auth.policy, "desktop-managed-local"); | 1102 | assert.equal(body.auth.policy, "desktop-managed-local"); |
| 1065 | assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); | 1103 | assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); |
| 1066 | assert.deepEqual(body.auth.sessionMethods, [ | 1104 | assert.deepEqual(body.auth.sessionMethods, ["browser-session-cookie", "bearer-access-token"]); |
| 1067 | "browser-session-cookie", | ||
| 1068 | "bearer-session-token", | ||
| 1069 | ]); | ||
| 1070 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); | 1105 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); |
| 1071 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1106 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1072 | ); | 1107 | ); |
| ... | @@ -1103,56 +1138,134 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1103,56 +1138,134 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1103 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1138 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1104 | ); | 1139 | ); |
| 1105 | 1140 | ||
| 1106 | it.effect( | 1141 | it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => |
| 1107 | "bootstraps a bearer session and authenticates the session endpoint via authorization header", | 1142 | Effect.gen(function* () { |
| 1108 | () => | 1143 | yield* buildAppUnderTest(); |
| 1109 | Effect.gen(function* () { | ||
| 1110 | yield* buildAppUnderTest(); | ||
| 1111 | 1144 | ||
| 1112 | const { response: bootstrapResponse, body: bootstrapBody } = | 1145 | const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken(); |
| 1113 | yield* bootstrapBearerSession(); | ||
| 1114 | 1146 | ||
| 1115 | assert.equal(bootstrapResponse.status, 200); | 1147 | assert.equal(bootstrapResponse.status, 200); |
| 1116 | assert.equal(bootstrapBody.authenticated, true); | 1148 | assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); |
| 1117 | assert.equal(bootstrapBody.sessionMethod, "bearer-session-token"); | 1149 | assert.equal(tokenBody.token_type, "Bearer"); |
| 1118 | assert.equal(typeof bootstrapBody.sessionToken, "string"); | 1150 | assert.equal( |
| 1119 | assert.isTrue((bootstrapBody.sessionToken?.length ?? 0) > 0); | 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", { | 1157 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { |
| 1122 | headers: { | 1158 | headers: { |
| 1123 | authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, | 1159 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, |
| 1124 | }, | 1160 | }, |
| 1125 | }); | 1161 | }); |
| 1126 | const sessionBody = (yield* sessionResponse.json) as { | 1162 | const sessionBody = (yield* sessionResponse.json) as { |
| 1127 | readonly authenticated: boolean; | 1163 | readonly authenticated: boolean; |
| 1128 | readonly sessionMethod?: string; | 1164 | readonly sessionMethod?: string; |
| 1129 | }; | 1165 | readonly scopes?: ReadonlyArray<string>; |
| 1166 | }; | ||
| 1130 | 1167 | ||
| 1131 | assert.equal(sessionResponse.status, 200); | 1168 | assert.equal(sessionResponse.status, 200); |
| 1132 | assert.equal(sessionBody.authenticated, true); | 1169 | assert.equal(sessionBody.authenticated, true); |
| 1133 | assert.equal(sessionBody.sessionMethod, "bearer-session-token"); | 1170 | assert.equal(sessionBody.sessionMethod, "bearer-access-token"); |
| 1134 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | Effect.gen(function* () { | 1185 | Effect.gen(function* () { |
| 1139 | yield* buildAppUnderTest(); | 1186 | yield* buildAppUnderTest(); |
| 1140 | 1187 | ||
| 1141 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | 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 | headers: { | 1190 | headers: { |
| 1144 | authorization: `Bearer ${bearerToken}`, | 1191 | authorization: `Bearer ${bearerToken}`, |
| 1145 | }, | 1192 | }, |
| 1146 | }); | 1193 | }); |
| 1147 | const wsTokenBody = (yield* wsTokenResponse.json) as { | 1194 | const wsTicketBody = (yield* wsTicketResponse.json) as { |
| 1148 | readonly token: string; | 1195 | readonly ticket: string; |
| 1149 | readonly expiresAt: string; | 1196 | readonly expiresAt: string; |
| 1150 | }; | 1197 | }; |
| 1151 | 1198 | ||
| 1152 | assert.equal(wsTokenResponse.status, 200); | 1199 | assert.equal(wsTicketResponse.status, 200); |
| 1153 | assert.equal(typeof wsTokenBody.token, "string"); | 1200 | assert.equal(typeof wsTicketBody.ticket, "string"); |
| 1154 | assert.isTrue(wsTokenBody.token.length > 0); | 1201 | assert.isTrue(wsTicketBody.ticket.length > 0); |
| 1155 | assert.equal(typeof wsTokenBody.expiresAt, "string"); | 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 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1269 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1157 | ); | 1270 | ); |
| 1158 | 1271 | ||
| ... | @@ -1161,7 +1274,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1161,7 +1274,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1161 | yield* buildAppUnderTest(); | 1274 | yield* buildAppUnderTest(); |
| 1162 | 1275 | ||
| 1163 | const origin = crossOriginClientOrigin; | 1276 | const origin = crossOriginClientOrigin; |
| 1164 | const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBearerSession( | 1277 | const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken( |
| 1165 | defaultDesktopBootstrapToken, | 1278 | defaultDesktopBootstrapToken, |
| 1166 | { | 1279 | { |
| 1167 | headers: { origin }, | 1280 | headers: { origin }, |
| ... | @@ -1169,13 +1282,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1169,13 +1282,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1169 | ); | 1282 | ); |
| 1170 | 1283 | ||
| 1171 | assert.equal(bootstrapResponse.status, 200); | 1284 | assert.equal(bootstrapResponse.status, 200); |
| 1172 | assertBrowserApiCorsHeaders(bootstrapResponse.headers); | 1285 | assertBrowserApiCorsResponseHeaders(bootstrapResponse.headers); |
| 1173 | assert.equal(bootstrapBody.authenticated, true); | 1286 | assert.equal(tokenBody.token_type, "Bearer"); |
| 1174 | assert.equal(typeof bootstrapBody.sessionToken, "string"); | 1287 | assert.equal(typeof tokenBody.access_token, "string"); |
| 1175 | 1288 | ||
| 1176 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | 1289 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { |
| 1177 | headers: { | 1290 | headers: { |
| 1178 | authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, | 1291 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, |
| 1179 | origin, | 1292 | origin, |
| 1180 | }, | 1293 | }, |
| 1181 | }); | 1294 | }); |
| ... | @@ -1185,33 +1298,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1185,33 +1298,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1185 | }; | 1298 | }; |
| 1186 | 1299 | ||
| 1187 | assert.equal(sessionResponse.status, 200); | 1300 | assert.equal(sessionResponse.status, 200); |
| 1188 | assertBrowserApiCorsHeaders(sessionResponse.headers); | 1301 | assertBrowserApiCorsResponseHeaders(sessionResponse.headers); |
| 1189 | assert.equal(sessionBody.authenticated, true); | 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 | headers: { | 1306 | headers: { |
| 1194 | authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, | 1307 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, |
| 1195 | origin, | 1308 | origin, |
| 1196 | }, | 1309 | }, |
| 1197 | }); | 1310 | }); |
| 1198 | const wsTokenBody = (yield* wsTokenResponse.json) as { | 1311 | const wsTicketBody = (yield* wsTicketResponse.json) as { |
| 1199 | readonly token: string; | 1312 | readonly ticket: string; |
| 1200 | }; | 1313 | }; |
| 1201 | 1314 | ||
| 1202 | assert.equal(wsTokenResponse.status, 200); | 1315 | assert.equal(wsTicketResponse.status, 200); |
| 1203 | assertBrowserApiCorsHeaders(wsTokenResponse.headers); | 1316 | assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers); |
| 1204 | assert.equal(typeof wsTokenBody.token, "string"); | 1317 | assert.equal(typeof wsTicketBody.ticket, "string"); |
| 1205 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1318 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1206 | ); | 1319 | ); |
| 1207 | 1320 | ||
| 1208 | it.effect( | 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 | Effect.gen(function* () { | 1324 | Effect.gen(function* () { |
| 1212 | yield* buildAppUnderTest(); | 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 | headers: { | 1328 | headers: { |
| 1216 | origin: crossOriginClientOrigin, | 1329 | origin: crossOriginClientOrigin, |
| 1217 | "access-control-request-method": "POST", | 1330 | "access-control-request-method": "POST", |
| ... | @@ -1220,26 +1333,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1220,26 +1333,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1220 | }); | 1333 | }); |
| 1221 | 1334 | ||
| 1222 | assert.equal(response.status, 204); | 1335 | assert.equal(response.status, 204); |
| 1223 | assertBrowserApiCorsHeaders(response.headers); | 1336 | assertBrowserApiCorsPreflightHeaders(response.headers); |
| 1224 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | Effect.gen(function* () { | 1341 | Effect.gen(function* () { |
| 1229 | yield* buildAppUnderTest(); | 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 | headers: { | 1345 | headers: { |
| 1233 | origin: crossOriginClientOrigin, | 1346 | origin: crossOriginClientOrigin, |
| 1234 | }, | 1347 | }, |
| 1235 | }); | 1348 | }); |
| 1236 | const body = (yield* response.json) as { | 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 | assert.equal(response.status, 401); | 1356 | assert.equal(response.status, 401); |
| 1241 | assertBrowserApiCorsHeaders(response.headers); | 1357 | assertBrowserApiCorsResponseHeaders(response.headers); |
| 1242 | assert.equal(body.error, "Authentication required."); | 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 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1423 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1244 | ); | 1424 | ); |
| 1245 | 1425 | ||
| ... | @@ -1251,6 +1431,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1251,6 +1431,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1251 | headers: { | 1431 | headers: { |
| 1252 | cookie: yield* getAuthenticatedSessionCookieHeader(), | 1432 | cookie: yield* getAuthenticatedSessionCookieHeader(), |
| 1253 | }, | 1433 | }, |
| 1434 | body: yield* HttpBody.json({}), | ||
| 1254 | }); | 1435 | }); |
| 1255 | const body = (yield* response.json) as { | 1436 | const body = (yield* response.json) as { |
| 1256 | readonly credential: string; | 1437 | readonly credential: string; |
| ... | @@ -1270,16 +1451,61 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1270,16 +1451,61 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1270 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("rejects unauthenticated pairing credential requests", () => | 1497 | it.effect("rejects unauthenticated pairing credential requests", () => |
| 1274 | Effect.gen(function* () { | 1498 | Effect.gen(function* () { |
| 1275 | yield* buildAppUnderTest(); | 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 | assert.equal(response.status, 401); | 1504 | assert.equal(response.status, 401); |
| 1279 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | Effect.gen(function* () { | 1509 | Effect.gen(function* () { |
| 1284 | yield* buildAppUnderTest({ | 1510 | yield* buildAppUnderTest({ |
| 1285 | config: { | 1511 | config: { |
| ... | @@ -1292,6 +1518,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1292,6 +1518,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1292 | headers: { | 1518 | headers: { |
| 1293 | cookie: ownerCookie, | 1519 | cookie: ownerCookie, |
| 1294 | }, | 1520 | }, |
| 1521 | body: yield* HttpBody.json({}), | ||
| 1295 | }); | 1522 | }); |
| 1296 | const createdBody = (yield* createdResponse.json) as { | 1523 | const createdBody = (yield* createdResponse.json) as { |
| 1297 | readonly id: string; | 1524 | readonly id: string; |
| ... | @@ -1324,7 +1551,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1324,7 +1551,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1324 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | Effect.gen(function* () { | 1555 | Effect.gen(function* () { |
| 1329 | yield* buildAppUnderTest({ | 1556 | yield* buildAppUnderTest({ |
| 1330 | config: { | 1557 | config: { |
| ... | @@ -1336,6 +1563,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1336,6 +1563,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1336 | headers: { | 1563 | headers: { |
| 1337 | cookie: yield* getAuthenticatedSessionCookieHeader(), | 1564 | cookie: yield* getAuthenticatedSessionCookieHeader(), |
| 1338 | }, | 1565 | }, |
| 1566 | body: yield* HttpBody.json({}), | ||
| 1339 | }); | 1567 | }); |
| 1340 | const ownerBody = (yield* ownerResponse.json) as { | 1568 | const ownerBody = (yield* ownerResponse.json) as { |
| 1341 | readonly credential: string; | 1569 | readonly credential: string; |
| ... | @@ -1347,17 +1575,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1347,17 +1575,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1347 | headers: { | 1575 | headers: { |
| 1348 | cookie: pairedSessionCookie, | 1576 | cookie: pairedSessionCookie, |
| 1349 | }, | 1577 | }, |
| 1578 | body: yield* HttpBody.json({}), | ||
| 1350 | }); | 1579 | }); |
| 1351 | const pairedBody = (yield* pairedResponse.json) as { | 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 | assert.equal(pairedResponse.status, 403); | 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 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | Effect.gen(function* () { | 1596 | Effect.gen(function* () { |
| 1362 | yield* buildAppUnderTest({ | 1597 | yield* buildAppUnderTest({ |
| 1363 | config: { | 1598 | config: { |
| ... | @@ -1429,9 +1664,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1429,9 +1664,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1429 | headers: { | 1664 | headers: { |
| 1430 | cookie: pairedSessionCookieHeader, | 1665 | cookie: pairedSessionCookieHeader, |
| 1431 | }, | 1666 | }, |
| 1667 | body: yield* HttpBody.json({}), | ||
| 1432 | }); | 1668 | }); |
| 1433 | const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as { | 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 | assert.equal(listBeforeResponse.status, 200); | 1676 | assert.equal(listBeforeResponse.status, 200); |
| ... | @@ -1452,7 +1691,69 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1452,7 +1691,69 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1452 | assert.lengthOf(clientsAfter, 1); | 1691 | assert.lengthOf(clientsAfter, 1); |
| 1453 | assert.equal(clientsAfter[0]?.current, true); | 1692 | assert.equal(clientsAfter[0]?.current, true); |
| 1454 | assert.equal(pairedClientPairingResponse.status, 401); | 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 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1757 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1457 | ); | 1758 | ); |
| 1458 | 1759 | ||
| ... | @@ -1469,6 +1770,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1469,6 +1770,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1469 | headers: { | 1770 | headers: { |
| 1470 | cookie: ownerCookie, | 1771 | cookie: ownerCookie, |
| 1471 | }, | 1772 | }, |
| 1773 | body: yield* HttpBody.json({}), | ||
| 1472 | }); | 1774 | }); |
| 1473 | const pairingBody = (yield* pairingResponse.json) as { | 1775 | const pairingBody = (yield* pairingResponse.json) as { |
| 1474 | readonly credential: string; | 1776 | readonly credential: string; |
| ... | @@ -1499,6 +1801,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1499,6 +1801,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1499 | headers: { | 1801 | headers: { |
| 1500 | cookie: pairedSessionCookie, | 1802 | cookie: pairedSessionCookie, |
| 1501 | }, | 1803 | }, |
| 1804 | body: yield* HttpBody.json({}), | ||
| 1502 | }); | 1805 | }); |
| 1503 | 1806 | ||
| 1504 | assert.equal(revokeResponse.status, 200); | 1807 | assert.equal(revokeResponse.status, 200); |
| ... | @@ -1515,10 +1818,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1515,10 +1818,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1515 | 1818 | ||
| 1516 | assert.equal(first.response.status, 200); | 1819 | assert.equal(first.response.status, 200); |
| 1517 | assert.equal(second.response.status, 401); | 1820 | assert.equal(second.response.status, 401); |
| 1518 | assert.equal( | 1821 | assert.equal((second.body as { readonly _tag?: string })._tag, "EnvironmentAuthInvalidError"); |
| 1519 | (second.body as { readonly error?: string }).error, | 1822 | assert.equal((second.body as { readonly code?: string }).code, "auth_invalid"); |
| 1520 | "Invalid bootstrap credential.", | 1823 | assert.equal((second.body as { readonly reason?: string }).reason, "invalid_credential"); |
| 1521 | ); | ||
| 1522 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1824 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1523 | ); | 1825 | ); |
| 1524 | 1826 | ||
| ... | @@ -1594,15 +1896,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1594,15 +1896,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1594 | yield* buildAppUnderTest(); | 1896 | yield* buildAppUnderTest(); |
| 1595 | 1897 | ||
| 1596 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | 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 | headers: { | 1900 | headers: { |
| 1599 | authorization: `Bearer ${bearerToken}`, | 1901 | authorization: `Bearer ${bearerToken}`, |
| 1600 | }, | 1902 | }, |
| 1601 | }); | 1903 | }); |
| 1602 | const wsTokenBody = (yield* wsTokenResponse.json) as { | 1904 | const wsTicketBody = (yield* wsTicketResponse.json) as { |
| 1603 | readonly token: string; | 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 | const response = yield* Effect.scoped( | 1909 | const response = yield* Effect.scoped( |
| 1608 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), | 1910 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), |
apps/server/src/server.ts+15-34| ... | @@ -1,13 +1,15 @@ | ... | @@ -1,13 +1,15 @@ |
| 1 | import { EnvironmentHttpApi } from "@t3tools/contracts"; | ||
| 1 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 2 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 3 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | 4 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; |
| 5 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ||
| 4 | 6 | ||
| 5 | import { ServerConfig } from "./config.ts"; | 7 | import { ServerConfig } from "./config.ts"; |
| 6 | import { | 8 | import { |
| 7 | attachmentsRouteLayer, | 9 | attachmentsRouteLayer, |
| 8 | otlpTracesProxyRouteLayer, | 10 | otlpTracesProxyRouteLayer, |
| 9 | projectFaviconRouteLayer, | 11 | projectFaviconRouteLayer, |
| 10 | serverEnvironmentRouteLayer, | 12 | serverEnvironmentHttpApiLayer, |
| 11 | staticAndDevRouteLayer, | 13 | staticAndDevRouteLayer, |
| 12 | browserApiCorsLayer, | 14 | browserApiCorsLayer, |
| 13 | } from "./http.ts"; | 15 | } from "./http.ts"; |
| ... | @@ -62,20 +64,9 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe | ... | @@ -62,20 +64,9 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe |
| 62 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; | 64 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; |
| 63 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; | 65 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 64 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; | 66 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; |
| 65 | import { | 67 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 66 | authBearerBootstrapRouteLayer, | 68 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 67 | authBootstrapRouteLayer, | 69 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 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"; | ||
| 79 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 70 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 80 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 71 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 81 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 72 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | @@ -85,10 +76,7 @@ import { | ... | @@ -85,10 +76,7 @@ import { |
| 85 | makePersistedServerRuntimeState, | 76 | makePersistedServerRuntimeState, |
| 86 | persistServerRuntimeState, | 77 | persistServerRuntimeState, |
| 87 | } from "./serverRuntimeState.ts"; | 78 | } from "./serverRuntimeState.ts"; |
| 88 | import { | 79 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; |
| 89 | orchestrationDispatchRouteLayer, | ||
| 90 | orchestrationSnapshotRouteLayer, | ||
| 91 | } from "./orchestration/http.ts"; | ||
| 92 | import * as NetService from "@t3tools/shared/Net"; | 80 | import * as NetService from "@t3tools/shared/Net"; |
| 93 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; | 81 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; |
| 94 | 82 | ||
| ... | @@ -238,9 +226,9 @@ const WorkspaceLayerLive = Layer.mergeAll( | ... | @@ -238,9 +226,9 @@ const WorkspaceLayerLive = Layer.mergeAll( |
| 238 | WorkspaceFileSystemLayerLive, | 226 | WorkspaceFileSystemLayerLive, |
| 239 | ); | 227 | ); |
| 240 | 228 | ||
| 241 | const AuthLayerLive = ServerAuthLive.pipe( | 229 | const AuthLayerLive = EnvironmentAuth.layer.pipe( |
| 242 | Layer.provideMerge(PersistenceLayerLive), | 230 | Layer.provideMerge(PersistenceLayerLive), |
| 243 | Layer.provide(ServerSecretStoreLive), | 231 | Layer.provide(ServerSecretStore.layer), |
| 244 | ); | 232 | ); |
| 245 | 233 | ||
| 246 | const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( | 234 | const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( |
| ... | @@ -301,22 +289,15 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( | ... | @@ -301,22 +289,15 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( |
| 301 | ); | 289 | ); |
| 302 | 290 | ||
| 303 | export const makeRoutesLayer = Layer.mergeAll( | 291 | export const makeRoutesLayer = Layer.mergeAll( |
| 304 | authBearerBootstrapRouteLayer, | 292 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( |
| 305 | authBootstrapRouteLayer, | 293 | Layer.provide(authHttpApiLayer), |
| 306 | authClientsRevokeOthersRouteLayer, | 294 | Layer.provide(orchestrationHttpApiLayer), |
| 307 | authClientsRevokeRouteLayer, | 295 | Layer.provide(serverEnvironmentHttpApiLayer), |
| 308 | authClientsRouteLayer, | 296 | Layer.provide(environmentAuthenticatedAuthLayer), |
| 309 | authPairingLinksRevokeRouteLayer, | 297 | ), |
| 310 | authPairingLinksRouteLayer, | ||
| 311 | authPairingCredentialRouteLayer, | ||
| 312 | authSessionRouteLayer, | ||
| 313 | authWebSocketTokenRouteLayer, | ||
| 314 | attachmentsRouteLayer, | 298 | attachmentsRouteLayer, |
| 315 | orchestrationDispatchRouteLayer, | ||
| 316 | orchestrationSnapshotRouteLayer, | ||
| 317 | otlpTracesProxyRouteLayer, | 299 | otlpTracesProxyRouteLayer, |
| 318 | projectFaviconRouteLayer, | 300 | projectFaviconRouteLayer, |
| 319 | serverEnvironmentRouteLayer, | ||
| 320 | staticAndDevRouteLayer, | 301 | staticAndDevRouteLayer, |
| 321 | websocketRpcRouteLayer, | 302 | websocketRpcRouteLayer, |
| 322 | ).pipe(Layer.provide(browserApiCorsLayer)); | 303 | ).pipe(Layer.provide(browserApiCorsLayer)); |
apps/server/src/serverRuntimeStartup.ts+2-2| ... | @@ -32,7 +32,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; | ... | @@ -32,7 +32,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 32 | import { ServerSettingsService } from "./serverSettings.ts"; | 32 | import { ServerSettingsService } from "./serverSettings.ts"; |
| 33 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | 33 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| 34 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | 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 | import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; | 36 | import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; |
| 37 | import { | 37 | import { |
| 38 | formatHeadlessServeOutput, | 38 | formatHeadlessServeOutput, |
| ... | @@ -244,7 +244,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { | ... | @@ -244,7 +244,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 244 | 244 | ||
| 245 | const resolveStartupBrowserTarget = Effect.gen(function* () { | 245 | const resolveStartupBrowserTarget = Effect.gen(function* () { |
| 246 | const serverConfig = yield* ServerConfig; | 246 | const serverConfig = yield* ServerConfig; |
| 247 | const serverAuth = yield* ServerAuth; | 247 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 248 | const localUrl = `http://localhost:${serverConfig.port}`; | 248 | const localUrl = `http://localhost:${serverConfig.port}`; |
| 249 | const bindUrl = | 249 | const bindUrl = |
| 250 | serverConfig.host && !isWildcardHost(serverConfig.host) | 250 | serverConfig.host && !isWildcardHost(serverConfig.host) |
apps/server/src/serverSettings.ts+3-4| ... | @@ -47,8 +47,7 @@ import { ServerConfig } from "./config.ts"; | ... | @@ -47,8 +47,7 @@ import { ServerConfig } from "./config.ts"; |
| 47 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; | 47 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 48 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; | 48 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 49 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | 49 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 50 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | 50 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 51 | import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; | ||
| 52 | 51 | ||
| 53 | const encodeServerSettings = Schema.encodeEffect(ServerSettings); | 52 | const encodeServerSettings = Schema.encodeEffect(ServerSettings); |
| 54 | const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); | 53 | const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); |
| ... | @@ -257,7 +256,7 @@ const makeServerSettings = Effect.gen(function* () { | ... | @@ -257,7 +256,7 @@ const makeServerSettings = Effect.gen(function* () { |
| 257 | const { settingsPath } = yield* ServerConfig; | 256 | const { settingsPath } = yield* ServerConfig; |
| 258 | const fs = yield* FileSystem.FileSystem; | 257 | const fs = yield* FileSystem.FileSystem; |
| 259 | const pathService = yield* Path.Path; | 258 | const pathService = yield* Path.Path; |
| 260 | const secretStore = yield* ServerSecretStore; | 259 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 261 | const writeSemaphore = yield* Semaphore.make(1); | 260 | const writeSemaphore = yield* Semaphore.make(1); |
| 262 | const cacheKey = "settings" as const; | 261 | const cacheKey = "settings" as const; |
| 263 | const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); | 262 | const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); |
| ... | @@ -580,5 +579,5 @@ const makeServerSettings = Effect.gen(function* () { | ... | @@ -580,5 +579,5 @@ const makeServerSettings = Effect.gen(function* () { |
| 580 | }); | 579 | }); |
| 581 | 580 | ||
| 582 | export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe( | 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,7 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | import { HttpServer } from "effect/unstable/http"; | 5 | import { HttpServer } from "effect/unstable/http"; |
| 6 | 6 | ||
| 7 | import { ServerConfig } from "./config.ts"; | 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 | export interface HeadlessServeAccessInfo { | 10 | export interface HeadlessServeAccessInfo { |
| 11 | readonly connectionString: string; | 11 | readonly connectionString: string; |
| ... | @@ -133,12 +133,12 @@ export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): | ... | @@ -133,12 +133,12 @@ export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): |
| 133 | export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () { | 133 | export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () { |
| 134 | const serverConfig = yield* ServerConfig; | 134 | const serverConfig = yield* ServerConfig; |
| 135 | const httpServer = yield* HttpServer.HttpServer; | 135 | const httpServer = yield* HttpServer.HttpServer; |
| 136 | const serverAuth = yield* ServerAuth; | 136 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 137 | const connectionString = resolveHeadlessConnectionString( | 137 | const connectionString = resolveHeadlessConnectionString( |
| 138 | serverConfig.host, | 138 | serverConfig.host, |
| 139 | resolveListeningPort(httpServer.address, serverConfig.port), | 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 | return { | 143 | return { |
| 144 | connectionString, | 144 | connectionString, |
apps/server/src/ws.ts+163-28| ... | @@ -11,7 +11,14 @@ import * as Schema from "effect/Schema"; | ... | @@ -11,7 +11,14 @@ import * as Schema from "effect/Schema"; |
| 11 | import * as Stream from "effect/Stream"; | 11 | import * as Stream from "effect/Stream"; |
| 12 | import { | 12 | import { |
| 13 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, | 13 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, |
| 14 | AuthOrchestrationOperateScope, | ||
| 15 | AuthOrchestrationReadScope, | ||
| 16 | AuthReviewWriteScope, | ||
| 17 | AuthTerminalOperateScope, | ||
| 18 | AuthAccessReadScope, | ||
| 19 | AuthAccessStreamError, | ||
| 14 | type AuthAccessStreamEvent, | 20 | type AuthAccessStreamEvent, |
| 21 | type AuthEnvironmentScope, | ||
| 15 | AuthSessionId, | 22 | AuthSessionId, |
| 16 | CommandId, | 23 | CommandId, |
| 17 | EventId, | 24 | EventId, |
| ... | @@ -29,6 +36,7 @@ import { | ... | @@ -29,6 +36,7 @@ import { |
| 29 | ProjectWriteFileError, | 36 | ProjectWriteFileError, |
| 30 | OrchestrationReplayEventsError, | 37 | OrchestrationReplayEventsError, |
| 31 | FilesystemBrowseError, | 38 | FilesystemBrowseError, |
| 39 | EnvironmentAuthorizationError, | ||
| 32 | ThreadId, | 40 | ThreadId, |
| 33 | type TerminalAttachStreamEvent, | 41 | type TerminalAttachStreamEvent, |
| 34 | type TerminalError, | 42 | type TerminalError, |
| ... | @@ -38,7 +46,7 @@ import { | ... | @@ -38,7 +46,7 @@ import { |
| 38 | WsRpcGroup, | 46 | WsRpcGroup, |
| 39 | } from "@t3tools/contracts"; | 47 | } from "@t3tools/contracts"; |
| 40 | import { clamp } from "effect/Number"; | 48 | import { clamp } from "effect/Number"; |
| 41 | import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; | 49 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; |
| 42 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | 50 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 43 | 51 | ||
| 44 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; | 52 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; |
| ... | @@ -49,9 +57,9 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; | ... | @@ -49,9 +57,9 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; |
| 49 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; | 57 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; |
| 50 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 58 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 51 | import { | 59 | import { |
| 52 | observeRpcEffect, | 60 | observeRpcEffect as instrumentRpcEffect, |
| 53 | observeRpcStream, | 61 | observeRpcStream as instrumentRpcStream, |
| 54 | observeRpcStreamEffect, | 62 | observeRpcStreamEffect as instrumentRpcStreamEffect, |
| 55 | } from "./observability/RpcInstrumentation.ts"; | 63 | } from "./observability/RpcInstrumentation.ts"; |
| 56 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; | 64 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; |
| 57 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; | 65 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| ... | @@ -69,7 +77,8 @@ import { ReviewService } from "./review/ReviewService.ts"; | ... | @@ -69,7 +77,8 @@ import { ReviewService } from "./review/ReviewService.ts"; |
| 69 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; | 77 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; |
| 70 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; | 78 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; |
| 71 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | 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 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 82 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 74 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 83 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 75 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 84 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | @@ -84,15 +93,9 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ... | @@ -84,15 +93,9 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 84 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; | 93 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 85 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; | 94 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; |
| 86 | import * as VcsProcess from "./vcs/VcsProcess.ts"; | 95 | import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 87 | import { | 96 | import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; |
| 88 | BootstrapCredentialService, | 97 | import * as SessionStore from "./auth/SessionStore.ts"; |
| 89 | type BootstrapCredentialChange, | 98 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; |
| 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 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); | 99 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); |
| 97 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); | 100 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); |
| 98 | 101 | ||
| ... | @@ -122,8 +125,62 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< | ... | @@ -122,8 +125,62 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< |
| 122 | 125 | ||
| 123 | const PROVIDER_STATUS_DEBOUNCE_MS = 200; | 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 | function toAuthAccessStreamEvent( | 182 | function toAuthAccessStreamEvent( |
| 126 | change: BootstrapCredentialChange | SessionCredentialChange, | 183 | change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, |
| 127 | revision: number, | 184 | revision: number, |
| 128 | currentSessionId: AuthSessionId, | 185 | currentSessionId: AuthSessionId, |
| 129 | ): AuthAccessStreamEvent { | 186 | ): AuthAccessStreamEvent { |
| ... | @@ -162,9 +219,10 @@ function toAuthAccessStreamEvent( | ... | @@ -162,9 +219,10 @@ function toAuthAccessStreamEvent( |
| 162 | } | 219 | } |
| 163 | } | 220 | } |
| 164 | 221 | ||
| 165 | const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | 222 | const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 166 | WsRpcGroup.toLayer( | 223 | WsRpcGroup.toLayer( |
| 167 | Effect.gen(function* () { | 224 | Effect.gen(function* () { |
| 225 | const currentSessionId = currentSession.sessionId; | ||
| 168 | const crypto = yield* Crypto.Crypto; | 226 | const crypto = yield* Crypto.Crypto; |
| 169 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 227 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 170 | const orchestrationEngine = yield* OrchestrationEngineService; | 228 | const orchestrationEngine = yield* OrchestrationEngineService; |
| ... | @@ -187,7 +245,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -187,7 +245,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 187 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | 245 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; |
| 188 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | 246 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; |
| 189 | const serverEnvironment = yield* ServerEnvironment; | 247 | const serverEnvironment = yield* ServerEnvironment; |
| 190 | const serverAuth = yield* ServerAuth; | 248 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 191 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; | 249 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; |
| 192 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( | 250 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( |
| 193 | Effect.map((settings) => settings.automaticGitFetchInterval), | 251 | Effect.map((settings) => settings.automaticGitFetchInterval), |
| ... | @@ -198,10 +256,70 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -198,10 +256,70 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 198 | ), | 256 | ), |
| 199 | ); | 257 | ); |
| 200 | const sourceControlRepositories = yield* SourceControlRepositoryService; | 258 | const sourceControlRepositories = yield* SourceControlRepositoryService; |
| 201 | const bootstrapCredentials = yield* BootstrapCredentialService; | 259 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 202 | const sessions = yield* SessionCredentialService; | 260 | const sessions = yield* SessionStore.SessionStore; |
| 203 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; | 261 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 204 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; | 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 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => | 323 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => |
| 206 | isOrchestrationDispatchCommandError(cause) | 324 | isOrchestrationDispatchCommandError(cause) |
| 207 | ? cause | 325 | ? cause |
| ... | @@ -220,9 +338,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -220,9 +338,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 220 | 338 | ||
| 221 | const loadAuthAccessSnapshot = () => | 339 | const loadAuthAccessSnapshot = () => |
| 222 | Effect.all({ | 340 | Effect.all({ |
| 223 | pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie), | 341 | pairingLinks: serverAuth.listPairingLinks(), |
| 224 | clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie), | 342 | clientSessions: serverAuth.listClientSessions(currentSessionId), |
| 225 | }); | 343 | }).pipe( |
| 344 | Effect.mapError( | ||
| 345 | (error) => | ||
| 346 | new AuthAccessStreamError({ | ||
| 347 | message: error.message, | ||
| 348 | }), | ||
| 349 | ), | ||
| 350 | ); | ||
| 226 | 351 | ||
| 227 | const appendSetupScriptActivity = (input: { | 352 | const appendSetupScriptActivity = (input: { |
| 228 | readonly threadId: ThreadId; | 353 | readonly threadId: ThreadId; |
| ... | @@ -1258,7 +1383,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -1258,7 +1383,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 1258 | const initialSnapshot = yield* loadAuthAccessSnapshot(); | 1383 | const initialSnapshot = yield* loadAuthAccessSnapshot(); |
| 1259 | const revisionRef = yield* Ref.make(1); | 1384 | const revisionRef = yield* Ref.make(1); |
| 1260 | const accessChanges: Stream.Stream< | 1385 | const accessChanges: Stream.Stream< |
| 1261 | BootstrapCredentialChange | SessionCredentialChange | 1386 | PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange |
| 1262 | > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); | 1387 | > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); |
| 1263 | 1388 | ||
| 1264 | const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe( | 1389 | const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe( |
| ... | @@ -1294,14 +1419,19 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1294,14 +1419,19 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1294 | "/ws", | 1419 | "/ws", |
| 1295 | Effect.gen(function* () { | 1420 | Effect.gen(function* () { |
| 1296 | const request = yield* HttpServerRequest.HttpServerRequest; | 1421 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 1297 | const serverAuth = yield* ServerAuth; | 1422 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 1298 | const sessions = yield* SessionCredentialService; | 1423 | const sessions = yield* SessionStore.SessionStore; |
| 1299 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request); | 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 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { | 1430 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { |
| 1301 | disableTracing: true, | 1431 | disableTracing: true, |
| 1302 | }).pipe( | 1432 | }).pipe( |
| 1303 | Effect.provide( | 1433 | Effect.provide( |
| 1304 | makeWsRpcLayer(session.sessionId).pipe( | 1434 | makeWsRpcLayer(session).pipe( |
| 1305 | Layer.provideMerge(RpcSerialization.layerJson), | 1435 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1306 | Layer.provide(ProviderMaintenanceRunner.layer), | 1436 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 1307 | Layer.provide( | 1437 | Layer.provide( |
| ... | @@ -1333,7 +1463,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1333,7 +1463,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1333 | () => rpcWebSocketHttpEffect, | 1463 | () => rpcWebSocketHttpEffect, |
| 1334 | () => sessions.markDisconnected(session.sessionId), | 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,6 +43,7 @@ |
| 43 | "zustand": "^5.0.11" | 43 | "zustand": "^5.0.11" |
| 44 | }, | 44 | }, |
| 45 | "devDependencies": { | 45 | "devDependencies": { |
| 46 | "@effect/platform-node": "catalog:", | ||
| 46 | "@rolldown/plugin-babel": "^0.2.0", | 47 | "@rolldown/plugin-babel": "^0.2.0", |
| 47 | "@tailwindcss/vite": "^4.0.0", | 48 | "@tailwindcss/vite": "^4.0.0", |
| 48 | "@tanstack/router-plugin": "^1.161.0", | 49 | "@tanstack/router-plugin": "^1.161.0", |
| ... | @@ -51,7 +52,7 @@ | ... | @@ -51,7 +52,7 @@ |
| 51 | "@types/react-dom": "~19.2.3", | 52 | "@types/react-dom": "~19.2.3", |
| 52 | "@vercel/config": "^0.3.0", | 53 | "@vercel/config": "^0.3.0", |
| 53 | "@vitejs/plugin-react": "^6.0.0", | 54 | "@vitejs/plugin-react": "^6.0.0", |
| 54 | "@vitest/browser-playwright": "^4.0.18", | 55 | "@vitest/browser-playwright": "catalog:", |
| 55 | "babel-plugin-react-compiler": "1.0.0", | 56 | "babel-plugin-react-compiler": "1.0.0", |
| 56 | "msw": "2.12.11", | 57 | "msw": "2.12.11", |
| 57 | "playwright": "^1.58.2", | 58 | "playwright": "^1.58.2", |
apps/web/src/authBootstrap.test.ts+174-316| ... | @@ -1,15 +1,17 @@ | ... | @@ -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 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 13 | ||
| 4 | function jsonResponse(body: unknown, init?: ResponseInit) { | 14 | import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; |
| 5 | return new Response(JSON.stringify(body), { | ||
| 6 | headers: { | ||
| 7 | "content-type": "application/json", | ||
| 8 | }, | ||
| 9 | status: 200, | ||
| 10 | ...init, | ||
| 11 | }); | ||
| 12 | } | ||
| 13 | 15 | ||
| 14 | type TestWindow = { | 16 | type TestWindow = { |
| 15 | location: URL; | 17 | location: URL; |
| ... | @@ -19,6 +21,42 @@ type TestWindow = { | ... | @@ -19,6 +21,42 @@ type TestWindow = { |
| 19 | desktopBridge?: DesktopBridge; | 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 | function installTestBrowser(url: string) { | 60 | function installTestBrowser(url: string) { |
| 23 | const testWindow: TestWindow = { | 61 | const testWindow: TestWindow = { |
| 24 | location: new URL(url), | 62 | location: new URL(url), |
| ... | @@ -35,8 +73,36 @@ function installTestBrowser(url: string) { | ... | @@ -35,8 +73,36 @@ function installTestBrowser(url: string) { |
| 35 | return testWindow; | 73 | return testWindow; |
| 36 | } | 74 | } |
| 37 | 75 | ||
| 38 | function sessionResponse(body: unknown, init?: ResponseInit) { | 76 | function sequence<A>(...values: ReadonlyArray<A>) { |
| 39 | return jsonResponse(body, init); | 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 | describe("resolveInitialServerAuthGateState", () => { | 108 | describe("resolveInitialServerAuthGateState", () => { |
| ... | @@ -47,6 +113,8 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -47,6 +113,8 @@ describe("resolveInitialServerAuthGateState", () => { |
| 47 | }); | 113 | }); |
| 48 | 114 | ||
| 49 | afterEach(async () => { | 115 | afterEach(async () => { |
| 116 | await disposeHttpTest?.(); | ||
| 117 | disposeHttpTest = undefined; | ||
| 50 | const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); | 118 | const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); |
| 51 | __resetServerAuthBootstrapForTests(); | 119 | __resetServerAuthBootstrapForTests(); |
| 52 | vi.unstubAllEnvs(); | 120 | vi.unstubAllEnvs(); |
| ... | @@ -55,40 +123,14 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -55,40 +123,14 @@ describe("resolveInitialServerAuthGateState", () => { |
| 55 | }); | 123 | }); |
| 56 | 124 | ||
| 57 | it("reuses an in-flight silent bootstrap attempt", async () => { | 125 | it("reuses an in-flight silent bootstrap attempt", async () => { |
| 58 | const fetchMock = vi | 126 | const nextSession = sequence( |
| 59 | .fn<typeof fetch>() | 127 | unauthenticatedSession(DESKTOP_AUTH), |
| 60 | .mockResolvedValueOnce( | 128 | authenticatedSession(DESKTOP_AUTH), |
| 61 | sessionResponse({ | 129 | ); |
| 62 | authenticated: false, | 130 | const testApi = await installAuthApi({ |
| 63 | auth: { | 131 | session: nextSession, |
| 64 | policy: "desktop-managed-local", | 132 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), |
| 65 | bootstrapMethods: ["desktop-bootstrap"], | 133 | }); |
| 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); | ||
| 92 | 134 | ||
| 93 | const testWindow = installTestBrowser("http://localhost/"); | 135 | const testWindow = installTestBrowser("http://localhost/"); |
| 94 | testWindow.desktopBridge = { | 136 | testWindow.desktopBridge = { |
| ... | @@ -104,90 +146,45 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -104,90 +146,45 @@ describe("resolveInitialServerAuthGateState", () => { |
| 104 | 146 | ||
| 105 | await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); | 147 | await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); |
| 106 | 148 | ||
| 107 | expect(fetchMock).toHaveBeenCalledTimes(3); | 149 | expect(testApi.calls.session).toBe(2); |
| 108 | expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3773/api/auth/session"); | 150 | expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); |
| 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"); | ||
| 111 | }); | 151 | }); |
| 112 | 152 | ||
| 113 | it("uses https fetch urls when the primary environment uses wss", async () => { | 153 | it("uses https urls when the primary environment uses wss", async () => { |
| 114 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | 154 | await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); |
| 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); | ||
| 126 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); | 155 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); |
| 127 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); | 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 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 161 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 132 | status: "requires-auth", | 162 | status: "requires-auth", |
| 133 | auth: { | 163 | auth: LOOPBACK_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", | ||
| 143 | }); | 164 | }); |
| 165 | expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( | ||
| 166 | "https://remote.example.com/api/auth/session", | ||
| 167 | ); | ||
| 144 | }); | 168 | }); |
| 145 | 169 | ||
| 146 | it("uses the current origin as an auth proxy base for local dev environments", async () => { | 170 | it("uses the current origin as an auth proxy base for local dev environments", async () => { |
| 147 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | 171 | await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); |
| 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); | ||
| 159 | installTestBrowser("http://localhost:5735/"); | 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 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 177 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 164 | status: "requires-auth", | 178 | status: "requires-auth", |
| 165 | auth: { | 179 | auth: LOOPBACK_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", | ||
| 175 | }); | 180 | }); |
| 181 | expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( | ||
| 182 | "http://localhost:5735/api/auth/session", | ||
| 183 | ); | ||
| 176 | }); | 184 | }); |
| 177 | 185 | ||
| 178 | it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => { | 186 | it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => { |
| 179 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | 187 | await installAuthApi({ session: () => unauthenticatedSession(DESKTOP_AUTH) }); |
| 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); | ||
| 191 | vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); | 188 | vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); |
| 192 | 189 | ||
| 193 | const testWindow = installTestBrowser("http://127.0.0.1:5733/"); | 190 | const testWindow = installTestBrowser("http://127.0.0.1:5733/"); |
| ... | @@ -199,47 +196,25 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -199,47 +196,25 @@ describe("resolveInitialServerAuthGateState", () => { |
| 199 | }), | 196 | }), |
| 200 | } as DesktopBridge; | 197 | } as DesktopBridge; |
| 201 | 198 | ||
| 202 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | 199 | const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = |
| 200 | await import("./environments/primary"); | ||
| 203 | 201 | ||
| 204 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 202 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 205 | status: "requires-auth", | 203 | status: "requires-auth", |
| 206 | auth: { | 204 | auth: DESKTOP_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", | ||
| 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 | it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { | 211 | it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { |
| 220 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | 212 | await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); |
| 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 | |||
| 233 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | 213 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 234 | 214 | ||
| 235 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 215 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 236 | status: "requires-auth", | 216 | status: "requires-auth", |
| 237 | auth: { | 217 | auth: LOOPBACK_AUTH, |
| 238 | policy: "loopback-browser", | ||
| 239 | bootstrapMethods: ["one-time-token"], | ||
| 240 | sessionMethods: ["browser-session-cookie"], | ||
| 241 | sessionCookieName: "t3_session", | ||
| 242 | }, | ||
| 243 | }); | 218 | }); |
| 244 | }); | 219 | }); |
| 245 | 220 | ||
| ... | @@ -251,15 +226,10 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -251,15 +226,10 @@ describe("resolveInitialServerAuthGateState", () => { |
| 251 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) | 226 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) |
| 252 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) | 227 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) |
| 253 | .mockResolvedValueOnce( | 228 | .mockResolvedValueOnce( |
| 254 | sessionResponse({ | 229 | new Response( |
| 255 | authenticated: false, | 230 | JSON.stringify(encodeAuthSessionState(unauthenticatedSession(LOOPBACK_AUTH))), |
| 256 | auth: { | 231 | { status: 200, headers: { "content-type": "application/json" } }, |
| 257 | policy: "loopback-browser", | 232 | ), |
| 258 | bootstrapMethods: ["one-time-token"], | ||
| 259 | sessionMethods: ["browser-session-cookie"], | ||
| 260 | sessionCookieName: "t3_session", | ||
| 261 | }, | ||
| 262 | }), | ||
| 263 | ); | 233 | ); |
| 264 | vi.stubGlobal("fetch", fetchMock); | 234 | vi.stubGlobal("fetch", fetchMock); |
| 265 | 235 | ||
| ... | @@ -270,12 +240,7 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -270,12 +240,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 270 | 240 | ||
| 271 | await expect(gateStatePromise).resolves.toEqual({ | 241 | await expect(gateStatePromise).resolves.toEqual({ |
| 272 | status: "requires-auth", | 242 | status: "requires-auth", |
| 273 | auth: { | 243 | auth: LOOPBACK_AUTH, |
| 274 | policy: "loopback-browser", | ||
| 275 | bootstrapMethods: ["one-time-token"], | ||
| 276 | sessionMethods: ["browser-session-cookie"], | ||
| 277 | sessionCookieName: "t3_session", | ||
| 278 | }, | ||
| 279 | }); | 244 | }); |
| 280 | expect(fetchMock).toHaveBeenCalledTimes(4); | 245 | expect(fetchMock).toHaveBeenCalledTimes(4); |
| 281 | }); | 246 | }); |
| ... | @@ -298,136 +263,60 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -298,136 +263,60 @@ describe("resolveInitialServerAuthGateState", () => { |
| 298 | }); | 263 | }); |
| 299 | 264 | ||
| 300 | it("allows manual token submission after the initial auth check requires pairing", async () => { | 265 | it("allows manual token submission after the initial auth check requires pairing", async () => { |
| 301 | const fetchMock = vi | 266 | const nextSession = sequence( |
| 302 | .fn<typeof fetch>() | 267 | unauthenticatedSession(LOOPBACK_AUTH), |
| 303 | .mockResolvedValueOnce( | 268 | authenticatedSession(LOOPBACK_AUTH), |
| 304 | sessionResponse({ | 269 | ); |
| 305 | authenticated: false, | 270 | const testApi = await installAuthApi({ |
| 306 | auth: { | 271 | session: nextSession, |
| 307 | policy: "loopback-browser", | 272 | browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), |
| 308 | bootstrapMethods: ["one-time-token"], | 273 | }); |
| 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 | |||
| 337 | const { resolveInitialServerAuthGateState, submitServerAuthCredential } = | 274 | const { resolveInitialServerAuthGateState, submitServerAuthCredential } = |
| 338 | await import("./environments/primary"); | 275 | await import("./environments/primary"); |
| 339 | 276 | ||
| 340 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 277 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 341 | status: "requires-auth", | 278 | status: "requires-auth", |
| 342 | auth: { | 279 | auth: LOOPBACK_AUTH, |
| 343 | policy: "loopback-browser", | ||
| 344 | bootstrapMethods: ["one-time-token"], | ||
| 345 | sessionMethods: ["browser-session-cookie"], | ||
| 346 | sessionCookieName: "t3_session", | ||
| 347 | }, | ||
| 348 | }); | 280 | }); |
| 349 | await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); | 281 | await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); |
| 350 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 282 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 351 | status: "authenticated", | 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 | it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { | 289 | it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { |
| 357 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | 290 | const testApi = await installAuthApi({ |
| 358 | jsonResponse( | 291 | browserSession: () => |
| 359 | { | 292 | Effect.fail( |
| 360 | error: "Invalid bootstrap credential.", | 293 | new EnvironmentAuthInvalidError({ |
| 361 | }, | 294 | code: "auth_invalid", |
| 362 | { | 295 | reason: "invalid_credential", |
| 363 | status: 401, | 296 | traceId: "trace-invalid-credential", |
| 364 | }, | 297 | }), |
| 365 | ), | 298 | ), |
| 366 | ); | 299 | }); |
| 367 | vi.stubGlobal("fetch", fetchMock); | ||
| 368 | 300 | ||
| 369 | const { submitServerAuthCredential } = await import("./environments/primary"); | 301 | const { submitServerAuthCredential } = await import("./environments/primary"); |
| 370 | 302 | ||
| 371 | await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( | 303 | await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( |
| 372 | "Invalid pairing token. Check the token and try again.", | 304 | "Invalid pairing token. Check the token and try again.", |
| 373 | ); | 305 | ); |
| 374 | expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/bootstrap", { | 306 | expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); |
| 375 | body: JSON.stringify({ credential: "bad-token" }), | ||
| 376 | credentials: "include", | ||
| 377 | headers: { | ||
| 378 | "content-type": "application/json", | ||
| 379 | }, | ||
| 380 | method: "POST", | ||
| 381 | }); | ||
| 382 | }); | 307 | }); |
| 383 | 308 | ||
| 384 | it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { | 309 | it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { |
| 385 | vi.useFakeTimers(); | 310 | vi.useFakeTimers(); |
| 386 | const fetchMock = vi | 311 | const nextSession = sequence( |
| 387 | .fn<typeof fetch>() | 312 | unauthenticatedSession(DESKTOP_AUTH), |
| 388 | .mockResolvedValueOnce( | 313 | unauthenticatedSession(DESKTOP_AUTH), |
| 389 | sessionResponse({ | 314 | authenticatedSession(DESKTOP_AUTH), |
| 390 | authenticated: false, | 315 | ); |
| 391 | auth: { | 316 | const testApi = await installAuthApi({ |
| 392 | policy: "desktop-managed-local", | 317 | session: nextSession, |
| 393 | bootstrapMethods: ["desktop-bootstrap"], | 318 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), |
| 394 | sessionMethods: ["browser-session-cookie"], | 319 | }); |
| 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); | ||
| 431 | 320 | ||
| 432 | const testWindow = installTestBrowser("http://localhost/"); | 321 | const testWindow = installTestBrowser("http://localhost/"); |
| 433 | testWindow.desktopBridge = { | 322 | testWindow.desktopBridge = { |
| ... | @@ -444,43 +333,14 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -444,43 +333,14 @@ describe("resolveInitialServerAuthGateState", () => { |
| 444 | const gateStatePromise = resolveInitialServerAuthGateState(); | 333 | const gateStatePromise = resolveInitialServerAuthGateState(); |
| 445 | await vi.advanceTimersByTimeAsync(100); | 334 | await vi.advanceTimersByTimeAsync(100); |
| 446 | 335 | ||
| 447 | await expect(gateStatePromise).resolves.toEqual({ | 336 | await expect(gateStatePromise).resolves.toEqual({ status: "authenticated" }); |
| 448 | status: "authenticated", | 337 | expect(testApi.calls.session).toBe(3); |
| 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"); | ||
| 453 | }); | 338 | }); |
| 454 | 339 | ||
| 455 | it("memoizes the authenticated gate state after the first successful read", async () => { | 340 | it("memoizes the authenticated gate state after the first successful read", async () => { |
| 456 | const fetchMock = vi | 341 | const testApi = await installAuthApi({ |
| 457 | .fn<typeof fetch>() | 342 | session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), |
| 458 | .mockResolvedValueOnce( | 343 | }); |
| 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 | |||
| 484 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | 344 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 485 | 345 | ||
| 486 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 346 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| ... | @@ -489,35 +349,33 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -489,35 +349,33 @@ describe("resolveInitialServerAuthGateState", () => { |
| 489 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 349 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 490 | status: "authenticated", | 350 | status: "authenticated", |
| 491 | }); | 351 | }); |
| 492 | expect(fetchMock).toHaveBeenCalledTimes(1); | 352 | expect(testApi.calls.session).toBe(1); |
| 493 | }); | 353 | }); |
| 494 | 354 | ||
| 495 | it("creates a pairing credential from the authenticated auth endpoint", async () => { | 355 | it("creates a pairing credential from the authenticated auth endpoint", async () => { |
| 496 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | 356 | const testApi = await installAuthApi({ |
| 497 | jsonResponse({ | 357 | pairingCredential: (payload) => |
| 498 | id: "pairing-link-1", | 358 | Effect.succeed({ |
| 499 | credential: "pairing-token", | 359 | id: "pairing-link-1", |
| 500 | label: "Julius iPhone", | 360 | credential: "pairing-token", |
| 501 | expiresAt: "2026-04-05T00:00:00.000Z", | 361 | ...(payload.label === undefined ? {} : { label: payload.label }), |
| 502 | }), | 362 | expiresAt: SESSION_EXPIRES_AT, |
| 503 | ); | 363 | }), |
| 504 | vi.stubGlobal("fetch", fetchMock); | 364 | }); |
| 505 | |||
| 506 | const { createServerPairingCredential } = await import("./environments/primary"); | 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 | id: "pairing-link-1", | 372 | id: "pairing-link-1", |
| 510 | credential: "pairing-token", | 373 | credential: "pairing-token", |
| 511 | label: "Julius iPhone", | 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,7 +194,7 @@ function createBaseServerConfig(): ServerConfig { |
| 194 | auth: { | 194 | auth: { |
| 195 | policy: "loopback-browser", | 195 | policy: "loopback-browser", |
| 196 | bootstrapMethods: ["one-time-token"], | 196 | bootstrapMethods: ["one-time-token"], |
| 197 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | 197 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], |
| 198 | sessionCookieName: "t3_session", | 198 | sessionCookieName: "t3_session", |
| 199 | }, | 199 | }, |
| 200 | cwd: "/repo/project", | 200 | cwd: "/repo/project", |
apps/web/src/components/KeybindingsToast.browser.tsx+1-1| ... | @@ -96,7 +96,7 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -96,7 +96,7 @@ function createBaseServerConfig(): ServerConfig { |
| 96 | auth: { | 96 | auth: { |
| 97 | policy: "loopback-browser", | 97 | policy: "loopback-browser", |
| 98 | bootstrapMethods: ["one-time-token"], | 98 | bootstrapMethods: ["one-time-token"], |
| 99 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | 99 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], |
| 100 | sessionCookieName: "t3_session", | 100 | sessionCookieName: "t3_session", |
| 101 | }, | 101 | }, |
| 102 | cwd: "/repo/project", | 102 | cwd: "/repo/project", |
apps/web/src/components/settings/ConnectionsSettings.tsx+209-52| ... | @@ -9,7 +9,18 @@ import { | ... | @@ -9,7 +9,18 @@ import { |
| 9 | } from "lucide-react"; | 9 | } from "lucide-react"; |
| 10 | import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; | 10 | import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; |
| 11 | import { | 11 | import { |
| 12 | AuthAccessReadScope, | ||
| 13 | AuthAccessWriteScope, | ||
| 14 | AuthAdministrativeScopes, | ||
| 15 | AuthOrchestrationOperateScope, | ||
| 16 | AuthOrchestrationReadScope, | ||
| 17 | AuthRelayReadScope, | ||
| 18 | AuthRelayWriteScope, | ||
| 19 | AuthReviewWriteScope, | ||
| 20 | AuthStandardClientScopes, | ||
| 21 | AuthTerminalOperateScope, | ||
| 12 | type AuthClientSession, | 22 | type AuthClientSession, |
| 23 | type AuthEnvironmentScope, | ||
| 13 | type AuthPairingLink, | 24 | type AuthPairingLink, |
| 14 | type AdvertisedEndpoint, | 25 | type AdvertisedEndpoint, |
| 15 | type DesktopDiscoveredSshHost, | 26 | type DesktopDiscoveredSshHost, |
| ... | @@ -31,6 +42,7 @@ import { | ... | @@ -31,6 +42,7 @@ import { |
| 31 | useRelativeTimeTick, | 42 | useRelativeTimeTick, |
| 32 | } from "./settingsLayout"; | 43 | } from "./settingsLayout"; |
| 33 | import { Input } from "../ui/input"; | 44 | import { Input } from "../ui/input"; |
| 45 | import { Checkbox } from "../ui/checkbox"; | ||
| 34 | import { | 46 | import { |
| 35 | Dialog, | 47 | Dialog, |
| 36 | DialogClose, | 48 | DialogClose, |
| ... | @@ -75,11 +87,11 @@ import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; | ... | @@ -75,11 +87,11 @@ import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; |
| 75 | import { readHostedPairingRequest } from "../../hostedPairing"; | 87 | import { readHostedPairingRequest } from "../../hostedPairing"; |
| 76 | import { | 88 | import { |
| 77 | createServerPairingCredential, | 89 | createServerPairingCredential, |
| 78 | fetchSessionState, | ||
| 79 | revokeOtherServerClientSessions, | 90 | revokeOtherServerClientSessions, |
| 80 | revokeServerClientSession, | 91 | revokeServerClientSession, |
| 81 | revokeServerPairingLink, | 92 | revokeServerPairingLink, |
| 82 | isLoopbackHostname, | 93 | isLoopbackHostname, |
| 94 | usePrimarySessionState, | ||
| 83 | type ServerClientSessionRecord, | 95 | type ServerClientSessionRecord, |
| 84 | type ServerPairingLinkRecord, | 96 | type ServerPairingLinkRecord, |
| 85 | } from "~/environments/primary"; | 97 | } from "~/environments/primary"; |
| ... | @@ -114,6 +126,97 @@ function formatAccessTimestamp(value: string): string { | ... | @@ -114,6 +126,97 @@ function formatAccessTimestamp(value: string): string { |
| 114 | return accessTimestampFormatter.format(parsed); | 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 | type ConnectionStatusDotProps = { | 220 | type ConnectionStatusDotProps = { |
| 118 | tooltipText?: string | null; | 221 | tooltipText?: string | null; |
| 119 | dotClassName: string; | 222 | dotClassName: string; |
| ... | @@ -629,8 +732,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -629,8 +732,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 629 | 732 | ||
| 630 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); | 733 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); |
| 631 | 734 | ||
| 632 | const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; | 735 | const primaryLabel = pairingLink.label ?? "Pairing link"; |
| 633 | const primaryLabel = pairingLink.label ?? `${roleLabel} link`; | ||
| 634 | const defaultEndpointCopyOption = | 736 | const defaultEndpointCopyOption = |
| 635 | endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? | 737 | endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? |
| 636 | endpointCopyOptions[0] ?? | 738 | endpointCopyOptions[0] ?? |
| ... | @@ -759,7 +861,9 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -759,7 +861,9 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 759 | </Popover> | 861 | </Popover> |
| 760 | </div> | 862 | </div> |
| 761 | <p className="text-xs text-muted-foreground" title={expiresAbsolute}> | 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 | </p> | 867 | </p> |
| 764 | {shareablePairingUrl === null ? ( | 868 | {shareablePairingUrl === null ? ( |
| 765 | <p className="text-[11px] text-muted-foreground/70"> | 869 | <p className="text-[11px] text-muted-foreground/70"> |
| ... | @@ -900,7 +1004,6 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ | ... | @@ -900,7 +1004,6 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 900 | : lastConnectedAt | 1004 | : lastConnectedAt |
| 901 | ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` | 1005 | ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` |
| 902 | : "Not connected yet."; | 1006 | : "Not connected yet."; |
| 903 | const roleLabel = clientSession.role === "owner" ? "Owner" : "Client"; | ||
| 904 | const deviceInfoBits = [ | 1007 | const deviceInfoBits = [ |
| 905 | clientSession.client.deviceType !== "unknown" | 1008 | clientSession.client.deviceType !== "unknown" |
| 906 | ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1) | 1009 | ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1) |
| ... | @@ -932,7 +1035,13 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ | ... | @@ -932,7 +1035,13 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 932 | ) : null} | 1035 | ) : null} |
| 933 | </div> | 1036 | </div> |
| 934 | <p className="text-xs text-muted-foreground"> | 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 | </p> | 1045 | </p> |
| 937 | </div> | 1046 | </div> |
| 938 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 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,13 +1074,17 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 965 | }: AuthorizedClientsHeaderActionProps) { | 1074 | }: AuthorizedClientsHeaderActionProps) { |
| 966 | const [dialogOpen, setDialogOpen] = useState(false); | 1075 | const [dialogOpen, setDialogOpen] = useState(false); |
| 967 | const [pairingLabel, setPairingLabel] = useState(""); | 1076 | const [pairingLabel, setPairingLabel] = useState(""); |
| 1077 | const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([ | ||
| 1078 | ...AuthStandardClientScopes, | ||
| 1079 | ]); | ||
| 968 | const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false); | 1080 | const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false); |
| 969 | 1081 | ||
| 970 | const handleCreatePairingLink = useCallback(async () => { | 1082 | const handleCreatePairingLink = useCallback(async () => { |
| 971 | setIsCreatingPairingLink(true); | 1083 | setIsCreatingPairingLink(true); |
| 972 | try { | 1084 | try { |
| 973 | await createServerPairingCredential(pairingLabel); | 1085 | await createServerPairingCredential({ label: pairingLabel, scopes: pairingScopes }); |
| 974 | setPairingLabel(""); | 1086 | setPairingLabel(""); |
| 1087 | setPairingScopes([...AuthStandardClientScopes]); | ||
| 975 | setDialogOpen(false); | 1088 | setDialogOpen(false); |
| 976 | } catch (error) { | 1089 | } catch (error) { |
| 977 | const message = error instanceof Error ? error.message : "Failed to create pairing URL."; | 1090 | const message = error instanceof Error ? error.message : "Failed to create pairing URL."; |
| ... | @@ -985,7 +1098,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -985,7 +1098,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 985 | } finally { | 1098 | } finally { |
| 986 | setIsCreatingPairingLink(false); | 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 | return ( | 1109 | return ( |
| 991 | <div className="flex items-center gap-2"> | 1110 | <div className="flex items-center gap-2"> |
| ... | @@ -1005,6 +1124,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1005,6 +1124,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1005 | setDialogOpen(open); | 1124 | setDialogOpen(open); |
| 1006 | if (!open) { | 1125 | if (!open) { |
| 1007 | setPairingLabel(""); | 1126 | setPairingLabel(""); |
| 1127 | setPairingScopes([...AuthStandardClientScopes]); | ||
| 1008 | } | 1128 | } |
| 1009 | }} | 1129 | }} |
| 1010 | > | 1130 | > |
| ... | @@ -1016,7 +1136,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1016,7 +1136,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1016 | </Button> | 1136 | </Button> |
| 1017 | } | 1137 | } |
| 1018 | /> | 1138 | /> |
| 1019 | <DialogPopup className="max-w-sm"> | 1139 | <DialogPopup className="max-w-md"> |
| 1020 | <DialogHeader> | 1140 | <DialogHeader> |
| 1021 | <DialogTitle>Create pairing link</DialogTitle> | 1141 | <DialogTitle>Create pairing link</DialogTitle> |
| 1022 | <DialogDescription> | 1142 | <DialogDescription> |
| ... | @@ -1024,7 +1144,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1024,7 +1144,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1024 | authorized client. | 1144 | authorized client. |
| 1025 | </DialogDescription> | 1145 | </DialogDescription> |
| 1026 | </DialogHeader> | 1146 | </DialogHeader> |
| 1027 | <DialogPanel> | 1147 | <DialogPanel className="space-y-5"> |
| 1028 | <label className="block"> | 1148 | <label className="block"> |
| 1029 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | 1149 | <span className="mb-1.5 block text-xs font-medium text-foreground"> |
| 1030 | Client label (optional) | 1150 | Client label (optional) |
| ... | @@ -1037,6 +1157,62 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1037,6 +1157,62 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1037 | autoFocus | 1157 | autoFocus |
| 1038 | /> | 1158 | /> |
| 1039 | </label> | 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 | </DialogPanel> | 1216 | </DialogPanel> |
| 1041 | <DialogFooter variant="bare"> | 1217 | <DialogFooter variant="bare"> |
| 1042 | <Button | 1218 | <Button |
| ... | @@ -1046,7 +1222,10 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -1046,7 +1222,10 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1046 | > | 1222 | > |
| 1047 | Cancel | 1223 | Cancel |
| 1048 | </Button> | 1224 | </Button> |
| 1049 | <Button disabled={isCreatingPairingLink} onClick={() => void handleCreatePairingLink()}> | 1225 | <Button |
| 1226 | disabled={isCreatingPairingLink || pairingScopes.length === 0} | ||
| 1227 | onClick={() => void handleCreatePairingLink()} | ||
| 1228 | > | ||
| 1050 | {isCreatingPairingLink ? "Creating…" : "Create link"} | 1229 | {isCreatingPairingLink ? "Creating…" : "Create link"} |
| 1051 | </Button> | 1230 | </Button> |
| 1052 | </DialogFooter> | 1231 | </DialogFooter> |
| ... | @@ -1290,14 +1469,12 @@ function SavedBackendListRow({ | ... | @@ -1290,14 +1469,12 @@ function SavedBackendListRow({ |
| 1290 | : connectionState === "error" | 1469 | : connectionState === "error" |
| 1291 | ? "bg-destructive" | 1470 | ? "bg-destructive" |
| 1292 | : "bg-muted-foreground/40"; | 1471 | : "bg-muted-foreground/40"; |
| 1293 | const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null; | ||
| 1294 | const descriptorLabel = runtime?.descriptor?.label ?? null; | 1472 | const descriptorLabel = runtime?.descriptor?.label ?? null; |
| 1295 | const displayLabel = descriptorLabel ?? record.label; | 1473 | const displayLabel = descriptorLabel ?? record.label; |
| 1296 | const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); | 1474 | const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); |
| 1297 | const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); | 1475 | const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); |
| 1298 | const metadataBits = [ | 1476 | const metadataBits = [ |
| 1299 | record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, | 1477 | record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, |
| 1300 | roleLabel, | ||
| 1301 | record.lastConnectedAt | 1478 | record.lastConnectedAt |
| 1302 | ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` | 1479 | ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` |
| 1303 | : null, | 1480 | : null, |
| ... | @@ -1317,8 +1494,14 @@ function SavedBackendListRow({ | ... | @@ -1317,8 +1494,14 @@ function SavedBackendListRow({ |
| 1317 | /> | 1494 | /> |
| 1318 | <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3> | 1495 | <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3> |
| 1319 | </div> | 1496 | </div> |
| 1320 | {metadataBits.length > 0 ? ( | 1497 | {metadataBits.length > 0 || runtime?.scopes ? ( |
| 1321 | <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> | 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 | ) : null} | 1505 | ) : null} |
| 1323 | {versionMismatch ? ( | 1506 | {versionMismatch ? ( |
| 1324 | <p className="flex items-center gap-1 text-warning text-xs"> | 1507 | <p className="flex items-center gap-1 text-warning text-xs"> |
| ... | @@ -1401,12 +1584,13 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({ | ... | @@ -1401,12 +1584,13 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({ |
| 1401 | 1584 | ||
| 1402 | export function ConnectionsSettings() { | 1585 | export function ConnectionsSettings() { |
| 1403 | const desktopBridge = window.desktopBridge; | 1586 | const desktopBridge = window.desktopBridge; |
| 1404 | const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>( | 1587 | const primarySessionState = usePrimarySessionState(); |
| 1405 | desktopBridge ? "owner" : null, | 1588 | const currentSessionScopes = desktopBridge |
| 1406 | ); | 1589 | ? AuthAdministrativeScopes |
| 1407 | const [currentAuthPolicy, setCurrentAuthPolicy] = useState< | 1590 | : primarySessionState.data?.authenticated |
| 1408 | "desktop-managed-local" | "loopback-browser" | "remote-reachable" | "unsafe-no-auth" | null | 1591 | ? (primarySessionState.data.scopes ?? null) |
| 1409 | >(desktopBridge ? null : null); | 1592 | : null; |
| 1593 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); | ||
| 1410 | const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId); | 1594 | const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId); |
| 1411 | const savedEnvironmentIds = useMemo( | 1595 | const savedEnvironmentIds = useMemo( |
| 1412 | () => | 1596 | () => |
| ... | @@ -1516,7 +1700,7 @@ export function ConnectionsSettings() { | ... | @@ -1516,7 +1700,7 @@ export function ConnectionsSettings() { |
| 1516 | const setDefaultAdvertisedEndpointKey = useUiStateStore( | 1700 | const setDefaultAdvertisedEndpointKey = useUiStateStore( |
| 1517 | (state) => state.setDefaultAdvertisedEndpointKey, | 1701 | (state) => state.setDefaultAdvertisedEndpointKey, |
| 1518 | ); | 1702 | ); |
| 1519 | const canManageLocalBackend = currentSessionRole === "owner"; | 1703 | const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; |
| 1520 | const isLocalBackendNetworkAccessible = desktopBridge | 1704 | const isLocalBackendNetworkAccessible = desktopBridge |
| 1521 | ? desktopServerExposureState?.mode === "network-accessible" | 1705 | ? desktopServerExposureState?.mode === "network-accessible" |
| 1522 | : currentAuthPolicy === "remote-reachable"; | 1706 | : currentAuthPolicy === "remote-reachable"; |
| ... | @@ -1929,30 +2113,6 @@ export function ConnectionsSettings() { | ... | @@ -1929,30 +2113,6 @@ export function ConnectionsSettings() { |
| 1929 | savedBackendMode, | 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 | useEffect(() => { | 2116 | useEffect(() => { |
| 1957 | if (!canManageLocalBackend) return; | 2117 | if (!canManageLocalBackend) return; |
| 1958 | 2118 | ||
| ... | @@ -2065,10 +2225,7 @@ export function ConnectionsSettings() { | ... | @@ -2065,10 +2225,7 @@ export function ConnectionsSettings() { |
| 2065 | setDesktopAdvertisedEndpoints([]); | 2225 | setDesktopAdvertisedEndpoints([]); |
| 2066 | setDesktopServerExposureError(null); | 2226 | setDesktopServerExposureError(null); |
| 2067 | }, [canManageLocalBackend]); | 2227 | }, [canManageLocalBackend]); |
| 2068 | const visibleDesktopPairingLinks = useMemo( | 2228 | const visibleDesktopPairingLinks = desktopPairingLinks; |
| 2069 | () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"), | ||
| 2070 | [desktopPairingLinks], | ||
| 2071 | ); | ||
| 2072 | const tailscaleHttpsEndpoint = useMemo( | 2229 | const tailscaleHttpsEndpoint = useMemo( |
| 2073 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, | 2230 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, |
| 2074 | [desktopAdvertisedEndpoints], | 2231 | [desktopAdvertisedEndpoints], |
| ... | @@ -2650,8 +2807,8 @@ export function ConnectionsSettings() { | ... | @@ -2650,8 +2807,8 @@ export function ConnectionsSettings() { |
| 2650 | ) : ( | 2807 | ) : ( |
| 2651 | <SettingsSection title="Local backend access"> | 2808 | <SettingsSection title="Local backend access"> |
| 2652 | <SettingsRow | 2809 | <SettingsRow |
| 2653 | title="Owner tools" | 2810 | title="Administrative access" |
| 2654 | description="Pairing links and client-session management are only available to owner sessions for this backend." | 2811 | description="Pairing links and client-session management require the access:write scope for this backend." |
| 2655 | /> | 2812 | /> |
| 2656 | </SettingsSection> | 2813 | </SettingsSection> |
| 2657 | )} | 2814 | )} |
apps/web/src/components/settings/SettingsPanels.browser.tsx+32-21| ... | @@ -3,6 +3,7 @@ import "../../index.css"; | ... | @@ -3,6 +3,7 @@ import "../../index.css"; |
| 3 | import { | 3 | import { |
| 4 | type AuthAccessStreamEvent, | 4 | type AuthAccessStreamEvent, |
| 5 | type AuthAccessSnapshot, | 5 | type AuthAccessSnapshot, |
| 6 | type AuthEnvironmentScope, | ||
| 6 | AuthSessionId, | 7 | AuthSessionId, |
| 7 | DEFAULT_SERVER_SETTINGS, | 8 | DEFAULT_SERVER_SETTINGS, |
| 8 | EnvironmentId, | 9 | EnvironmentId, |
| ... | @@ -217,7 +218,7 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -217,7 +218,7 @@ function createBaseServerConfig(): ServerConfig { |
| 217 | auth: { | 218 | auth: { |
| 218 | policy: "loopback-browser", | 219 | policy: "loopback-browser", |
| 219 | bootstrapMethods: ["one-time-token"], | 220 | bootstrapMethods: ["one-time-token"], |
| 220 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | 221 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], |
| 221 | sessionCookieName: "t3_session", | 222 | sessionCookieName: "t3_session", |
| 222 | }, | 223 | }, |
| 223 | cwd: "/repo/project", | 224 | cwd: "/repo/project", |
| ... | @@ -286,7 +287,7 @@ function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistory | ... | @@ -286,7 +287,7 @@ function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistory |
| 286 | function makePairingLink(input: { | 287 | function makePairingLink(input: { |
| 287 | readonly id: string; | 288 | readonly id: string; |
| 288 | readonly credential: string; | 289 | readonly credential: string; |
| 289 | readonly role: "owner" | "client"; | 290 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 290 | readonly subject: string; | 291 | readonly subject: string; |
| 291 | readonly label?: string; | 292 | readonly label?: string; |
| 292 | readonly createdAt: string; | 293 | readonly createdAt: string; |
| ... | @@ -302,7 +303,7 @@ function makePairingLink(input: { | ... | @@ -302,7 +303,7 @@ function makePairingLink(input: { |
| 302 | function makeClientSession(input: { | 303 | function makeClientSession(input: { |
| 303 | readonly sessionId: string; | 304 | readonly sessionId: string; |
| 304 | readonly subject: string; | 305 | readonly subject: string; |
| 305 | readonly role: "owner" | "client"; | 306 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 306 | readonly method: "browser-session-cookie"; | 307 | readonly method: "browser-session-cookie"; |
| 307 | readonly client?: { | 308 | readonly client?: { |
| 308 | readonly label?: string; | 309 | readonly label?: string; |
| ... | @@ -394,26 +395,26 @@ const createDesktopBridgeStub = (overrides?: { | ... | @@ -394,26 +395,26 @@ const createDesktopBridgeStub = (overrides?: { |
| 394 | }, | 395 | }, |
| 395 | }), | 396 | }), |
| 396 | bootstrapSshBearerSession: vi.fn().mockResolvedValue({ | 397 | bootstrapSshBearerSession: vi.fn().mockResolvedValue({ |
| 397 | authenticated: true, | 398 | access_token: "ssh-bearer-token", |
| 398 | role: "owner", | 399 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 399 | sessionMethod: "bearer-session-token", | 400 | token_type: "Bearer", |
| 400 | expiresAt: "2026-05-01T12:00:00.000Z", | 401 | expires_in: 3_600, |
| 401 | sessionToken: "ssh-bearer-token", | 402 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 402 | }), | 403 | }), |
| 403 | fetchSshSessionState: vi.fn().mockResolvedValue({ | 404 | fetchSshSessionState: vi.fn().mockResolvedValue({ |
| 404 | authenticated: true, | 405 | authenticated: true, |
| 405 | auth: { | 406 | auth: { |
| 406 | policy: "remote-reachable", | 407 | policy: "remote-reachable", |
| 407 | bootstrapMethods: ["one-time-token"], | 408 | bootstrapMethods: ["one-time-token"], |
| 408 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | 409 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], |
| 409 | sessionCookieName: "t3_session", | 410 | sessionCookieName: "t3_session", |
| 410 | }, | 411 | }, |
| 411 | role: "owner", | 412 | scopes: ["orchestration:read", "access:write"], |
| 412 | sessionMethod: "bearer-session-token", | 413 | sessionMethod: "bearer-access-token", |
| 413 | expiresAt: "2026-05-01T12:00:00.000Z", | 414 | expiresAt: "2026-05-01T12:00:00.000Z", |
| 414 | }), | 415 | }), |
| 415 | issueSshWebSocketToken: vi.fn().mockResolvedValue({ | 416 | issueSshWebSocketTicket: vi.fn().mockResolvedValue({ |
| 416 | token: "ssh-ws-token", | 417 | ticket: "ssh-ws-ticket", |
| 417 | expiresAt: "2026-05-01T12:05:00.000Z", | 418 | expiresAt: "2026-05-01T12:05:00.000Z", |
| 418 | }), | 419 | }), |
| 419 | onSshPasswordPrompt: vi.fn(() => () => {}), | 420 | onSshPasswordPrompt: vi.fn(() => () => {}), |
| ... | @@ -508,7 +509,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -508,7 +509,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 508 | makeClientSession({ | 509 | makeClientSession({ |
| 509 | sessionId: "session-owner", | 510 | sessionId: "session-owner", |
| 510 | subject: "browser-owner", | 511 | subject: "browser-owner", |
| 511 | role: "owner", | 512 | scopes: ["orchestration:read", "access:write"], |
| 512 | method: "browser-session-cookie", | 513 | method: "browser-session-cookie", |
| 513 | client: { | 514 | client: { |
| 514 | label: "Chrome on Mac", | 515 | label: "Chrome on Mac", |
| ... | @@ -531,7 +532,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -531,7 +532,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 531 | JSON.stringify({ | 532 | JSON.stringify({ |
| 532 | authenticated: true, | 533 | authenticated: true, |
| 533 | auth: createBaseServerConfig().auth, | 534 | auth: createBaseServerConfig().auth, |
| 534 | role: "owner", | 535 | scopes: ["orchestration:read", "access:write"], |
| 535 | sessionMethod: "browser-session-cookie", | 536 | sessionMethod: "browser-session-cookie", |
| 536 | expiresAt: "2036-05-07T00:00:00.000Z", | 537 | expiresAt: "2036-05-07T00:00:00.000Z", |
| 537 | }), | 538 | }), |
| ... | @@ -776,7 +777,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -776,7 +777,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 776 | makeClientSession({ | 777 | makeClientSession({ |
| 777 | sessionId: "session-owner", | 778 | sessionId: "session-owner", |
| 778 | subject: "desktop-bootstrap", | 779 | subject: "desktop-bootstrap", |
| 779 | role: "owner", | 780 | scopes: ["orchestration:read", "access:write"], |
| 780 | method: "browser-session-cookie", | 781 | method: "browser-session-cookie", |
| 781 | client: { | 782 | client: { |
| 782 | label: "This Mac", | 783 | label: "This Mac", |
| ... | @@ -805,7 +806,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -805,7 +806,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 805 | makePairingLink({ | 806 | makePairingLink({ |
| 806 | id: "pairing-link-1", | 807 | id: "pairing-link-1", |
| 807 | credential: "pairing-token", | 808 | credential: "pairing-token", |
| 808 | role: "client", | 809 | scopes: ["orchestration:read"], |
| 809 | subject: "one-time-token", | 810 | subject: "one-time-token", |
| 810 | label: "Julius iPhone", | 811 | label: "Julius iPhone", |
| 811 | createdAt: "2036-04-07T00:00:00.000Z", | 812 | createdAt: "2036-04-07T00:00:00.000Z", |
| ... | @@ -817,7 +818,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -817,7 +818,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 817 | makeClientSession({ | 818 | makeClientSession({ |
| 818 | sessionId: "session-client", | 819 | sessionId: "session-client", |
| 819 | subject: "one-time-token", | 820 | subject: "one-time-token", |
| 820 | role: "client", | 821 | scopes: ["orchestration:read"], |
| 821 | method: "browser-session-cookie", | 822 | method: "browser-session-cookie", |
| 822 | client: { | 823 | client: { |
| 823 | label: "Julius iPhone", | 824 | label: "Julius iPhone", |
| ... | @@ -867,12 +868,22 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -867,12 +868,22 @@ describe("GeneralSettingsPanel observability", () => { |
| 867 | await expect.element(page.getByText("This Mac")).toBeInTheDocument(); | 868 | await expect.element(page.getByText("This Mac")).toBeInTheDocument(); |
| 868 | await page.getByRole("button", { name: "Create link", exact: true }).click(); | 869 | await page.getByRole("button", { name: "Create link", exact: true }).click(); |
| 869 | await expect.element(page.getByText("Create pairing link")).toBeInTheDocument(); | 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 | await page.getByRole("button", { name: "Create link", exact: true }).click(); | 876 | await page.getByRole("button", { name: "Create link", exact: true }).click(); |
| 871 | authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!); | 877 | authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!); |
| 872 | authAccessHarness.emitClientUpserted(clientSessions[1]!); | 878 | authAccessHarness.emitClientUpserted(clientSessions[1]!); |
| 873 | await expect | 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 | .toBeInTheDocument(); | 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 | await expect | 887 | await expect |
| 877 | .element(page.getByRole("button", { name: /^Copy pairing URL for:/ })) | 888 | .element(page.getByRole("button", { name: /^Copy pairing URL for:/ })) |
| 878 | .toBeInTheDocument(); | 889 | .toBeInTheDocument(); |
| ... | @@ -893,7 +904,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -893,7 +904,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 893 | makeClientSession({ | 904 | makeClientSession({ |
| 894 | sessionId: "session-owner", | 905 | sessionId: "session-owner", |
| 895 | subject: "desktop-bootstrap", | 906 | subject: "desktop-bootstrap", |
| 896 | role: "owner", | 907 | scopes: ["orchestration:read", "access:write"], |
| 897 | method: "browser-session-cookie", | 908 | method: "browser-session-cookie", |
| 898 | client: { | 909 | client: { |
| 899 | label: "This Mac", | 910 | label: "This Mac", |
| ... | @@ -909,7 +920,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -909,7 +920,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 909 | makeClientSession({ | 920 | makeClientSession({ |
| 910 | sessionId: "session-client", | 921 | sessionId: "session-client", |
| 911 | subject: "one-time-token", | 922 | subject: "one-time-token", |
| 912 | role: "client", | 923 | scopes: ["orchestration:read"], |
| 913 | method: "browser-session-cookie", | 924 | method: "browser-session-cookie", |
| 914 | client: { | 925 | client: { |
| 915 | label: "Julius iPhone", | 926 | label: "Julius iPhone", |
apps/web/src/environments/primary/auth.ts+209-132| ... | @@ -1,21 +1,25 @@ | ... | @@ -1,21 +1,25 @@ |
| 1 | import type { | 1 | import type { |
| 2 | AuthBootstrapInput, | 2 | AuthBrowserSessionResult, |
| 3 | AuthBootstrapResult, | ||
| 4 | AuthClientMetadata, | 3 | AuthClientMetadata, |
| 5 | AuthCreatePairingCredentialInput, | 4 | AuthEnvironmentScope, |
| 6 | AuthPairingCredentialResult, | 5 | AuthPairingCredentialResult, |
| 7 | AuthRevokeClientSessionInput, | ||
| 8 | AuthRevokePairingLinkInput, | ||
| 9 | AuthSessionId, | 6 | AuthSessionId, |
| 10 | AuthSessionState, | 7 | AuthSessionState, |
| 11 | } from "@t3tools/contracts"; | 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 | import { | 16 | import { |
| 14 | getPairingTokenFromUrl, | 17 | getPairingTokenFromUrl, |
| 15 | stripPairingTokenFromUrl as stripPairingTokenUrl, | 18 | stripPairingTokenFromUrl as stripPairingTokenUrl, |
| 16 | } from "../../pairingUrl"; | 19 | } from "../../pairingUrl"; |
| 17 | 20 | ||
| 18 | import { resolvePrimaryEnvironmentHttpUrl } from "./target"; | 21 | import { PrimaryEnvironmentHttpClient } from "./httpClient"; |
| 22 | import { runPrimaryHttp } from "../../lib/runtime"; | ||
| 19 | import * as Data from "effect/Data"; | 23 | import * as Data from "effect/Data"; |
| 20 | import * as Predicate from "effect/Predicate"; | 24 | import * as Predicate from "effect/Predicate"; |
| 21 | 25 | ||
| ... | @@ -25,11 +29,12 @@ export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ | ... | @@ -25,11 +29,12 @@ export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ |
| 25 | }> {} | 29 | }> {} |
| 26 | const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => | 30 | const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => |
| 27 | Predicate.isTagged(u, "BootstrapHttpError"); | 31 | Predicate.isTagged(u, "BootstrapHttpError"); |
| 32 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); | ||
| 28 | 33 | ||
| 29 | export interface ServerPairingLinkRecord { | 34 | export interface ServerPairingLinkRecord { |
| 30 | readonly id: string; | 35 | readonly id: string; |
| 31 | readonly credential: string; | 36 | readonly credential: string; |
| 32 | readonly role: "owner" | "client"; | 37 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 33 | readonly subject: string; | 38 | readonly subject: string; |
| 34 | readonly label?: string; | 39 | readonly label?: string; |
| 35 | readonly createdAt: string; | 40 | readonly createdAt: string; |
| ... | @@ -39,8 +44,8 @@ export interface ServerPairingLinkRecord { | ... | @@ -39,8 +44,8 @@ export interface ServerPairingLinkRecord { |
| 39 | export interface ServerClientSessionRecord { | 44 | export interface ServerClientSessionRecord { |
| 40 | readonly sessionId: AuthSessionId; | 45 | readonly sessionId: AuthSessionId; |
| 41 | readonly subject: string; | 46 | readonly subject: string; |
| 42 | readonly role: "owner" | "client"; | 47 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 43 | readonly method: "browser-session-cookie" | "bearer-session-token"; | 48 | readonly method: "browser-session-cookie" | "bearer-access-token"; |
| 44 | readonly client: AuthClientMetadata; | 49 | readonly client: AuthClientMetadata; |
| 45 | readonly issuedAt: string; | 50 | readonly issuedAt: string; |
| 46 | readonly expiresAt: string; | 51 | readonly expiresAt: string; |
| ... | @@ -93,79 +98,97 @@ function getDesktopBootstrapCredential(): string | null { | ... | @@ -93,79 +98,97 @@ function getDesktopBootstrapCredential(): string | null { |
| 93 | 98 | ||
| 94 | export async function fetchSessionState(): Promise<AuthSessionState> { | 99 | export async function fetchSessionState(): Promise<AuthSessionState> { |
| 95 | return retryTransientBootstrap(async () => { | 100 | return retryTransientBootstrap(async () => { |
| 96 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/session"), { | 101 | try { |
| 97 | credentials: "include", | 102 | return await runPrimaryHttp( |
| 98 | }); | 103 | PrimaryEnvironmentHttpClient.pipe( |
| 99 | if (!response.ok) { | 104 | Effect.flatMap((client) => client.auth.session({ headers: {} })), |
| 105 | ), | ||
| 106 | ); | ||
| 107 | } catch (error) { | ||
| 108 | const status = readHttpApiStatus(error); | ||
| 100 | throw new BootstrapHttpError({ | 109 | throw new BootstrapHttpError({ |
| 101 | message: `Failed to load server auth session state (${response.status}).`, | 110 | message: `Failed to load server auth session state (${status ?? "unknown"}).`, |
| 102 | status: response.status, | 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> { | 117 | function readHttpApiStatus(error: unknown): number | null { |
| 110 | const text = await response.text(); | 118 | if (isEnvironmentHttpCommonError(error)) { |
| 111 | return text || fallbackMessage; | 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([ | 126 | function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): number { |
| 115 | "Invalid bootstrap credential.", | 127 | switch (error._tag) { |
| 116 | "Unknown bootstrap credential.", | 128 | case "EnvironmentRequestInvalidError": |
| 117 | ]); | 129 | return 400; |
| 118 | 130 | case "EnvironmentAuthInvalidError": | |
| 119 | function parseBootstrapErrorMessage(message: string): string { | 131 | return 401; |
| 120 | const trimmed = message.trim(); | 132 | case "EnvironmentScopeRequiredError": |
| 121 | if (trimmed.length === 0) { | 133 | case "EnvironmentOperationForbiddenError": |
| 122 | return ""; | 134 | return 403; |
| 135 | case "EnvironmentInternalError": | ||
| 136 | return 500; | ||
| 123 | } | 137 | } |
| 138 | } | ||
| 124 | 139 | ||
| 125 | try { | 140 | function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string { |
| 126 | const parsed = JSON.parse(trimmed) as { | 141 | if (!isEnvironmentHttpCommonError(error)) { |
| 127 | error?: unknown; | 142 | return fallbackMessage; |
| 128 | }; | 143 | } |
| 129 | if (typeof parsed.error === "string" && parsed.error.trim().length > 0) { | 144 | switch (error._tag) { |
| 130 | return parsed.error.trim(); | 145 | case "EnvironmentAuthInvalidError": |
| 131 | } | 146 | return error.reason === "missing_credential" |
| 132 | } catch { | 147 | ? "Authentication required." |
| 133 | // Not JSON; fall back to plain text. | 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 | function toFriendlyBootstrapErrorMessage(status: number, message: string): string { | 167 | function toFriendlyBootstrapErrorMessage(status: number, message: string): string { |
| 140 | const parsedMessage = parseBootstrapErrorMessage(message); | 168 | const trimmedMessage = message.trim(); |
| 141 | if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(parsedMessage)) { | 169 | if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) { |
| 142 | return "Invalid pairing token. Check the token and try again."; | 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 | return retryTransientBootstrap(async () => { | 177 | return retryTransientBootstrap(async () => { |
| 150 | const payload: AuthBootstrapInput = { credential }; | 178 | try { |
| 151 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/bootstrap"), { | 179 | return await runPrimaryHttp( |
| 152 | body: JSON.stringify(payload), | 180 | PrimaryEnvironmentHttpClient.pipe( |
| 153 | credentials: "include", | 181 | Effect.flatMap((client) => client.auth.browserSession({ payload: { credential } })), |
| 154 | headers: { | 182 | ), |
| 155 | "content-type": "application/json", | 183 | ); |
| 156 | }, | 184 | } catch (error) { |
| 157 | method: "POST", | 185 | const status = readHttpApiStatus(error) ?? 500; |
| 158 | }); | 186 | const message = toFriendlyBootstrapErrorMessage(status, readHttpApiErrorMessage(error, "")); |
| 159 | |||
| 160 | if (!response.ok) { | ||
| 161 | const message = toFriendlyBootstrapErrorMessage(response.status, await response.text()); | ||
| 162 | throw new BootstrapHttpError({ | 187 | throw new BootstrapHttpError({ |
| 163 | message: message || `Failed to bootstrap auth session (${response.status}).`, | 188 | message: message || `Failed to bootstrap auth session (${status}).`, |
| 164 | status: response.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,57 +289,93 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 266 | stripPairingTokenFromUrl(); | 289 | stripPairingTokenFromUrl(); |
| 267 | } | 290 | } |
| 268 | 291 | ||
| 269 | export async function createServerPairingCredential( | 292 | export async function createServerPairingCredential(input?: { |
| 270 | label?: string, | 293 | readonly label?: string; |
| 271 | ): Promise<AuthPairingCredentialResult> { | 294 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 272 | const trimmedLabel = label?.trim(); | 295 | }): Promise<AuthPairingCredentialResult> { |
| 273 | const payload: AuthCreatePairingCredentialInput = trimmedLabel ? { label: trimmedLabel } : {}; | 296 | const trimmedLabel = input?.label?.trim(); |
| 274 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-token"), { | 297 | try { |
| 275 | body: JSON.stringify(payload), | 298 | return await runPrimaryHttp( |
| 276 | credentials: "include", | 299 | PrimaryEnvironmentHttpClient.pipe( |
| 277 | headers: { | 300 | Effect.flatMap((client) => |
| 278 | "content-type": "application/json", | 301 | client.auth.pairingCredential({ |
| 279 | }, | 302 | headers: {}, |
| 280 | method: "POST", | 303 | payload: { |
| 281 | }); | 304 | ...(trimmedLabel ? { label: trimmedLabel } : {}), |
| 282 | 305 | ...(input?.scopes ? { scopes: input.scopes } : {}), | |
| 283 | if (!response.ok) { | 306 | }, |
| 307 | }), | ||
| 308 | ), | ||
| 309 | ), | ||
| 310 | ); | ||
| 311 | } catch (error) { | ||
| 284 | throw new Error( | 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 | export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPairingLinkRecord>> { | 322 | export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPairingLinkRecord>> { |
| 293 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links"), { | 323 | try { |
| 294 | credentials: "include", | 324 | const pairingLinks = await runPrimaryHttp( |
| 295 | }); | 325 | PrimaryEnvironmentHttpClient.pipe( |
| 296 | 326 | Effect.flatMap((client) => client.auth.pairingLinks({ headers: {} })), | |
| 297 | if (!response.ok) { | 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 | throw new Error( | 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 | export async function revokeServerPairingLink(id: string): Promise<void> { | 365 | export async function revokeServerPairingLink(id: string): Promise<void> { |
| 307 | const payload: AuthRevokePairingLinkInput = { id }; | 366 | try { |
| 308 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links/revoke"), { | 367 | await runPrimaryHttp( |
| 309 | body: JSON.stringify(payload), | 368 | PrimaryEnvironmentHttpClient.pipe( |
| 310 | credentials: "include", | 369 | Effect.flatMap((client) => client.auth.revokePairingLink({ headers: {}, payload: { id } })), |
| 311 | headers: { | 370 | ), |
| 312 | "content-type": "application/json", | 371 | ); |
| 313 | }, | 372 | } catch (error) { |
| 314 | method: "POST", | ||
| 315 | }); | ||
| 316 | |||
| 317 | if (!response.ok) { | ||
| 318 | throw new Error( | 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,57 +383,75 @@ export async function revokeServerPairingLink(id: string): Promise<void> { |
| 324 | export async function listServerClientSessions(): Promise< | 383 | export async function listServerClientSessions(): Promise< |
| 325 | ReadonlyArray<ServerClientSessionRecord> | 384 | ReadonlyArray<ServerClientSessionRecord> |
| 326 | > { | 385 | > { |
| 327 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients"), { | 386 | try { |
| 328 | credentials: "include", | 387 | const clientSessions = await runPrimaryHttp( |
| 329 | }); | 388 | PrimaryEnvironmentHttpClient.pipe( |
| 330 | 389 | Effect.flatMap((client) => client.auth.clients({ headers: {} })), | |
| 331 | if (!response.ok) { | 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 | throw new Error( | 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 | export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> { | 418 | export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> { |
| 341 | const payload: AuthRevokeClientSessionInput = { sessionId }; | 419 | try { |
| 342 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke"), { | 420 | await runPrimaryHttp( |
| 343 | body: JSON.stringify(payload), | 421 | PrimaryEnvironmentHttpClient.pipe( |
| 344 | credentials: "include", | 422 | Effect.flatMap((client) => |
| 345 | headers: { | 423 | client.auth.revokeClient({ headers: {}, payload: { sessionId } }), |
| 346 | "content-type": "application/json", | 424 | ), |
| 347 | }, | 425 | ), |
| 348 | method: "POST", | 426 | ); |
| 349 | }); | 427 | } catch (error) { |
| 350 | |||
| 351 | if (!response.ok) { | ||
| 352 | throw new Error( | 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 | export async function revokeOtherServerClientSessions(): Promise<number> { | 438 | export async function revokeOtherServerClientSessions(): Promise<number> { |
| 359 | const response = await fetch( | 439 | try { |
| 360 | resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke-others"), | 440 | const result = await runPrimaryHttp( |
| 361 | { | 441 | PrimaryEnvironmentHttpClient.pipe( |
| 362 | credentials: "include", | 442 | Effect.flatMap((client) => client.auth.revokeOtherClients({ headers: {} })), |
| 363 | method: "POST", | 443 | ), |
| 364 | }, | 444 | ); |
| 365 | ); | 445 | return result.revokedCount; |
| 366 | 446 | } catch (error) { | |
| 367 | if (!response.ok) { | ||
| 368 | throw new Error( | 447 | throw new Error( |
| 369 | await readErrorMessage( | 448 | readHttpApiErrorMessage( |
| 370 | response, | 449 | error, |
| 371 | `Failed to revoke other client sessions (${response.status}).`, | 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 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { | 457 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { |
apps/web/src/environments/primary/bootstrap.test.ts+41-33| ... | @@ -1,25 +1,18 @@ | ... | @@ -1,25 +1,18 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 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 | import { | 5 | import { |
| 5 | getPrimaryKnownEnvironment, | 6 | getPrimaryKnownEnvironment, |
| 7 | resolvePrimaryEnvironmentHttpUrl, | ||
| 6 | resolveInitialPrimaryEnvironmentDescriptor, | 8 | resolveInitialPrimaryEnvironmentDescriptor, |
| 7 | resetPrimaryEnvironmentDescriptorForTests, | 9 | resetPrimaryEnvironmentDescriptorForTests, |
| 8 | writePrimaryEnvironmentDescriptor, | 10 | writePrimaryEnvironmentDescriptor, |
| 9 | } from "."; | 11 | } from "."; |
| 10 | 12 | import { installEnvironmentHttpTest } from "../../../test/environmentHttpTest"; | |
| 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 | } | ||
| 20 | 13 | ||
| 21 | const BASE_ENVIRONMENT = { | 14 | const BASE_ENVIRONMENT = { |
| 22 | environmentId: "environment-local", | 15 | environmentId: EnvironmentId.make("environment-local"), |
| 23 | label: "Local environment", | 16 | label: "Local environment", |
| 24 | platform: { | 17 | platform: { |
| 25 | os: "darwin", | 18 | os: "darwin", |
| ... | @@ -29,7 +22,17 @@ const BASE_ENVIRONMENT = { | ... | @@ -29,7 +22,17 @@ const BASE_ENVIRONMENT = { |
| 29 | capabilities: { | 22 | capabilities: { |
| 30 | repositoryIdentity: true, | 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 | function installTestBrowser(url: string) { | 37 | function installTestBrowser(url: string) { |
| 35 | vi.stubGlobal("window", { | 38 | vi.stubGlobal("window", { |
| ... | @@ -47,7 +50,9 @@ describe("environmentBootstrap", () => { | ... | @@ -47,7 +50,9 @@ describe("environmentBootstrap", () => { |
| 47 | installTestBrowser("http://localhost/"); | 50 | installTestBrowser("http://localhost/"); |
| 48 | }); | 51 | }); |
| 49 | 52 | ||
| 50 | afterEach(() => { | 53 | afterEach(async () => { |
| 54 | await disposeHttpTest?.(); | ||
| 55 | disposeHttpTest = undefined; | ||
| 51 | resetPrimaryEnvironmentDescriptorForTests(); | 56 | resetPrimaryEnvironmentDescriptorForTests(); |
| 52 | vi.unstubAllEnvs(); | 57 | vi.unstubAllEnvs(); |
| 53 | vi.useRealTimers(); | 58 | vi.useRealTimers(); |
| ... | @@ -88,35 +93,35 @@ describe("environmentBootstrap", () => { | ... | @@ -88,35 +93,35 @@ describe("environmentBootstrap", () => { |
| 88 | }); | 93 | }); |
| 89 | 94 | ||
| 90 | it("reuses an in-flight descriptor bootstrap request", async () => { | 95 | it("reuses an in-flight descriptor bootstrap request", async () => { |
| 91 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | 96 | const testApi = await installDescriptorApi(); |
| 92 | vi.stubGlobal("fetch", fetchMock); | ||
| 93 | 97 | ||
| 94 | await Promise.all([ | 98 | await Promise.all([ |
| 95 | resolveInitialPrimaryEnvironmentDescriptor(), | 99 | resolveInitialPrimaryEnvironmentDescriptor(), |
| 96 | resolveInitialPrimaryEnvironmentDescriptor(), | 100 | resolveInitialPrimaryEnvironmentDescriptor(), |
| 97 | ]); | 101 | ]); |
| 98 | 102 | ||
| 99 | expect(fetchMock).toHaveBeenCalledTimes(1); | 103 | expect(testApi.calls.descriptor).toBe(1); |
| 100 | expect(fetchMock).toHaveBeenCalledWith("http://localhost/.well-known/t3/environment"); | ||
| 101 | }); | 104 | }); |
| 102 | 105 | ||
| 103 | it("uses https descriptor urls when the primary environment uses wss", async () => { | 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 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); | 107 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); |
| 107 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); | 108 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); |
| 109 | await installDescriptorApi(); | ||
| 108 | 110 | ||
| 109 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); | 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 | it("derives the websocket url when only VITE_HTTP_URL is configured", async () => { | 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 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); | 118 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); |
| 119 | await installDescriptorApi(); | ||
| 117 | 120 | ||
| 118 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); | 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 | expect(getPrimaryKnownEnvironment()?.target).toEqual({ | 125 | expect(getPrimaryKnownEnvironment()?.target).toEqual({ |
| 121 | httpBaseUrl: "https://remote.example.com/", | 126 | httpBaseUrl: "https://remote.example.com/", |
| 122 | wsBaseUrl: "wss://remote.example.com/", | 127 | wsBaseUrl: "wss://remote.example.com/", |
| ... | @@ -124,12 +129,13 @@ describe("environmentBootstrap", () => { | ... | @@ -124,12 +129,13 @@ describe("environmentBootstrap", () => { |
| 124 | }); | 129 | }); |
| 125 | 130 | ||
| 126 | it("derives the http url when only VITE_WS_URL is configured", async () => { | 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 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); | 132 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); |
| 133 | await installDescriptorApi(); | ||
| 130 | 134 | ||
| 131 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); | 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 | expect(getPrimaryKnownEnvironment()?.target).toEqual({ | 139 | expect(getPrimaryKnownEnvironment()?.target).toEqual({ |
| 134 | httpBaseUrl: "https://remote.example.com/", | 140 | httpBaseUrl: "https://remote.example.com/", |
| 135 | wsBaseUrl: "wss://remote.example.com/", | 141 | wsBaseUrl: "wss://remote.example.com/", |
| ... | @@ -137,17 +143,16 @@ describe("environmentBootstrap", () => { | ... | @@ -137,17 +143,16 @@ describe("environmentBootstrap", () => { |
| 137 | }); | 143 | }); |
| 138 | 144 | ||
| 139 | it("uses the current origin as the descriptor base for local dev environments", async () => { | 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 | installTestBrowser("http://localhost:5735/"); | 146 | installTestBrowser("http://localhost:5735/"); |
| 147 | await installDescriptorApi(); | ||
| 143 | 148 | ||
| 144 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); | 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 | it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => { | 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 | vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); | 156 | vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); |
| 152 | vi.stubGlobal("window", { | 157 | vi.stubGlobal("window", { |
| 153 | location: new URL("http://127.0.0.1:5733/"), | 158 | location: new URL("http://127.0.0.1:5733/"), |
| ... | @@ -163,8 +168,11 @@ describe("environmentBootstrap", () => { | ... | @@ -163,8 +168,11 @@ describe("environmentBootstrap", () => { |
| 163 | }), | 168 | }), |
| 164 | }, | 169 | }, |
| 165 | }); | 170 | }); |
| 171 | await installDescriptorApi(); | ||
| 166 | 172 | ||
| 167 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); | 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,13 +4,15 @@ import { |
| 4 | type KnownEnvironment, | 4 | type KnownEnvironment, |
| 5 | } from "@t3tools/client-runtime"; | 5 | } from "@t3tools/client-runtime"; |
| 6 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | 6 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import { HttpClientError } from "effect/unstable/http"; | ||
| 7 | import { create } from "zustand"; | 9 | import { create } from "zustand"; |
| 8 | 10 | ||
| 9 | import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; | 11 | import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; |
| 12 | import { PrimaryEnvironmentHttpClient } from "./httpClient"; | ||
| 10 | 13 | ||
| 11 | import { readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl } from "./target"; | 14 | import { runPrimaryHttp } from "../../lib/runtime"; |
| 12 | 15 | import { readPrimaryEnvironmentTarget } from "./target"; | |
| 13 | const SERVER_ENVIRONMENT_DESCRIPTOR_PATH = "/.well-known/t3/environment"; | ||
| 14 | 16 | ||
| 15 | interface PrimaryEnvironmentBootstrapState { | 17 | interface PrimaryEnvironmentBootstrapState { |
| 16 | readonly descriptor: ExecutionEnvironmentDescriptor | null; | 18 | readonly descriptor: ExecutionEnvironmentDescriptor | null; |
| ... | @@ -48,17 +50,22 @@ function createPrimaryKnownEnvironment(input: { | ... | @@ -48,17 +50,22 @@ function createPrimaryKnownEnvironment(input: { |
| 48 | 50 | ||
| 49 | async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> { | 51 | async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> { |
| 50 | return retryTransientBootstrap(async () => { | 52 | return retryTransientBootstrap(async () => { |
| 51 | const response = await fetch( | 53 | let descriptor: ExecutionEnvironmentDescriptor; |
| 52 | resolvePrimaryEnvironmentHttpUrl(SERVER_ENVIRONMENT_DESCRIPTOR_PATH), | 54 | try { |
| 53 | ); | 55 | descriptor = await runPrimaryHttp( |
| 54 | if (!response.ok) { | 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 | throw new BootstrapHttpError({ | 63 | throw new BootstrapHttpError({ |
| 56 | message: `Failed to load server environment descriptor (${response.status}).`, | 64 | message: `Failed to load server environment descriptor (${status}).`, |
| 57 | status: response.status, | 65 | status, |
| 58 | }); | 66 | }); |
| 59 | } | 67 | } |
| 60 | 68 | ||
| 61 | const descriptor = (await response.json()) as ExecutionEnvironmentDescriptor; | ||
| 62 | writePrimaryEnvironmentDescriptor(descriptor); | 69 | writePrimaryEnvironmentDescriptor(descriptor); |
| 63 | return descriptor; | 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,4 +32,6 @@ export { |
| 32 | __resetServerAuthBootstrapForTests, | 32 | __resetServerAuthBootstrapForTests, |
| 33 | } from "./auth"; | 33 | } from "./auth"; |
| 34 | 34 | ||
| 35 | export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; | ||
| 36 | |||
| 35 | export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target"; | 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,6 +1,6 @@ |
| 1 | import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; | 1 | import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; |
| 2 | import type { | 2 | import type { |
| 3 | AuthSessionRole, | 3 | AuthEnvironmentScope, |
| 4 | EnvironmentId, | 4 | EnvironmentId, |
| 5 | ExecutionEnvironmentDescriptor, | 5 | ExecutionEnvironmentDescriptor, |
| 6 | PersistedSavedEnvironmentRecord, | 6 | PersistedSavedEnvironmentRecord, |
| ... | @@ -272,7 +272,7 @@ export interface SavedEnvironmentRuntimeState { | ... | @@ -272,7 +272,7 @@ export interface SavedEnvironmentRuntimeState { |
| 272 | readonly authState: SavedEnvironmentAuthState; | 272 | readonly authState: SavedEnvironmentAuthState; |
| 273 | readonly lastError: string | null; | 273 | readonly lastError: string | null; |
| 274 | readonly lastErrorAt: string | null; | 274 | readonly lastErrorAt: string | null; |
| 275 | readonly role: AuthSessionRole | null; | 275 | readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null; |
| 276 | readonly descriptor: ExecutionEnvironmentDescriptor | null; | 276 | readonly descriptor: ExecutionEnvironmentDescriptor | null; |
| 277 | readonly serverConfig: ServerConfig | null; | 277 | readonly serverConfig: ServerConfig | null; |
| 278 | readonly connectedAt: string | null; | 278 | readonly connectedAt: string | null; |
| ... | @@ -295,7 +295,7 @@ const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Ob | ... | @@ -295,7 +295,7 @@ const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Ob |
| 295 | authState: "unknown", | 295 | authState: "unknown", |
| 296 | lastError: null, | 296 | lastError: null, |
| 297 | lastErrorAt: null, | 297 | lastErrorAt: null, |
| 298 | role: null, | 298 | scopes: null, |
| 299 | descriptor: null, | 299 | descriptor: null, |
| 300 | serverConfig: null, | 300 | serverConfig: null, |
| 301 | connectedAt: null, | 301 | connectedAt: null, |
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+30-31| ... | @@ -1,13 +1,15 @@ | ... | @@ -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 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 4 | ||
| 5 | const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError); | ||
| 6 | |||
| 4 | let mockSavedRecords: Array<Record<string, unknown>> = []; | 7 | let mockSavedRecords: Array<Record<string, unknown>> = []; |
| 5 | 8 | ||
| 6 | const mockResolveRemotePairingTarget = vi.fn(); | 9 | const mockResolveRemotePairingTarget = vi.fn(); |
| 7 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); | 10 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); |
| 8 | const mockBootstrapRemoteBearerSession = vi.fn(); | 11 | const mockBootstrapRemoteBearerSession = vi.fn(); |
| 9 | const mockFetchRemoteSessionState = vi.fn(); | 12 | const mockFetchRemoteSessionState = vi.fn(); |
| 10 | const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); | ||
| 11 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); | 13 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); |
| 12 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); | 14 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); |
| 13 | const mockBootstrapSshBearerSession = vi.fn(); | 15 | const mockBootstrapSshBearerSession = vi.fn(); |
| ... | @@ -128,7 +130,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ... | @@ -128,7 +130,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 128 | })), | 130 | })), |
| 129 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, | 131 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, |
| 130 | fetchRemoteSessionState: mockFetchRemoteSessionState, | 132 | fetchRemoteSessionState: mockFetchRemoteSessionState, |
| 131 | isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, | ||
| 132 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | 133 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, |
| 133 | }; | 134 | }; |
| 134 | }); | 135 | }); |
| ... | @@ -153,7 +154,7 @@ describe("addSavedEnvironment", () => { | ... | @@ -153,7 +154,7 @@ describe("addSavedEnvironment", () => { |
| 153 | fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, | 154 | fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, |
| 154 | bootstrapSshBearerSession: mockBootstrapSshBearerSession, | 155 | bootstrapSshBearerSession: mockBootstrapSshBearerSession, |
| 155 | fetchSshSessionState: mockFetchSshSessionState, | 156 | fetchSshSessionState: mockFetchSshSessionState, |
| 156 | issueSshWebSocketToken: vi.fn(), | 157 | issueSshWebSocketTicket: vi.fn(), |
| 157 | }, | 158 | }, |
| 158 | }); | 159 | }); |
| 159 | mockResolveRemotePairingTarget.mockImplementation( | 160 | mockResolveRemotePairingTarget.mockImplementation( |
| ... | @@ -176,24 +177,23 @@ describe("addSavedEnvironment", () => { | ... | @@ -176,24 +177,23 @@ describe("addSavedEnvironment", () => { |
| 176 | label: "Remote environment", | 177 | label: "Remote environment", |
| 177 | }); | 178 | }); |
| 178 | mockBootstrapRemoteBearerSession.mockResolvedValue({ | 179 | mockBootstrapRemoteBearerSession.mockResolvedValue({ |
| 179 | sessionToken: "bearer-token", | 180 | access_token: "bearer-token", |
| 180 | role: "owner", | 181 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 181 | }); | 182 | }); |
| 182 | mockFetchRemoteSessionState.mockResolvedValue({ | 183 | mockFetchRemoteSessionState.mockResolvedValue({ |
| 183 | authenticated: true, | 184 | authenticated: true, |
| 184 | role: "owner", | 185 | scopes: ["orchestration:read", "access:write"], |
| 185 | }); | 186 | }); |
| 186 | mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false); | ||
| 187 | mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( | 187 | mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( |
| 188 | "wss://remote.example.com/?wsToken=remote-token", | 188 | "wss://remote.example.com/?wsTicket=remote-token", |
| 189 | ); | 189 | ); |
| 190 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | 190 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ |
| 191 | environmentId: EnvironmentId.make("environment-1"), | 191 | environmentId: EnvironmentId.make("environment-1"), |
| 192 | label: "Remote environment", | 192 | label: "Remote environment", |
| 193 | }); | 193 | }); |
| 194 | mockBootstrapSshBearerSession.mockResolvedValue({ | 194 | mockBootstrapSshBearerSession.mockResolvedValue({ |
| 195 | sessionToken: "ssh-bearer-token", | 195 | access_token: "ssh-bearer-token", |
| 196 | role: "owner", | 196 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 197 | }); | 197 | }); |
| 198 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); | 198 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); |
| 199 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); | 199 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); |
| ... | @@ -202,7 +202,7 @@ describe("addSavedEnvironment", () => { | ... | @@ -202,7 +202,7 @@ describe("addSavedEnvironment", () => { |
| 202 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); | 202 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); |
| 203 | mockFetchSshSessionState.mockResolvedValue({ | 203 | mockFetchSshSessionState.mockResolvedValue({ |
| 204 | authenticated: true, | 204 | authenticated: true, |
| 205 | role: "owner", | 205 | scopes: ["orchestration:read", "access:write"], |
| 206 | }); | 206 | }); |
| 207 | mockCreateEnvironmentConnection.mockImplementation( | 207 | mockCreateEnvironmentConnection.mockImplementation( |
| 208 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ | 208 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ |
| ... | @@ -381,18 +381,18 @@ describe("addSavedEnvironment", () => { | ... | @@ -381,18 +381,18 @@ describe("addSavedEnvironment", () => { |
| 381 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | 381 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); |
| 382 | mockBootstrapSshBearerSession | 382 | mockBootstrapSshBearerSession |
| 383 | .mockResolvedValueOnce({ | 383 | .mockResolvedValueOnce({ |
| 384 | sessionToken: "ssh-bearer-token", | 384 | access_token: "ssh-bearer-token", |
| 385 | role: "owner", | 385 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 386 | }) | 386 | }) |
| 387 | .mockResolvedValueOnce({ | 387 | .mockResolvedValueOnce({ |
| 388 | sessionToken: "ssh-bearer-token-2", | 388 | access_token: "ssh-bearer-token-2", |
| 389 | role: "owner", | 389 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 390 | }); | 390 | }); |
| 391 | mockFetchSshSessionState | 391 | mockFetchSshSessionState |
| 392 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) | 392 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) |
| 393 | .mockResolvedValueOnce({ | 393 | .mockResolvedValueOnce({ |
| 394 | authenticated: true, | 394 | authenticated: true, |
| 395 | role: "owner", | 395 | scopes: ["orchestration:read", "access:write"], |
| 396 | }); | 396 | }); |
| 397 | 397 | ||
| 398 | const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = | 398 | const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = |
| ... | @@ -418,14 +418,13 @@ describe("addSavedEnvironment", () => { | ... | @@ -418,14 +418,13 @@ describe("addSavedEnvironment", () => { |
| 418 | 418 | ||
| 419 | it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { | 419 | it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { |
| 420 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | 420 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); |
| 421 | const authError = { | 421 | const authError = decodeEnvironmentAuthInvalidError({ |
| 422 | status: 401, | 422 | _tag: "EnvironmentAuthInvalidError", |
| 423 | message: "Unauthorized", | 423 | code: "auth_invalid", |
| 424 | }; | 424 | reason: "invalid_credential", |
| 425 | traceId: "trace-auth-test", | ||
| 426 | }); | ||
| 425 | mockFetchRemoteSessionState.mockRejectedValueOnce(authError); | 427 | mockFetchRemoteSessionState.mockRejectedValueOnce(authError); |
| 426 | mockIsRemoteEnvironmentAuthHttpError.mockImplementation( | ||
| 427 | (error: unknown) => error === authError, | ||
| 428 | ); | ||
| 429 | 428 | ||
| 430 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | 429 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); |
| 431 | 430 | ||
| ... | @@ -450,18 +449,18 @@ describe("addSavedEnvironment", () => { | ... | @@ -450,18 +449,18 @@ describe("addSavedEnvironment", () => { |
| 450 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | 449 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); |
| 451 | mockBootstrapSshBearerSession | 450 | mockBootstrapSshBearerSession |
| 452 | .mockResolvedValueOnce({ | 451 | .mockResolvedValueOnce({ |
| 453 | sessionToken: "ssh-bearer-token", | 452 | access_token: "ssh-bearer-token", |
| 454 | role: "owner", | 453 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 455 | }) | 454 | }) |
| 456 | .mockResolvedValueOnce({ | 455 | .mockResolvedValueOnce({ |
| 457 | sessionToken: "ssh-bearer-token-2", | 456 | access_token: "ssh-bearer-token-2", |
| 458 | role: "owner", | 457 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 459 | }); | 458 | }); |
| 460 | mockFetchSshSessionState | 459 | mockFetchSshSessionState |
| 461 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) | 460 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) |
| 462 | .mockResolvedValueOnce({ | 461 | .mockResolvedValueOnce({ |
| 463 | authenticated: true, | 462 | authenticated: true, |
| 464 | role: "owner", | 463 | scopes: ["orchestration:read", "access:write"], |
| 465 | }); | 464 | }); |
| 466 | 465 | ||
| 467 | const createdConnections: Array<{ | 466 | const createdConnections: Array<{ |
| ... | @@ -745,7 +744,7 @@ describe("addSavedEnvironment", () => { | ... | @@ -745,7 +744,7 @@ describe("addSavedEnvironment", () => { |
| 745 | ); | 744 | ); |
| 746 | let resolveSessionState!: (value: { | 745 | let resolveSessionState!: (value: { |
| 747 | readonly authenticated: true; | 746 | readonly authenticated: true; |
| 748 | readonly role: "owner"; | 747 | readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; |
| 749 | }) => void; | 748 | }) => void; |
| 750 | mockFetchRemoteSessionState.mockReturnValue( | 749 | mockFetchRemoteSessionState.mockReturnValue( |
| 751 | new Promise((resolve) => { | 750 | new Promise((resolve) => { |
| ... | @@ -768,7 +767,7 @@ describe("addSavedEnvironment", () => { | ... | @@ -768,7 +767,7 @@ describe("addSavedEnvironment", () => { |
| 768 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); | 767 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); |
| 769 | resolveSessionState({ | 768 | resolveSessionState({ |
| 770 | authenticated: true, | 769 | authenticated: true, |
| 771 | role: "owner", | 770 | scopes: ["orchestration:read", "access:write"], |
| 772 | }); | 771 | }); |
| 773 | await expect(reconnectPromise).resolves.toBeUndefined(); | 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,7 +222,7 @@ describe("saved environment startup", () => { |
| 222 | 222 | ||
| 223 | mockFetchRemoteSessionState.mockResolvedValue({ | 223 | mockFetchRemoteSessionState.mockResolvedValue({ |
| 224 | authenticated: true, | 224 | authenticated: true, |
| 225 | role: "owner", | 225 | scopes: ["orchestration:read", "access:write"], |
| 226 | }); | 226 | }); |
| 227 | mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) => | 227 | mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) => |
| 228 | environmentId === savedRecord.environmentId ? savedRecord : null, | 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,7 +153,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 153 | ...actual, | 153 | ...actual, |
| 154 | createWsRpcClient: vi.fn(() => stubWsClient), | 154 | createWsRpcClient: vi.fn(() => stubWsClient), |
| 155 | fetchRemoteSessionState: mockFetchRemoteSessionState, | 155 | fetchRemoteSessionState: mockFetchRemoteSessionState, |
| 156 | isRemoteEnvironmentAuthHttpError: vi.fn(() => false), | ||
| 157 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | 156 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, |
| 158 | }; | 157 | }; |
| 159 | }); | 158 | }); |
| ... | @@ -304,7 +303,7 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -304,7 +303,7 @@ describe("retainThreadDetailSubscription", () => { |
| 304 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | 303 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); |
| 305 | mockFetchRemoteSessionState.mockResolvedValue({ | 304 | mockFetchRemoteSessionState.mockResolvedValue({ |
| 306 | authenticated: true, | 305 | authenticated: true, |
| 307 | role: "client", | 306 | scopes: ["orchestration:read"], |
| 308 | }); | 307 | }); |
| 309 | mockConnectionReconnects.length = 0; | 308 | mockConnectionReconnects.length = 0; |
| 310 | }); | 309 | }); |
apps/web/src/environments/runtime/service.ts+31-22| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | type AuthSessionRole, | 2 | AuthEnvironmentScope, |
| 3 | AuthStandardClientScopes, | ||
| 3 | type DesktopSshEnvironmentBootstrap, | 4 | type DesktopSshEnvironmentBootstrap, |
| 4 | type DesktopSshEnvironmentTarget, | 5 | type DesktopSshEnvironmentTarget, |
| 5 | type EnvironmentId, | 6 | type EnvironmentId, |
| ... | @@ -7,6 +8,7 @@ import { | ... | @@ -7,6 +8,7 @@ import { |
| 7 | type OrchestrationShellSnapshot, | 8 | type OrchestrationShellSnapshot, |
| 8 | type OrchestrationShellStreamEvent, | 9 | type OrchestrationShellStreamEvent, |
| 9 | type ServerConfig, | 10 | type ServerConfig, |
| 11 | EnvironmentAuthInvalidError, | ||
| 10 | ThreadId, | 12 | ThreadId, |
| 11 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 12 | import { | 14 | import { |
| ... | @@ -15,12 +17,12 @@ import { | ... | @@ -15,12 +17,12 @@ import { |
| 15 | bootstrapRemoteBearerSession, | 17 | bootstrapRemoteBearerSession, |
| 16 | fetchRemoteEnvironmentDescriptor, | 18 | fetchRemoteEnvironmentDescriptor, |
| 17 | fetchRemoteSessionState, | 19 | fetchRemoteSessionState, |
| 18 | isRemoteEnvironmentAuthHttpError, | ||
| 19 | resolveRemoteWebSocketConnectionUrl, | 20 | resolveRemoteWebSocketConnectionUrl, |
| 20 | } from "@t3tools/client-runtime"; | 21 | } from "@t3tools/client-runtime"; |
| 21 | 22 | ||
| 22 | import { type QueryClient } from "@tanstack/react-query"; | 23 | import { type QueryClient } from "@tanstack/react-query"; |
| 23 | import { Throttler } from "@tanstack/react-pacer"; | 24 | import { Throttler } from "@tanstack/react-pacer"; |
| 25 | import * as Schema from "effect/Schema"; | ||
| 24 | import { | 26 | import { |
| 25 | createKnownEnvironment, | 27 | createKnownEnvironment, |
| 26 | getKnownEnvironmentWsBaseUrl, | 28 | getKnownEnvironmentWsBaseUrl, |
| ... | @@ -76,6 +78,8 @@ import { | ... | @@ -76,6 +78,8 @@ import { |
| 76 | deriveLogicalProjectKeyFromSettings, | 78 | deriveLogicalProjectKeyFromSettings, |
| 77 | derivePhysicalProjectKey, | 79 | derivePhysicalProjectKey, |
| 78 | } from "../../logicalProject"; | 80 | } from "../../logicalProject"; |
| 81 | |||
| 82 | const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); | ||
| 79 | import { getClientSettings } from "~/hooks/useSettings"; | 83 | import { getClientSettings } from "~/hooks/useSettings"; |
| 80 | import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; | 84 | import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; |
| 81 | import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; | 85 | import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; |
| ... | @@ -99,6 +103,7 @@ type ThreadDetailSubscriptionEntry = { | ... | @@ -99,6 +103,7 @@ type ThreadDetailSubscriptionEntry = { |
| 99 | }; | 103 | }; |
| 100 | 104 | ||
| 101 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); | 105 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); |
| 106 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); | ||
| 102 | 107 | ||
| 103 | function isSavedEnvironmentConnectionCancelledError( | 108 | function isSavedEnvironmentConnectionCancelledError( |
| 104 | error: unknown, | 109 | error: unknown, |
| ... | @@ -738,6 +743,10 @@ async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: | ... | @@ -738,6 +743,10 @@ async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: |
| 738 | return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); | 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 | async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { | 750 | async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { |
| 742 | return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); | 751 | return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); |
| 743 | } | 752 | } |
| ... | @@ -747,9 +756,9 @@ async function resolveDesktopSshWebSocketConnectionUrl( | ... | @@ -747,9 +756,9 @@ async function resolveDesktopSshWebSocketConnectionUrl( |
| 747 | httpBaseUrl: string, | 756 | httpBaseUrl: string, |
| 748 | bearerToken: string, | 757 | bearerToken: string, |
| 749 | ) { | 758 | ) { |
| 750 | const issued = await getDesktopSshBridge().issueSshWebSocketToken(httpBaseUrl, bearerToken); | 759 | const issued = await getDesktopSshBridge().issueSshWebSocketTicket(httpBaseUrl, bearerToken); |
| 751 | const url = new URL(wsBaseUrl, window.location.origin); | 760 | const url = new URL(wsBaseUrl, window.location.origin); |
| 752 | url.searchParams.set("wsToken", issued.token); | 761 | url.searchParams.set("wsTicket", issued.ticket); |
| 753 | return url.toString(); | 762 | return url.toString(); |
| 754 | } | 763 | } |
| 755 | 764 | ||
| ... | @@ -799,7 +808,7 @@ async function prepareSavedEnvironmentRecordForConnection( | ... | @@ -799,7 +808,7 @@ async function prepareSavedEnvironmentRecordForConnection( |
| 799 | async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ | 808 | async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ |
| 800 | readonly record: SavedEnvironmentRecord; | 809 | readonly record: SavedEnvironmentRecord; |
| 801 | readonly bearerToken: string; | 810 | readonly bearerToken: string; |
| 802 | readonly role: AuthSessionRole | null; | 811 | readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null; |
| 803 | }> { | 812 | }> { |
| 804 | const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); | 813 | const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); |
| 805 | const prepared = await prepareSavedEnvironmentRecordForConnection(record, { | 814 | const prepared = await prepareSavedEnvironmentRecordForConnection(record, { |
| ... | @@ -827,7 +836,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro | ... | @@ -827,7 +836,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro |
| 827 | }); | 836 | }); |
| 828 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( | 837 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( |
| 829 | prepared.record.environmentId, | 838 | prepared.record.environmentId, |
| 830 | bearerSession.sessionToken, | 839 | bearerSession.access_token, |
| 831 | ); | 840 | ); |
| 832 | if (!didPersistBearerToken) { | 841 | if (!didPersistBearerToken) { |
| 833 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | 842 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); |
| ... | @@ -836,8 +845,8 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro | ... | @@ -836,8 +845,8 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro |
| 836 | 845 | ||
| 837 | return { | 846 | return { |
| 838 | record: prepared.record, | 847 | record: prepared.record, |
| 839 | bearerToken: bearerSession.sessionToken, | 848 | bearerToken: bearerSession.access_token, |
| 840 | role: bearerSession.role ?? null, | 849 | scopes: readIssuedBearerScopes(bearerSession.scope), |
| 841 | }; | 850 | }; |
| 842 | } | 851 | } |
| 843 | 852 | ||
| ... | @@ -1214,7 +1223,7 @@ async function refreshSavedEnvironmentMetadata( | ... | @@ -1214,7 +1223,7 @@ async function refreshSavedEnvironmentMetadata( |
| 1214 | environmentId: EnvironmentId, | 1223 | environmentId: EnvironmentId, |
| 1215 | bearerToken: string, | 1224 | bearerToken: string, |
| 1216 | client: WsRpcClient, | 1225 | client: WsRpcClient, |
| 1217 | roleHint?: AuthSessionRole | null, | 1226 | scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null, |
| 1218 | configHint?: ServerConfig | null, | 1227 | configHint?: ServerConfig | null, |
| 1219 | ): Promise<void> { | 1228 | ): Promise<void> { |
| 1220 | const record = getSavedEnvironmentRecord(environmentId); | 1229 | const record = getSavedEnvironmentRecord(environmentId); |
| ... | @@ -1238,7 +1247,7 @@ async function refreshSavedEnvironmentMetadata( | ... | @@ -1238,7 +1247,7 @@ async function refreshSavedEnvironmentMetadata( |
| 1238 | authState: sessionState.authenticated ? "authenticated" : "requires-auth", | 1247 | authState: sessionState.authenticated ? "authenticated" : "requires-auth", |
| 1239 | descriptor: serverConfig.environment, | 1248 | descriptor: serverConfig.environment, |
| 1240 | serverConfig, | 1249 | serverConfig, |
| 1241 | role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null, | 1250 | scopes: sessionState.authenticated ? (sessionState.scopes ?? scopeHint ?? null) : null, |
| 1242 | }); | 1251 | }); |
| 1243 | useSavedEnvironmentRegistryStore | 1252 | useSavedEnvironmentRegistryStore |
| 1244 | .getState() | 1253 | .getState() |
| ... | @@ -1311,7 +1320,7 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1311,7 +1320,7 @@ async function ensureSavedEnvironmentConnection( |
| 1311 | options?: { | 1320 | options?: { |
| 1312 | readonly client?: WsRpcClient; | 1321 | readonly client?: WsRpcClient; |
| 1313 | readonly bearerToken?: string; | 1322 | readonly bearerToken?: string; |
| 1314 | readonly role?: AuthSessionRole | null; | 1323 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null; |
| 1315 | readonly serverConfig?: ServerConfig | null; | 1324 | readonly serverConfig?: ServerConfig | null; |
| 1316 | }, | 1325 | }, |
| 1317 | ): Promise<EnvironmentConnection> { | 1326 | ): Promise<EnvironmentConnection> { |
| ... | @@ -1330,7 +1339,7 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1330,7 +1339,7 @@ async function ensureSavedEnvironmentConnection( |
| 1330 | isCurrent: attempt.isCurrent, | 1339 | isCurrent: attempt.isCurrent, |
| 1331 | promise: Promise.resolve().then(async () => { | 1340 | promise: Promise.resolve().then(async () => { |
| 1332 | let activeRecord = record; | 1341 | let activeRecord = record; |
| 1333 | let roleHint = options?.role ?? null; | 1342 | let scopeHint = options?.scopes ?? null; |
| 1334 | let bearerToken = | 1343 | let bearerToken = |
| 1335 | options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); | 1344 | options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); |
| 1336 | if (!bearerToken) { | 1345 | if (!bearerToken) { |
| ... | @@ -1338,11 +1347,11 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1338,11 +1347,11 @@ async function ensureSavedEnvironmentConnection( |
| 1338 | const issued = await issueDesktopSshBearerSession(record); | 1347 | const issued = await issueDesktopSshBearerSession(record); |
| 1339 | activeRecord = issued.record; | 1348 | activeRecord = issued.record; |
| 1340 | bearerToken = issued.bearerToken; | 1349 | bearerToken = issued.bearerToken; |
| 1341 | roleHint = issued.role; | 1350 | scopeHint = [...AuthStandardClientScopes]; |
| 1342 | } else { | 1351 | } else { |
| 1343 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1352 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| 1344 | authState: "requires-auth", | 1353 | authState: "requires-auth", |
| 1345 | role: null, | 1354 | scopes: null, |
| 1346 | connectionState: "disconnected", | 1355 | connectionState: "disconnected", |
| 1347 | lastError: "Saved environment is missing its saved credential. Pair it again.", | 1356 | lastError: "Saved environment is missing its saved credential. Pair it again.", |
| 1348 | lastErrorAt: isoNow(), | 1357 | lastErrorAt: isoNow(), |
| ... | @@ -1409,13 +1418,13 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1409,13 +1418,13 @@ async function ensureSavedEnvironmentConnection( |
| 1409 | activeRecord.environmentId, | 1418 | activeRecord.environmentId, |
| 1410 | activeBearerToken, | 1419 | activeBearerToken, |
| 1411 | client, | 1420 | client, |
| 1412 | roleHint, | 1421 | scopeHint, |
| 1413 | initialServerConfig, | 1422 | initialServerConfig, |
| 1414 | ); | 1423 | ); |
| 1415 | } catch (error) { | 1424 | } catch (error) { |
| 1416 | const isAuthError = activeRecord.desktopSsh | 1425 | const isAuthError = activeRecord.desktopSsh |
| 1417 | ? isSshHttpAuthError(error, 401) | 1426 | ? isSshHttpAuthError(error, 401) |
| 1418 | : isRemoteEnvironmentAuthHttpError(error) && error.status === 401; | 1427 | : isEnvironmentAuthInvalidError(error); |
| 1419 | if (!isAuthError) { | 1428 | if (!isAuthError) { |
| 1420 | throw error; | 1429 | throw error; |
| 1421 | } | 1430 | } |
| ... | @@ -1429,12 +1438,12 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1429,12 +1438,12 @@ async function ensureSavedEnvironmentConnection( |
| 1429 | const issued = await issueDesktopSshBearerSession(activeRecord); | 1438 | const issued = await issueDesktopSshBearerSession(activeRecord); |
| 1430 | activeRecord = issued.record; | 1439 | activeRecord = issued.record; |
| 1431 | bearerToken = issued.bearerToken; | 1440 | bearerToken = issued.bearerToken; |
| 1432 | roleHint = issued.role; | 1441 | scopeHint = [...AuthStandardClientScopes]; |
| 1433 | await connection.dispose().catch(() => undefined); | 1442 | await connection.dispose().catch(() => undefined); |
| 1434 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); | 1443 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); |
| 1435 | return await ensureSavedEnvironmentConnection(activeRecord, { | 1444 | return await ensureSavedEnvironmentConnection(activeRecord, { |
| 1436 | bearerToken, | 1445 | bearerToken, |
| 1437 | role: roleHint, | 1446 | scopes: scopeHint, |
| 1438 | serverConfig: options?.serverConfig ?? null, | 1447 | serverConfig: options?.serverConfig ?? null, |
| 1439 | }); | 1448 | }); |
| 1440 | } | 1449 | } |
| ... | @@ -1638,7 +1647,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P | ... | @@ -1638,7 +1647,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P |
| 1638 | await removeConnection(environmentId).catch(() => false); | 1647 | await removeConnection(environmentId).catch(() => false); |
| 1639 | await ensureSavedEnvironmentConnection(issued.record, { | 1648 | await ensureSavedEnvironmentConnection(issued.record, { |
| 1640 | bearerToken: issued.bearerToken, | 1649 | bearerToken: issued.bearerToken, |
| 1641 | role: issued.role, | 1650 | scopes: [...AuthStandardClientScopes], |
| 1642 | }); | 1651 | }); |
| 1643 | return; | 1652 | return; |
| 1644 | } catch (recoveryError) { | 1653 | } catch (recoveryError) { |
| ... | @@ -1714,7 +1723,7 @@ export async function addSavedEnvironment(input: { | ... | @@ -1714,7 +1723,7 @@ export async function addSavedEnvironment(input: { |
| 1714 | await persistSavedEnvironmentRecord(record); | 1723 | await persistSavedEnvironmentRecord(record); |
| 1715 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( | 1724 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( |
| 1716 | environmentId, | 1725 | environmentId, |
| 1717 | bearerSession.sessionToken, | 1726 | bearerSession.access_token, |
| 1718 | ); | 1727 | ); |
| 1719 | if (!didPersistBearerToken) { | 1728 | if (!didPersistBearerToken) { |
| 1720 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | 1729 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); |
| ... | @@ -1726,8 +1735,8 @@ export async function addSavedEnvironment(input: { | ... | @@ -1726,8 +1735,8 @@ export async function addSavedEnvironment(input: { |
| 1726 | } | 1735 | } |
| 1727 | await removeConnection(environmentId).catch(() => false); | 1736 | await removeConnection(environmentId).catch(() => false); |
| 1728 | await ensureSavedEnvironmentConnection(record, { | 1737 | await ensureSavedEnvironmentConnection(record, { |
| 1729 | bearerToken: bearerSession.sessionToken, | 1738 | bearerToken: bearerSession.access_token, |
| 1730 | role: bearerSession.role, | 1739 | scopes: readIssuedBearerScopes(bearerSession.scope), |
| 1731 | }); | 1740 | }); |
| 1732 | return record; | 1741 | return record; |
| 1733 | } | 1742 | } |
apps/web/src/lib/runtime.ts+34| ... | @@ -1,5 +1,39 @@ | ... | @@ -1,5 +1,39 @@ |
| 1 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 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 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; |
| 7 | import { | ||
| 8 | PrimaryEnvironmentHttpClient, | ||
| 9 | primaryEnvironmentHttpClientLive, | ||
| 10 | } from "../environments/primary/httpClient"; | ||
| 4 | 11 | ||
| 5 | export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch)); | 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,8 +202,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg |
| 202 | fetchSshSessionState: async () => { | 202 | fetchSshSessionState: async () => { |
| 203 | throw new Error("fetchSshSessionState not implemented in test"); | 203 | throw new Error("fetchSshSessionState not implemented in test"); |
| 204 | }, | 204 | }, |
| 205 | issueSshWebSocketToken: async () => { | 205 | issueSshWebSocketTicket: async () => { |
| 206 | throw new Error("issueSshWebSocketToken not implemented in test"); | 206 | throw new Error("issueSshWebSocketTicket not implemented in test"); |
| 207 | }, | 207 | }, |
| 208 | onSshPasswordPrompt: () => () => undefined, | 208 | onSshPasswordPrompt: () => () => undefined, |
| 209 | resolveSshPasswordPrompt: async () => undefined, | 209 | resolveSshPasswordPrompt: async () => undefined, |
| ... | @@ -289,7 +289,7 @@ const baseServerConfig: ServerConfig = { | ... | @@ -289,7 +289,7 @@ const baseServerConfig: ServerConfig = { |
| 289 | auth: { | 289 | auth: { |
| 290 | policy: "loopback-browser", | 290 | policy: "loopback-browser", |
| 291 | bootstrapMethods: ["one-time-token"], | 291 | bootstrapMethods: ["one-time-token"], |
| 292 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | 292 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], |
| 293 | sessionCookieName: "t3_session", | 293 | sessionCookieName: "t3_session", |
| 294 | }, | 294 | }, |
| 295 | cwd: "/tmp/workspace", | 295 | cwd: "/tmp/workspace", |
apps/web/src/rpc/serverState.test.ts+1-1| ... | @@ -76,7 +76,7 @@ const baseServerConfig: ServerConfig = { | ... | @@ -76,7 +76,7 @@ const baseServerConfig: ServerConfig = { |
| 76 | auth: { | 76 | auth: { |
| 77 | policy: "loopback-browser", | 77 | policy: "loopback-browser", |
| 78 | bootstrapMethods: ["one-time-token"], | 78 | bootstrapMethods: ["one-time-token"], |
| 79 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | 79 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], |
| 80 | sessionCookieName: "t3_session", | 80 | sessionCookieName: "t3_session", |
| 81 | }, | 81 | }, |
| 82 | cwd: "/tmp/workspace", | 82 | cwd: "/tmp/workspace", |
apps/web/test/authHttpHandlers.ts+77-15| ... | @@ -1,11 +1,27 @@ | ... | @@ -1,11 +1,27 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthSessionId, | ||
| 3 | EnvironmentAuthenticatedAuth, | ||
| 4 | EnvironmentAuthenticatedPrincipal, | ||
| 5 | EnvironmentAuthHttpApi, | ||
| 2 | EnvironmentId, | 6 | EnvironmentId, |
| 7 | EnvironmentMetadataHttpApi, | ||
| 8 | type AuthEnvironmentScope, | ||
| 3 | type ExecutionEnvironmentDescriptor, | 9 | type ExecutionEnvironmentDescriptor, |
| 4 | type ServerAuthDescriptor, | 10 | type ServerAuthDescriptor, |
| 5 | } from "@t3tools/contracts"; | 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 | const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { | 25 | const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { |
| 10 | environmentId: EnvironmentId.make("environment-local"), | 26 | environmentId: EnvironmentId.make("environment-local"), |
| 11 | label: "Local environment", | 27 | label: "Local environment", |
| ... | @@ -19,23 +35,69 @@ const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { | ... | @@ -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 | export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { | 41 | export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { |
| 23 | return [ | 42 | const authenticatedAuthLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => |
| 24 | http.get("*/.well-known/t3/environment", () => HttpResponse.json(TEST_ENVIRONMENT_DESCRIPTOR)), | 43 | httpEffect.pipe( |
| 25 | http.get("*/api/auth/session", () => | 44 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { |
| 26 | HttpResponse.json({ | 45 | sessionId: AuthSessionId.make("browser-session"), |
| 27 | authenticated: true, | 46 | subject: "browser-client", |
| 28 | auth: getAuthDescriptor(), | 47 | method: "browser-session-cookie", |
| 29 | sessionMethod: "browser-session-cookie", | 48 | scopes: new Set<AuthEnvironmentScope>(), |
| 30 | expiresAt: TEST_SESSION_EXPIRES_AT, | 49 | expiresAt: TEST_SESSION_EXPIRES_AT, |
| 31 | }), | 50 | }), |
| 32 | ), | 51 | ), |
| 33 | http.post("*/api/auth/bootstrap", () => | 52 | ); |
| 34 | HttpResponse.json({ | 53 | const metadataLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "metadata", (handlers) => |
| 35 | authenticated: true, | 54 | handlers.handle("descriptor", () => Effect.succeed(TEST_ENVIRONMENT_DESCRIPTOR)), |
| 36 | sessionMethod: "browser-session-cookie", | 55 | ); |
| 37 | expiresAt: TEST_SESSION_EXPIRES_AT, | 56 | const authLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "auth", (handlers) => |
| 38 | }), | 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 | ] as const; | 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,6 +20,7 @@ |
| 20 | "version": "0.0.24", | 20 | "version": "0.0.24", |
| 21 | "dependencies": { | 21 | "dependencies": { |
| 22 | "@effect/platform-node": "catalog:", | 22 | "@effect/platform-node": "catalog:", |
| 23 | "@t3tools/client-runtime": "workspace:*", | ||
| 23 | "@t3tools/contracts": "workspace:*", | 24 | "@t3tools/contracts": "workspace:*", |
| 24 | "@t3tools/shared": "workspace:*", | 25 | "@t3tools/shared": "workspace:*", |
| 25 | "@t3tools/ssh": "workspace:*", | 26 | "@t3tools/ssh": "workspace:*", |
| ... | @@ -179,6 +180,7 @@ | ... | @@ -179,6 +180,7 @@ |
| 179 | "zustand": "^5.0.11", | 180 | "zustand": "^5.0.11", |
| 180 | }, | 181 | }, |
| 181 | "devDependencies": { | 182 | "devDependencies": { |
| 183 | "@effect/platform-node": "catalog:", | ||
| 182 | "@rolldown/plugin-babel": "^0.2.0", | 184 | "@rolldown/plugin-babel": "^0.2.0", |
| 183 | "@tailwindcss/vite": "^4.0.0", | 185 | "@tailwindcss/vite": "^4.0.0", |
| 184 | "@tanstack/router-plugin": "^1.161.0", | 186 | "@tanstack/router-plugin": "^1.161.0", |
| ... | @@ -187,7 +189,7 @@ | ... | @@ -187,7 +189,7 @@ |
| 187 | "@types/react-dom": "~19.2.3", | 189 | "@types/react-dom": "~19.2.3", |
| 188 | "@vercel/config": "^0.3.0", | 190 | "@vercel/config": "^0.3.0", |
| 189 | "@vitejs/plugin-react": "^6.0.0", | 191 | "@vitejs/plugin-react": "^6.0.0", |
| 190 | "@vitest/browser-playwright": "^4.0.18", | 192 | "@vitest/browser-playwright": "catalog:", |
| 191 | "babel-plugin-react-compiler": "1.0.0", | 193 | "babel-plugin-react-compiler": "1.0.0", |
| 192 | "msw": "2.12.11", | 194 | "msw": "2.12.11", |
| 193 | "playwright": "^1.58.2", | 195 | "playwright": "^1.58.2", |
| ... | @@ -345,10 +347,11 @@ | ... | @@ -345,10 +347,11 @@ |
| 345 | "@types/bun": "^1.3.11", | 347 | "@types/bun": "^1.3.11", |
| 346 | "@types/node": "^24.10.13", | 348 | "@types/node": "^24.10.13", |
| 347 | "@typescript/native-preview": "7.0.0-dev.20260527.2", | 349 | "@typescript/native-preview": "7.0.0-dev.20260527.2", |
| 350 | "@vitest/browser-playwright": "4.1.0", | ||
| 348 | "effect": "4.0.0-beta.73", | 351 | "effect": "4.0.0-beta.73", |
| 349 | "tsdown": "^0.20.3", | 352 | "tsdown": "^0.20.3", |
| 350 | "typescript": "~6.0.3", | 353 | "typescript": "~6.0.3", |
| 351 | "vitest": "^4.0.0", | 354 | "vitest": "4.1.0", |
| 352 | }, | 355 | }, |
| 353 | "packages": { | 356 | "packages": { |
| 354 | "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], | 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,7 +24,8 @@ |
| 24 | "@typescript/native-preview": "7.0.0-dev.20260527.2", | 24 | "@typescript/native-preview": "7.0.0-dev.20260527.2", |
| 25 | "tsdown": "^0.20.3", | 25 | "tsdown": "^0.20.3", |
| 26 | "typescript": "~6.0.3", | 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 | "type": "module", | 31 | "type": "module", |
packages/client-runtime/src/remote.test.ts+167-32| ... | @@ -2,19 +2,23 @@ import { describe, expect, it } from "@effect/vitest"; | ... | @@ -2,19 +2,23 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Duration from "effect/Duration"; | 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as TestClock from "effect/testing/TestClock"; | 6 | import * as TestClock from "effect/testing/TestClock"; |
| 6 | import { HttpClient } from "effect/unstable/http"; | ||
| 7 | 7 | ||
| 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; | ||
| 8 | import { | 9 | import { |
| 9 | bootstrapRemoteBearerSession, | 10 | bootstrapRemoteBearerSession, |
| 10 | fetchRemoteEnvironmentDescriptor, | 11 | fetchRemoteEnvironmentDescriptor, |
| 11 | fetchRemoteSessionState, | 12 | fetchRemoteSessionState, |
| 12 | issueRemoteWebSocketToken, | 13 | issueRemoteWebSocketTicket, |
| 13 | remoteHttpClientLayer, | 14 | remoteHttpClientLayer, |
| 15 | RemoteEnvironmentAuthInvalidJsonError, | ||
| 14 | RemoteEnvironmentAuthTimeoutError, | 16 | RemoteEnvironmentAuthTimeoutError, |
| 15 | resolveRemoteWebSocketConnectionUrl, | 17 | resolveRemoteWebSocketConnectionUrl, |
| 16 | } from "./remote.ts"; | 18 | } from "./remote.ts"; |
| 17 | 19 | ||
| 20 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); | ||
| 21 | |||
| 18 | type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; | 22 | type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; |
| 19 | 23 | ||
| 20 | const recordedFetch = (...responses: ReadonlyArray<Response>) => { | 24 | const recordedFetch = (...responses: ReadonlyArray<Response>) => { |
| ... | @@ -42,10 +46,7 @@ const hangingFetch = () => { | ... | @@ -42,10 +46,7 @@ const hangingFetch = () => { |
| 42 | return { fetchFn, calls }; | 46 | return { fetchFn, calls }; |
| 43 | }; | 47 | }; |
| 44 | 48 | ||
| 45 | const provideRemoteHttp = | 49 | const provideRemoteHttp = (fetchFn: typeof fetch) => Effect.provide(remoteHttpClientLayer(fetchFn)); |
| 46 | (fetchFn: typeof fetch) => | ||
| 47 | <A, E, R>(effect: Effect.Effect<A, E, R | HttpClient.HttpClient>) => | ||
| 48 | effect.pipe(Effect.provide(remoteHttpClientLayer(fetchFn))); | ||
| 49 | 50 | ||
| 50 | const expectFetchCall = ( | 51 | const expectFetchCall = ( |
| 51 | calls: ReadonlyArray<FetchCall>, | 52 | calls: ReadonlyArray<FetchCall>, |
| ... | @@ -90,11 +91,12 @@ describe("remote", () => { | ... | @@ -90,11 +91,12 @@ describe("remote", () => { |
| 90 | const fetch = recordedFetch( | 91 | const fetch = recordedFetch( |
| 91 | Response.json( | 92 | Response.json( |
| 92 | { | 93 | { |
| 93 | authenticated: true, | 94 | access_token: "bearer-token", |
| 94 | role: "client", | 95 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 95 | sessionMethod: "bearer-session-token", | 96 | token_type: "Bearer", |
| 96 | expiresAt: "2026-05-01T12:00:00.000Z", | 97 | expires_in: 3600, |
| 97 | sessionToken: "bearer-token", | 98 | scope: |
| 99 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 98 | }, | 100 | }, |
| 99 | { status: 200 }, | 101 | { status: 200 }, |
| 100 | ), | 102 | ), |
| ... | @@ -106,16 +108,80 @@ describe("remote", () => { | ... | @@ -106,16 +108,80 @@ describe("remote", () => { |
| 106 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | 108 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 107 | 109 | ||
| 108 | expect(result).toMatchObject({ | 110 | expect(result).toMatchObject({ |
| 109 | sessionMethod: "bearer-session-token", | 111 | token_type: "Bearer", |
| 110 | sessionToken: "bearer-token", | 112 | access_token: "bearer-token", |
| 113 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 111 | }); | 114 | }); |
| 112 | expectFetchCall(fetch.calls, 1, { | 115 | expectFetchCall(fetch.calls, 1, { |
| 113 | url: "https://remote.example.com/api/auth/bootstrap/bearer", | 116 | url: "https://remote.example.com/oauth/token", |
| 114 | method: "POST", | 117 | method: "POST", |
| 115 | headers: { | 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,18 +210,24 @@ describe("remote", () => { |
| 144 | auth: { | 210 | auth: { |
| 145 | policy: "remote-reachable", | 211 | policy: "remote-reachable", |
| 146 | bootstrapMethods: ["one-time-token"], | 212 | bootstrapMethods: ["one-time-token"], |
| 147 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | 213 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], |
| 148 | sessionCookieName: "t3_session", | 214 | sessionCookieName: "t3_session", |
| 149 | }, | 215 | }, |
| 150 | role: "client", | 216 | scopes: [ |
| 151 | sessionMethod: "bearer-session-token", | 217 | "orchestration:read", |
| 218 | "orchestration:operate", | ||
| 219 | "terminal:operate", | ||
| 220 | "review:write", | ||
| 221 | "relay:read", | ||
| 222 | ], | ||
| 223 | sessionMethod: "bearer-access-token", | ||
| 152 | expiresAt: "2026-05-01T12:00:00.000Z", | 224 | expiresAt: "2026-05-01T12:00:00.000Z", |
| 153 | }, | 225 | }, |
| 154 | { status: 200 }, | 226 | { status: 200 }, |
| 155 | ), | 227 | ), |
| 156 | Response.json( | 228 | Response.json( |
| 157 | { | 229 | { |
| 158 | token: "ws-token", | 230 | ticket: "ws-ticket", |
| 159 | expiresAt: "2026-05-01T12:05:00.000Z", | 231 | expiresAt: "2026-05-01T12:05:00.000Z", |
| 160 | }, | 232 | }, |
| 161 | { status: 200 }, | 233 | { status: 200 }, |
| ... | @@ -176,15 +248,21 @@ describe("remote", () => { | ... | @@ -176,15 +248,21 @@ describe("remote", () => { |
| 176 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | 248 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 177 | expect(session).toMatchObject({ | 249 | expect(session).toMatchObject({ |
| 178 | authenticated: true, | 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 | httpBaseUrl: "https://remote.example.com/", | 261 | httpBaseUrl: "https://remote.example.com/", |
| 184 | bearerToken: "bearer-token", | 262 | bearerToken: "bearer-token", |
| 185 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | 263 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 186 | expect(token).toMatchObject({ | 264 | expect(token).toMatchObject({ |
| 187 | token: "ws-token", | 265 | ticket: "ws-ticket", |
| 188 | }); | 266 | }); |
| 189 | 267 | ||
| 190 | expectFetchCall(fetch.calls, 1, { | 268 | expectFetchCall(fetch.calls, 1, { |
| ... | @@ -199,7 +277,7 @@ describe("remote", () => { | ... | @@ -199,7 +277,7 @@ describe("remote", () => { |
| 199 | }, | 277 | }, |
| 200 | }); | 278 | }); |
| 201 | expectFetchCall(fetch.calls, 3, { | 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 | method: "POST", | 281 | method: "POST", |
| 204 | headers: { | 282 | headers: { |
| 205 | authorization: "Bearer bearer-token", | 283 | authorization: "Bearer bearer-token", |
| ... | @@ -227,25 +305,82 @@ describe("remote", () => { | ... | @@ -227,25 +305,82 @@ describe("remote", () => { |
| 227 | }).pipe(Effect.provide(TestClock.layer())), | 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 | Effect.gen(function* () { | 309 | Effect.gen(function* () { |
| 232 | const fetch = recordedFetch( | 310 | const fetch = recordedFetch( |
| 233 | Response.json( | 311 | Response.json( |
| 234 | { | 312 | { |
| 235 | token: "ws-token", | 313 | _tag: "EnvironmentAuthInvalidError", |
| 236 | expiresAt: "2026-05-01T12:05:00.000Z", | 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 | { status: 200 }, | 347 | { status: 200 }, |
| 239 | ), | 348 | ), |
| 240 | ); | 349 | ); |
| 241 | 350 | ||
| 242 | const url = yield* resolveRemoteWebSocketConnectionUrl({ | 351 | const error = yield* bootstrapRemoteBearerSession({ |
| 243 | wsBaseUrl: "wss://remote.example.com/", | ||
| 244 | httpBaseUrl: "https://remote.example.com/", | 352 | httpBaseUrl: "https://remote.example.com/", |
| 245 | bearerToken: "bearer-token", | 353 | credential: "pairing-token", |
| 246 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | 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,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 | import type { | 11 | import type { |
| 2 | AuthBearerBootstrapResult, | 12 | EnvironmentAuthInvalidError, |
| 3 | AuthSessionState, | 13 | EnvironmentInternalError, |
| 4 | AuthWebSocketTokenResult, | 14 | EnvironmentOperationForbiddenError, |
| 5 | ExecutionEnvironmentDescriptor, | 15 | EnvironmentRequestInvalidError, |
| 16 | EnvironmentScopeRequiredError, | ||
| 6 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 7 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 8 | import * as Data from "effect/Data"; | 18 | import * as Data from "effect/Data"; |
| 9 | import * as Duration from "effect/Duration"; | 19 | import * as Duration from "effect/Duration"; |
| 10 | import * as Effect from "effect/Effect"; | 20 | import * as Effect from "effect/Effect"; |
| 11 | import * as Layer from "effect/Layer"; | 21 | import * as Layer from "effect/Layer"; |
| 12 | import * as Option from "effect/Option"; | 22 | import * as Option from "effect/Option"; |
| 13 | import * as Result from "effect/Result"; | ||
| 14 | import * as Schema from "effect/Schema"; | 23 | import * as Schema from "effect/Schema"; |
| 15 | import { identity } from "effect/Function"; | 24 | import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; |
| 16 | import { | 25 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; |
| 17 | FetchHttpClient, | ||
| 18 | HttpClient, | ||
| 19 | HttpClientRequest, | ||
| 20 | HttpClientResponse, | ||
| 21 | } from "effect/unstable/http"; | ||
| 22 | 26 | ||
| 23 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; | 27 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; |
| 24 | const RemoteAuthErrorBody = Schema.Struct({ | 28 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); |
| 25 | error: Schema.optional(Schema.String), | ||
| 26 | }); | ||
| 27 | const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody); | ||
| 28 | 29 | ||
| 29 | const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { | 30 | const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { |
| 30 | const url = new URL(httpBaseUrl); | 31 | const url = new URL(httpBaseUrl); |
| ... | @@ -34,6 +35,14 @@ const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { | ... | @@ -34,6 +35,14 @@ const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { |
| 34 | return url.toString(); | 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 | export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( | 46 | export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( |
| 38 | "RemoteEnvironmentAuthFetchError", | 47 | "RemoteEnvironmentAuthFetchError", |
| 39 | )<{ | 48 | )<{ |
| ... | @@ -41,13 +50,6 @@ export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( | ... | @@ -41,13 +50,6 @@ export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( |
| 41 | readonly cause: unknown; | 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 | export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( | 53 | export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( |
| 52 | "RemoteEnvironmentAuthInvalidJsonError", | 54 | "RemoteEnvironmentAuthInvalidJsonError", |
| 53 | )<{ | 55 | )<{ |
| ... | @@ -55,14 +57,19 @@ export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( | ... | @@ -55,14 +57,19 @@ export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( |
| 55 | readonly cause: unknown; | 57 | readonly cause: unknown; |
| 56 | }> {} | 58 | }> {} |
| 57 | 59 | ||
| 58 | export class RemoteEnvironmentAuthHttpError extends Data.TaggedError( | 60 | export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( |
| 59 | "RemoteEnvironmentAuthHttpError", | 61 | "RemoteEnvironmentAuthUndeclaredStatusError", |
| 60 | )<{ | 62 | )<{ |
| 61 | readonly message: string; | 63 | readonly message: string; |
| 62 | readonly status: number; | 64 | readonly status: number; |
| 65 | readonly requestUrl: string; | ||
| 63 | }> { | 66 | }> { |
| 64 | constructor(message: string, status: number) { | 67 | constructor(requestUrl: string, status: number) { |
| 65 | super({ message, status }); | 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,87 +90,67 @@ export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( |
| 83 | } | 90 | } |
| 84 | 91 | ||
| 85 | export type RemoteEnvironmentAuthError = | 92 | export type RemoteEnvironmentAuthError = |
| 93 | | EnvironmentRequestInvalidError | ||
| 94 | | EnvironmentAuthInvalidError | ||
| 95 | | EnvironmentScopeRequiredError | ||
| 96 | | EnvironmentOperationForbiddenError | ||
| 97 | | EnvironmentInternalError | ||
| 86 | | RemoteEnvironmentAuthFetchError | 98 | | RemoteEnvironmentAuthFetchError |
| 87 | | RemoteEnvironmentAuthResponseReadError | ||
| 88 | | RemoteEnvironmentAuthInvalidJsonError | 99 | | RemoteEnvironmentAuthInvalidJsonError |
| 89 | | RemoteEnvironmentAuthHttpError | 100 | | RemoteEnvironmentAuthUndeclaredStatusError |
| 90 | | RemoteEnvironmentAuthTimeoutError; | 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 | export const remoteHttpClientLayer = ( | 103 | export const remoteHttpClientLayer = ( |
| 138 | fetchFn: typeof globalThis.fetch, | 104 | fetchFn: typeof globalThis.fetch, |
| 139 | ): Layer.Layer<HttpClient.HttpClient> => | 105 | ): Layer.Layer<HttpClient.HttpClient> => |
| 140 | FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))); | 106 | FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))); |
| 141 | 107 | ||
| 142 | const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(function* <T>(input: { | 108 | const failRemoteRequest = ( |
| 143 | readonly httpBaseUrl: string; | 109 | requestUrl: string, |
| 144 | readonly pathname: string; | 110 | cause: unknown, |
| 145 | readonly method?: "GET" | "POST"; | 111 | ): Effect.Effect<never, RemoteEnvironmentAuthError> => { |
| 146 | readonly bearerToken?: string; | 112 | if (cause instanceof RemoteEnvironmentAuthTimeoutError) { |
| 147 | readonly body?: unknown; | 113 | return Effect.fail(cause); |
| 148 | readonly timeoutMs?: number; | 114 | } |
| 149 | }) { | 115 | if (isEnvironmentHttpCommonError(cause)) { |
| 150 | const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname); | 116 | return Effect.fail(cause); |
| 151 | const method = input.method ?? "GET"; | 117 | } |
| 152 | const timeoutMs = input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS; | 118 | if (Schema.isSchemaError(cause)) { |
| 153 | const request = HttpClientRequest.make(method)(requestUrl).pipe( | 119 | return Effect.fail( |
| 154 | input.bearerToken ? HttpClientRequest.bearerToken(input.bearerToken) : identity, | 120 | new RemoteEnvironmentAuthInvalidJsonError({ |
| 155 | input.body !== undefined ? HttpClientRequest.bodyJsonUnsafe(input.body) : identity, | 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; | 148 | const executeRemoteRequest = <A, E, R>( |
| 159 | const response = yield* client.execute(request).pipe( | 149 | requestUrl: string, |
| 160 | Effect.mapError( | 150 | timeoutMs: number, |
| 161 | (cause) => | 151 | request: Effect.Effect<A, E, R>, |
| 162 | new RemoteEnvironmentAuthFetchError({ | 152 | ): Effect.Effect<A, RemoteEnvironmentAuthError, R> => |
| 163 | message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`, | 153 | request.pipe( |
| 164 | cause, | ||
| 165 | }), | ||
| 166 | ), | ||
| 167 | Effect.timeoutOption(Duration.millis(timeoutMs)), | 154 | Effect.timeoutOption(Duration.millis(timeoutMs)), |
| 168 | Effect.flatMap( | 155 | Effect.flatMap( |
| 169 | Option.match({ | 156 | Option.match({ |
| ... | @@ -171,38 +158,46 @@ const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(functi | ... | @@ -171,38 +158,46 @@ const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(functi |
| 171 | onSome: Effect.succeed, | 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) { | 164 | export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => |
| 177 | return yield* readRemoteAuthErrorMessage( | 165 | Effect.gen(function* () { |
| 178 | response, | 166 | const httpClient = yield* HttpClient.HttpClient; |
| 179 | `Remote auth request failed (${response.status}).`, | 167 | return yield* HttpApiClient.makeWith(EnvironmentHttpApi, { |
| 180 | ).pipe( | 168 | httpClient, |
| 181 | Effect.flatMap((message) => | 169 | baseUrl: remoteApiBaseUrl(httpBaseUrl), |
| 182 | Effect.fail(new RemoteEnvironmentAuthHttpError(message, response.status)), | 170 | }); |
| 183 | ), | 171 | }); |
| 184 | ); | ||
| 185 | } | ||
| 186 | |||
| 187 | return yield* readRemoteJson<T>(response, requestUrl); | ||
| 188 | }); | ||
| 189 | 172 | ||
| 190 | export const bootstrapRemoteBearerSession = Effect.fn( | 173 | export const bootstrapRemoteBearerSession = Effect.fn( |
| 191 | "clientRuntime.remote.bootstrapRemoteBearerSession", | 174 | "clientRuntime.remote.bootstrapRemoteBearerSession", |
| 192 | )(function* (input: { | 175 | )(function* (input: { |
| 193 | readonly httpBaseUrl: string; | 176 | readonly httpBaseUrl: string; |
| 194 | readonly credential: string; | 177 | readonly credential: string; |
| 178 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 179 | readonly clientMetadata?: AuthClientPresentationMetadata; | ||
| 195 | readonly timeoutMs?: number; | 180 | readonly timeoutMs?: number; |
| 196 | }) { | 181 | }) { |
| 197 | return yield* fetchRemoteJson<AuthBearerBootstrapResult>({ | 182 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); |
| 198 | httpBaseUrl: input.httpBaseUrl, | 183 | return yield* executeRemoteRequest( |
| 199 | pathname: "/api/auth/bootstrap/bearer", | 184 | remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), |
| 200 | method: "POST", | 185 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 201 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | 186 | client.auth.token({ |
| 202 | body: { | 187 | payload: { |
| 203 | credential: input.credential, | 188 | grant_type: AuthTokenExchangeGrantType, |
| 204 | }, | 189 | subject_token: input.credential, |
| 205 | }); | 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 | export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")( | 203 | export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")( |
| ... | @@ -211,39 +206,47 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo | ... | @@ -211,39 +206,47 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo |
| 211 | readonly bearerToken: string; | 206 | readonly bearerToken: string; |
| 212 | readonly timeoutMs?: number; | 207 | readonly timeoutMs?: number; |
| 213 | }) { | 208 | }) { |
| 214 | return yield* fetchRemoteJson<AuthSessionState>({ | 209 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); |
| 215 | httpBaseUrl: input.httpBaseUrl, | 210 | return yield* executeRemoteRequest( |
| 216 | pathname: "/api/auth/session", | 211 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), |
| 217 | bearerToken: input.bearerToken, | 212 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 218 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | 213 | client.auth.session({ |
| 219 | }); | 214 | headers: { |
| 215 | authorization: `Bearer ${input.bearerToken}`, | ||
| 216 | }, | ||
| 217 | }), | ||
| 218 | ); | ||
| 220 | }, | 219 | }, |
| 221 | ); | 220 | ); |
| 222 | 221 | ||
| 223 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( | 222 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( |
| 224 | "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", | 223 | "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", |
| 225 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { | 224 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { |
| 226 | return yield* fetchRemoteJson<ExecutionEnvironmentDescriptor>({ | 225 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); |
| 227 | httpBaseUrl: input.httpBaseUrl, | 226 | return yield* executeRemoteRequest( |
| 228 | pathname: "/.well-known/t3/environment", | 227 | remoteEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), |
| 229 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | 228 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 230 | }); | 229 | client.metadata.descriptor(), |
| 230 | ); | ||
| 231 | }); | 231 | }); |
| 232 | 232 | ||
| 233 | export const issueRemoteWebSocketToken = Effect.fn( | 233 | export const issueRemoteWebSocketTicket = Effect.fn( |
| 234 | "clientRuntime.remote.issueRemoteWebSocketToken", | 234 | "clientRuntime.remote.issueRemoteWebSocketTicket", |
| 235 | )(function* (input: { | 235 | )(function* (input: { |
| 236 | readonly httpBaseUrl: string; | 236 | readonly httpBaseUrl: string; |
| 237 | readonly bearerToken: string; | 237 | readonly bearerToken: string; |
| 238 | readonly timeoutMs?: number; | 238 | readonly timeoutMs?: number; |
| 239 | }) { | 239 | }) { |
| 240 | return yield* fetchRemoteJson<AuthWebSocketTokenResult>({ | 240 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); |
| 241 | httpBaseUrl: input.httpBaseUrl, | 241 | return yield* executeRemoteRequest( |
| 242 | pathname: "/api/auth/ws-token", | 242 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), |
| 243 | method: "POST", | 243 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 244 | bearerToken: input.bearerToken, | 244 | client.auth.webSocketTicket({ |
| 245 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | 245 | headers: { |
| 246 | }); | 246 | authorization: `Bearer ${input.bearerToken}`, |
| 247 | }, | ||
| 248 | }), | ||
| 249 | ); | ||
| 247 | }); | 250 | }); |
| 248 | 251 | ||
| 249 | export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | 252 | export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| ... | @@ -254,7 +257,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ... | @@ -254,7 +257,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 254 | readonly bearerToken: string; | 257 | readonly bearerToken: string; |
| 255 | readonly timeoutMs?: number; | 258 | readonly timeoutMs?: number; |
| 256 | }) { | 259 | }) { |
| 257 | const issued = yield* issueRemoteWebSocketToken({ | 260 | const issued = yield* issueRemoteWebSocketTicket({ |
| 258 | httpBaseUrl: input.httpBaseUrl, | 261 | httpBaseUrl: input.httpBaseUrl, |
| 259 | bearerToken: input.bearerToken, | 262 | bearerToken: input.bearerToken, |
| 260 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | 263 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), |
| ... | @@ -264,6 +267,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ... | @@ -264,6 +267,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 264 | if (url.pathname === "" || url.pathname === "/") { | 267 | if (url.pathname === "" || url.pathname === "/") { |
| 265 | url.pathname = "/ws"; | 268 | url.pathname = "/ws"; |
| 266 | } | 269 | } |
| 267 | url.searchParams.set("wsToken", issued.token); | 270 | url.searchParams.set("wsTicket", issued.ticket); |
| 268 | return url.toString(); | 271 | return url.toString(); |
| 269 | }); | 272 | }); |
packages/client-runtime/src/wsTransport.test.ts+2-2| ... | @@ -170,13 +170,13 @@ describe("WsTransport", () => { | ... | @@ -170,13 +170,13 @@ describe("WsTransport", () => { |
| 170 | }); | 170 | }); |
| 171 | 171 | ||
| 172 | it("supports async websocket url providers", async () => { | 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 | await waitFor(() => { | 175 | await waitFor(() => { |
| 176 | expect(sockets).toHaveLength(1); | 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 | await transport.dispose(); | 180 | await transport.dispose(); |
| 181 | }); | 181 | }); |
| 182 | 182 |
packages/contracts/src/auth.ts+106-31| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; | ||
| 2 | 3 | ||
| 3 | import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 4 | import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | 5 | ||
| ... | @@ -60,17 +61,56 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; | ... | @@ -60,17 +61,56 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; |
| 60 | * Current methods: | 61 | * Current methods: |
| 61 | * - `browser-session-cookie`: cookie-backed browser session, used by the web | 62 | * - `browser-session-cookie`: cookie-backed browser session, used by the web |
| 62 | * app after bootstrap/pairing | 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 | * non-browser clients | 65 | * non-browser clients |
| 65 | */ | 66 | */ |
| 66 | export const ServerAuthSessionMethod = Schema.Literals([ | 67 | export const ServerAuthSessionMethod = Schema.Literals([ |
| 67 | "browser-session-cookie", | 68 | "browser-session-cookie", |
| 68 | "bearer-session-token", | 69 | "bearer-access-token", |
| 69 | ]); | 70 | ]); |
| 70 | export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; | 71 | export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; |
| 71 | 72 | ||
| 72 | export const AuthSessionRole = Schema.Literals(["owner", "client"]); | 73 | export const AuthOrchestrationReadScope = "orchestration:read" as const; |
| 73 | export type AuthSessionRole = typeof AuthSessionRole.Type; | 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 | * Server-advertised auth capabilities for a specific execution environment. | 116 | * Server-advertised auth capabilities for a specific execution environment. |
| ... | @@ -100,33 +140,61 @@ export const ServerAuthDescriptor = Schema.Struct({ | ... | @@ -100,33 +140,61 @@ export const ServerAuthDescriptor = Schema.Struct({ |
| 100 | }); | 140 | }); |
| 101 | export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type; | 141 | export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type; |
| 102 | 142 | ||
| 103 | export const AuthBootstrapInput = Schema.Struct({ | 143 | export const AuthBrowserSessionRequest = Schema.Struct({ |
| 104 | credential: TrimmedNonEmptyString, | 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 | authenticated: Schema.Literal(true), | 149 | authenticated: Schema.Literal(true), |
| 110 | role: AuthSessionRole, | 150 | scopes: AuthEnvironmentScopes, |
| 111 | sessionMethod: ServerAuthSessionMethod, | 151 | sessionMethod: ServerAuthSessionMethod, |
| 112 | expiresAt: Schema.DateTimeUtc, | 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({ | 156 | export const AuthClientMetadataDeviceType = Schema.Literals([ |
| 117 | authenticated: Schema.Literal(true), | 157 | "desktop", |
| 118 | role: AuthSessionRole, | 158 | "mobile", |
| 119 | sessionMethod: Schema.Literal("bearer-session-token"), | 159 | "tablet", |
| 120 | expiresAt: Schema.DateTimeUtc, | 160 | "bot", |
| 121 | sessionToken: TrimmedNonEmptyString, | 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({ | 193 | export const AuthWebSocketTicketResult = Schema.Struct({ |
| 126 | token: TrimmedNonEmptyString, | 194 | ticket: TrimmedNonEmptyString, |
| 127 | expiresAt: Schema.DateTimeUtc, | 195 | expiresAt: Schema.DateTimeUtc, |
| 128 | }); | 196 | }); |
| 129 | export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type; | 197 | export type AuthWebSocketTicketResult = typeof AuthWebSocketTicketResult.Type; |
| 130 | 198 | ||
| 131 | export const AuthPairingCredentialResult = Schema.Struct({ | 199 | export const AuthPairingCredentialResult = Schema.Struct({ |
| 132 | id: TrimmedNonEmptyString, | 200 | id: TrimmedNonEmptyString, |
| ... | @@ -139,7 +207,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ | ... | @@ -139,7 +207,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ |
| 139 | export const AuthPairingLink = Schema.Struct({ | 207 | export const AuthPairingLink = Schema.Struct({ |
| 140 | id: TrimmedNonEmptyString, | 208 | id: TrimmedNonEmptyString, |
| 141 | credential: TrimmedNonEmptyString, | 209 | credential: TrimmedNonEmptyString, |
| 142 | role: AuthSessionRole, | 210 | scopes: AuthEnvironmentScopes, |
| 143 | subject: TrimmedNonEmptyString, | 211 | subject: TrimmedNonEmptyString, |
| 144 | label: Schema.optionalKey(TrimmedNonEmptyString), | 212 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 145 | createdAt: Schema.DateTimeUtc, | 213 | createdAt: Schema.DateTimeUtc, |
| ... | @@ -147,15 +215,6 @@ export const AuthPairingLink = Schema.Struct({ | ... | @@ -147,15 +215,6 @@ export const AuthPairingLink = Schema.Struct({ |
| 147 | }); | 215 | }); |
| 148 | export type AuthPairingLink = typeof AuthPairingLink.Type; | 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 | export const AuthClientMetadata = Schema.Struct({ | 218 | export const AuthClientMetadata = Schema.Struct({ |
| 160 | label: Schema.optionalKey(TrimmedNonEmptyString), | 219 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 161 | ipAddress: Schema.optionalKey(TrimmedNonEmptyString), | 220 | ipAddress: Schema.optionalKey(TrimmedNonEmptyString), |
| ... | @@ -169,7 +228,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type; | ... | @@ -169,7 +228,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type; |
| 169 | export const AuthClientSession = Schema.Struct({ | 228 | export const AuthClientSession = Schema.Struct({ |
| 170 | sessionId: AuthSessionId, | 229 | sessionId: AuthSessionId, |
| 171 | subject: TrimmedNonEmptyString, | 230 | subject: TrimmedNonEmptyString, |
| 172 | role: AuthSessionRole, | 231 | scopes: AuthEnvironmentScopes, |
| 173 | method: ServerAuthSessionMethod, | 232 | method: ServerAuthSessionMethod, |
| 174 | client: AuthClientMetadata, | 233 | client: AuthClientMetadata, |
| 175 | issuedAt: Schema.DateTimeUtc, | 234 | issuedAt: Schema.DateTimeUtc, |
| ... | @@ -214,6 +273,21 @@ export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({ | ... | @@ -214,6 +273,21 @@ export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({ |
| 214 | export type AuthAccessStreamPairingLinkRemovedEvent = | 273 | export type AuthAccessStreamPairingLinkRemovedEvent = |
| 215 | typeof AuthAccessStreamPairingLinkRemovedEvent.Type; | 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 | export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({ | 291 | export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({ |
| 218 | version: Schema.Literal(1), | 292 | version: Schema.Literal(1), |
| 219 | revision: Schema.Number, | 293 | revision: Schema.Number, |
| ... | @@ -253,13 +327,14 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T | ... | @@ -253,13 +327,14 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T |
| 253 | 327 | ||
| 254 | export const AuthCreatePairingCredentialInput = Schema.Struct({ | 328 | export const AuthCreatePairingCredentialInput = Schema.Struct({ |
| 255 | label: Schema.optionalKey(TrimmedNonEmptyString), | 329 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 330 | scopes: Schema.optionalKey(AuthEnvironmentScopes), | ||
| 256 | }); | 331 | }); |
| 257 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; | 332 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; |
| 258 | 333 | ||
| 259 | export const AuthSessionState = Schema.Struct({ | 334 | export const AuthSessionState = Schema.Struct({ |
| 260 | authenticated: Schema.Boolean, | 335 | authenticated: Schema.Boolean, |
| 261 | auth: ServerAuthDescriptor, | 336 | auth: ServerAuthDescriptor, |
| 262 | role: Schema.optionalKey(AuthSessionRole), | 337 | scopes: Schema.optionalKey(AuthEnvironmentScopes), |
| 263 | sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), | 338 | sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), |
| 264 | expiresAt: Schema.optionalKey(Schema.DateTimeUtc), | 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,6 +1,7 @@ |
| 1 | export * from "./baseSchemas.ts"; | 1 | export * from "./baseSchemas.ts"; |
| 2 | export * from "./auth.ts"; | 2 | export * from "./auth.ts"; |
| 3 | export * from "./environment.ts"; | 3 | export * from "./environment.ts"; |
| 4 | export * from "./environmentHttp.ts"; | ||
| 4 | export * from "./desktopBootstrap.ts"; | 5 | export * from "./desktopBootstrap.ts"; |
| 5 | export * from "./remoteAccess.ts"; | 6 | export * from "./remoteAccess.ts"; |
| 6 | export * from "./ipc.ts"; | 7 | export * from "./ipc.ts"; |
packages/contracts/src/ipc.ts+4-4| ... | @@ -66,7 +66,7 @@ import type { | ... | @@ -66,7 +66,7 @@ import type { |
| 66 | OrchestrationThreadStreamItem, | 66 | OrchestrationThreadStreamItem, |
| 67 | } from "./orchestration.ts"; | 67 | } from "./orchestration.ts"; |
| 68 | import { EnvironmentId } from "./baseSchemas.ts"; | 68 | import { EnvironmentId } from "./baseSchemas.ts"; |
| 69 | import { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts"; | 69 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; |
| 70 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; | 70 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 71 | import { EditorId } from "./editor.ts"; | 71 | import { EditorId } from "./editor.ts"; |
| 72 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 72 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| ... | @@ -394,12 +394,12 @@ export interface DesktopBridge { | ... | @@ -394,12 +394,12 @@ export interface DesktopBridge { |
| 394 | bootstrapSshBearerSession: ( | 394 | bootstrapSshBearerSession: ( |
| 395 | httpBaseUrl: string, | 395 | httpBaseUrl: string, |
| 396 | credential: string, | 396 | credential: string, |
| 397 | ) => Promise<AuthBearerBootstrapResult>; | 397 | ) => Promise<AuthAccessTokenResult>; |
| 398 | fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise<AuthSessionState>; | 398 | fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise<AuthSessionState>; |
| 399 | issueSshWebSocketToken: ( | 399 | issueSshWebSocketTicket: ( |
| 400 | httpBaseUrl: string, | 400 | httpBaseUrl: string, |
| 401 | bearerToken: string, | 401 | bearerToken: string, |
| 402 | ) => Promise<AuthWebSocketTokenResult>; | 402 | ) => Promise<AuthWebSocketTicketResult>; |
| 403 | onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; | 403 | onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; |
| 404 | resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise<void>; | 404 | resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise<void>; |
| 405 | getServerExposureState: () => Promise<DesktopServerExposureState>; | 405 | getServerExposureState: () => Promise<DesktopServerExposureState>; |
packages/contracts/src/rpc.ts+56-42| ... | @@ -3,7 +3,11 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; | ... | @@ -3,7 +3,11 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | 4 | ||
| 5 | import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; | 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 | import { | 11 | import { |
| 8 | FilesystemBrowseInput, | 12 | FilesystemBrowseInput, |
| 9 | FilesystemBrowseResult, | 13 | FilesystemBrowseResult, |
| ... | @@ -179,19 +183,19 @@ export const WS_METHODS = { | ... | @@ -179,19 +183,19 @@ export const WS_METHODS = { |
| 179 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { | 183 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { |
| 180 | payload: ServerUpsertKeybindingInput, | 184 | payload: ServerUpsertKeybindingInput, |
| 181 | success: ServerUpsertKeybindingResult, | 185 | success: ServerUpsertKeybindingResult, |
| 182 | error: KeybindingsConfigError, | 186 | error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), |
| 183 | }); | 187 | }); |
| 184 | 188 | ||
| 185 | export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { | 189 | export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { |
| 186 | payload: ServerRemoveKeybindingInput, | 190 | payload: ServerRemoveKeybindingInput, |
| 187 | success: ServerRemoveKeybindingResult, | 191 | success: ServerRemoveKeybindingResult, |
| 188 | error: KeybindingsConfigError, | 192 | error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), |
| 189 | }); | 193 | }); |
| 190 | 194 | ||
| 191 | export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { | 195 | export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { |
| 192 | payload: Schema.Struct({}), | 196 | payload: Schema.Struct({}), |
| 193 | success: ServerConfig, | 197 | success: ServerConfig, |
| 194 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), | 198 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), |
| 195 | }); | 199 | }); |
| 196 | 200 | ||
| 197 | export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { | 201 | export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { |
| ... | @@ -205,39 +209,43 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv | ... | @@ -205,39 +209,43 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv |
| 205 | instanceId: Schema.optional(ProviderInstanceId), | 209 | instanceId: Schema.optional(ProviderInstanceId), |
| 206 | }), | 210 | }), |
| 207 | success: ServerProviderUpdatedPayload, | 211 | success: ServerProviderUpdatedPayload, |
| 212 | error: EnvironmentAuthorizationError, | ||
| 208 | }); | 213 | }); |
| 209 | 214 | ||
| 210 | export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { | 215 | export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { |
| 211 | payload: ServerProviderUpdateInput, | 216 | payload: ServerProviderUpdateInput, |
| 212 | success: ServerProviderUpdatedPayload, | 217 | success: ServerProviderUpdatedPayload, |
| 213 | error: ServerProviderUpdateError, | 218 | error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), |
| 214 | }); | 219 | }); |
| 215 | 220 | ||
| 216 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { | 221 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { |
| 217 | payload: Schema.Struct({}), | 222 | payload: Schema.Struct({}), |
| 218 | success: ServerSettings, | 223 | success: ServerSettings, |
| 219 | error: ServerSettingsError, | 224 | error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), |
| 220 | }); | 225 | }); |
| 221 | 226 | ||
| 222 | export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { | 227 | export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { |
| 223 | payload: Schema.Struct({ patch: ServerSettingsPatch }), | 228 | payload: Schema.Struct({ patch: ServerSettingsPatch }), |
| 224 | success: ServerSettings, | 229 | success: ServerSettings, |
| 225 | error: ServerSettingsError, | 230 | error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), |
| 226 | }); | 231 | }); |
| 227 | 232 | ||
| 228 | export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { | 233 | export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { |
| 229 | payload: Schema.Struct({}), | 234 | payload: Schema.Struct({}), |
| 230 | success: SourceControlDiscoveryResult, | 235 | success: SourceControlDiscoveryResult, |
| 236 | error: EnvironmentAuthorizationError, | ||
| 231 | }); | 237 | }); |
| 232 | 238 | ||
| 233 | export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { | 239 | export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { |
| 234 | payload: Schema.Struct({}), | 240 | payload: Schema.Struct({}), |
| 235 | success: ServerTraceDiagnosticsResult, | 241 | success: ServerTraceDiagnosticsResult, |
| 242 | error: EnvironmentAuthorizationError, | ||
| 236 | }); | 243 | }); |
| 237 | 244 | ||
| 238 | export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { | 245 | export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { |
| 239 | payload: Schema.Struct({}), | 246 | payload: Schema.Struct({}), |
| 240 | success: ServerProcessDiagnosticsResult, | 247 | success: ServerProcessDiagnosticsResult, |
| 248 | error: EnvironmentAuthorizationError, | ||
| 241 | }); | 249 | }); |
| 242 | 250 | ||
| 243 | export const WsServerGetProcessResourceHistoryRpc = Rpc.make( | 251 | export const WsServerGetProcessResourceHistoryRpc = Rpc.make( |
| ... | @@ -245,12 +253,14 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( | ... | @@ -245,12 +253,14 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make( |
| 245 | { | 253 | { |
| 246 | payload: ServerProcessResourceHistoryInput, | 254 | payload: ServerProcessResourceHistoryInput, |
| 247 | success: ServerProcessResourceHistoryResult, | 255 | success: ServerProcessResourceHistoryResult, |
| 256 | error: EnvironmentAuthorizationError, | ||
| 248 | }, | 257 | }, |
| 249 | ); | 258 | ); |
| 250 | 259 | ||
| 251 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { | 260 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { |
| 252 | payload: ServerSignalProcessInput, | 261 | payload: ServerSignalProcessInput, |
| 253 | success: ServerSignalProcessResult, | 262 | success: ServerSignalProcessResult, |
| 263 | error: EnvironmentAuthorizationError, | ||
| 254 | }); | 264 | }); |
| 255 | 265 | ||
| 256 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( | 266 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( |
| ... | @@ -258,14 +268,14 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make( | ... | @@ -258,14 +268,14 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make( |
| 258 | { | 268 | { |
| 259 | payload: SourceControlRepositoryLookupInput, | 269 | payload: SourceControlRepositoryLookupInput, |
| 260 | success: SourceControlRepositoryInfo, | 270 | success: SourceControlRepositoryInfo, |
| 261 | error: SourceControlRepositoryError, | 271 | error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), |
| 262 | }, | 272 | }, |
| 263 | ); | 273 | ); |
| 264 | 274 | ||
| 265 | export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { | 275 | export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { |
| 266 | payload: SourceControlCloneRepositoryInput, | 276 | payload: SourceControlCloneRepositoryInput, |
| 267 | success: SourceControlCloneRepositoryResult, | 277 | success: SourceControlCloneRepositoryResult, |
| 268 | error: SourceControlRepositoryError, | 278 | error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), |
| 269 | }); | 279 | }); |
| 270 | 280 | ||
| 271 | export const WsSourceControlPublishRepositoryRpc = Rpc.make( | 281 | export const WsSourceControlPublishRepositoryRpc = Rpc.make( |
| ... | @@ -273,103 +283,103 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make( | ... | @@ -273,103 +283,103 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make( |
| 273 | { | 283 | { |
| 274 | payload: SourceControlPublishRepositoryInput, | 284 | payload: SourceControlPublishRepositoryInput, |
| 275 | success: SourceControlPublishRepositoryResult, | 285 | success: SourceControlPublishRepositoryResult, |
| 276 | error: SourceControlRepositoryError, | 286 | error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), |
| 277 | }, | 287 | }, |
| 278 | ); | 288 | ); |
| 279 | 289 | ||
| 280 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { | 290 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { |
| 281 | payload: ProjectSearchEntriesInput, | 291 | payload: ProjectSearchEntriesInput, |
| 282 | success: ProjectSearchEntriesResult, | 292 | success: ProjectSearchEntriesResult, |
| 283 | error: ProjectSearchEntriesError, | 293 | error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), |
| 284 | }); | 294 | }); |
| 285 | 295 | ||
| 286 | export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { | 296 | export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { |
| 287 | payload: ProjectWriteFileInput, | 297 | payload: ProjectWriteFileInput, |
| 288 | success: ProjectWriteFileResult, | 298 | success: ProjectWriteFileResult, |
| 289 | error: ProjectWriteFileError, | 299 | error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), |
| 290 | }); | 300 | }); |
| 291 | 301 | ||
| 292 | export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { | 302 | export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { |
| 293 | payload: LaunchEditorInput, | 303 | payload: LaunchEditorInput, |
| 294 | error: ExternalLauncherError, | 304 | error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), |
| 295 | }); | 305 | }); |
| 296 | 306 | ||
| 297 | export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { | 307 | export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { |
| 298 | payload: FilesystemBrowseInput, | 308 | payload: FilesystemBrowseInput, |
| 299 | success: FilesystemBrowseResult, | 309 | success: FilesystemBrowseResult, |
| 300 | error: FilesystemBrowseError, | 310 | error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), |
| 301 | }); | 311 | }); |
| 302 | 312 | ||
| 303 | export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { | 313 | export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { |
| 304 | payload: VcsStatusInput, | 314 | payload: VcsStatusInput, |
| 305 | success: VcsStatusStreamEvent, | 315 | success: VcsStatusStreamEvent, |
| 306 | error: GitManagerServiceError, | 316 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), |
| 307 | stream: true, | 317 | stream: true, |
| 308 | }); | 318 | }); |
| 309 | 319 | ||
| 310 | export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { | 320 | export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { |
| 311 | payload: VcsPullInput, | 321 | payload: VcsPullInput, |
| 312 | success: VcsPullResult, | 322 | success: VcsPullResult, |
| 313 | error: GitCommandError, | 323 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), |
| 314 | }); | 324 | }); |
| 315 | 325 | ||
| 316 | export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { | 326 | export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { |
| 317 | payload: VcsStatusInput, | 327 | payload: VcsStatusInput, |
| 318 | success: VcsStatusResult, | 328 | success: VcsStatusResult, |
| 319 | error: GitManagerServiceError, | 329 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), |
| 320 | }); | 330 | }); |
| 321 | 331 | ||
| 322 | export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { | 332 | export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { |
| 323 | payload: GitRunStackedActionInput, | 333 | payload: GitRunStackedActionInput, |
| 324 | success: GitActionProgressEvent, | 334 | success: GitActionProgressEvent, |
| 325 | error: GitManagerServiceError, | 335 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), |
| 326 | stream: true, | 336 | stream: true, |
| 327 | }); | 337 | }); |
| 328 | 338 | ||
| 329 | export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { | 339 | export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { |
| 330 | payload: GitPullRequestRefInput, | 340 | payload: GitPullRequestRefInput, |
| 331 | success: GitResolvePullRequestResult, | 341 | success: GitResolvePullRequestResult, |
| 332 | error: GitManagerServiceError, | 342 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), |
| 333 | }); | 343 | }); |
| 334 | 344 | ||
| 335 | export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { | 345 | export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { |
| 336 | payload: GitPreparePullRequestThreadInput, | 346 | payload: GitPreparePullRequestThreadInput, |
| 337 | success: GitPreparePullRequestThreadResult, | 347 | success: GitPreparePullRequestThreadResult, |
| 338 | error: GitManagerServiceError, | 348 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), |
| 339 | }); | 349 | }); |
| 340 | 350 | ||
| 341 | export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { | 351 | export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { |
| 342 | payload: VcsListRefsInput, | 352 | payload: VcsListRefsInput, |
| 343 | success: VcsListRefsResult, | 353 | success: VcsListRefsResult, |
| 344 | error: GitCommandError, | 354 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), |
| 345 | }); | 355 | }); |
| 346 | 356 | ||
| 347 | export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { | 357 | export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { |
| 348 | payload: VcsCreateWorktreeInput, | 358 | payload: VcsCreateWorktreeInput, |
| 349 | success: VcsCreateWorktreeResult, | 359 | success: VcsCreateWorktreeResult, |
| 350 | error: GitCommandError, | 360 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), |
| 351 | }); | 361 | }); |
| 352 | 362 | ||
| 353 | export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { | 363 | export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { |
| 354 | payload: VcsRemoveWorktreeInput, | 364 | payload: VcsRemoveWorktreeInput, |
| 355 | error: GitCommandError, | 365 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), |
| 356 | }); | 366 | }); |
| 357 | 367 | ||
| 358 | export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { | 368 | export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { |
| 359 | payload: VcsCreateRefInput, | 369 | payload: VcsCreateRefInput, |
| 360 | success: VcsCreateRefResult, | 370 | success: VcsCreateRefResult, |
| 361 | error: GitCommandError, | 371 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), |
| 362 | }); | 372 | }); |
| 363 | 373 | ||
| 364 | export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { | 374 | export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { |
| 365 | payload: VcsSwitchRefInput, | 375 | payload: VcsSwitchRefInput, |
| 366 | success: VcsSwitchRefResult, | 376 | success: VcsSwitchRefResult, |
| 367 | error: GitCommandError, | 377 | error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), |
| 368 | }); | 378 | }); |
| 369 | 379 | ||
| 370 | export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { | 380 | export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { |
| 371 | payload: VcsInitInput, | 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,46 +390,46 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { |
| 380 | export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { | 390 | export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { |
| 381 | payload: ReviewDiffPreviewInput, | 391 | payload: ReviewDiffPreviewInput, |
| 382 | success: ReviewDiffPreviewResult, | 392 | success: ReviewDiffPreviewResult, |
| 383 | error: ReviewDiffPreviewError, | 393 | error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), |
| 384 | }); | 394 | }); |
| 385 | 395 | ||
| 386 | export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { | 396 | export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { |
| 387 | payload: TerminalOpenInput, | 397 | payload: TerminalOpenInput, |
| 388 | success: TerminalSessionSnapshot, | 398 | success: TerminalSessionSnapshot, |
| 389 | error: TerminalError, | 399 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 390 | }); | 400 | }); |
| 391 | 401 | ||
| 392 | export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { | 402 | export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { |
| 393 | payload: TerminalAttachInput, | 403 | payload: TerminalAttachInput, |
| 394 | success: TerminalAttachStreamEvent, | 404 | success: TerminalAttachStreamEvent, |
| 395 | error: TerminalError, | 405 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 396 | stream: true, | 406 | stream: true, |
| 397 | }); | 407 | }); |
| 398 | 408 | ||
| 399 | export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { | 409 | export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { |
| 400 | payload: TerminalWriteInput, | 410 | payload: TerminalWriteInput, |
| 401 | error: TerminalError, | 411 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 402 | }); | 412 | }); |
| 403 | 413 | ||
| 404 | export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { | 414 | export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { |
| 405 | payload: TerminalResizeInput, | 415 | payload: TerminalResizeInput, |
| 406 | error: TerminalError, | 416 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 407 | }); | 417 | }); |
| 408 | 418 | ||
| 409 | export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { | 419 | export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { |
| 410 | payload: TerminalClearInput, | 420 | payload: TerminalClearInput, |
| 411 | error: TerminalError, | 421 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 412 | }); | 422 | }); |
| 413 | 423 | ||
| 414 | export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { | 424 | export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { |
| 415 | payload: TerminalRestartInput, | 425 | payload: TerminalRestartInput, |
| 416 | success: TerminalSessionSnapshot, | 426 | success: TerminalSessionSnapshot, |
| 417 | error: TerminalError, | 427 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 418 | }); | 428 | }); |
| 419 | 429 | ||
| 420 | export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { | 430 | export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { |
| 421 | payload: TerminalCloseInput, | 431 | payload: TerminalCloseInput, |
| 422 | error: TerminalError, | 432 | error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), |
| 423 | }); | 433 | }); |
| 424 | 434 | ||
| 425 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( | 435 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| ... | @@ -427,14 +437,14 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make( | ... | @@ -427,14 +437,14 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| 427 | { | 437 | { |
| 428 | payload: ClientOrchestrationCommand, | 438 | payload: ClientOrchestrationCommand, |
| 429 | success: OrchestrationRpcSchemas.dispatchCommand.output, | 439 | success: OrchestrationRpcSchemas.dispatchCommand.output, |
| 430 | error: OrchestrationDispatchCommandError, | 440 | error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), |
| 431 | }, | 441 | }, |
| 432 | ); | 442 | ); |
| 433 | 443 | ||
| 434 | export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { | 444 | export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { |
| 435 | payload: OrchestrationGetTurnDiffInput, | 445 | payload: OrchestrationGetTurnDiffInput, |
| 436 | success: OrchestrationRpcSchemas.getTurnDiff.output, | 446 | success: OrchestrationRpcSchemas.getTurnDiff.output, |
| 437 | error: OrchestrationGetTurnDiffError, | 447 | error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]), |
| 438 | }); | 448 | }); |
| 439 | 449 | ||
| 440 | export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( | 450 | export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( |
| ... | @@ -442,14 +452,14 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( | ... | @@ -442,14 +452,14 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( |
| 442 | { | 452 | { |
| 443 | payload: OrchestrationGetFullThreadDiffInput, | 453 | payload: OrchestrationGetFullThreadDiffInput, |
| 444 | success: OrchestrationRpcSchemas.getFullThreadDiff.output, | 454 | success: OrchestrationRpcSchemas.getFullThreadDiff.output, |
| 445 | error: OrchestrationGetFullThreadDiffError, | 455 | error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]), |
| 446 | }, | 456 | }, |
| 447 | ); | 457 | ); |
| 448 | 458 | ||
| 449 | export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { | 459 | export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { |
| 450 | payload: OrchestrationReplayEventsInput, | 460 | payload: OrchestrationReplayEventsInput, |
| 451 | success: OrchestrationRpcSchemas.replayEvents.output, | 461 | success: OrchestrationRpcSchemas.replayEvents.output, |
| 452 | error: OrchestrationReplayEventsError, | 462 | error: Schema.Union([OrchestrationReplayEventsError, EnvironmentAuthorizationError]), |
| 453 | }); | 463 | }); |
| 454 | 464 | ||
| 455 | export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( | 465 | export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( |
| ... | @@ -457,14 +467,14 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( | ... | @@ -457,14 +467,14 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( |
| 457 | { | 467 | { |
| 458 | payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input, | 468 | payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input, |
| 459 | success: OrchestrationRpcSchemas.getArchivedShellSnapshot.output, | 469 | success: OrchestrationRpcSchemas.getArchivedShellSnapshot.output, |
| 460 | error: OrchestrationGetSnapshotError, | 470 | error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), |
| 461 | }, | 471 | }, |
| 462 | ); | 472 | ); |
| 463 | 473 | ||
| 464 | export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { | 474 | export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { |
| 465 | payload: OrchestrationRpcSchemas.subscribeShell.input, | 475 | payload: OrchestrationRpcSchemas.subscribeShell.input, |
| 466 | success: OrchestrationRpcSchemas.subscribeShell.output, | 476 | success: OrchestrationRpcSchemas.subscribeShell.output, |
| 467 | error: OrchestrationGetSnapshotError, | 477 | error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), |
| 468 | stream: true, | 478 | stream: true, |
| 469 | }); | 479 | }); |
| 470 | 480 | ||
| ... | @@ -473,7 +483,7 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( | ... | @@ -473,7 +483,7 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( |
| 473 | { | 483 | { |
| 474 | payload: OrchestrationRpcSchemas.subscribeThread.input, | 484 | payload: OrchestrationRpcSchemas.subscribeThread.input, |
| 475 | success: OrchestrationRpcSchemas.subscribeThread.output, | 485 | success: OrchestrationRpcSchemas.subscribeThread.output, |
| 476 | error: OrchestrationGetSnapshotError, | 486 | error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), |
| 477 | stream: true, | 487 | stream: true, |
| 478 | }, | 488 | }, |
| 479 | ); | 489 | ); |
| ... | @@ -481,31 +491,35 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( | ... | @@ -481,31 +491,35 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( |
| 481 | export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { | 491 | export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { |
| 482 | payload: Schema.Struct({}), | 492 | payload: Schema.Struct({}), |
| 483 | success: TerminalEvent, | 493 | success: TerminalEvent, |
| 494 | error: EnvironmentAuthorizationError, | ||
| 484 | stream: true, | 495 | stream: true, |
| 485 | }); | 496 | }); |
| 486 | 497 | ||
| 487 | export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { | 498 | export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { |
| 488 | payload: Schema.Struct({}), | 499 | payload: Schema.Struct({}), |
| 489 | success: TerminalMetadataStreamEvent, | 500 | success: TerminalMetadataStreamEvent, |
| 501 | error: EnvironmentAuthorizationError, | ||
| 490 | stream: true, | 502 | stream: true, |
| 491 | }); | 503 | }); |
| 492 | 504 | ||
| 493 | export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { | 505 | export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { |
| 494 | payload: Schema.Struct({}), | 506 | payload: Schema.Struct({}), |
| 495 | success: ServerConfigStreamEvent, | 507 | success: ServerConfigStreamEvent, |
| 496 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError]), | 508 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), |
| 497 | stream: true, | 509 | stream: true, |
| 498 | }); | 510 | }); |
| 499 | 511 | ||
| 500 | export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { | 512 | export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { |
| 501 | payload: Schema.Struct({}), | 513 | payload: Schema.Struct({}), |
| 502 | success: ServerLifecycleStreamEvent, | 514 | success: ServerLifecycleStreamEvent, |
| 515 | error: EnvironmentAuthorizationError, | ||
| 503 | stream: true, | 516 | stream: true, |
| 504 | }); | 517 | }); |
| 505 | 518 | ||
| 506 | export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { | 519 | export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { |
| 507 | payload: Schema.Struct({}), | 520 | payload: Schema.Struct({}), |
| 508 | success: AuthAccessStreamEvent, | 521 | success: AuthAccessStreamEvent, |
| 522 | error: Schema.Union([AuthAccessStreamError, EnvironmentAuthorizationError]), | ||
| 509 | stream: true, | 523 | stream: true, |
| 510 | }); | 524 | }); |
| 511 | 525 |
packages/shared/package.json+4| ... | @@ -79,6 +79,10 @@ | ... | @@ -79,6 +79,10 @@ |
| 79 | "types": "./src/remote.ts", | 79 | "types": "./src/remote.ts", |
| 80 | "import": "./src/remote.ts" | 80 | "import": "./src/remote.ts" |
| 81 | }, | 81 | }, |
| 82 | "./oauthScope": { | ||
| 83 | "types": "./src/oauthScope.ts", | ||
| 84 | "import": "./src/oauthScope.ts" | ||
| 85 | }, | ||
| 82 | "./searchRanking": { | 86 | "./searchRanking": { |
| 83 | "types": "./src/searchRanking.ts", | 87 | "types": "./src/searchRanking.ts", |
| 84 | "import": "./src/searchRanking.ts" | 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,6 +1,7 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Arr from "effect/Array"; | 3 | import * as Arr from "effect/Array"; |
| 4 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 6 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| ... | @@ -12,12 +13,27 @@ import * as Schema from "effect/Schema"; | ... | @@ -12,12 +13,27 @@ import * as Schema from "effect/Schema"; |
| 12 | import * as Tracer from "effect/Tracer"; | 13 | import * as Tracer from "effect/Tracer"; |
| 13 | 14 | ||
| 14 | import { | 15 | import { |
| 16 | causeErrorTag, | ||
| 15 | compactTraceAttributes, | 17 | compactTraceAttributes, |
| 16 | makeLocalFileTracer, | 18 | makeLocalFileTracer, |
| 17 | makeTraceSink, | 19 | makeTraceSink, |
| 18 | type TraceRecord, | 20 | type TraceRecord, |
| 19 | } from "./observability.ts"; | 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 | const TraceRecordLine = Schema.Struct({ | 37 | const TraceRecordLine = Schema.Struct({ |
| 22 | name: Schema.String, | 38 | name: Schema.String, |
| 23 | spanId: Schema.String, | 39 | spanId: Schema.String, |
packages/shared/src/observability.ts+16| ... | @@ -73,6 +73,22 @@ export interface OtlpTraceRecord extends BaseTraceRecord { | ... | @@ -73,6 +73,22 @@ export interface OtlpTraceRecord extends BaseTraceRecord { |
| 73 | 73 | ||
| 74 | export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; | 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 | export interface TraceSinkOptions { | 92 | export interface TraceSinkOptions { |
| 77 | readonly filePath: string; | 93 | readonly filePath: string; |
| 78 | readonly maxBytes: number; | 94 | readonly maxBytes: number; |
packages/ssh/src/tunnel.test.ts+2-2| ... | @@ -293,7 +293,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -293,7 +293,7 @@ describe("ssh tunnel scripts", () => { |
| 293 | makeSuccessfulProcess(`{ | 293 | makeSuccessfulProcess(`{ |
| 294 | "id": "88941235-6ed5-4184-a2ff-5339e2075958", | 294 | "id": "88941235-6ed5-4184-a2ff-5339e2075958", |
| 295 | "credential": "LCL4R2TPHDKQ", | 295 | "credential": "LCL4R2TPHDKQ", |
| 296 | "role": "client", | 296 | "scopes": ["orchestration:read"], |
| 297 | "expiresAt": "2026-04-29T01:01:20.994Z" | 297 | "expiresAt": "2026-04-29T01:01:20.994Z" |
| 298 | } | 298 | } |
| 299 | 299 | ||
| ... | @@ -321,7 +321,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -321,7 +321,7 @@ describe("ssh tunnel scripts", () => { |
| 321 | { | 321 | { |
| 322 | "id": "88941235-6ed5-4184-a2ff-5339e2075958", | 322 | "id": "88941235-6ed5-4184-a2ff-5339e2075958", |
| 323 | "credential": "LCL4R2TPHDKQ", | 323 | "credential": "LCL4R2TPHDKQ", |
| 324 | "role": "client", | 324 | "scopes": ["orchestration:read"], |
| 325 | "expiresAt": "2026-04-29T01:01:20.994Z" | 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,13 +160,8 @@ const RemotePairingResult = Schema.Struct({ |
| 160 | credential: Schema.String, | 160 | credential: Schema.String, |
| 161 | }); | 161 | }); |
| 162 | 162 | ||
| 163 | const RemoteHttpError = Schema.Struct({ | ||
| 164 | error: Schema.optional(Schema.String), | ||
| 165 | }); | ||
| 166 | |||
| 167 | const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult)); | 163 | const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult)); |
| 168 | const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult)); | 164 | const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult)); |
| 169 | const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError)); | ||
| 170 | 165 | ||
| 171 | const decodeRemoteJsonOutput = <A, E>( | 166 | const decodeRemoteJsonOutput = <A, E>( |
| 172 | stdout: string, | 167 | stdout: string, |
| ... | @@ -991,92 +986,27 @@ function isLoopbackHostname(hostname: string): boolean { | ... | @@ -991,92 +986,27 @@ function isLoopbackHostname(hostname: string): boolean { |
| 991 | return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; | 986 | return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; |
| 992 | } | 987 | } |
| 993 | 988 | ||
| 994 | function resolveLoopbackSshHttpUrl( | 989 | export const resolveLoopbackSshHttpBaseUrl = Effect.fn("ssh/tunnel.resolveLoopbackSshHttpBaseUrl")( |
| 995 | rawHttpBaseUrl: unknown, | 990 | function* (rawHttpBaseUrl: unknown): Effect.fn.Return<string, SshHttpBridgeError> { |
| 996 | pathname: string, | 991 | return yield* Effect.try({ |
| 997 | ): Effect.Effect<URL, SshHttpBridgeError> { | 992 | try: () => { |
| 998 | return Effect.try({ | 993 | if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { |
| 999 | try: () => { | 994 | throw new Error("Invalid SSH forwarded http base URL."); |
| 1000 | if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { | 995 | } |
| 1001 | throw new Error("Invalid SSH forwarded http base URL."); | 996 | const baseUrl = new URL(rawHttpBaseUrl); |
| 1002 | } | 997 | if (!isLoopbackHostname(baseUrl.hostname)) { |
| 1003 | const baseUrl = new URL(rawHttpBaseUrl); | 998 | throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); |
| 1004 | if (!isLoopbackHostname(baseUrl.hostname)) { | 999 | } |
| 1005 | throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); | 1000 | return baseUrl.toString(); |
| 1006 | } | 1001 | }, |
| 1007 | const url = new URL(baseUrl.toString()); | 1002 | catch: (cause) => |
| 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) => | ||
| 1050 | new SshHttpBridgeError({ | 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 | cause, | 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 | } | 1008 | }, |
| 1070 | return (yield* response.json.pipe( | 1009 | ); |
| 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 | }); | ||
| 1080 | 1010 | ||
| 1081 | const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () { | 1011 | const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () { |
| 1082 | const net = yield* NetService.NetService; | 1012 | const net = yield* NetService.NetService; |