authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 17:01:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 17:01:42-07:00
log7e65b226e7a74ecd13bf8b4738ea6bdd096cb1ba
tree4a2d7bb10fd81ccdfe9d40fe5e4b5ec50d9e870e
parentdb9a0671bb8921ebf5f9da16cd54f7e87f5ef3b0
signature Signed by PGP key B5690EEEBB952194

feat(auth): share provider sign-in flows and credential bindings (#12983)

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 @@
1import {1import {
2 defaultInstanceIdForDriver,2 defaultInstanceIdForDriver,
3 ProviderDriverKind,3 ProviderDriverKind,
4 ThreadId,
4 type ServerProvider,5 type ServerProvider,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { it, assert, vi } from "@effect/vitest";7import { it, assert, vi } from "@effect/vitest";
78
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Deferred from "effect/Deferred";
11import * as Fiber from "effect/Fiber";
12import * as Exit from "effect/Exit";
9import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
10import * as PubSub from "effect/PubSub";14import * as PubSub from "effect/PubSub";
11import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
1216
17import * as ProviderAuthFlow from "../ProviderAuthFlow.ts";
18
13import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts";19import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts";
14import type * as CodexAdapter from "../Services/CodexAdapter.ts";20import type * as CodexAdapter from "../Services/CodexAdapter.ts";
15import type * as CursorAdapter from "../Services/CursorAdapter.ts";21import 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
195it.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 ProviderAdapterRegistryLive16 * @module ProviderAdapterRegistryLive
17 */17 */
18import { ProviderInstanceId } from "@t3tools/contracts";18import { ProviderInstanceId, ProviderSetupError, type ProviderSession } from "@t3tools/contracts";
19import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
20import * as Schema from "effect/Schema";
20import * as Layer from "effect/Layer";21import * as Layer from "effect/Layer";
22import type * as Scope from "effect/Scope";
2123
22import { ProviderUnsupportedError } from "../Errors.ts";24import {
25 ProviderUnsupportedError,
26 ProviderAdapterValidationError,
27 type ProviderAdapterError,
28} from "../Errors.ts";
23import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";29import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
24import {30import {
25 ProviderAdapterRegistry,31 ProviderAdapterRegistry,
26 type ProviderAdapterRegistryShape,32 type ProviderAdapterRegistryShape,
27} from "../Services/ProviderAdapterRegistry.ts";33} from "../Services/ProviderAdapterRegistry.ts";
2834
35import type { ProviderInstance } from "../ProviderDriver.ts";
36import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
37
38const isSetupError = Schema.is(ProviderSetupError);
39
29const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () {40const 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 };
31101
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 );
44114
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 });
139147
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});
226286
227const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")(287const 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});
306366
307describe("ProviderAuthService", () => {367describe("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
720it.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
762it.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
833it.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
873it.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"
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Stream from "effect/Stream";4import * as Stream from "effect/Stream";
5import * as Semaphore from "effect/Semaphore";
56
6import { ProviderAuthService } from "../Services/ProviderAuthService.ts";7import * as ProviderAuthService from "../Services/ProviderAuthService.ts";
7import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";8import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
8import { ProviderService } from "../Services/ProviderService.ts";9import { ProviderService } from "../Services/ProviderService.ts";
9import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";10import { 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);
1517
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 });
3234
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 bindings66 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 });
75119
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});
119221
120export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService);222export const ProviderAuthServiceLive = Layer.effect(
223 ProviderAuthService.ProviderAuthService,
224 makeProviderAuthService,
225);
apps/server/src/provider/ProviderAuthFlow.test.ts created+520
...@@ -0,0 +1,520 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 ProviderInstanceId,
5 ProviderSetupError,
6 type ProviderAuthInteraction,
7 type ProviderAuthResponse,
8 type ProviderAuthState,
9} from "@t3tools/contracts";
10import * as Fiber from "effect/Fiber";
11import * as Exit from "effect/Exit";
12import * as Scope from "effect/Scope";
13import * as Deferred from "effect/Deferred";
14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16import * as Stream from "effect/Stream";
17import * as TestClock from "effect/testing/TestClock";
18
19import * as ProviderAuthFlow from "./ProviderAuthFlow.ts";
20
21const instanceId = ProviderInstanceId.make("auth-flow-test");
22const method = { id: "browser", name: "Browser", description: null, type: "agent" as const };
23
24it.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
48const 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
88it.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
119it.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
139it.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
157it.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
177it.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
194it.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
259it.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
293it.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
327const 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
391it.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
418it.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
440it.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
462it.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
478it.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
505it.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 @@
1import {
2 ProviderSetupError,
3 type ProviderAuthInteraction,
4 type ProviderAuthMethod,
5 type ProviderAuthResponse,
6 type ProviderAuthState,
7 type ProviderInstanceId,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as Clock from "effect/Clock";
13import * as Crypto from "effect/Crypto";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Deferred from "effect/Deferred";
17import * as Exit from "effect/Exit";
18import * as Fiber from "effect/Fiber";
19import * as Scope from "effect/Scope";
20import * as Semaphore from "effect/Semaphore";
21import * as Stream from "effect/Stream";
22import * as SubscriptionRef from "effect/SubscriptionRef";
23
24import type * as ProviderAuthService from "./Services/ProviderAuthService.ts";
25
26export 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
35const isSetupError = Schema.is(ProviderSetupError);
36
37const 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
44interface 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. */
56export 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 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
5import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
6
7it.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 @@
1import * as NodeCrypto from "node:crypto";
2import * as Effect from "effect/Effect";
3import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
4
5/** A provider binding stores opaque bytes; only its adapter decodes or refreshes them. */
6export 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 @@
1import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts";1import type {
2 ProviderAuthRespondInput,
3 ProviderAuthStartInput,
4 ProviderAuthState,
5 ProviderInstanceId,
6 ProviderSetupError,
7} from "@t3tools/contracts";
2import * as Context from "effect/Context";8import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";9import type * as Effect from "effect/Effect";
4import type * as Stream from "effect/Stream";10import type * as Stream from "effect/Stream";
11import type * as Scope from "effect/Scope";
512
6export interface ProviderAuthController {13export 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 {
3050
31export interface ProviderAuthServiceShape {51export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3
4import { ProviderAuthResponse, ProviderAuthState } from "./providerSetup.ts";
5
6const decodeResponse = Schema.decodeUnknownSync(ProviderAuthResponse);
7const decodeState = Schema.decodeUnknownSync(ProviderAuthState);
8
9describe("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
28describe("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 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
22
3import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts";3import {
4 ForwardCompatibleArray,
5 ForwardCompatibleOptional,
6 IsoDateTime,
7 TrimmedNonEmptyString,
8} from "./baseSchemas.ts";
4import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";9import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
510
6export const ProviderSetupInput = Schema.Struct({11export const ProviderSetupInput = Schema.Struct({
...@@ -10,6 +15,88 @@ export type ProviderSetupInput = typeof ProviderSetupInput.Type;...@@ -10,6 +15,88 @@ export type ProviderSetupInput = typeof ProviderSetupInput.Type;
1015
11const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));16const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
1217
18export const ProviderAuthMethod = Schema.Struct({
19 id: SetupOperationId,
20 name: TrimmedNonEmptyString,
21 description: Schema.NullOr(Schema.String),
22 type: Schema.Literals(["agent", "terminal", "credentials"]),
23});
24export 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.
28export 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]);
60export type ProviderAuthInteraction = typeof ProviderAuthInteraction.Type;
61
62export 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]);
84export type ProviderAuthResponse = typeof ProviderAuthResponse.Type;
85
86export const ProviderAuthStartInput = Schema.Struct({
87 instanceId: ProviderInstanceId,
88 methodId: Schema.optionalKey(SetupOperationId),
89});
90export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type;
91
92export const ProviderAuthRespondInput = Schema.Struct({
93 instanceId: ProviderInstanceId,
94 flowId: SetupOperationId,
95 interactionId: SetupOperationId,
96 response: ProviderAuthResponse,
97});
98export type ProviderAuthRespondInput = typeof ProviderAuthRespondInput.Type;
99
13export const ProviderAuthState = Schema.Struct({100export 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});
29export type ProviderAuthState = typeof ProviderAuthState.Type;123export type ProviderAuthState = typeof ProviderAuthState.Type;
30124
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});
500503
501const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {504const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {
502 payload: ProviderSetupInput,505 payload: ProviderAuthStartInput,
506 success: ProviderAuthState,
507 error: ProviderSetupRpcError,
508});
509
510const 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,