| 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,6 +37,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 37 | [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, | 37 | [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, |
| 38 | [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, | 38 | [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, |
| 39 | [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, | 39 | [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, |
| 40 | [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope, | ||
| 40 | [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, | 41 | [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, |
| 41 | [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, | 42 | [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, |
| 42 | [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, | 43 | [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+98| ... | @@ -1,15 +1,21 @@ | ... | @@ -1,15 +1,21 @@ |
| 1 | import { | 1 | import { |
| 2 | defaultInstanceIdForDriver, | 2 | defaultInstanceIdForDriver, |
| 3 | ProviderDriverKind, | 3 | ProviderDriverKind, |
| 4 | ThreadId, | ||
| 4 | type ServerProvider, | 5 | type ServerProvider, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import { it, assert, vi } from "@effect/vitest"; | 7 | import { it, assert, vi } from "@effect/vitest"; |
| 7 | 8 | ||
| 8 | import * as Effect from "effect/Effect"; | 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 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 10 | import * as PubSub from "effect/PubSub"; | 14 | import * as PubSub from "effect/PubSub"; |
| 11 | import * as Stream from "effect/Stream"; | 15 | import * as Stream from "effect/Stream"; |
| 12 | 16 | ||
| 17 | import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; | ||
| 18 | |||
| 13 | import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; | 19 | import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; |
| 14 | import type * as CodexAdapter from "../Services/CodexAdapter.ts"; | 20 | import type * as CodexAdapter from "../Services/CodexAdapter.ts"; |
| 15 | import type * as CursorAdapter from "../Services/CursorAdapter.ts"; | 21 | import type * as CursorAdapter from "../Services/CursorAdapter.ts"; |
| ... | @@ -185,3 +191,95 @@ it.layer(layer)("ProviderAdapterRegistryLive", (it) => { | ... | @@ -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,19 +15,89 @@ |
| 15 | * | 15 | * |
| 16 | * @module ProviderAdapterRegistryLive | 16 | * @module ProviderAdapterRegistryLive |
| 17 | */ | 17 | */ |
| 18 | import { ProviderInstanceId } from "@t3tools/contracts"; | 18 | import { ProviderInstanceId, ProviderSetupError, type ProviderSession } from "@t3tools/contracts"; |
| 19 | import * as Effect from "effect/Effect"; | 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as Schema from "effect/Schema"; | ||
| 20 | import * as Layer from "effect/Layer"; | 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 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | 29 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 24 | import { | 30 | import { |
| 25 | ProviderAdapterRegistry, | 31 | ProviderAdapterRegistry, |
| 26 | type ProviderAdapterRegistryShape, | 32 | type ProviderAdapterRegistryShape, |
| 27 | } from "../Services/ProviderAdapterRegistry.ts"; | 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 | const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { | 40 | const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { |
| 30 | const registry = yield* ProviderInstanceRegistry; | 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 | const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => | 102 | const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => |
| 33 | registry.getInstance(instanceId).pipe( | 103 | registry.getInstance(instanceId).pipe( |
| ... | @@ -38,7 +108,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun | ... | @@ -38,7 +108,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun |
| 38 | provider: instanceId, | 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,6 +112,14 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 112 | directoryError?: ProviderSessionDirectoryPersistenceError; | 112 | directoryError?: ProviderSessionDirectoryPersistenceError; |
| 113 | stopError?: ProviderServiceError; | 113 | stopError?: ProviderServiceError; |
| 114 | logoutError?: ProviderSetupError; | 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 | const actions: string[] = []; | 125 | const actions: string[] = []; |
| ... | @@ -138,6 +146,18 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* | ... | @@ -138,6 +146,18 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 138 | }); | 146 | }); |
| 139 | 147 | ||
| 140 | const auth: ProviderAuthController = { | 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 | start: Effect.fn(function* (ownerSessionId, stopSessions) { | 161 | start: Effect.fn(function* (ownerSessionId, stopSessions) { |
| 142 | gateClosed = true; | 162 | gateClosed = true; |
| 143 | actions.push("close-gate"); | 163 | actions.push("close-gate"); |
| ... | @@ -169,6 +189,7 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* | ... | @@ -169,6 +189,7 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 169 | actions.push("close-gate"); | 189 | actions.push("close-gate"); |
| 170 | yield* stopSessions; | 190 | yield* stopSessions; |
| 171 | if (input.logoutError) return yield* input.logoutError; | 191 | if (input.logoutError) return yield* input.logoutError; |
| 192 | yield* input.beforeLogout ?? Effect.void; | ||
| 172 | actions.push("native-logout"); | 193 | actions.push("native-logout"); |
| 173 | state = idle; | 194 | state = idle; |
| 174 | flowOwner = undefined; | 195 | flowOwner = undefined; |
| ... | @@ -181,13 +202,34 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* | ... | @@ -181,13 +202,34 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 181 | const instances = [ | 202 | const instances = [ |
| 182 | makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), | 203 | makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), |
| 183 | makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), | 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 | const service = yield* makeProviderAuthService.pipe( | 222 | const service = yield* makeProviderAuthService.pipe( |
| 186 | Effect.provide( | 223 | Effect.provide( |
| 187 | Layer.mergeAll( | 224 | Layer.mergeAll( |
| 188 | Layer.mock(ProviderInstanceRegistry)({ | 225 | Layer.mock(ProviderInstanceRegistry)({ |
| 189 | getInstance: (id) => | 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 | subscribeChanges: PubSub.subscribe(registryChanges), | 233 | subscribeChanges: PubSub.subscribe(registryChanges), |
| 192 | }), | 234 | }), |
| 193 | Layer.mock(ProviderSessionDirectory)({ | 235 | Layer.mock(ProviderSessionDirectory)({ |
| ... | @@ -202,26 +244,44 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* | ... | @@ -202,26 +244,44 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* |
| 202 | }), | 244 | }), |
| 203 | Layer.mock(ProviderService)({ | 245 | Layer.mock(ProviderService)({ |
| 204 | listSessions: () => | 246 | listSessions: () => |
| 205 | Effect.sync(() => { | 247 | Effect.gen(function* () { |
| 206 | assert.isTrue(gateClosed); | 248 | assert.isTrue(gateClosed); |
| 207 | actions.push("list-sessions"); | 249 | actions.push("list-sessions"); |
| 250 | yield* input.beforeListSessions ?? Effect.void; | ||
| 208 | return [...sessions.values()]; | 251 | return [...sessions.values()]; |
| 209 | }), | 252 | }), |
| 210 | stopSession: ({ threadId }) => | 253 | stopSession: ({ threadId }) => |
| 211 | Effect.suspend(() => { | 254 | Effect.gen(function* () { |
| 212 | assert.isTrue(gateClosed); | 255 | assert.isTrue(gateClosed); |
| 213 | actions.push(`stop:${threadId}`); | 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 | sessions.delete(threadId); | 259 | sessions.delete(threadId); |
| 216 | const binding = bindings.get(threadId); | 260 | const binding = bindings.get(threadId); |
| 217 | if (binding) bindings.set(threadId, { ...binding, status: "stopped" }); | 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 | const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( | 287 | const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( |
| ... | @@ -305,6 +365,56 @@ const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* | ... | @@ -305,6 +365,56 @@ const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* |
| 305 | }); | 365 | }); |
| 306 | 366 | ||
| 307 | describe("ProviderAuthService", () => { | 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 | it.effect("stops routed sessions before sign-in, including for a disabled instance", () => | 418 | it.effect("stops routed sessions before sign-in, including for a disabled instance", () => |
| 309 | Effect.gen(function* () { | 419 | Effect.gen(function* () { |
| 310 | const { service, actions, sessions } = yield* makeHarness({ | 420 | const { service, actions, sessions } = yield* makeHarness({ |
| ... | @@ -606,3 +716,200 @@ describe("ProviderAuthService", () => { | ... | @@ -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,8 +2,9 @@ import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts" |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Stream from "effect/Stream"; | 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 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | 8 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 8 | import { ProviderService } from "../Services/ProviderService.ts"; | 9 | import { ProviderService } from "../Services/ProviderService.ts"; |
| 9 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 10 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| ... | @@ -12,6 +13,7 @@ export const makeProviderAuthService = Effect.gen(function* () { | ... | @@ -12,6 +13,7 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 12 | const registry = yield* ProviderInstanceRegistry; | 13 | const registry = yield* ProviderInstanceRegistry; |
| 13 | const providers = yield* ProviderService; | 14 | const providers = yield* ProviderService; |
| 14 | const directory = yield* ProviderSessionDirectory; | 15 | const directory = yield* ProviderSessionDirectory; |
| 16 | const credentialChanges = yield* Semaphore.make(1); | ||
| 15 | 17 | ||
| 16 | const getController = Effect.fn("ProviderAuthService.getController")(function* ( | 18 | const getController = Effect.fn("ProviderAuthService.getController")(function* ( |
| 17 | instanceId: ProviderInstanceId, | 19 | instanceId: ProviderInstanceId, |
| ... | @@ -30,9 +32,25 @@ export const makeProviderAuthService = Effect.gen(function* () { | ... | @@ -30,9 +32,25 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 30 | return instance.auth; | 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 | const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( | 38 | const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( |
| 34 | instanceId: ProviderInstanceId, | 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 | const bindings = yield* directory.listBindings().pipe( | 54 | const bindings = yield* directory.listBindings().pipe( |
| 37 | Effect.mapError( | 55 | Effect.mapError( |
| 38 | () => | 56 | () => |
| ... | @@ -44,39 +62,111 @@ export const makeProviderAuthService = Effect.gen(function* () { | ... | @@ -44,39 +62,111 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 44 | ), | 62 | ), |
| 45 | ); | 63 | ); |
| 46 | const sessions = yield* providers.listSessions(); | 64 | const sessions = yield* providers.listSessions(); |
| 47 | const threadIds = new Set( | 65 | const sessionsToStop = new Map( |
| 48 | bindings | 66 | bindings.flatMap((session) => |
| 49 | .filter( | 67 | session.providerInstanceId !== undefined && |
| 50 | (binding) => binding.providerInstanceId === instanceId && binding.status !== "stopped", | 68 | affectedIds.has(session.providerInstanceId) && |
| 51 | ) | 69 | session.status !== "stopped" |
| 52 | .map((binding) => binding.threadId), | 70 | ? [[session.threadId, session.providerInstanceId] as const] |
| 71 | : [], | ||
| 72 | ), | ||
| 53 | ); | 73 | ); |
| 54 | for (const session of sessions) { | 74 | for (const session of sessions) { |
| 55 | if (session.providerInstanceId === instanceId) { | 75 | if (session.providerInstanceId !== undefined && affectedIds.has(session.providerInstanceId)) { |
| 56 | threadIds.add(session.threadId); | 76 | sessionsToStop.set(session.threadId, session.providerInstanceId); |
| 57 | } | 77 | } |
| 58 | } | 78 | } |
| 59 | yield* Effect.forEach( | 79 | yield* Effect.forEach( |
| 60 | threadIds, | 80 | sessionsToStop, |
| 61 | (threadId) => | 81 | ([threadId, sessionInstanceId]) => |
| 62 | providers.stopSession({ threadId }).pipe( | 82 | Effect.gen(function* () { |
| 63 | Effect.mapError( | 83 | if (sessionInstanceId !== instanceId) { |
| 64 | () => | 84 | const current = yield* registry.getInstance(sessionInstanceId); |
| 65 | new ProviderSetupError({ | 85 | if ( |
| 66 | instanceId, | 86 | !binding || |
| 67 | operation: "stopSessions", | 87 | current?.auth?.credentialBinding?.key !== binding.key || |
| 68 | detail: "Could not stop all sessions for this provider. Try again.", | 88 | current.auth.credentialBinding.owner !== binding.owner |
| 69 | }), | 89 | ) |
| 70 | ), | 90 | return; |
| 71 | ), | 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 | { discard: true }, | 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 | start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { | 147 | start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { |
| 78 | const auth = yield* getController(input.instanceId, "start"); | 148 | return yield* credentialChanges.withPermit( |
| 79 | return yield* auth.start(ownerSessionId, stopSessions(input.instanceId)); | 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 | complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { | 171 | complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { |
| 82 | const auth = yield* getController(input.instanceId, "complete"); | 172 | const auth = yield* getController(input.instanceId, "complete"); |
| ... | @@ -87,8 +177,13 @@ export const makeProviderAuthService = Effect.gen(function* () { | ... | @@ -87,8 +177,13 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 87 | return yield* auth.cancel(ownerSessionId, input.flowId); | 177 | return yield* auth.cancel(ownerSessionId, input.flowId); |
| 88 | }), | 178 | }), |
| 89 | logout: Effect.fn("ProviderAuthService.logout")(function* (input) { | 179 | logout: Effect.fn("ProviderAuthService.logout")(function* (input) { |
| 90 | const auth = yield* getController(input.instanceId, "logout"); | 180 | return yield* credentialChanges.withPermit( |
| 91 | return yield* auth.logout(stopSessions(input.instanceId)); | 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 | subscribe: (input, ownerSessionId) => | 188 | subscribe: (input, ownerSessionId) => |
| 94 | Effect.gen(function* () { | 189 | Effect.gen(function* () { |
| ... | @@ -110,11 +205,21 @@ export const makeProviderAuthService = Effect.gen(function* () { | ... | @@ -110,11 +205,21 @@ export const makeProviderAuthService = Effect.gen(function* () { |
| 110 | if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { | 205 | if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { |
| 111 | return false; | 206 | return false; |
| 112 | } | 207 | } |
| 113 | yield* instance.auth.logout(stopSessions(input.instanceId)); | 208 | return yield* credentialChanges.withPermit( |
| 114 | return true; | 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,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 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| 3 | import type * as Effect from "effect/Effect"; | 9 | import type * as Effect from "effect/Effect"; |
| 4 | import type * as Stream from "effect/Stream"; | 10 | import type * as Stream from "effect/Stream"; |
| 11 | import type * as Scope from "effect/Scope"; | ||
| 5 | 12 | ||
| 6 | export interface ProviderAuthController { | 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 | readonly start: ( | 22 | readonly start: ( |
| 8 | ownerSessionId: string, | 23 | ownerSessionId: string, |
| 9 | stopSessions?: Effect.Effect<void, ProviderSetupError>, | 24 | stopSessions?: Effect.Effect<void, ProviderSetupError>, |
| 25 | methodId?: string, | ||
| 10 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | 26 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; |
| 11 | readonly complete: ( | 27 | readonly complete: ( |
| 12 | ownerSessionId: string, | 28 | ownerSessionId: string, |
| ... | @@ -16,6 +32,10 @@ export interface ProviderAuthController { | ... | @@ -16,6 +32,10 @@ export interface ProviderAuthController { |
| 16 | ownerSessionId: string, | 32 | ownerSessionId: string, |
| 17 | flowId: string, | 33 | flowId: string, |
| 18 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | 34 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; |
| 35 | readonly respond?: ( | ||
| 36 | ownerSessionId: string, | ||
| 37 | input: ProviderAuthRespondInput, | ||
| 38 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 19 | /** The controller closes process admission before it stops routed sessions. */ | 39 | /** The controller closes process admission before it stops routed sessions. */ |
| 20 | readonly logout: ( | 40 | readonly logout: ( |
| 21 | stopSessions: Effect.Effect<void, ProviderSetupError>, | 41 | stopSessions: Effect.Effect<void, ProviderSetupError>, |
| ... | @@ -30,13 +50,17 @@ interface ProviderAuthTarget { | ... | @@ -30,13 +50,17 @@ interface ProviderAuthTarget { |
| 30 | 50 | ||
| 31 | export interface ProviderAuthServiceShape { | 51 | export interface ProviderAuthServiceShape { |
| 32 | readonly start: ( | 52 | readonly start: ( |
| 33 | input: ProviderAuthTarget, | 53 | input: ProviderAuthStartInput, |
| 34 | ownerSessionId: string, | 54 | ownerSessionId: string, |
| 35 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | 55 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; |
| 36 | readonly complete: ( | 56 | readonly complete: ( |
| 37 | input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, | 57 | input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, |
| 38 | ownerSessionId: string, | 58 | ownerSessionId: string, |
| 39 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | 59 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; |
| 60 | readonly respond: ( | ||
| 61 | input: ProviderAuthRespondInput, | ||
| 62 | ownerSessionId: string, | ||
| 63 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 40 | readonly cancel: ( | 64 | readonly cancel: ( |
| 41 | input: ProviderAuthTarget & { readonly flowId: string }, | 65 | input: ProviderAuthTarget & { readonly flowId: string }, |
| 42 | ownerSessionId: string, | 66 | ownerSessionId: string, |
apps/server/src/ws.ts+9| ... | @@ -2497,6 +2497,15 @@ const makeWsRpcLayer = ( | ... | @@ -2497,6 +2497,15 @@ const makeWsRpcLayer = ( |
| 2497 | providerAuth.start(input, currentSessionId), | 2497 | providerAuth.start(input, currentSessionId), |
| 2498 | { "rpc.aggregate": "provider" }, | 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 | [WS_METHODS.providerAuthComplete]: (input) => | 2509 | [WS_METHODS.providerAuthComplete]: (input) => |
| 2501 | observeRpcEffect( | 2510 | observeRpcEffect( |
| 2502 | WS_METHODS.providerAuthComplete, | 2511 | WS_METHODS.providerAuthComplete, |
packages/client-runtime/src/state/server.ts+4| ... | @@ -978,6 +978,10 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -978,6 +978,10 @@ export function createServerEnvironmentAtoms<R, E>( |
| 978 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), | 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 | completeProviderAuth: createEnvironmentRpcCommand(runtime, { | 985 | completeProviderAuth: createEnvironmentRpcCommand(runtime, { |
| 982 | label: "environment-data:provider:auth-complete", | 986 | label: "environment-data:provider:auth-complete", |
| 983 | tag: WS_METHODS.providerAuthComplete, | 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,6 +1,11 @@ |
| 1 | import * as Schema from "effect/Schema"; | 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 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; | 9 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | 10 | ||
| 6 | export const ProviderSetupInput = Schema.Struct({ | 11 | export const ProviderSetupInput = Schema.Struct({ |
| ... | @@ -10,6 +15,88 @@ export type ProviderSetupInput = typeof ProviderSetupInput.Type; | ... | @@ -10,6 +15,88 @@ export type ProviderSetupInput = typeof ProviderSetupInput.Type; |
| 10 | 15 | ||
| 11 | const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); | 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 | export const ProviderAuthState = Schema.Struct({ | 100 | export const ProviderAuthState = Schema.Struct({ |
| 14 | instanceId: ProviderInstanceId, | 101 | instanceId: ProviderInstanceId, |
| 15 | phase: Schema.Literals([ | 102 | phase: Schema.Literals([ |
| ... | @@ -25,6 +112,13 @@ export const ProviderAuthState = Schema.Struct({ | ... | @@ -25,6 +112,13 @@ export const ProviderAuthState = Schema.Struct({ |
| 25 | authorizationUrl: Schema.NullOr(Schema.String), | 112 | authorizationUrl: Schema.NullOr(Schema.String), |
| 26 | expiresAt: Schema.NullOr(IsoDateTime), | 113 | expiresAt: Schema.NullOr(IsoDateTime), |
| 27 | message: Schema.NullOr(Schema.String), | 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 | export type ProviderAuthState = typeof ProviderAuthState.Type; | 123 | export type ProviderAuthState = typeof ProviderAuthState.Type; |
| 30 | 124 |
packages/contracts/src/rpc.ts+11-1| ... | @@ -6,6 +6,8 @@ import { | ... | @@ -6,6 +6,8 @@ import { |
| 6 | ProviderAuthCancelInput, | 6 | ProviderAuthCancelInput, |
| 7 | ProviderAuthCompleteInput, | 7 | ProviderAuthCompleteInput, |
| 8 | ProviderAuthState, | 8 | ProviderAuthState, |
| 9 | ProviderAuthStartInput, | ||
| 10 | ProviderAuthRespondInput, | ||
| 9 | ProviderInstallCancelInput, | 11 | ProviderInstallCancelInput, |
| 10 | ProviderInstallState, | 12 | ProviderInstallState, |
| 11 | ProviderSetupError, | 13 | ProviderSetupError, |
| ... | @@ -299,6 +301,7 @@ export const WS_METHODS = { | ... | @@ -299,6 +301,7 @@ export const WS_METHODS = { |
| 299 | providerAuthStart: "provider.auth.start", | 301 | providerAuthStart: "provider.auth.start", |
| 300 | providerConsumeResetCredit: "provider.consumeResetCredit", | 302 | providerConsumeResetCredit: "provider.consumeResetCredit", |
| 301 | providerAuthComplete: "provider.auth.complete", | 303 | providerAuthComplete: "provider.auth.complete", |
| 304 | providerAuthRespond: "provider.auth.respond", | ||
| 302 | providerAuthCancel: "provider.auth.cancel", | 305 | providerAuthCancel: "provider.auth.cancel", |
| 303 | providerAuthLogout: "provider.auth.logout", | 306 | providerAuthLogout: "provider.auth.logout", |
| 304 | providerAuthSubscribe: "provider.auth.subscribe", | 307 | providerAuthSubscribe: "provider.auth.subscribe", |
| ... | @@ -499,7 +502,13 @@ const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeReset | ... | @@ -499,7 +502,13 @@ const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeReset |
| 499 | }); | 502 | }); |
| 500 | 503 | ||
| 501 | const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { | 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 | success: ProviderAuthState, | 512 | success: ProviderAuthState, |
| 504 | error: ProviderSetupRpcError, | 513 | error: ProviderSetupRpcError, |
| 505 | }); | 514 | }); |
| ... | @@ -1390,6 +1399,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1390,6 +1399,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1390 | WsProviderConsumeResetCreditRpc, | 1399 | WsProviderConsumeResetCreditRpc, |
| 1391 | WsProviderAuthStartRpc, | 1400 | WsProviderAuthStartRpc, |
| 1392 | WsProviderAuthCompleteRpc, | 1401 | WsProviderAuthCompleteRpc, |
| 1402 | WsProviderAuthRespondRpc, | ||
| 1393 | WsProviderAuthCancelRpc, | 1403 | WsProviderAuthCancelRpc, |
| 1394 | WsProviderAuthLogoutRpc, | 1404 | WsProviderAuthLogoutRpc, |
| 1395 | WsProviderAuthSubscribeRpc, | 1405 | WsProviderAuthSubscribeRpc, |