| author | |
| committer | |
| log | 7e65b226e7a74ecd13bf8b4738ea6bdd096cb1ba |
| tree | 4a2d7bb10fd81ccdfe9d40fe5e4b5ec50d9e870e |
| parent | db9a0671bb8921ebf5f9da16cd54f7e87f5ef3b0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>15 files changed, 1870 insertions(+), 42 deletions(-)
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -37,6 +37,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 37 | 37 | [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, |
| 38 | 38 | [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, |
| 39 | 39 | [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, |
| 40 | [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope, | |
| 40 | 41 | [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, |
| 41 | 42 | [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, |
| 42 | 43 | [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+98| ... | ... | @@ -1,15 +1,21 @@ |
| 1 | 1 | import { |
| 2 | 2 | defaultInstanceIdForDriver, |
| 3 | 3 | ProviderDriverKind, |
| 4 | ThreadId, | |
| 4 | 5 | type ServerProvider, |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import { it, assert, vi } from "@effect/vitest"; |
| 7 | 8 | |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Deferred from "effect/Deferred"; | |
| 11 | import * as Fiber from "effect/Fiber"; | |
| 12 | import * as Exit from "effect/Exit"; | |
| 9 | 13 | import * as Layer from "effect/Layer"; |
| 10 | 14 | import * as PubSub from "effect/PubSub"; |
| 11 | 15 | import * as Stream from "effect/Stream"; |
| 12 | 16 | |
| 17 | import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; | |
| 18 | ||
| 13 | 19 | import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; |
| 14 | 20 | import type * as CodexAdapter from "../Services/CodexAdapter.ts"; |
| 15 | 21 | import type * as CursorAdapter from "../Services/CursorAdapter.ts"; |
| ... | ... | @@ -185,3 +191,95 @@ it.layer(layer)("ProviderAdapterRegistryLive", (it) => { |
| 185 | 191 | ]); |
| 186 | 192 | })); |
| 187 | 193 | }); |
| 194 | ||
| 195 | it.effect("blocks shared credential session startup and preserves guarded adapter identity", () => | |
| 196 | Effect.gen(function* () { | |
| 197 | const target = fakeInstances[0]!; | |
| 198 | const peer = fakeInstances[1]!; | |
| 199 | const auth = yield* ProviderAuthFlow.make({ | |
| 200 | instanceId: target.instanceId, | |
| 201 | credentialBinding: { owner: "t3", key: "shared-auth" }, | |
| 202 | methods: Effect.succeed([ | |
| 203 | { id: "browser", name: "Browser", description: null, type: "agent" }, | |
| 204 | ]), | |
| 205 | authenticate: () => Effect.never, | |
| 206 | logout: Effect.void, | |
| 207 | }); | |
| 208 | const peerAuth = yield* ProviderAuthFlow.make({ | |
| 209 | instanceId: peer.instanceId, | |
| 210 | credentialBinding: { owner: "t3", key: "shared-auth" }, | |
| 211 | methods: Effect.succeed([]), | |
| 212 | authenticate: () => Effect.void, | |
| 213 | logout: Effect.void, | |
| 214 | }); | |
| 215 | const session = { | |
| 216 | threadId: ThreadId.make("new-session"), | |
| 217 | provider: peer.driverKind, | |
| 218 | providerInstanceId: peer.instanceId, | |
| 219 | status: "ready" as const, | |
| 220 | runtimeMode: "approval-required" as const, | |
| 221 | createdAt: "2026-09-21T00:00:00.000Z", | |
| 222 | updatedAt: "2026-09-21T00:00:00.000Z", | |
| 223 | }; | |
| 224 | const start = vi.fn(() => Effect.succeed(session)); | |
| 225 | const instances = [ | |
| 226 | { ...target, auth }, | |
| 227 | { ...peer, auth: peerAuth, adapter: { ...peer.adapter, startSession: start } }, | |
| 228 | ]; | |
| 229 | const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry.pipe( | |
| 230 | Effect.provide( | |
| 231 | ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive.pipe( | |
| 232 | Layer.provide( | |
| 233 | Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ | |
| 234 | getInstance: (id) => | |
| 235 | Effect.succeed(instances.find((instance) => instance.instanceId === id)), | |
| 236 | listInstances: Effect.succeed(instances), | |
| 237 | }), | |
| 238 | ), | |
| 239 | ), | |
| 240 | ), | |
| 241 | ); | |
| 242 | const guarded = yield* registry.getByInstance(peer.instanceId); | |
| 243 | assert.strictEqual(yield* registry.getByInstance(peer.instanceId), guarded); | |
| 244 | const flow = yield* auth.start("owner"); | |
| 245 | const error = yield* guarded | |
| 246 | .startSession({ | |
| 247 | threadId: session.threadId, | |
| 248 | providerInstanceId: peer.instanceId, | |
| 249 | runtimeMode: "approval-required", | |
| 250 | }) | |
| 251 | .pipe(Effect.flip); | |
| 252 | assert.strictEqual(error._tag, "ProviderAdapterValidationError"); | |
| 253 | assert.strictEqual(start.mock.calls.length, 0); | |
| 254 | yield* auth.cancel("owner", flow.flowId!); | |
| 255 | assert.deepStrictEqual( | |
| 256 | yield* guarded.startSession({ | |
| 257 | threadId: session.threadId, | |
| 258 | providerInstanceId: peer.instanceId, | |
| 259 | runtimeMode: "approval-required", | |
| 260 | }), | |
| 261 | session, | |
| 262 | ); | |
| 263 | assert.strictEqual(start.mock.calls.length, 1); | |
| 264 | const entered = yield* Deferred.make<void>(); | |
| 265 | const stopped = yield* Deferred.make<void>(); | |
| 266 | start.mockImplementation(() => | |
| 267 | Effect.gen(function* () { | |
| 268 | yield* Deferred.succeed(entered, undefined); | |
| 269 | return yield* Effect.never; | |
| 270 | }).pipe(Effect.ensuring(Deferred.succeed(stopped, undefined))), | |
| 271 | ); | |
| 272 | const startup = yield* guarded | |
| 273 | .startSession({ | |
| 274 | threadId: session.threadId, | |
| 275 | providerInstanceId: peer.instanceId, | |
| 276 | runtimeMode: "approval-required", | |
| 277 | }) | |
| 278 | .pipe(Effect.forkChild); | |
| 279 | yield* Deferred.await(entered); | |
| 280 | // Signing out through another instance must drain its peer's startup too. | |
| 281 | yield* auth.logout(Effect.void); | |
| 282 | yield* Deferred.await(stopped); | |
| 283 | assert.strictEqual(Exit.isFailure(yield* Fiber.await(startup)), true); | |
| 284 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 285 | ); |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+73-3| ... | ... | @@ -15,19 +15,89 @@ |
| 15 | 15 | * |
| 16 | 16 | * @module ProviderAdapterRegistryLive |
| 17 | 17 | */ |
| 18 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 18 | import { ProviderInstanceId, ProviderSetupError, type ProviderSession } from "@t3tools/contracts"; | |
| 19 | 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as Schema from "effect/Schema"; | |
| 20 | 21 | import * as Layer from "effect/Layer"; |
| 22 | import type * as Scope from "effect/Scope"; | |
| 21 | 23 | |
| 22 | import { ProviderUnsupportedError } from "../Errors.ts"; | |
| 24 | import { | |
| 25 | ProviderUnsupportedError, | |
| 26 | ProviderAdapterValidationError, | |
| 27 | type ProviderAdapterError, | |
| 28 | } from "../Errors.ts"; | |
| 23 | 29 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 24 | 30 | import { |
| 25 | 31 | ProviderAdapterRegistry, |
| 26 | 32 | type ProviderAdapterRegistryShape, |
| 27 | 33 | } from "../Services/ProviderAdapterRegistry.ts"; |
| 28 | 34 | |
| 35 | import type { ProviderInstance } from "../ProviderDriver.ts"; | |
| 36 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | |
| 37 | ||
| 38 | const isSetupError = Schema.is(ProviderSetupError); | |
| 39 | ||
| 29 | 40 | const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { |
| 30 | 41 | const registry = yield* ProviderInstanceRegistry; |
| 42 | // Stable identity keeps ProviderService's event subscriptions attached once. | |
| 43 | const guarded = new WeakMap<ProviderInstance, ProviderAdapterShape<ProviderAdapterError>>(); | |
| 44 | const guard = (instance: ProviderInstance) => { | |
| 45 | const auth = instance.auth; | |
| 46 | if (!auth || (!auth.withAccess && !auth.isChangingCredentials && !auth.credentialBinding)) | |
| 47 | return instance.adapter; | |
| 48 | const cached = guarded.get(instance); | |
| 49 | if (cached) return cached; | |
| 50 | const adapter: ProviderAdapterShape<ProviderAdapterError> = { | |
| 51 | ...instance.adapter, | |
| 52 | startSession: (input) => { | |
| 53 | const start = Effect.gen(function* () { | |
| 54 | const binding = auth.credentialBinding; | |
| 55 | const related = binding | |
| 56 | ? (yield* registry.listInstances).filter( | |
| 57 | (peer) => | |
| 58 | peer.auth?.credentialBinding?.key === binding.key && | |
| 59 | peer.auth.credentialBinding.owner === binding.owner, | |
| 60 | ) | |
| 61 | : [instance]; | |
| 62 | for (const peer of related) { | |
| 63 | if (peer.auth?.isChangingCredentials && (yield* peer.auth.isChangingCredentials)) { | |
| 64 | return yield* new ProviderSetupError({ | |
| 65 | instanceId: instance.instanceId, | |
| 66 | operation: "session", | |
| 67 | detail: "Provider sign-in is changing. Try again after it finishes.", | |
| 68 | }); | |
| 69 | } | |
| 70 | } | |
| 71 | let admitted: Effect.Effect< | |
| 72 | ProviderSession, | |
| 73 | ProviderAdapterError | ProviderSetupError, | |
| 74 | Scope.Scope | |
| 75 | > = instance.adapter.startSession(input); | |
| 76 | // Every shared owner holds the startup scope. A credential change | |
| 77 | // interrupts admitted startup before it can escape the session drain. | |
| 78 | for (const peer of related) { | |
| 79 | if (peer.auth?.withAccess) admitted = peer.auth.withAccess(admitted); | |
| 80 | } | |
| 81 | return yield* Effect.scoped(admitted); | |
| 82 | }); | |
| 83 | // Adapters own established session lifetimes. This scope guards startup; | |
| 84 | // ProviderAuthService drains routed sessions before changing credentials. | |
| 85 | return start.pipe( | |
| 86 | Effect.mapError((cause) => | |
| 87 | isSetupError(cause) | |
| 88 | ? new ProviderAdapterValidationError({ | |
| 89 | provider: instance.driverKind, | |
| 90 | operation: "startSession", | |
| 91 | issue: cause.detail, | |
| 92 | }) | |
| 93 | : cause, | |
| 94 | ), | |
| 95 | ); | |
| 96 | }, | |
| 97 | }; | |
| 98 | guarded.set(instance, adapter); | |
| 99 | return adapter; | |
| 100 | }; | |
| 31 | 101 | |
| 32 | 102 | const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => |
| 33 | 103 | registry.getInstance(instanceId).pipe( |
| ... | ... | @@ -38,7 +108,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun |
| 38 | 108 | provider: instanceId, |
| 39 | 109 | }), |
| 40 | 110 | ) |
| 41 | : Effect.succeed(instance.adapter), | |
| 111 | : Effect.succeed(guard(instance)), | |
| 42 | 112 | ), |
| 43 | 113 | ); |
| 44 | 114 |
apps/server/src/provider/Layers/ProviderAuthService.test.ts+313-6| ... | ... | @@ -112,6 +112,14 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 112 | 112 | directoryError?: ProviderSessionDirectoryPersistenceError; |
| 113 | 113 | stopError?: ProviderServiceError; |
| 114 | 114 | logoutError?: ProviderSetupError; |
| 115 | sharedCredentials?: boolean; | |
| 116 | sharedBusy?: boolean; | |
| 117 | sharedBusyEffect?: Effect.Effect<boolean>; | |
| 118 | responds?: boolean; | |
| 119 | beforeLogout?: Effect.Effect<void>; | |
| 120 | beforeStop?: Effect.Effect<void>; | |
| 121 | beforeListSessions?: Effect.Effect<void>; | |
| 122 | onLookup?: Effect.Effect<void>; | |
| 115 | 123 | } = {}, |
| 116 | 124 | ) { |
| 117 | 125 | const actions: string[] = []; |
| ... | ... | @@ -138,6 +146,18 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 138 | 146 | }); |
| 139 | 147 | |
| 140 | 148 | const auth: ProviderAuthController = { |
| 149 | ...(input.sharedCredentials | |
| 150 | ? { credentialBinding: { owner: "provider" as const, key: "shared" } } | |
| 151 | : {}), | |
| 152 | ...(input.responds | |
| 153 | ? { | |
| 154 | respond: Effect.fn(function* (ownerSessionId, request) { | |
| 155 | yield* checkOwner(ownerSessionId, request.flowId, "respond"); | |
| 156 | actions.push(`respond:${request.response.type}`); | |
| 157 | return state; | |
| 158 | }), | |
| 159 | } | |
| 160 | : {}), | |
| 141 | 161 | start: Effect.fn(function* (ownerSessionId, stopSessions) { |
| 142 | 162 | gateClosed = true; |
| 143 | 163 | actions.push("close-gate"); |
| ... | ... | @@ -169,6 +189,7 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 169 | 189 | actions.push("close-gate"); |
| 170 | 190 | yield* stopSessions; |
| 171 | 191 | if (input.logoutError) return yield* input.logoutError; |
| 192 | yield* input.beforeLogout ?? Effect.void; | |
| 172 | 193 | actions.push("native-logout"); |
| 173 | 194 | state = idle; |
| 174 | 195 | flowOwner = undefined; |
| ... | ... | @@ -181,13 +202,34 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 181 | 202 | const instances = [ |
| 182 | 203 | makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), |
| 183 | 204 | makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), |
| 205 | ...(input.sharedCredentials | |
| 206 | ? [ | |
| 207 | makeInstance({ | |
| 208 | instanceId: otherInstanceId, | |
| 209 | enabled: true, | |
| 210 | auth: { | |
| 211 | ...auth, | |
| 212 | isChangingCredentials: | |
| 213 | input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), | |
| 214 | invalidate: Effect.sync(() => { | |
| 215 | actions.push("invalidate-shared"); | |
| 216 | }), | |
| 217 | }, | |
| 218 | }), | |
| 219 | ] | |
| 220 | : []), | |
| 184 | 221 | ]; |
| 185 | 222 | const service = yield* makeProviderAuthService.pipe( |
| 186 | 223 | Effect.provide( |
| 187 | 224 | Layer.mergeAll( |
| 188 | 225 | Layer.mock(ProviderInstanceRegistry)({ |
| 189 | 226 | getInstance: (id) => |
| 190 | Effect.succeed(instances.find((instance) => instance.instanceId === id)), | |
| 227 | Effect.gen(function* () { | |
| 228 | const found = instances.find((instance) => instance.instanceId === id); | |
| 229 | yield* input.onLookup ?? Effect.void; | |
| 230 | return found; | |
| 231 | }), | |
| 232 | listInstances: Effect.succeed(instances), | |
| 191 | 233 | subscribeChanges: PubSub.subscribe(registryChanges), |
| 192 | 234 | }), |
| 193 | 235 | Layer.mock(ProviderSessionDirectory)({ |
| ... | ... | @@ -202,26 +244,44 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 202 | 244 | }), |
| 203 | 245 | Layer.mock(ProviderService)({ |
| 204 | 246 | listSessions: () => |
| 205 | Effect.sync(() => { | |
| 247 | Effect.gen(function* () { | |
| 206 | 248 | assert.isTrue(gateClosed); |
| 207 | 249 | actions.push("list-sessions"); |
| 250 | yield* input.beforeListSessions ?? Effect.void; | |
| 208 | 251 | return [...sessions.values()]; |
| 209 | 252 | }), |
| 210 | 253 | stopSession: ({ threadId }) => |
| 211 | Effect.suspend(() => { | |
| 254 | Effect.gen(function* () { | |
| 212 | 255 | assert.isTrue(gateClosed); |
| 213 | 256 | actions.push(`stop:${threadId}`); |
| 214 | if (input.stopError) return Effect.fail(input.stopError); | |
| 257 | yield* input.beforeStop ?? Effect.void; | |
| 258 | if (input.stopError) return yield* input.stopError; | |
| 215 | 259 | sessions.delete(threadId); |
| 216 | 260 | const binding = bindings.get(threadId); |
| 217 | 261 | if (binding) bindings.set(threadId, { ...binding, status: "stopped" }); |
| 218 | return Effect.void; | |
| 219 | 262 | }), |
| 220 | 263 | }), |
| 221 | 264 | ), |
| 222 | 265 | ), |
| 223 | 266 | ); |
| 224 | return { service, actions, sessions, bindings }; | |
| 267 | return { | |
| 268 | service, | |
| 269 | actions, | |
| 270 | sessions, | |
| 271 | bindings, | |
| 272 | auth, | |
| 273 | addInstance: (instance: ProviderInstance) => instances.push(instance), | |
| 274 | replaceInstance: (replacement: ProviderInstance) => { | |
| 275 | const index = instances.findIndex( | |
| 276 | (instance) => instance.instanceId === replacement.instanceId, | |
| 277 | ); | |
| 278 | assert.isAtLeast(index, 0); | |
| 279 | instances[index] = replacement; | |
| 280 | }, | |
| 281 | replaceAuth: (next: ProviderAuthController) => { | |
| 282 | instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); | |
| 283 | }, | |
| 284 | }; | |
| 225 | 285 | }); |
| 226 | 286 | |
| 227 | 287 | const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( |
| ... | ... | @@ -305,6 +365,56 @@ const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* |
| 305 | 365 | }); |
| 306 | 366 | |
| 307 | 367 | describe("ProviderAuthService", () => { |
| 368 | it.effect( | |
| 369 | "stops sessions sharing credentials and invalidates their processes before logout", | |
| 370 | () => | |
| 371 | Effect.gen(function* () { | |
| 372 | const { service, actions, sessions } = yield* makeHarness({ | |
| 373 | sharedCredentials: true, | |
| 374 | sessions: [ | |
| 375 | makeSession("target"), | |
| 376 | makeSession("shared", otherInstanceId), | |
| 377 | makeSession("unrelated", unsupportedInstanceId), | |
| 378 | ], | |
| 379 | }); | |
| 380 | yield* service.logout({ instanceId }); | |
| 381 | assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); | |
| 382 | assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); | |
| 383 | assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); | |
| 384 | }), | |
| 385 | ); | |
| 386 | it.effect("rejects overlapping changes to a shared sign-in", () => | |
| 387 | Effect.gen(function* () { | |
| 388 | const { service, actions } = yield* makeHarness({ | |
| 389 | sharedCredentials: true, | |
| 390 | sharedBusy: true, | |
| 391 | }); | |
| 392 | for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { | |
| 393 | const error = yield* task.pipe(Effect.flip); | |
| 394 | assert.include(error.detail, "shared sign-in"); | |
| 395 | } | |
| 396 | assert.deepStrictEqual(actions, []); | |
| 397 | }), | |
| 398 | ); | |
| 399 | it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => | |
| 400 | Effect.gen(function* () { | |
| 401 | const { service, actions } = yield* makeHarness({ responds: true }); | |
| 402 | yield* service.start({ instanceId }, owner); | |
| 403 | const request = { | |
| 404 | instanceId, | |
| 405 | flowId, | |
| 406 | interactionId: "consent", | |
| 407 | response: { type: "browser" as const, action: "accept" as const }, | |
| 408 | }; | |
| 409 | const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); | |
| 410 | assert.strictEqual(rejected.operation, "respond"); | |
| 411 | yield* service.respond(request, owner); | |
| 412 | assert.strictEqual(actions.at(-1), "respond:browser"); | |
| 413 | const unsupported = yield* makeHarness(); | |
| 414 | const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); | |
| 415 | assert.include(error.detail, "does not accept"); | |
| 416 | }), | |
| 417 | ); | |
| 308 | 418 | it.effect("stops routed sessions before sign-in, including for a disabled instance", () => |
| 309 | 419 | Effect.gen(function* () { |
| 310 | 420 | const { service, actions, sessions } = yield* makeHarness({ |
| ... | ... | @@ -606,3 +716,200 @@ describe("ProviderAuthService", () => { |
| 606 | 716 | }), |
| 607 | 717 | ); |
| 608 | 718 | }); |
| 719 | ||
| 720 | it.effect("queued logout prompts resolve the current controller after provider replacement", () => | |
| 721 | Effect.gen(function* () { | |
| 722 | const entered = yield* Deferred.make<void>(); | |
| 723 | const release = yield* Deferred.make<void>(); | |
| 724 | const lookup = yield* Deferred.make<void>(); | |
| 725 | let observeLookup = false; | |
| 726 | const harness = yield* makeHarness({ | |
| 727 | beforeLogout: Deferred.succeed(entered, undefined).pipe( | |
| 728 | Effect.andThen(Deferred.await(release)), | |
| 729 | ), | |
| 730 | onLookup: Effect.suspend(() => | |
| 731 | observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, | |
| 732 | ), | |
| 733 | }); | |
| 734 | const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | |
| 735 | yield* Deferred.await(entered); | |
| 736 | observeLookup = true; | |
| 737 | const queued = yield* harness.service | |
| 738 | .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) | |
| 739 | .pipe(Effect.forkChild); | |
| 740 | yield* Deferred.await(lookup); | |
| 741 | let replacementLoggedOut = false; | |
| 742 | harness.replaceAuth({ | |
| 743 | ...harness.auth, | |
| 744 | logout: (stopSessions) => | |
| 745 | stopSessions.pipe( | |
| 746 | Effect.andThen( | |
| 747 | Effect.sync(() => { | |
| 748 | replacementLoggedOut = true; | |
| 749 | return idleAuthState; | |
| 750 | }), | |
| 751 | ), | |
| 752 | ), | |
| 753 | }); | |
| 754 | yield* Deferred.succeed(release, undefined); | |
| 755 | yield* Fiber.join(first); | |
| 756 | assert.isTrue(yield* Fiber.join(queued)); | |
| 757 | assert.isTrue(replacementLoggedOut); | |
| 758 | assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); | |
| 759 | }).pipe(Effect.scoped), | |
| 760 | ); | |
| 761 | ||
| 762 | it.effect.each(["start", "logout", "prompt"] as const)( | |
| 763 | "%s uses the same credential controller for shared exclusion and mutation during replacement", | |
| 764 | (action) => | |
| 765 | Effect.gen(function* () { | |
| 766 | const checked = yield* Deferred.make<void>(); | |
| 767 | const continueCheck = yield* Deferred.make<void>(); | |
| 768 | const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); | |
| 769 | const harness = yield* makeHarness({ | |
| 770 | sharedCredentials: true, | |
| 771 | sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( | |
| 772 | Effect.andThen(Deferred.await(continueCheck)), | |
| 773 | Effect.as(false), | |
| 774 | ), | |
| 775 | sessions: [ | |
| 776 | makeSession("old-shared", otherInstanceId), | |
| 777 | makeSession("replacement-shared", replacementPeerId), | |
| 778 | ], | |
| 779 | }); | |
| 780 | harness.addInstance( | |
| 781 | makeInstance({ | |
| 782 | instanceId: replacementPeerId, | |
| 783 | enabled: true, | |
| 784 | auth: { | |
| 785 | ...harness.auth, | |
| 786 | credentialBinding: { owner: "provider", key: "replacement-binding" }, | |
| 787 | isChangingCredentials: Effect.succeed(true), | |
| 788 | invalidate: Effect.die( | |
| 789 | "The unrelated replacement credential binding must stay intact.", | |
| 790 | ), | |
| 791 | }, | |
| 792 | }), | |
| 793 | ); | |
| 794 | const operation = | |
| 795 | action === "start" | |
| 796 | ? harness.service.start({ instanceId }, owner) | |
| 797 | : action === "logout" | |
| 798 | ? harness.service.logout({ instanceId }) | |
| 799 | : harness.service.tryHandlePromptCommand({ | |
| 800 | instanceId, | |
| 801 | text: "/logout", | |
| 802 | hasAttachments: false, | |
| 803 | }); | |
| 804 | const running = yield* operation.pipe(Effect.forkChild); | |
| 805 | yield* Deferred.await(checked); | |
| 806 | let replacementMutations = 0; | |
| 807 | harness.replaceAuth({ | |
| 808 | ...harness.auth, | |
| 809 | credentialBinding: { owner: "provider", key: "replacement-binding" }, | |
| 810 | start: () => | |
| 811 | Effect.sync(() => { | |
| 812 | replacementMutations++; | |
| 813 | return waitingAuthState; | |
| 814 | }), | |
| 815 | logout: () => | |
| 816 | Effect.sync(() => { | |
| 817 | replacementMutations++; | |
| 818 | return idleAuthState; | |
| 819 | }), | |
| 820 | }); | |
| 821 | yield* Deferred.succeed(continueCheck, undefined); | |
| 822 | yield* Fiber.join(running); | |
| 823 | assert.equal(replacementMutations, 0); | |
| 824 | assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); | |
| 825 | assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); | |
| 826 | assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); | |
| 827 | const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); | |
| 828 | assert.include(blocked.detail, "shared sign-in"); | |
| 829 | assert.equal(replacementMutations, 0); | |
| 830 | }).pipe(Effect.scoped), | |
| 831 | ); | |
| 832 | ||
| 833 | it.effect.each([ | |
| 834 | { owner: "provider" as const, key: "different-binding" }, | |
| 835 | { owner: "t3" as const, key: "shared" }, | |
| 836 | ])( | |
| 837 | "does not invalidate a peer that switches credential binding during session draining %#", | |
| 838 | (binding) => | |
| 839 | Effect.gen(function* () { | |
| 840 | const draining = yield* Deferred.make<void>(); | |
| 841 | const continueDrain = yield* Deferred.make<void>(); | |
| 842 | const harness = yield* makeHarness({ | |
| 843 | sharedCredentials: true, | |
| 844 | sessions: [makeSession("shared-draining", otherInstanceId)], | |
| 845 | beforeStop: Deferred.succeed(draining, undefined).pipe( | |
| 846 | Effect.andThen(Deferred.await(continueDrain)), | |
| 847 | ), | |
| 848 | }); | |
| 849 | const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | |
| 850 | yield* Deferred.await(draining); | |
| 851 | let replacementInvalidated = false; | |
| 852 | harness.replaceInstance( | |
| 853 | makeInstance({ | |
| 854 | instanceId: otherInstanceId, | |
| 855 | enabled: true, | |
| 856 | auth: { | |
| 857 | ...harness.auth, | |
| 858 | credentialBinding: binding, | |
| 859 | invalidate: Effect.sync(() => { | |
| 860 | replacementInvalidated = true; | |
| 861 | }), | |
| 862 | }, | |
| 863 | }), | |
| 864 | ); | |
| 865 | yield* Deferred.succeed(continueDrain, undefined); | |
| 866 | yield* Fiber.join(logout); | |
| 867 | assert.isFalse(replacementInvalidated); | |
| 868 | assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); | |
| 869 | assert.include(harness.actions, "native-logout"); | |
| 870 | }).pipe(Effect.scoped), | |
| 871 | ); | |
| 872 | ||
| 873 | it.effect.each(["selection", "drain"] as const)( | |
| 874 | "preserves replacement peer sessions when credentials change during session %s", | |
| 875 | (phase) => | |
| 876 | Effect.gen(function* () { | |
| 877 | const entered = yield* Deferred.make<void>(); | |
| 878 | const proceed = yield* Deferred.make<void>(); | |
| 879 | const block = Deferred.succeed(entered, undefined).pipe( | |
| 880 | Effect.andThen(Deferred.await(proceed)), | |
| 881 | ); | |
| 882 | const harness = yield* makeHarness({ | |
| 883 | sharedCredentials: true, | |
| 884 | sessions: [ | |
| 885 | makeSession("target-draining"), | |
| 886 | makeSession("peer-replacement", otherInstanceId), | |
| 887 | ], | |
| 888 | bindings: [ | |
| 889 | makeBinding("target-draining", "running"), | |
| 890 | makeBinding("peer-persisted", "running", otherInstanceId), | |
| 891 | ], | |
| 892 | ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), | |
| 893 | }); | |
| 894 | const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | |
| 895 | yield* Deferred.await(entered); | |
| 896 | harness.replaceInstance( | |
| 897 | makeInstance({ | |
| 898 | instanceId: otherInstanceId, | |
| 899 | enabled: true, | |
| 900 | auth: { | |
| 901 | ...harness.auth, | |
| 902 | credentialBinding: { owner: "provider", key: "replacement-credentials" }, | |
| 903 | invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), | |
| 904 | }, | |
| 905 | }), | |
| 906 | ); | |
| 907 | yield* Deferred.succeed(proceed, undefined); | |
| 908 | yield* Fiber.join(logout); | |
| 909 | assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); | |
| 910 | assert.notInclude(harness.actions, "stop:peer-replacement"); | |
| 911 | assert.notInclude(harness.actions, "stop:peer-persisted"); | |
| 912 | assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); | |
| 913 | assert.include(harness.actions, "native-logout"); | |
| 914 | }).pipe(Effect.scoped), | |
| 915 | ); |
apps/server/src/provider/Layers/ProviderAuthService.ts+134-29| ... | ... | @@ -2,8 +2,9 @@ import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts" |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Stream from "effect/Stream"; |
| 5 | import * as Semaphore from "effect/Semaphore"; | |
| 5 | 6 | |
| 6 | import { ProviderAuthService } from "../Services/ProviderAuthService.ts"; | |
| 7 | import * as ProviderAuthService from "../Services/ProviderAuthService.ts"; | |
| 7 | 8 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 8 | 9 | import { ProviderService } from "../Services/ProviderService.ts"; |
| 9 | 10 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| ... | ... | @@ -12,6 +13,7 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 12 | 13 | const registry = yield* ProviderInstanceRegistry; |
| 13 | 14 | const providers = yield* ProviderService; |
| 14 | 15 | const directory = yield* ProviderSessionDirectory; |
| 16 | const credentialChanges = yield* Semaphore.make(1); | |
| 15 | 17 | |
| 16 | 18 | const getController = Effect.fn("ProviderAuthService.getController")(function* ( |
| 17 | 19 | instanceId: ProviderInstanceId, |
| ... | ... | @@ -30,9 +32,25 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 30 | 32 | return instance.auth; |
| 31 | 33 | }); |
| 32 | 34 | |
| 35 | // Native sessions may still belong to the previous provider after the | |
| 36 | // selected model changes. Read session bindings, not the selected model, | |
| 37 | // when invalidating credentials for sign-in or sign-out. | |
| 33 | 38 | const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( |
| 34 | 39 | instanceId: ProviderInstanceId, |
| 40 | binding: ProviderAuthService.ProviderAuthController["credentialBinding"], | |
| 35 | 41 | ) { |
| 42 | const affectedIds = new Set([ | |
| 43 | instanceId, | |
| 44 | ...(binding === undefined | |
| 45 | ? [] | |
| 46 | : (yield* registry.listInstances) | |
| 47 | .filter( | |
| 48 | (instance) => | |
| 49 | instance.auth?.credentialBinding?.key === binding.key && | |
| 50 | instance.auth.credentialBinding.owner === binding.owner, | |
| 51 | ) | |
| 52 | .map((instance) => instance.instanceId)), | |
| 53 | ]); | |
| 36 | 54 | const bindings = yield* directory.listBindings().pipe( |
| 37 | 55 | Effect.mapError( |
| 38 | 56 | () => |
| ... | ... | @@ -44,39 +62,111 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 44 | 62 | ), |
| 45 | 63 | ); |
| 46 | 64 | const sessions = yield* providers.listSessions(); |
| 47 | const threadIds = new Set( | |
| 48 | bindings | |
| 49 | .filter( | |
| 50 | (binding) => binding.providerInstanceId === instanceId && binding.status !== "stopped", | |
| 51 | ) | |
| 52 | .map((binding) => binding.threadId), | |
| 65 | const sessionsToStop = new Map( | |
| 66 | bindings.flatMap((session) => | |
| 67 | session.providerInstanceId !== undefined && | |
| 68 | affectedIds.has(session.providerInstanceId) && | |
| 69 | session.status !== "stopped" | |
| 70 | ? [[session.threadId, session.providerInstanceId] as const] | |
| 71 | : [], | |
| 72 | ), | |
| 53 | 73 | ); |
| 54 | 74 | for (const session of sessions) { |
| 55 | if (session.providerInstanceId === instanceId) { | |
| 56 | threadIds.add(session.threadId); | |
| 75 | if (session.providerInstanceId !== undefined && affectedIds.has(session.providerInstanceId)) { | |
| 76 | sessionsToStop.set(session.threadId, session.providerInstanceId); | |
| 57 | 77 | } |
| 58 | 78 | } |
| 59 | 79 | yield* Effect.forEach( |
| 60 | threadIds, | |
| 61 | (threadId) => | |
| 62 | providers.stopSession({ threadId }).pipe( | |
| 63 | Effect.mapError( | |
| 64 | () => | |
| 65 | new ProviderSetupError({ | |
| 66 | instanceId, | |
| 67 | operation: "stopSessions", | |
| 68 | detail: "Could not stop all sessions for this provider. Try again.", | |
| 69 | }), | |
| 70 | ), | |
| 71 | ), | |
| 80 | sessionsToStop, | |
| 81 | ([threadId, sessionInstanceId]) => | |
| 82 | Effect.gen(function* () { | |
| 83 | if (sessionInstanceId !== instanceId) { | |
| 84 | const current = yield* registry.getInstance(sessionInstanceId); | |
| 85 | if ( | |
| 86 | !binding || | |
| 87 | current?.auth?.credentialBinding?.key !== binding.key || | |
| 88 | current.auth.credentialBinding.owner !== binding.owner | |
| 89 | ) | |
| 90 | return; | |
| 91 | } | |
| 92 | yield* providers.stopSession({ threadId }).pipe( | |
| 93 | Effect.mapError( | |
| 94 | () => | |
| 95 | new ProviderSetupError({ | |
| 96 | instanceId, | |
| 97 | operation: "stopSessions", | |
| 98 | detail: "Could not stop all sessions for this provider. Try again.", | |
| 99 | }), | |
| 100 | ), | |
| 101 | ); | |
| 102 | }), | |
| 72 | 103 | { discard: true }, |
| 73 | 104 | ); |
| 105 | if (binding) { | |
| 106 | yield* Effect.forEach( | |
| 107 | (yield* registry.listInstances).filter( | |
| 108 | (instance) => | |
| 109 | instance.instanceId !== instanceId && | |
| 110 | affectedIds.has(instance.instanceId) && | |
| 111 | instance.auth?.credentialBinding?.key === binding.key && | |
| 112 | instance.auth.credentialBinding.owner === binding.owner, | |
| 113 | ), | |
| 114 | (instance) => instance.auth?.invalidate ?? Effect.void, | |
| 115 | { discard: true }, | |
| 116 | ); | |
| 117 | } | |
| 74 | 118 | }); |
| 75 | 119 | |
| 76 | return ProviderAuthService.of({ | |
| 120 | const checkSharedBinding = Effect.fnUntraced(function* ( | |
| 121 | instanceId: ProviderInstanceId, | |
| 122 | operation: "start" | "logout", | |
| 123 | auth: ProviderAuthService.ProviderAuthController, | |
| 124 | ) { | |
| 125 | const binding = auth.credentialBinding; | |
| 126 | if (!binding) return; | |
| 127 | const instances = yield* registry.listInstances; | |
| 128 | for (const instance of instances) { | |
| 129 | if ( | |
| 130 | instance.instanceId !== instanceId && | |
| 131 | instance.auth?.credentialBinding?.key === binding.key && | |
| 132 | instance.auth.credentialBinding.owner === binding.owner && | |
| 133 | instance.auth.isChangingCredentials && | |
| 134 | (yield* instance.auth.isChangingCredentials) | |
| 135 | ) { | |
| 136 | return yield* new ProviderSetupError({ | |
| 137 | instanceId, | |
| 138 | operation, | |
| 139 | detail: | |
| 140 | "Another provider instance is changing this shared sign-in. Finish or cancel it first.", | |
| 141 | }); | |
| 142 | } | |
| 143 | } | |
| 144 | }); | |
| 145 | ||
| 146 | return ProviderAuthService.ProviderAuthService.of({ | |
| 77 | 147 | start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { |
| 78 | const auth = yield* getController(input.instanceId, "start"); | |
| 79 | return yield* auth.start(ownerSessionId, stopSessions(input.instanceId)); | |
| 148 | return yield* credentialChanges.withPermit( | |
| 149 | Effect.gen(function* () { | |
| 150 | const auth = yield* getController(input.instanceId, "start"); | |
| 151 | yield* checkSharedBinding(input.instanceId, "start", auth); | |
| 152 | return yield* auth.start( | |
| 153 | ownerSessionId, | |
| 154 | stopSessions(input.instanceId, auth.credentialBinding), | |
| 155 | input.methodId, | |
| 156 | ); | |
| 157 | }), | |
| 158 | ); | |
| 159 | }), | |
| 160 | respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { | |
| 161 | const auth = yield* getController(input.instanceId, "respond"); | |
| 162 | if (!auth.respond) { | |
| 163 | return yield* new ProviderSetupError({ | |
| 164 | instanceId: input.instanceId, | |
| 165 | operation: "respond", | |
| 166 | detail: "This provider does not accept this sign-in interaction.", | |
| 167 | }); | |
| 168 | } | |
| 169 | return yield* auth.respond(ownerSessionId, input); | |
| 80 | 170 | }), |
| 81 | 171 | complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { |
| 82 | 172 | const auth = yield* getController(input.instanceId, "complete"); |
| ... | ... | @@ -87,8 +177,13 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 87 | 177 | return yield* auth.cancel(ownerSessionId, input.flowId); |
| 88 | 178 | }), |
| 89 | 179 | logout: Effect.fn("ProviderAuthService.logout")(function* (input) { |
| 90 | const auth = yield* getController(input.instanceId, "logout"); | |
| 91 | return yield* auth.logout(stopSessions(input.instanceId)); | |
| 180 | return yield* credentialChanges.withPermit( | |
| 181 | Effect.gen(function* () { | |
| 182 | const auth = yield* getController(input.instanceId, "logout"); | |
| 183 | yield* checkSharedBinding(input.instanceId, "logout", auth); | |
| 184 | return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); | |
| 185 | }), | |
| 186 | ); | |
| 92 | 187 | }), |
| 93 | 188 | subscribe: (input, ownerSessionId) => |
| 94 | 189 | Effect.gen(function* () { |
| ... | ... | @@ -110,11 +205,21 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 110 | 205 | if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { |
| 111 | 206 | return false; |
| 112 | 207 | } |
| 113 | yield* instance.auth.logout(stopSessions(input.instanceId)); | |
| 114 | return true; | |
| 208 | return yield* credentialChanges.withPermit( | |
| 209 | Effect.gen(function* () { | |
| 210 | const auth = yield* getController(input.instanceId, "logout"); | |
| 211 | if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; | |
| 212 | yield* checkSharedBinding(input.instanceId, "logout", auth); | |
| 213 | yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); | |
| 214 | return true; | |
| 215 | }), | |
| 216 | ); | |
| 115 | 217 | }, |
| 116 | 218 | ), |
| 117 | 219 | }); |
| 118 | 220 | }); |
| 119 | 221 | |
| 120 | export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService); | |
| 222 | export const ProviderAuthServiceLive = Layer.effect( | |
| 223 | ProviderAuthService.ProviderAuthService, | |
| 224 | makeProviderAuthService, | |
| 225 | ); |
apps/server/src/provider/ProviderAuthFlow.test.ts created+520| ... | ... | @@ -0,0 +1,520 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | ProviderInstanceId, | |
| 5 | ProviderSetupError, | |
| 6 | type ProviderAuthInteraction, | |
| 7 | type ProviderAuthResponse, | |
| 8 | type ProviderAuthState, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Scope from "effect/Scope"; | |
| 13 | import * as Deferred from "effect/Deferred"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | import * as TestClock from "effect/testing/TestClock"; | |
| 18 | ||
| 19 | import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; | |
| 20 | ||
| 21 | const instanceId = ProviderInstanceId.make("auth-flow-test"); | |
| 22 | const method = { id: "browser", name: "Browser", description: null, type: "agent" as const }; | |
| 23 | ||
| 24 | it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () => | |
| 25 | Effect.gen(function* () { | |
| 26 | const discovered = yield* Deferred.make<ReadonlyArray<typeof method>>(); | |
| 27 | const controller = yield* ProviderAuthFlow.make({ | |
| 28 | instanceId, | |
| 29 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 30 | methods: Deferred.await(discovered), | |
| 31 | authenticate: () => Effect.void, | |
| 32 | logout: Effect.void, | |
| 33 | }); | |
| 34 | const pending = yield* controller | |
| 35 | .subscribe("owner") | |
| 36 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 37 | assert.isUndefined(pending.methods); | |
| 38 | yield* Deferred.succeed(discovered, []); | |
| 39 | const ready = yield* controller.subscribe("owner").pipe( | |
| 40 | Stream.filter((state) => state.methods !== undefined), | |
| 41 | Stream.runHead, | |
| 42 | Effect.map(Option.getOrThrow), | |
| 43 | ); | |
| 44 | assert.deepEqual(ready.methods, []); | |
| 45 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 46 | ); | |
| 47 | ||
| 48 | const makeHarness = Effect.gen(function* () { | |
| 49 | const approved = yield* Deferred.make<void>(); | |
| 50 | const verified = yield* Deferred.make<void>(); | |
| 51 | const started = yield* Deferred.make<void>(); | |
| 52 | let attempts = 0; | |
| 53 | const controller = yield* ProviderAuthFlow.make({ | |
| 54 | instanceId, | |
| 55 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 56 | methods: Effect.succeed([method]), | |
| 57 | authenticate: (_, context) => | |
| 58 | Effect.gen(function* () { | |
| 59 | attempts++; | |
| 60 | yield* context.setInteraction( | |
| 61 | { | |
| 62 | type: "browser", | |
| 63 | id: "consent", | |
| 64 | url: "https://example.com/login", | |
| 65 | requiresConsent: true, | |
| 66 | }, | |
| 67 | (response) => | |
| 68 | response.type === "browser" && response.action === "accept" | |
| 69 | ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid) | |
| 70 | : Effect.void, | |
| 71 | ); | |
| 72 | yield* Deferred.succeed(started, undefined); | |
| 73 | yield* Deferred.await(approved); | |
| 74 | yield* context.verifying; | |
| 75 | yield* Deferred.await(verified); | |
| 76 | }), | |
| 77 | logout: Effect.void, | |
| 78 | }); | |
| 79 | const phase = (phase: ProviderAuthState["phase"], owner = "owner") => | |
| 80 | controller.subscribe(owner).pipe( | |
| 81 | Stream.filter((state) => state.phase === phase), | |
| 82 | Stream.runHead, | |
| 83 | Effect.map(Option.getOrThrow), | |
| 84 | ); | |
| 85 | return { controller, phase, started, verified, attempts: () => attempts }; | |
| 86 | }); | |
| 87 | ||
| 88 | it.effect("requires owner consent and provider verification before success", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const { controller, phase, started, verified, attempts } = yield* makeHarness; | |
| 91 | const state = yield* controller.start("owner"); | |
| 92 | yield* Deferred.await(started); | |
| 93 | const response = { | |
| 94 | instanceId, | |
| 95 | flowId: state.flowId!, | |
| 96 | interactionId: "consent", | |
| 97 | response: { type: "browser" as const, action: "accept" as const }, | |
| 98 | }; | |
| 99 | const other = yield* phase("waiting", "other"); | |
| 100 | assert.isNull(other.interaction); | |
| 101 | assert.isNull(other.flowId); | |
| 102 | assert.isTrue( | |
| 103 | (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure", | |
| 104 | ); | |
| 105 | assert.isTrue( | |
| 106 | (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe( | |
| 107 | Effect.result, | |
| 108 | ))._tag === "Failure", | |
| 109 | ); | |
| 110 | yield* controller.start("owner"); | |
| 111 | assert.strictEqual(attempts(), 1); | |
| 112 | yield* controller.respond!("owner", response); | |
| 113 | yield* phase("verifying"); | |
| 114 | yield* Deferred.succeed(verified, undefined); | |
| 115 | assert.strictEqual((yield* phase("succeeded")).phase, "succeeded"); | |
| 116 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("cancellation rejects late consent and permits another login", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const { controller, started, phase } = yield* makeHarness; | |
| 122 | const state = yield* controller.start("owner"); | |
| 123 | yield* Deferred.await(started); | |
| 124 | yield* controller.cancel("owner", state.flowId!); | |
| 125 | assert.strictEqual((yield* phase("cancelled")).phase, "cancelled"); | |
| 126 | assert.isTrue( | |
| 127 | (yield* controller.respond!("owner", { | |
| 128 | instanceId, | |
| 129 | flowId: state.flowId!, | |
| 130 | interactionId: "consent", | |
| 131 | response: { type: "browser", action: "accept" }, | |
| 132 | }).pipe(Effect.result))._tag === "Failure", | |
| 133 | ); | |
| 134 | const next = yield* controller.start("other"); | |
| 135 | assert.notStrictEqual(next.flowId, state.flowId); | |
| 136 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 137 | ); | |
| 138 | ||
| 139 | it.effect("expires pending login and rejects its response", () => | |
| 140 | Effect.gen(function* () { | |
| 141 | const { controller, started, phase } = yield* makeHarness; | |
| 142 | const state = yield* controller.start("owner"); | |
| 143 | yield* Deferred.await(started); | |
| 144 | yield* TestClock.adjust(300_001); | |
| 145 | assert.include((yield* phase("failed")).message ?? "", "expired"); | |
| 146 | assert.isTrue( | |
| 147 | (yield* controller.respond!("owner", { | |
| 148 | instanceId, | |
| 149 | flowId: state.flowId!, | |
| 150 | interactionId: "consent", | |
| 151 | response: { type: "browser", action: "accept" }, | |
| 152 | }).pipe(Effect.result))._tag === "Failure", | |
| 153 | ); | |
| 154 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 155 | ); | |
| 156 | ||
| 157 | it.effect("closes admitted provider processes and blocks new ones while login is pending", () => | |
| 158 | Effect.gen(function* () { | |
| 159 | const { controller, started } = yield* makeHarness; | |
| 160 | let closed = false; | |
| 161 | yield* controller.withAccess!( | |
| 162 | Effect.addFinalizer(() => | |
| 163 | Effect.sync(() => { | |
| 164 | closed = true; | |
| 165 | }), | |
| 166 | ), | |
| 167 | ); | |
| 168 | yield* controller.start("owner"); | |
| 169 | yield* Deferred.await(started); | |
| 170 | assert.isTrue(closed); | |
| 171 | assert.isTrue( | |
| 172 | (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure", | |
| 173 | ); | |
| 174 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 175 | ); | |
| 176 | ||
| 177 | it.effect("shared credential invalidation closes admitted processes before permitting reuse", () => | |
| 178 | Effect.gen(function* () { | |
| 179 | const { controller } = yield* makeHarness; | |
| 180 | let closed = false; | |
| 181 | yield* controller.withAccess!( | |
| 182 | Effect.addFinalizer(() => | |
| 183 | Effect.sync(() => { | |
| 184 | closed = true; | |
| 185 | }), | |
| 186 | ), | |
| 187 | ); | |
| 188 | yield* controller.invalidate!; | |
| 189 | assert.isTrue(closed); | |
| 190 | assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process"); | |
| 191 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 192 | ); | |
| 193 | ||
| 194 | it.effect.each([ | |
| 195 | { | |
| 196 | interaction: { type: "terminal", id: "terminal", output: "Log in" }, | |
| 197 | response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } }, | |
| 198 | }, | |
| 199 | { | |
| 200 | interaction: { | |
| 201 | type: "credentials", | |
| 202 | id: "credentials", | |
| 203 | fields: [{ name: "token", label: "Token", secret: true }], | |
| 204 | }, | |
| 205 | response: { type: "credentials", values: { token: "private-token" } }, | |
| 206 | }, | |
| 207 | ] satisfies ReadonlyArray<{ | |
| 208 | interaction: ProviderAuthInteraction; | |
| 209 | response: ProviderAuthResponse; | |
| 210 | }>)( | |
| 211 | "keeps $interaction.type responses private and waits for adapter verification", | |
| 212 | ({ interaction, response }) => | |
| 213 | Effect.gen(function* () { | |
| 214 | const received = yield* Deferred.make<ProviderAuthResponse>(); | |
| 215 | const controller = yield* ProviderAuthFlow.make({ | |
| 216 | instanceId, | |
| 217 | credentialBinding: { owner: "t3", key: "binding" }, | |
| 218 | methods: Effect.succeed([method]), | |
| 219 | authenticate: (_, context) => | |
| 220 | Effect.gen(function* () { | |
| 221 | yield* context.setInteraction(interaction, (response) => | |
| 222 | Deferred.succeed(received, response).pipe(Effect.asVoid), | |
| 223 | ); | |
| 224 | yield* Deferred.await(received); | |
| 225 | yield* context.verifying; | |
| 226 | return yield* Effect.never; | |
| 227 | }), | |
| 228 | logout: Effect.void, | |
| 229 | }); | |
| 230 | const state = yield* controller.start("owner"); | |
| 231 | const pending = yield* controller.subscribe("owner").pipe( | |
| 232 | Stream.filter((state) => state.phase === "waiting"), | |
| 233 | Stream.runHead, | |
| 234 | Effect.map(Option.getOrThrow), | |
| 235 | ); | |
| 236 | assert.deepStrictEqual(pending.interaction, interaction); | |
| 237 | const hidden = yield* controller | |
| 238 | .subscribe("other") | |
| 239 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 240 | assert.isNull(hidden.interaction); | |
| 241 | yield* controller.respond!("owner", { | |
| 242 | instanceId, | |
| 243 | flowId: state.flowId!, | |
| 244 | interactionId: interaction.id, | |
| 245 | response, | |
| 246 | }); | |
| 247 | assert.deepStrictEqual(yield* Deferred.await(received), response); | |
| 248 | const verifying = yield* controller.subscribe("owner").pipe( | |
| 249 | Stream.filter((state) => state.phase === "verifying"), | |
| 250 | Stream.runHead, | |
| 251 | Effect.map(Option.getOrThrow), | |
| 252 | ); | |
| 253 | assert.isNull(verifying.interaction); | |
| 254 | assert.notInclude(verifying.message ?? "", "private-token"); | |
| 255 | yield* controller.cancel("owner", state.flowId!); | |
| 256 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 257 | ); | |
| 258 | ||
| 259 | it.effect("shows a device code only to its owner without treating it as authenticated", () => | |
| 260 | Effect.gen(function* () { | |
| 261 | const controller = yield* ProviderAuthFlow.make({ | |
| 262 | instanceId, | |
| 263 | credentialBinding: { owner: "provider", key: "device" }, | |
| 264 | methods: Effect.succeed([method]), | |
| 265 | authenticate: (_, context) => | |
| 266 | context | |
| 267 | .setInteraction({ | |
| 268 | type: "deviceCode", | |
| 269 | id: "code", | |
| 270 | url: "https://example.com/device", | |
| 271 | userCode: "ABCD-EFGH", | |
| 272 | }) | |
| 273 | .pipe(Effect.andThen(Effect.never)), | |
| 274 | logout: Effect.void, | |
| 275 | }); | |
| 276 | const start = yield* controller.start("owner"); | |
| 277 | const waiting = yield* controller.subscribe("owner").pipe( | |
| 278 | Stream.filter((state) => state.phase === "waiting"), | |
| 279 | Stream.runHead, | |
| 280 | Effect.map(Option.getOrThrow), | |
| 281 | ); | |
| 282 | assert.strictEqual(waiting.interaction?.type, "deviceCode"); | |
| 283 | assert.strictEqual(waiting.authorizationUrl, "https://example.com/device"); | |
| 284 | const other = yield* controller | |
| 285 | .subscribe("other") | |
| 286 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 287 | assert.isNull(other.interaction); | |
| 288 | assert.isNull(other.authorizationUrl); | |
| 289 | yield* controller.cancel("owner", start.flowId!); | |
| 290 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 291 | ); | |
| 292 | ||
| 293 | it.effect.each([ | |
| 294 | { | |
| 295 | failure: Effect.fail( | |
| 296 | new ProviderSetupError({ | |
| 297 | instanceId, | |
| 298 | operation: "authenticate", | |
| 299 | detail: "This account cannot sign in. Choose another account.", | |
| 300 | }), | |
| 301 | ), | |
| 302 | message: "This account cannot sign in. Choose another account.", | |
| 303 | }, | |
| 304 | { | |
| 305 | failure: Effect.die(new Error("private-token in native diagnostics")), | |
| 306 | message: "Sign-in failed. Start again.", | |
| 307 | }, | |
| 308 | ])("publishes only safe authentication failure text %#", ({ failure, message }) => | |
| 309 | Effect.gen(function* () { | |
| 310 | const controller = yield* ProviderAuthFlow.make({ | |
| 311 | instanceId, | |
| 312 | credentialBinding: { owner: "t3", key: "failure" }, | |
| 313 | methods: Effect.succeed([method]), | |
| 314 | authenticate: () => failure, | |
| 315 | logout: Effect.void, | |
| 316 | }); | |
| 317 | yield* controller.start("owner"); | |
| 318 | const failed = yield* controller.subscribe("owner").pipe( | |
| 319 | Stream.filter((state) => state.phase === "failed"), | |
| 320 | Stream.runHead, | |
| 321 | Effect.map(Option.getOrThrow), | |
| 322 | ); | |
| 323 | assert.strictEqual(failed.message, message); | |
| 324 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 325 | ); | |
| 326 | ||
| 327 | const makeBlockingResponseHarness = Effect.gen(function* () { | |
| 328 | const entered = yield* Deferred.make<void>(); | |
| 329 | const cleanupStarted = yield* Deferred.make<void>(); | |
| 330 | const cleanupReleased = yield* Deferred.make<void>(); | |
| 331 | const cleanupFinished = yield* Deferred.make<void>(); | |
| 332 | const authenticationFinished = yield* Deferred.make<void>(); | |
| 333 | let responses = 0; | |
| 334 | const controller = yield* ProviderAuthFlow.make({ | |
| 335 | instanceId, | |
| 336 | credentialBinding: { owner: "t3", key: "blocked-response" }, | |
| 337 | methods: Effect.succeed([method]), | |
| 338 | authenticate: (_, context) => | |
| 339 | Effect.gen(function* () { | |
| 340 | yield* context.setInteraction( | |
| 341 | { | |
| 342 | type: "browser", | |
| 343 | id: "blocked", | |
| 344 | url: "https://example.com/login", | |
| 345 | requiresConsent: true, | |
| 346 | }, | |
| 347 | () => | |
| 348 | Effect.gen(function* () { | |
| 349 | responses++; | |
| 350 | yield* Deferred.succeed(entered, undefined); | |
| 351 | return yield* Effect.never; | |
| 352 | }).pipe( | |
| 353 | Effect.ensuring( | |
| 354 | Effect.gen(function* () { | |
| 355 | yield* Deferred.succeed(cleanupStarted, undefined); | |
| 356 | yield* Deferred.await(cleanupReleased); | |
| 357 | yield* Deferred.succeed(cleanupFinished, undefined); | |
| 358 | }), | |
| 359 | ), | |
| 360 | ), | |
| 361 | ); | |
| 362 | yield* Deferred.await(authenticationFinished); | |
| 363 | }), | |
| 364 | logout: Effect.void, | |
| 365 | }); | |
| 366 | const start = yield* controller.start("owner"); | |
| 367 | yield* controller.subscribe("owner").pipe( | |
| 368 | Stream.filter((state) => state.phase === "waiting"), | |
| 369 | Stream.runHead, | |
| 370 | ); | |
| 371 | const input = { | |
| 372 | instanceId, | |
| 373 | flowId: start.flowId!, | |
| 374 | interactionId: "blocked", | |
| 375 | response: { type: "browser" as const, action: "accept" as const }, | |
| 376 | }; | |
| 377 | const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild); | |
| 378 | yield* Deferred.await(entered); | |
| 379 | return { | |
| 380 | controller, | |
| 381 | input, | |
| 382 | response, | |
| 383 | cleanupStarted, | |
| 384 | cleanupReleased, | |
| 385 | cleanupFinished, | |
| 386 | authenticationFinished, | |
| 387 | responses: () => responses, | |
| 388 | }; | |
| 389 | }); | |
| 390 | ||
| 391 | it.effect.each(["cancel", "logout"] as const)( | |
| 392 | "%s interrupts and awaits a blocked adapter response before admitting another sign-in", | |
| 393 | (action) => | |
| 394 | Effect.gen(function* () { | |
| 395 | const harness = yield* makeBlockingResponseHarness; | |
| 396 | const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input)); | |
| 397 | assert.include(duplicate.detail, "already in progress"); | |
| 398 | assert.equal(harness.responses(), 1); | |
| 399 | const stopping = yield* ( | |
| 400 | action === "cancel" | |
| 401 | ? harness.controller.cancel("owner", harness.input.flowId) | |
| 402 | : harness.controller.logout(Effect.void) | |
| 403 | ).pipe(Effect.forkChild); | |
| 404 | yield* Deferred.await(harness.cleanupStarted); | |
| 405 | assert.isTrue(yield* harness.controller.isChangingCredentials!); | |
| 406 | assert.isUndefined(stopping.pollUnsafe()); | |
| 407 | const premature = yield* Effect.flip(harness.controller.start("other")); | |
| 408 | assert.include(premature.detail, "in progress"); | |
| 409 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 410 | yield* Fiber.join(stopping); | |
| 411 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 412 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 413 | assert.isFalse(yield* harness.controller.isChangingCredentials!); | |
| 414 | assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId); | |
| 415 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 416 | ); | |
| 417 | ||
| 418 | it.effect("timeout interrupts and drains a blocked response before publishing failure", () => | |
| 419 | Effect.gen(function* () { | |
| 420 | const harness = yield* makeBlockingResponseHarness; | |
| 421 | yield* TestClock.adjust(300_001); | |
| 422 | yield* Deferred.await(harness.cleanupStarted); | |
| 423 | assert.isTrue(yield* harness.controller.isChangingCredentials!); | |
| 424 | const duringCleanup = yield* harness.controller | |
| 425 | .subscribe("owner") | |
| 426 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 427 | assert.notEqual(duringCleanup.phase, "failed"); | |
| 428 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 429 | const failed = yield* harness.controller.subscribe("owner").pipe( | |
| 430 | Stream.filter((state) => state.phase === "failed"), | |
| 431 | Stream.runHead, | |
| 432 | Effect.map(Option.getOrThrow), | |
| 433 | ); | |
| 434 | assert.include(failed.message ?? "", "expired"); | |
| 435 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 436 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 437 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 438 | ); | |
| 439 | ||
| 440 | it.effect( | |
| 441 | "successful authentication drains a still-running response before admitting provider access", | |
| 442 | () => | |
| 443 | Effect.gen(function* () { | |
| 444 | const harness = yield* makeBlockingResponseHarness; | |
| 445 | yield* Deferred.succeed(harness.authenticationFinished, undefined); | |
| 446 | yield* Deferred.await(harness.cleanupStarted); | |
| 447 | const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void)); | |
| 448 | assert.include(denied.detail, "changing"); | |
| 449 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 450 | const succeeded = yield* harness.controller.subscribe("owner").pipe( | |
| 451 | Stream.filter((state) => state.phase === "succeeded"), | |
| 452 | Stream.runHead, | |
| 453 | Effect.map(Option.getOrThrow), | |
| 454 | ); | |
| 455 | assert.equal(succeeded.phase, "succeeded"); | |
| 456 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 457 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 458 | yield* harness.controller.withAccess!(Effect.void); | |
| 459 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 460 | ); | |
| 461 | ||
| 462 | it.effect("closing the controller scope interrupts and drains its adapter response", () => | |
| 463 | Effect.gen(function* () { | |
| 464 | const scope = yield* Scope.make(); | |
| 465 | const harness = yield* makeBlockingResponseHarness.pipe( | |
| 466 | Effect.provideService(Scope.Scope, scope), | |
| 467 | ); | |
| 468 | const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); | |
| 469 | yield* Deferred.await(harness.cleanupStarted); | |
| 470 | assert.isUndefined(closing.pollUnsafe()); | |
| 471 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 472 | yield* Fiber.join(closing); | |
| 473 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 474 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 475 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 476 | ); | |
| 477 | ||
| 478 | it.effect("a failed method refresh keeps the last discovered methods", () => | |
| 479 | Effect.gen(function* () { | |
| 480 | let fail = false; | |
| 481 | const controller = yield* ProviderAuthFlow.make({ | |
| 482 | instanceId, | |
| 483 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 484 | methods: Effect.suspend(() => | |
| 485 | fail | |
| 486 | ? Effect.fail( | |
| 487 | new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }), | |
| 488 | ) | |
| 489 | : Effect.succeed([method]), | |
| 490 | ), | |
| 491 | authenticate: () => Effect.void, | |
| 492 | logout: Effect.void, | |
| 493 | }); | |
| 494 | yield* controller.refreshMethods!; | |
| 495 | fail = true; | |
| 496 | yield* controller.refreshMethods!; | |
| 497 | const state = yield* controller | |
| 498 | .subscribe("owner") | |
| 499 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 500 | assert.deepEqual(state.methods, [method]); | |
| 501 | assert.strictEqual(state.message, "interrupted"); | |
| 502 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 503 | ); | |
| 504 | ||
| 505 | it.effect("rebuilding a controller leaves sessions it admitted to their owners", () => | |
| 506 | Effect.gen(function* () { | |
| 507 | const scope = yield* Scope.make(); | |
| 508 | const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope)); | |
| 509 | let closed = false; | |
| 510 | yield* controller.withAccess!( | |
| 511 | Effect.addFinalizer(() => | |
| 512 | Effect.sync(() => { | |
| 513 | closed = true; | |
| 514 | }), | |
| 515 | ), | |
| 516 | ); | |
| 517 | yield* Scope.close(scope, Exit.void); | |
| 518 | assert.isFalse(closed); | |
| 519 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 520 | ); |
apps/server/src/provider/ProviderAuthFlow.ts created+463| ... | ... | @@ -0,0 +1,463 @@ |
| 1 | import { | |
| 2 | ProviderSetupError, | |
| 3 | type ProviderAuthInteraction, | |
| 4 | type ProviderAuthMethod, | |
| 5 | type ProviderAuthResponse, | |
| 6 | type ProviderAuthState, | |
| 7 | type ProviderInstanceId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Cause from "effect/Cause"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as Clock from "effect/Clock"; | |
| 13 | import * as Crypto from "effect/Crypto"; | |
| 14 | import * as DateTime from "effect/DateTime"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Deferred from "effect/Deferred"; | |
| 17 | import * as Exit from "effect/Exit"; | |
| 18 | import * as Fiber from "effect/Fiber"; | |
| 19 | import * as Scope from "effect/Scope"; | |
| 20 | import * as Semaphore from "effect/Semaphore"; | |
| 21 | import * as Stream from "effect/Stream"; | |
| 22 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 23 | ||
| 24 | import type * as ProviderAuthService from "./Services/ProviderAuthService.ts"; | |
| 25 | ||
| 26 | export interface ProviderAuthFlowContext { | |
| 27 | readonly flowId: string; | |
| 28 | readonly setInteraction: ( | |
| 29 | interaction: ProviderAuthInteraction, | |
| 30 | respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>, | |
| 31 | ) => Effect.Effect<void>; | |
| 32 | readonly verifying: Effect.Effect<void>; | |
| 33 | } | |
| 34 | ||
| 35 | const isSetupError = Schema.is(ProviderSetupError); | |
| 36 | ||
| 37 | const failureMessage = (cause: Cause.Cause<ProviderSetupError>) => { | |
| 38 | const error = Cause.findErrorOption(cause); | |
| 39 | return Option.isSome(error) && isSetupError(error.value) | |
| 40 | ? error.value.detail | |
| 41 | : "Sign-in failed. Start again."; | |
| 42 | }; | |
| 43 | ||
| 44 | interface Flow { | |
| 45 | readonly id: string; | |
| 46 | readonly owner: string; | |
| 47 | readonly expiresAt: number; | |
| 48 | fiber?: Fiber.Fiber<void>; | |
| 49 | responseFiber?: Fiber.Fiber<void, ProviderSetupError>; | |
| 50 | respond: | |
| 51 | | ((response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>) | |
| 52 | | undefined; | |
| 53 | } | |
| 54 | ||
| 55 | /** Adapters do login and credential handling; this owns client consent and flow lifetime. */ | |
| 56 | export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { | |
| 57 | readonly instanceId: ProviderInstanceId; | |
| 58 | readonly credentialBinding: NonNullable< | |
| 59 | ProviderAuthService.ProviderAuthController["credentialBinding"] | |
| 60 | >; | |
| 61 | readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>; | |
| 62 | readonly defaultMethodId?: string; | |
| 63 | /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ | |
| 64 | readonly authenticate: ( | |
| 65 | methodId: string, | |
| 66 | context: ProviderAuthFlowContext, | |
| 67 | ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>; | |
| 68 | readonly logout: Effect.Effect<void, ProviderSetupError>; | |
| 69 | readonly timeoutMs?: number; | |
| 70 | }) { | |
| 71 | const scope = yield* Scope.Scope; | |
| 72 | const crypto = yield* Crypto.Crypto; | |
| 73 | const lock = yield* Semaphore.make(1); | |
| 74 | const timeoutMs = options.timeoutMs ?? 300_000; | |
| 75 | const empty: ProviderAuthState = { | |
| 76 | instanceId: options.instanceId, | |
| 77 | phase: "idle", | |
| 78 | flowId: null, | |
| 79 | authorizationUrl: null, | |
| 80 | expiresAt: null, | |
| 81 | message: null, | |
| 82 | interaction: null, | |
| 83 | credentialOwner: options.credentialBinding.owner, | |
| 84 | }; | |
| 85 | const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty }); | |
| 86 | let active: Flow | undefined; | |
| 87 | let operation: "idle" | "auth" | "stopping" | "closed" = "idle"; | |
| 88 | const sessions = new Set<Scope.Closeable>(); | |
| 89 | const stopOwnedSessions = Effect.suspend(() => | |
| 90 | Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { | |
| 91 | discard: true, | |
| 92 | concurrency: "unbounded", | |
| 93 | }), | |
| 94 | ); | |
| 95 | const publish = (flow: Flow, patch: Partial<ProviderAuthState>) => | |
| 96 | Effect.suspend(() => | |
| 97 | active === flow | |
| 98 | ? SubscriptionRef.update(snapshot, (current) => ({ | |
| 99 | owner: flow.owner, | |
| 100 | state: { ...current.state, ...patch }, | |
| 101 | })) | |
| 102 | : Effect.void, | |
| 103 | ); | |
| 104 | ||
| 105 | const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) { | |
| 106 | if ( | |
| 107 | operation !== "auth" || | |
| 108 | !active || | |
| 109 | active.owner !== owner || | |
| 110 | active.id !== id || | |
| 111 | (yield* Clock.currentTimeMillis) >= active.expiresAt | |
| 112 | ) { | |
| 113 | return yield* new ProviderSetupError({ | |
| 114 | instanceId: options.instanceId, | |
| 115 | operation: "respond", | |
| 116 | detail: "This sign-in is no longer active in this client.", | |
| 117 | }); | |
| 118 | } | |
| 119 | return active; | |
| 120 | }); | |
| 121 | ||
| 122 | // Method discovery does not start a sign-in flow. | |
| 123 | const refreshMethods = options.methods.pipe( | |
| 124 | Effect.flatMap((methods) => | |
| 125 | SubscriptionRef.update(snapshot, (current) => ({ | |
| 126 | ...current, | |
| 127 | state: { ...current.state, methods }, | |
| 128 | })), | |
| 129 | ), | |
| 130 | // Keep the last known methods when a refresh fails or is interrupted. | |
| 131 | Effect.catch((error) => | |
| 132 | SubscriptionRef.update(snapshot, (current) => ({ | |
| 133 | ...current, | |
| 134 | state: { ...current.state, methods: current.state.methods ?? [], message: error.detail }, | |
| 135 | })), | |
| 136 | ), | |
| 137 | ); | |
| 138 | yield* refreshMethods.pipe(Effect.forkIn(scope)); | |
| 139 | ||
| 140 | const controller: ProviderAuthService.ProviderAuthController = { | |
| 141 | credentialBinding: options.credentialBinding, | |
| 142 | refreshMethods, | |
| 143 | invalidate: lock.withPermit( | |
| 144 | Effect.gen(function* () { | |
| 145 | if (operation !== "idle") return; | |
| 146 | yield* stopOwnedSessions; | |
| 147 | yield* SubscriptionRef.set(snapshot, { | |
| 148 | owner: null, | |
| 149 | state: { | |
| 150 | ...empty, | |
| 151 | methods: snapshot.value.state.methods ?? [], | |
| 152 | message: "This provider's shared sign-in changed.", | |
| 153 | }, | |
| 154 | }); | |
| 155 | }), | |
| 156 | ), | |
| 157 | isChangingCredentials: Effect.sync(() => operation !== "idle"), | |
| 158 | withAccess: (task) => | |
| 159 | Effect.uninterruptibleMask((restore) => | |
| 160 | Effect.gen(function* () { | |
| 161 | const parent = yield* Scope.Scope; | |
| 162 | const child = yield* lock.withPermit( | |
| 163 | Effect.gen(function* () { | |
| 164 | if (operation !== "idle") | |
| 165 | return yield* new ProviderSetupError({ | |
| 166 | instanceId: options.instanceId, | |
| 167 | operation: "session", | |
| 168 | detail: "Provider sign-in is changing. Try again after it finishes.", | |
| 169 | }); | |
| 170 | const child = yield* Scope.make(); | |
| 171 | sessions.add(child); | |
| 172 | yield* Scope.addFinalizer( | |
| 173 | child, | |
| 174 | Effect.sync(() => { | |
| 175 | sessions.delete(child); | |
| 176 | }), | |
| 177 | ); | |
| 178 | yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); | |
| 179 | return child; | |
| 180 | }), | |
| 181 | ); | |
| 182 | const fiber = yield* restore(task).pipe( | |
| 183 | Effect.provideService(Scope.Scope, child), | |
| 184 | Effect.forkIn(child), | |
| 185 | ); | |
| 186 | return yield* restore(Fiber.await(fiber)).pipe( | |
| 187 | Effect.flatMap((result) => result), | |
| 188 | Effect.onExit((result) => | |
| 189 | Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, | |
| 190 | ), | |
| 191 | ); | |
| 192 | }), | |
| 193 | ), | |
| 194 | start: (owner, stopSessions = Effect.void, selectedMethodId) => | |
| 195 | lock.withPermit( | |
| 196 | Effect.gen(function* () { | |
| 197 | if (operation === "auth" && active?.owner === owner) return snapshot.value.state; | |
| 198 | if (operation !== "idle") | |
| 199 | return yield* new ProviderSetupError({ | |
| 200 | instanceId: options.instanceId, | |
| 201 | operation: "start", | |
| 202 | detail: "Provider setup is already in progress.", | |
| 203 | }); | |
| 204 | const id = yield* crypto.randomUUIDv4.pipe( | |
| 205 | Effect.mapError( | |
| 206 | () => | |
| 207 | new ProviderSetupError({ | |
| 208 | instanceId: options.instanceId, | |
| 209 | operation: "start", | |
| 210 | detail: "Could not start sign-in. Try again.", | |
| 211 | }), | |
| 212 | ), | |
| 213 | ); | |
| 214 | const flow: Flow = { | |
| 215 | id, | |
| 216 | owner, | |
| 217 | expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs, | |
| 218 | respond: undefined, | |
| 219 | }; | |
| 220 | active = flow; | |
| 221 | operation = "auth"; | |
| 222 | const state: ProviderAuthState = { | |
| 223 | ...empty, | |
| 224 | methods: snapshot.value.state.methods ?? [], | |
| 225 | phase: "starting", | |
| 226 | flowId: id, | |
| 227 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)), | |
| 228 | message: "Starting sign-in.", | |
| 229 | }; | |
| 230 | yield* SubscriptionRef.set(snapshot, { owner, state }); | |
| 231 | flow.fiber = yield* Effect.gen(function* () { | |
| 232 | const methods = yield* options.methods; | |
| 233 | yield* publish(flow, { methods }); | |
| 234 | const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id; | |
| 235 | if (!methodId || !methods.some((method) => method.id === methodId)) | |
| 236 | return yield* new ProviderSetupError({ | |
| 237 | instanceId: options.instanceId, | |
| 238 | operation: "start", | |
| 239 | detail: "The provider did not advertise this sign-in method.", | |
| 240 | }); | |
| 241 | yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); | |
| 242 | yield* options.authenticate(methodId, { | |
| 243 | flowId: id, | |
| 244 | setInteraction: (interaction, respond) => | |
| 245 | Effect.gen(function* () { | |
| 246 | if (active !== flow) return; | |
| 247 | flow.respond = respond; | |
| 248 | yield* publish(flow, { | |
| 249 | phase: "waiting", | |
| 250 | interaction, | |
| 251 | authorizationUrl: | |
| 252 | interaction.type === "browser" || interaction.type === "deviceCode" | |
| 253 | ? interaction.url | |
| 254 | : null, | |
| 255 | message: "Complete sign-in to continue.", | |
| 256 | }); | |
| 257 | }), | |
| 258 | verifying: Effect.gen(function* () { | |
| 259 | flow.respond = undefined; | |
| 260 | yield* publish(flow, { | |
| 261 | phase: "verifying", | |
| 262 | interaction: null, | |
| 263 | authorizationUrl: null, | |
| 264 | message: "Checking provider sign-in.", | |
| 265 | }); | |
| 266 | }), | |
| 267 | }); | |
| 268 | }).pipe( | |
| 269 | Effect.scoped, | |
| 270 | Effect.timeoutOrElse({ | |
| 271 | duration: timeoutMs, | |
| 272 | orElse: () => | |
| 273 | Effect.fail( | |
| 274 | new ProviderSetupError({ | |
| 275 | instanceId: options.instanceId, | |
| 276 | operation: "start", | |
| 277 | detail: "Sign-in expired. Start again.", | |
| 278 | }), | |
| 279 | ), | |
| 280 | }), | |
| 281 | Effect.exit, | |
| 282 | Effect.flatMap((result) => | |
| 283 | Effect.gen(function* () { | |
| 284 | const finishing = yield* lock.withPermit( | |
| 285 | Effect.sync(() => { | |
| 286 | if (active !== flow) return false; | |
| 287 | operation = "stopping"; | |
| 288 | return true; | |
| 289 | }), | |
| 290 | ); | |
| 291 | if (!finishing) return; | |
| 292 | if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 293 | yield* lock.withPermit( | |
| 294 | Effect.gen(function* () { | |
| 295 | if (active !== flow) return; | |
| 296 | yield* publish(flow, { | |
| 297 | phase: Exit.isSuccess(result) ? "succeeded" : "failed", | |
| 298 | interaction: null, | |
| 299 | authorizationUrl: null, | |
| 300 | expiresAt: null, | |
| 301 | message: Exit.isSuccess(result) | |
| 302 | ? "Sign-in complete." | |
| 303 | : failureMessage(result.cause), | |
| 304 | }); | |
| 305 | active = undefined; | |
| 306 | operation = "idle"; | |
| 307 | }), | |
| 308 | ); | |
| 309 | }), | |
| 310 | ), | |
| 311 | Effect.interruptible, | |
| 312 | Effect.forkIn(scope), | |
| 313 | ); | |
| 314 | return state; | |
| 315 | }).pipe(Effect.uninterruptible), | |
| 316 | ), | |
| 317 | respond: (owner, input) => | |
| 318 | Effect.uninterruptibleMask((restore) => | |
| 319 | Effect.gen(function* () { | |
| 320 | const { flow, fiber, ready } = yield* lock.withPermit( | |
| 321 | Effect.gen(function* () { | |
| 322 | const flow = yield* requireFlow(owner, input.flowId); | |
| 323 | const interaction = snapshot.value.state.interaction; | |
| 324 | if ( | |
| 325 | !interaction || | |
| 326 | interaction.id !== input.interactionId || | |
| 327 | interaction.type !== input.response.type || | |
| 328 | !flow.respond | |
| 329 | ) | |
| 330 | return yield* new ProviderSetupError({ | |
| 331 | instanceId: options.instanceId, | |
| 332 | operation: "respond", | |
| 333 | detail: "This sign-in interaction is no longer available.", | |
| 334 | }); | |
| 335 | if (flow.responseFiber) | |
| 336 | return yield* new ProviderSetupError({ | |
| 337 | instanceId: options.instanceId, | |
| 338 | operation: "respond", | |
| 339 | detail: "A sign-in response is already in progress.", | |
| 340 | }); | |
| 341 | const ready = yield* Deferred.make<void>(); | |
| 342 | const callback = flow.respond; | |
| 343 | const fiber = yield* Deferred.await(ready).pipe( | |
| 344 | Effect.andThen(Effect.suspend(() => callback(input.response))), | |
| 345 | Effect.interruptible, | |
| 346 | Effect.forkIn(scope), | |
| 347 | ); | |
| 348 | flow.responseFiber = fiber; | |
| 349 | return { flow, fiber, ready }; | |
| 350 | }), | |
| 351 | ); | |
| 352 | yield* Deferred.succeed(ready, undefined); | |
| 353 | yield* restore(Fiber.join(fiber)).pipe( | |
| 354 | Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)), | |
| 355 | Effect.ensuring( | |
| 356 | lock.withPermit( | |
| 357 | Effect.sync(() => { | |
| 358 | if (flow.responseFiber === fiber) delete flow.responseFiber; | |
| 359 | }), | |
| 360 | ), | |
| 361 | ), | |
| 362 | ); | |
| 363 | return snapshot.value.state; | |
| 364 | }), | |
| 365 | ), | |
| 366 | complete: () => | |
| 367 | Effect.fail( | |
| 368 | new ProviderSetupError({ | |
| 369 | instanceId: options.instanceId, | |
| 370 | operation: "complete", | |
| 371 | detail: "This provider does not accept a pasted redirect URL.", | |
| 372 | }), | |
| 373 | ), | |
| 374 | cancel: (owner, id) => | |
| 375 | Effect.gen(function* () { | |
| 376 | const flow = yield* lock.withPermit( | |
| 377 | Effect.gen(function* () { | |
| 378 | const flow = yield* requireFlow(owner, id); | |
| 379 | yield* publish(flow, { | |
| 380 | phase: "cancelled", | |
| 381 | interaction: null, | |
| 382 | authorizationUrl: null, | |
| 383 | expiresAt: null, | |
| 384 | message: "Sign-in cancelled.", | |
| 385 | }); | |
| 386 | active = undefined; | |
| 387 | operation = "stopping"; | |
| 388 | return flow; | |
| 389 | }), | |
| 390 | ); | |
| 391 | if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 392 | if (flow.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 393 | operation = "idle"; | |
| 394 | return snapshot.value.state; | |
| 395 | }).pipe(Effect.uninterruptible), | |
| 396 | logout: (stopSessions) => | |
| 397 | Effect.gen(function* () { | |
| 398 | const flow = yield* lock.withPermit( | |
| 399 | Effect.gen(function* () { | |
| 400 | if (operation !== "idle" && operation !== "auth") | |
| 401 | return yield* new ProviderSetupError({ | |
| 402 | instanceId: options.instanceId, | |
| 403 | operation: "logout", | |
| 404 | detail: "Provider setup is already stopping.", | |
| 405 | }); | |
| 406 | operation = "stopping"; | |
| 407 | const flow = active; | |
| 408 | active = undefined; | |
| 409 | return flow; | |
| 410 | }), | |
| 411 | ); | |
| 412 | const result = yield* Effect.gen(function* () { | |
| 413 | if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 414 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 415 | yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); | |
| 416 | yield* options.logout; | |
| 417 | }).pipe(Effect.exit); | |
| 418 | const state: ProviderAuthState = { | |
| 419 | ...empty, | |
| 420 | methods: snapshot.value.state.methods ?? [], | |
| 421 | phase: Exit.isSuccess(result) ? "idle" : "failed", | |
| 422 | message: Exit.isSuccess(result) ? "Signed out." : "Could not sign out. Try again.", | |
| 423 | }; | |
| 424 | yield* lock.withPermit( | |
| 425 | Effect.gen(function* () { | |
| 426 | yield* SubscriptionRef.set(snapshot, { owner: null, state }); | |
| 427 | operation = "idle"; | |
| 428 | }), | |
| 429 | ); | |
| 430 | if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); | |
| 431 | return state; | |
| 432 | }).pipe(Effect.uninterruptible), | |
| 433 | subscribe: (owner) => | |
| 434 | SubscriptionRef.changes(snapshot).pipe( | |
| 435 | Stream.map((current) => | |
| 436 | current.owner === null || current.owner === owner | |
| 437 | ? current.state | |
| 438 | : { | |
| 439 | ...current.state, | |
| 440 | flowId: null, | |
| 441 | authorizationUrl: null, | |
| 442 | interaction: null, | |
| 443 | expiresAt: null, | |
| 444 | message: active | |
| 445 | ? "Sign-in is in progress in another client." | |
| 446 | : current.state.message, | |
| 447 | }, | |
| 448 | ), | |
| 449 | ), | |
| 450 | }; | |
| 451 | yield* Effect.addFinalizer(() => | |
| 452 | Effect.gen(function* () { | |
| 453 | operation = "closed"; | |
| 454 | const flow = active; | |
| 455 | active = undefined; | |
| 456 | if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 457 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 458 | // Settings edits rebuild instances; admitted sessions (including those of | |
| 459 | // peers sharing this binding) end with their own scopes, not this one. | |
| 460 | }), | |
| 461 | ); | |
| 462 | return controller; | |
| 463 | }); |
apps/server/src/provider/ProviderCredentialStore.test.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | |
| 5 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | |
| 6 | ||
| 7 | it.effect("isolates provider bindings and preserves opaque credentials", () => | |
| 8 | Effect.gen(function* () { | |
| 9 | const data = new Map<string, Uint8Array>(); | |
| 10 | const secretStore = ServerSecretStore.of({ | |
| 11 | get: (name) => Effect.sync(() => Option.fromUndefinedOr(data.get(name))), | |
| 12 | set: (name, value) => | |
| 13 | Effect.sync(() => { | |
| 14 | data.set(name, value); | |
| 15 | }), | |
| 16 | remove: (name) => | |
| 17 | Effect.sync(() => { | |
| 18 | data.delete(name); | |
| 19 | }), | |
| 20 | create: () => Effect.die("unused"), | |
| 21 | getOrCreateRandom: () => Effect.die("unused"), | |
| 22 | }); | |
| 23 | const a = yield* ProviderCredentialStore.make("cursor", "../../personal").pipe( | |
| 24 | Effect.provideService(ServerSecretStore, secretStore), | |
| 25 | ); | |
| 26 | const b = yield* ProviderCredentialStore.make("cursor", "work").pipe( | |
| 27 | Effect.provideService(ServerSecretStore, secretStore), | |
| 28 | ); | |
| 29 | const c = yield* ProviderCredentialStore.make("other", "../../personal").pipe( | |
| 30 | Effect.provideService(ServerSecretStore, secretStore), | |
| 31 | ); | |
| 32 | const bytes = Uint8Array.from([0, 255, 128, 1]); | |
| 33 | yield* a.set(bytes); | |
| 34 | assert.deepStrictEqual(Option.getOrThrow(yield* a.get), bytes); | |
| 35 | assert.isTrue(Option.isNone(yield* b.get)); | |
| 36 | assert.isTrue(Option.isNone(yield* c.get)); | |
| 37 | assert.isFalse(a.binding.key.includes("/")); | |
| 38 | const long = yield* ProviderCredentialStore.make("a".repeat(64), "b".repeat(64)).pipe( | |
| 39 | Effect.provideService(ServerSecretStore, secretStore), | |
| 40 | ); | |
| 41 | assert.isBelow(long.binding.key.length, 255); | |
| 42 | const delimiter = yield* ProviderCredentialStore.make("cur", "sor../../personal").pipe( | |
| 43 | Effect.provideService(ServerSecretStore, secretStore), | |
| 44 | ); | |
| 45 | assert.notStrictEqual(delimiter.binding.key, a.binding.key); | |
| 46 | yield* a.remove; | |
| 47 | assert.isTrue(Option.isNone(yield* a.get)); | |
| 48 | }), | |
| 49 | ); |
apps/server/src/provider/ProviderCredentialStore.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import * as NodeCrypto from "node:crypto"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 4 | ||
| 5 | /** A provider binding stores opaque bytes; only its adapter decodes or refreshes them. */ | |
| 6 | export const make = Effect.fn("ProviderCredentialStore.make")(function* ( | |
| 7 | driver: string, | |
| 8 | bindingId: string, | |
| 9 | ) { | |
| 10 | const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 11 | // Hash the tuple so arbitrary bindings cannot escape or exceed a filename. | |
| 12 | const key = `provider-auth-${NodeCrypto.createHash("sha256") | |
| 13 | .update(`${driver.length}:${driver}${bindingId}`) | |
| 14 | .digest("hex")}`; | |
| 15 | return { | |
| 16 | binding: { owner: "t3" as const, key }, | |
| 17 | get: secrets.get(key), | |
| 18 | set: (credentials: Uint8Array) => secrets.set(key, credentials), | |
| 19 | remove: secrets.remove(key), | |
| 20 | }; | |
| 21 | }); |
apps/server/src/provider/Services/ProviderAuthService.ts+26-2| ... | ... | @@ -1,12 +1,28 @@ |
| 1 | import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | ProviderAuthRespondInput, | |
| 3 | ProviderAuthStartInput, | |
| 4 | ProviderAuthState, | |
| 5 | ProviderInstanceId, | |
| 6 | ProviderSetupError, | |
| 7 | } from "@t3tools/contracts"; | |
| 2 | 8 | import * as Context from "effect/Context"; |
| 3 | 9 | import type * as Effect from "effect/Effect"; |
| 4 | 10 | import type * as Stream from "effect/Stream"; |
| 11 | import type * as Scope from "effect/Scope"; | |
| 5 | 12 | |
| 6 | 13 | export interface ProviderAuthController { |
| 14 | /** Equal keys mean these instances share credentials on this environment. */ | |
| 15 | readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; | |
| 16 | readonly isChangingCredentials?: Effect.Effect<boolean>; | |
| 17 | readonly invalidate?: Effect.Effect<void>; | |
| 18 | readonly refreshMethods?: Effect.Effect<void>; | |
| 19 | readonly withAccess?: <A, E, R>( | |
| 20 | task: Effect.Effect<A, E, R>, | |
| 21 | ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>; | |
| 7 | 22 | readonly start: ( |
| 8 | 23 | ownerSessionId: string, |
| 9 | 24 | stopSessions?: Effect.Effect<void, ProviderSetupError>, |
| 25 | methodId?: string, | |
| 10 | 26 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; |
| 11 | 27 | readonly complete: ( |
| 12 | 28 | ownerSessionId: string, |
| ... | ... | @@ -16,6 +32,10 @@ export interface ProviderAuthController { |
| 16 | 32 | ownerSessionId: string, |
| 17 | 33 | flowId: string, |
| 18 | 34 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; |
| 35 | readonly respond?: ( | |
| 36 | ownerSessionId: string, | |
| 37 | input: ProviderAuthRespondInput, | |
| 38 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | |
| 19 | 39 | /** The controller closes process admission before it stops routed sessions. */ |
| 20 | 40 | readonly logout: ( |
| 21 | 41 | stopSessions: Effect.Effect<void, ProviderSetupError>, |
| ... | ... | @@ -30,13 +50,17 @@ interface ProviderAuthTarget { |
| 30 | 50 | |
| 31 | 51 | export interface ProviderAuthServiceShape { |
| 32 | 52 | readonly start: ( |
| 33 | input: ProviderAuthTarget, | |
| 53 | input: ProviderAuthStartInput, | |
| 34 | 54 | ownerSessionId: string, |
| 35 | 55 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; |
| 36 | 56 | readonly complete: ( |
| 37 | 57 | input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, |
| 38 | 58 | ownerSessionId: string, |
| 39 | 59 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; |
| 60 | readonly respond: ( | |
| 61 | input: ProviderAuthRespondInput, | |
| 62 | ownerSessionId: string, | |
| 63 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | |
| 40 | 64 | readonly cancel: ( |
| 41 | 65 | input: ProviderAuthTarget & { readonly flowId: string }, |
| 42 | 66 | ownerSessionId: string, |
apps/server/src/ws.ts+9| ... | ... | @@ -2497,6 +2497,15 @@ const makeWsRpcLayer = ( |
| 2497 | 2497 | providerAuth.start(input, currentSessionId), |
| 2498 | 2498 | { "rpc.aggregate": "provider" }, |
| 2499 | 2499 | ), |
| 2500 | [WS_METHODS.providerAuthRespond]: (input) => | |
| 2501 | observeRpcEffect( | |
| 2502 | WS_METHODS.providerAuthRespond, | |
| 2503 | providerAuth.respond(input, currentSessionId), | |
| 2504 | { | |
| 2505 | "rpc.aggregate": "provider", | |
| 2506 | instanceId: input.instanceId, | |
| 2507 | }, | |
| 2508 | ), | |
| 2500 | 2509 | [WS_METHODS.providerAuthComplete]: (input) => |
| 2501 | 2510 | observeRpcEffect( |
| 2502 | 2511 | WS_METHODS.providerAuthComplete, |
packages/client-runtime/src/state/server.ts+4| ... | ... | @@ -978,6 +978,10 @@ export function createServerEnvironmentAtoms<R, E>( |
| 978 | 978 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), |
| 979 | 979 | }, |
| 980 | 980 | }), |
| 981 | respondProviderAuth: createEnvironmentRpcCommand(runtime, { | |
| 982 | label: "environment-data:provider:auth-respond", | |
| 983 | tag: WS_METHODS.providerAuthRespond, | |
| 984 | }), | |
| 981 | 985 | completeProviderAuth: createEnvironmentRpcCommand(runtime, { |
| 982 | 986 | label: "environment-data:provider:auth-complete", |
| 983 | 987 | tag: WS_METHODS.providerAuthComplete, |
packages/contracts/src/providerSetup.test.ts created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | import { ProviderAuthResponse, ProviderAuthState } from "./providerSetup.ts"; | |
| 5 | ||
| 6 | const decodeResponse = Schema.decodeUnknownSync(ProviderAuthResponse); | |
| 7 | const decodeState = Schema.decodeUnknownSync(ProviderAuthState); | |
| 8 | ||
| 9 | describe("provider credential responses", () => { | |
| 10 | it("accepts the advertised field limit and rejects oversized or invalid fields", () => { | |
| 11 | const values = Object.fromEntries( | |
| 12 | Array.from({ length: 16 }, (_, i) => [`field_${i}`, "value"]), | |
| 13 | ); | |
| 14 | expect(decodeResponse({ type: "credentials", values })).toEqual({ | |
| 15 | type: "credentials", | |
| 16 | values, | |
| 17 | }); | |
| 18 | expect(() => | |
| 19 | decodeResponse({ type: "credentials", values: { ...values, extra: "value" } }), | |
| 20 | ).toThrow(); | |
| 21 | expect(() => decodeResponse({ type: "credentials", values: { "": "value" } })).toThrow(); | |
| 22 | expect(() => | |
| 23 | decodeResponse({ type: "credentials", values: { token: "x".repeat(16_385) } }), | |
| 24 | ).toThrow(); | |
| 25 | }); | |
| 26 | }); | |
| 27 | ||
| 28 | describe("provider auth state", () => { | |
| 29 | it("drops auth variants from newer servers instead of rejecting the state", () => { | |
| 30 | const method = { id: "browser", name: "Browser", description: null, type: "agent" }; | |
| 31 | expect( | |
| 32 | decodeState({ | |
| 33 | instanceId: "cursor", | |
| 34 | phase: "waiting", | |
| 35 | flowId: null, | |
| 36 | authorizationUrl: null, | |
| 37 | expiresAt: null, | |
| 38 | message: null, | |
| 39 | methods: [method, { ...method, id: "passkey", type: "passkey" }], | |
| 40 | interaction: { type: "passkey", id: "passkey" }, | |
| 41 | credentialOwner: "keychain", | |
| 42 | }), | |
| 43 | ).toEqual({ | |
| 44 | instanceId: "cursor", | |
| 45 | phase: "waiting", | |
| 46 | flowId: null, | |
| 47 | authorizationUrl: null, | |
| 48 | expiresAt: null, | |
| 49 | message: null, | |
| 50 | methods: [method], | |
| 51 | }); | |
| 52 | }); | |
| 53 | }); |
packages/contracts/src/providerSetup.ts+95-1| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | |
| 3 | import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 3 | import { | |
| 4 | ForwardCompatibleArray, | |
| 5 | ForwardCompatibleOptional, | |
| 6 | IsoDateTime, | |
| 7 | TrimmedNonEmptyString, | |
| 8 | } from "./baseSchemas.ts"; | |
| 4 | 9 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | 10 | |
| 6 | 11 | export const ProviderSetupInput = Schema.Struct({ |
| ... | ... | @@ -10,6 +15,88 @@ export type ProviderSetupInput = typeof ProviderSetupInput.Type; |
| 10 | 15 | |
| 11 | 16 | const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); |
| 12 | 17 | |
| 18 | export const ProviderAuthMethod = Schema.Struct({ | |
| 19 | id: SetupOperationId, | |
| 20 | name: TrimmedNonEmptyString, | |
| 21 | description: Schema.NullOr(Schema.String), | |
| 22 | type: Schema.Literals(["agent", "terminal", "credentials"]), | |
| 23 | }); | |
| 24 | export type ProviderAuthMethod = typeof ProviderAuthMethod.Type; | |
| 25 | ||
| 26 | // These describe client interactions, not OAuth grant types. The provider | |
| 27 | // adapter remains responsible for credentials, callbacks, and refresh. | |
| 28 | export const ProviderAuthInteraction = Schema.Union([ | |
| 29 | Schema.Struct({ | |
| 30 | type: Schema.Literal("browser"), | |
| 31 | id: SetupOperationId, | |
| 32 | url: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)), | |
| 33 | requiresConsent: Schema.Boolean, | |
| 34 | acceptsCallback: Schema.optionalKey(Schema.Boolean), | |
| 35 | }), | |
| 36 | Schema.Struct({ | |
| 37 | type: Schema.Literal("deviceCode"), | |
| 38 | id: SetupOperationId, | |
| 39 | url: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)), | |
| 40 | userCode: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), | |
| 41 | }), | |
| 42 | Schema.Struct({ | |
| 43 | type: Schema.Literal("terminal"), | |
| 44 | id: SetupOperationId, | |
| 45 | output: Schema.String.check(Schema.isMaxLength(16_384)), | |
| 46 | outputOffset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), | |
| 47 | }), | |
| 48 | Schema.Struct({ | |
| 49 | type: Schema.Literal("credentials"), | |
| 50 | id: SetupOperationId, | |
| 51 | fields: Schema.Array( | |
| 52 | Schema.Struct({ | |
| 53 | name: SetupOperationId, | |
| 54 | label: TrimmedNonEmptyString, | |
| 55 | secret: Schema.Boolean, | |
| 56 | }), | |
| 57 | ).check(Schema.isMaxLength(16)), | |
| 58 | }), | |
| 59 | ]); | |
| 60 | export type ProviderAuthInteraction = typeof ProviderAuthInteraction.Type; | |
| 61 | ||
| 62 | export const ProviderAuthResponse = Schema.Union([ | |
| 63 | Schema.Struct({ | |
| 64 | type: Schema.Literal("browser"), | |
| 65 | action: Schema.Literals(["accept", "decline"]), | |
| 66 | }), | |
| 67 | Schema.Struct({ | |
| 68 | type: Schema.Literal("terminal"), | |
| 69 | data: Schema.String.check(Schema.isMaxLength(4_096)), | |
| 70 | size: Schema.optionalKey( | |
| 71 | Schema.Struct({ | |
| 72 | cols: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 500 })), | |
| 73 | rows: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 })), | |
| 74 | }), | |
| 75 | ), | |
| 76 | }), | |
| 77 | Schema.Struct({ | |
| 78 | type: Schema.Literal("credentials"), | |
| 79 | values: Schema.Record(SetupOperationId, Schema.String.check(Schema.isMaxLength(16_384))).check( | |
| 80 | Schema.isMaxProperties(16), | |
| 81 | ), | |
| 82 | }), | |
| 83 | ]); | |
| 84 | export type ProviderAuthResponse = typeof ProviderAuthResponse.Type; | |
| 85 | ||
| 86 | export const ProviderAuthStartInput = Schema.Struct({ | |
| 87 | instanceId: ProviderInstanceId, | |
| 88 | methodId: Schema.optionalKey(SetupOperationId), | |
| 89 | }); | |
| 90 | export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type; | |
| 91 | ||
| 92 | export const ProviderAuthRespondInput = Schema.Struct({ | |
| 93 | instanceId: ProviderInstanceId, | |
| 94 | flowId: SetupOperationId, | |
| 95 | interactionId: SetupOperationId, | |
| 96 | response: ProviderAuthResponse, | |
| 97 | }); | |
| 98 | export type ProviderAuthRespondInput = typeof ProviderAuthRespondInput.Type; | |
| 99 | ||
| 13 | 100 | export const ProviderAuthState = Schema.Struct({ |
| 14 | 101 | instanceId: ProviderInstanceId, |
| 15 | 102 | phase: Schema.Literals([ |
| ... | ... | @@ -25,6 +112,13 @@ export const ProviderAuthState = Schema.Struct({ |
| 25 | 112 | authorizationUrl: Schema.NullOr(Schema.String), |
| 26 | 113 | expiresAt: Schema.NullOr(IsoDateTime), |
| 27 | 114 | message: Schema.NullOr(Schema.String), |
| 115 | // Newer servers may add method types, interactions, or owners; older | |
| 116 | // clients drop what they cannot decode instead of rejecting the state. | |
| 117 | methods: Schema.optionalKey( | |
| 118 | ForwardCompatibleArray(ProviderAuthMethod).check(Schema.isMaxLength(32)), | |
| 119 | ), | |
| 120 | interaction: ForwardCompatibleOptional(Schema.NullOr(ProviderAuthInteraction)), | |
| 121 | credentialOwner: ForwardCompatibleOptional(Schema.Literals(["provider", "t3"])), | |
| 28 | 122 | }); |
| 29 | 123 | export type ProviderAuthState = typeof ProviderAuthState.Type; |
| 30 | 124 |
packages/contracts/src/rpc.ts+11-1| ... | ... | @@ -6,6 +6,8 @@ import { |
| 6 | 6 | ProviderAuthCancelInput, |
| 7 | 7 | ProviderAuthCompleteInput, |
| 8 | 8 | ProviderAuthState, |
| 9 | ProviderAuthStartInput, | |
| 10 | ProviderAuthRespondInput, | |
| 9 | 11 | ProviderInstallCancelInput, |
| 10 | 12 | ProviderInstallState, |
| 11 | 13 | ProviderSetupError, |
| ... | ... | @@ -299,6 +301,7 @@ export const WS_METHODS = { |
| 299 | 301 | providerAuthStart: "provider.auth.start", |
| 300 | 302 | providerConsumeResetCredit: "provider.consumeResetCredit", |
| 301 | 303 | providerAuthComplete: "provider.auth.complete", |
| 304 | providerAuthRespond: "provider.auth.respond", | |
| 302 | 305 | providerAuthCancel: "provider.auth.cancel", |
| 303 | 306 | providerAuthLogout: "provider.auth.logout", |
| 304 | 307 | providerAuthSubscribe: "provider.auth.subscribe", |
| ... | ... | @@ -499,7 +502,13 @@ const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeReset |
| 499 | 502 | }); |
| 500 | 503 | |
| 501 | 504 | const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { |
| 502 | payload: ProviderSetupInput, | |
| 505 | payload: ProviderAuthStartInput, | |
| 506 | success: ProviderAuthState, | |
| 507 | error: ProviderSetupRpcError, | |
| 508 | }); | |
| 509 | ||
| 510 | const WsProviderAuthRespondRpc = Rpc.make(WS_METHODS.providerAuthRespond, { | |
| 511 | payload: ProviderAuthRespondInput, | |
| 503 | 512 | success: ProviderAuthState, |
| 504 | 513 | error: ProviderSetupRpcError, |
| 505 | 514 | }); |
| ... | ... | @@ -1390,6 +1399,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1390 | 1399 | WsProviderConsumeResetCreditRpc, |
| 1391 | 1400 | WsProviderAuthStartRpc, |
| 1392 | 1401 | WsProviderAuthCompleteRpc, |
| 1402 | WsProviderAuthRespondRpc, | |
| 1393 | 1403 | WsProviderAuthCancelRpc, |
| 1394 | 1404 | WsProviderAuthLogoutRpc, |
| 1395 | 1405 | WsProviderAuthSubscribeRpc, |