1import { AuthStandardClientScopes, type ManagedPolicyDocument } from "@t3tools/contracts";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7
8import * as ServerConfig from "../config.ts";
9import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
10import * as SqlitePersistence from "../persistence/Sqlite.ts";
11import * as EnvironmentAuth from "./EnvironmentAuth.ts";
12import * as ServerSecretStore from "./ServerSecretStore.ts";
13import * as SessionStore from "./SessionStore.ts";
14import * as PairingGrantStore from "./PairingGrantStore.ts";
15import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
16
17const layerServerConfig = (
18 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,
19) =>
20 Layer.effect(
21 ServerConfig.ServerConfig,
22 Effect.gen(function* () {
23 const config = yield* ServerConfig.ServerConfig;
24 return {
25 ...config,
26 ...overrides,
27 } satisfies ServerConfig.ServerConfig["Service"];
28 }),
29 ).pipe(
30 Layer.provide(
31 ServerConfig.layerTest(process.cwd(), {
32 prefix: "t3-auth-control-plane-test-",
33 }),
34 ),
35 );
36
37const layerEnvironmentAuth = (
38 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,
39 policy?: ManagedPolicyDocument,
40) =>
41 EnvironmentAuth.layer.pipe(
42 Layer.provide(ManagedPolicy.layerTest(policy)),
43 Layer.provideMerge(ServerSecretStore.layer),
44 Layer.provideMerge(SqlitePersistence.layerMemory),
45 Layer.provide(ServerEnvironment.layerIdentity),
46 Layer.provide(layerServerConfig(overrides)),
47 );
48
49const desktopAndLocalOnly: ManagedPolicyDocument = {
50 schemaVersion: 1,
51 organization: "Example Corp",
52 limits: { clients: ["desktop", "local-browser"] },
53};
54
55it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))(
56 "EnvironmentAuth administrative operations",
57 (it) => {
58 it.effect("refuses pairing links when the policy does not allow remote clients", () =>
59 Effect.gen(function* () {
60 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
61
62 const error = yield* environmentAuth.issuePairingCredential().pipe(Effect.flip);
63 const startup = yield* environmentAuth.issueStartupPairingCredential();
64
65 assert.equal(
66 error.message,
67 "Example Corp's policy does not allow remote clients, so this server cannot create pairing links.",
68 );
69 assert.isAbove(startup.credential.length, 0);
70 }).pipe(Effect.provide(layerEnvironmentAuth(undefined, desktopAndLocalOnly))),
71 );
72
73 it.effect("revokes link-paired clients on startup under a policy without remote clients", () =>
74 Effect.gen(function* () {
75 const sessions = yield* SessionStore.SessionStore;
76 const pairingLinks = yield* PairingGrantStore.PairingGrantStore;
77 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
78 const phone = yield* sessions.issue({ subject: "one-time-token" });
79 const bot = yield* sessions.issue({ subject: "cli-issued-session" });
80 const startup = (ipAddress: string) =>
81 sessions.issue({
82 subject: EnvironmentAuth.INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
83 client: { deviceType: "unknown", ipAddress },
84 });
85 const startupPhone = yield* startup("192.168.1.20");
86 const startupBrowser = yield* startup("127.0.0.1");
87 yield* environmentAuth.createPairingLink({ subject: "one-time-token" });
88 yield* environmentAuth.issuePairingCredential({ access: "auto" });
89
90 yield* EnvironmentAuth.make.pipe(
91 Effect.provide(
92 Layer.merge(ManagedPolicy.layerTest(desktopAndLocalOnly), layerServerConfig()),
93 ),
94 );
95
96 const remaining = (yield* sessions.listActive()).map((session) => session.sessionId);
97 assert.notInclude(remaining, phone.sessionId);
98 assert.notInclude(remaining, startupPhone.sessionId);
99 assert.include(remaining, startupBrowser.sessionId);
100 assert.include(remaining, bot.sessionId);
101 assert.lengthOf(yield* pairingLinks.listActive(), 0);
102 }).pipe(Effect.provide(layerEnvironmentAuth())),
103 );
104
105 it.effect("revokes only T3 Connect clients when the policy keeps remote clients", () =>
106 Effect.gen(function* () {
107 const sessions = yield* SessionStore.SessionStore;
108 const phone = yield* sessions.issue({ subject: "one-time-token" });
109 const connect = yield* sessions.issue({ subject: "cloud-connect" });
110
111 yield* EnvironmentAuth.make.pipe(
112 Effect.provide(
113 Layer.merge(
114 ManagedPolicy.layerTest({
115 schemaVersion: 1,
116 organization: "Example Corp",
117 limits: { connect: false },
118 }),
119 layerServerConfig(),
120 ),
121 ),
122 );
123
124 const remaining = (yield* sessions.listActive()).map((session) => session.sessionId);
125 assert.include(remaining, phone.sessionId);
126 assert.notInclude(remaining, connect.sessionId);
127 }).pipe(Effect.provide(layerEnvironmentAuth())),
128 );
129
130 it.effect("creates, lists, and revokes client pairing links", () =>
131 Effect.gen(function* () {
132 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
133
134 const created = yield* environmentAuth.createPairingLink({
135 scopes: ["orchestration:read"],
136 subject: "one-time-token",
137 label: "CI phone",
138 });
139 // An app approval code is never listed.
140 yield* environmentAuth.issuePairingCredential({ access: "auto" });
141 const listedBeforeRevoke = yield* environmentAuth.listPairingLinks();
142 const revoked = yield* environmentAuth.revokePairingLink(created.id);
143 const listedAfterRevoke = yield* environmentAuth.listPairingLinks();
144
145 expect(created.scopes).toEqual(["orchestration:read"]);
146 expect(created.credential.length).toBeGreaterThan(0);
147 expect(listedBeforeRevoke).toHaveLength(1);
148 expect(listedBeforeRevoke[0]?.id).toBe(created.id);
149 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
150 expect(listedBeforeRevoke[0]).not.toHaveProperty("credential");
151 expect(revoked).toBe(true);
152 expect(listedAfterRevoke).toHaveLength(0);
153 }).pipe(Effect.provide(layerEnvironmentAuth())),
154 );
155
156 it.effect("issues bearer access token sessions without exposing raw tokens", () =>
157 Effect.gen(function* () {
158 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
159 const sessionCredentials = yield* SessionStore.SessionStore;
160
161 const issued = yield* environmentAuth.issueSession({
162 label: "deploy-bot",
163 });
164 const verified = yield* sessionCredentials.verify(issued.token);
165 const listedBeforeRevoke = yield* environmentAuth.listSessions();
166 const revoked = yield* environmentAuth.revokeSession(issued.sessionId);
167 const listedAfterRevoke = yield* environmentAuth.listSessions();
168
169 expect(issued.method).toBe("bearer-access-token");
170 expect(issued.scopes).toEqual(AuthStandardClientScopes);
171 expect(issued.client.deviceType).toBe("bot");
172 expect(issued.client.label).toBe("deploy-bot");
173 expect(verified.sessionId).toBe(issued.sessionId);
174 expect(verified.scopes).toEqual(AuthStandardClientScopes);
175 expect(verified.method).toBe("bearer-access-token");
176 expect(listedBeforeRevoke).toHaveLength(1);
177 expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
178 expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false);
179 expect(revoked).toBe(true);
180 expect(listedAfterRevoke).toHaveLength(0);
181 }).pipe(Effect.provide(layerEnvironmentAuth())),
182 );
183 },
184);