1import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl";
2import {
3 DesktopHostedAuthState,
4 AuthArchiveReadScope,
5 AuthArchiveWriteScope,
6 AuthEnvironmentConnectScope,
7 HostedAuthDeviceStartResult,
8 HostedAuthGrant,
9 PRIMARY_LOCAL_ENVIRONMENT_ID,
10} from "@t3tools/contracts";
11import * as Clock from "effect/Clock";
12import * as Context from "effect/Context";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as Schema from "effect/Schema";
20import * as Semaphore from "effect/Semaphore";
21import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
22import * as ElectronApp from "../electron/ElectronApp.ts";
23import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
24import * as ElectronShell from "../electron/ElectronShell.ts";
25import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
26import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts";
27import * as DesktopEnvironment from "./DesktopEnvironment.ts";
28import * as HostProcess from "@t3tools/shared/HostProcess";
29import {
30 createHostedAuthAttempt,
31 isHostedAuthCallback,
32 readHostedAuthCallback,
33 type HostedAuthAttempt,
34} from "./hostedAuthHandoff.ts";
35
36const SavedCredential = Schema.Struct({
37 serverUrl: Schema.String,
38 deviceId: Schema.String,
39 refreshToken: Schema.String,
40});
41
42const encodeSavedCredential = Schema.encodeEffect(Schema.fromJsonString(SavedCredential));
43
44export class DesktopHostedAuthError extends Schema.TaggedError<DesktopHostedAuthError>()(
45 "DesktopHostedAuthError",
46 { message: Schema.String },
47) {}
48
49export class DesktopHostedAuth extends Context.Service<
50 DesktopHostedAuth,
51 {
52 readonly state: Effect.Effect<DesktopHostedAuthState, DesktopHostedAuthError>;
53 readonly begin: (server: string) => Effect.Effect<void, DesktopHostedAuthError>;
54 readonly accessToken: (server: string) => Effect.Effect<string, DesktopHostedAuthError>;
55 readonly disconnect: Effect.Effect<void, DesktopHostedAuthError>;
56 readonly cancel: Effect.Effect<void>;
57 readonly receive: (url: string) => Effect.Effect<void>;
58 readonly configure: Effect.Effect<void, never, import("effect/Scope").Scope>;
59 }
60>()("@t3tools/desktop/app/DesktopHostedAuth") {}
61
62export const make = Effect.gen(function* () {
63 const fs = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65 const environment = yield* DesktopEnvironment.DesktopEnvironment;
66 const safe = yield* ElectronSafeStorage.ElectronSafeStorage;
67 const shell = yield* ElectronShell.ElectronShell;
68 const app = yield* ElectronApp.ElectronApp;
69 const http = yield* HttpClient.HttpClient;
70 const args = yield* HostProcess.Arguments;
71 const lock = yield* Semaphore.make(1);
72 const pool = yield* DesktopBackendPool.DesktopBackendPool;
73 const localAuth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth;
74 const credentialPath = path.join(environment.stateDir, "hosted-account.bin");
75 let pending: HostedAuthAttempt | null = null;
76 let access: { serverUrl: string; token: string; expiresAt: number } | null = null;
77 let source: { serverUrl: string; token: string; expiresAt: number } | null = null;
78 let state: DesktopHostedAuthState = { serverUrl: null, phase: "idle", error: null };
79
80 const fail = () =>
81 new DesktopHostedAuthError({ message: "Sign in to your server again to reconnect." });
82 const read = Effect.gen(function* () {
83 if (!(yield* fs.exists(credentialPath))) return null;
84 const bytes = yield* fs.readFile(credentialPath);
85 return yield* safe
86 .decryptString(bytes)
87 .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(SavedCredential))));
88 }).pipe(Effect.mapError(fail));
89
90 const protectedStorageAvailable = Effect.gen(function* () {
91 return (
92 (yield* safe.isEncryptionAvailable) &&
93 Option.getOrNull(yield* safe.selectedStorageBackend) !== "basic_text"
94 );
95 });
96
97 const save = (serverUrl: string, grant: HostedAuthGrant) =>
98 Effect.gen(function* () {
99 if (!(yield* protectedStorageAvailable)) return yield* fail();
100 const json = yield* encodeSavedCredential({
101 serverUrl,
102 deviceId: grant.deviceId,
103 refreshToken: grant.refreshToken,
104 });
105 const bytes = yield* safe.encryptString(json);
106 yield* fs.writeFile(`${credentialPath}.tmp`, bytes, { mode: 0o600 });
107 yield* fs.rename(`${credentialPath}.tmp`, credentialPath);
108 access = {
109 serverUrl,
110 token: grant.accessToken,
111 expiresAt: DateTime.toEpochMillis(grant.expiresAt),
112 };
113 }).pipe(Effect.mapError(fail));
114
115 const post = <A, I>(
116 server: string,
117 endpoint: string,
118 body: unknown,
119 schema: Schema.Codec<A, I>,
120 authorization?: string,
121 ) =>
122 Effect.scoped(
123 Effect.gen(function* () {
124 const request = HttpClientRequest.post(`${server}${endpoint}`).pipe(
125 HttpClientRequest.bodyJsonUnsafe(body),
126 HttpClientRequest.setHeaders(authorization === undefined ? {} : { authorization }),
127 );
128 const response = yield* HttpClient.withScope(http).execute(request);
129 if (response.status !== 200) return yield* fail();
130 return yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema)));
131 }),
132 ).pipe(
133 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
134 Effect.mapError(fail),
135 Effect.timeout("30 seconds"),
136 Effect.mapError(fail),
137 );
138
139 const deliver = (serverUrl: string, token: string) =>
140 Effect.scoped(
141 Effect.gen(function* () {
142 const primary = (yield* pool.list).find((item) => item.id === PRIMARY_LOCAL_ENVIRONMENT_ID);
143 const config = primary === undefined ? Option.none() : yield* primary.currentConfig;
144 if (Option.isNone(config)) return;
145 if (
146 source?.serverUrl !== serverUrl ||
147 source.expiresAt <= (yield* Clock.currentTimeMillis) + 60_000
148 ) {
149 const narrow = yield* post(
150 serverUrl,
151 "/api/auth/devices/token",
152 {
153 scopes: [AuthArchiveReadScope, AuthArchiveWriteScope, AuthEnvironmentConnectScope],
154 },
155 Schema.toCodecJson(
156 Schema.Struct({ accessToken: Schema.String, expiresAt: Schema.DateTimeUtc }),
157 ),
158 `Bearer ${token}`,
159 );
160 source = {
161 serverUrl,
162 token: narrow.accessToken,
163 expiresAt: DateTime.toEpochMillis(narrow.expiresAt),
164 };
165 }
166 const localToken = yield* localAuth.getBearerToken;
167 const response = yield* HttpClient.withScope(http).execute(
168 HttpClientRequest.post(
169 new URL("/api/self-hosted/connection", config.value.httpBaseUrl).href,
170 ).pipe(
171 HttpClientRequest.setHeader("authorization", `Bearer ${localToken}`),
172 HttpClientRequest.bodyJsonUnsafe({
173 serverUrl,
174 authorization: `Bearer ${source.token}`,
175 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(source.expiresAt)),
176 }),
177 ),
178 );
179 if (response.status < 200 || response.status >= 300) return yield* fail();
180 }),
181 ).pipe(
182 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
183 Effect.mapError(fail),
184 );
185
186 const resolveAccessToken = (server: string) =>
187 Effect.gen(function* () {
188 const serverUrl = yield* Effect.try({ try: () => hostedServerUrl(server), catch: fail });
189 const saved = yield* read;
190 if (saved === null || saved.serverUrl !== serverUrl) return yield* fail();
191 if (
192 access?.serverUrl === serverUrl &&
193 access.expiresAt > (yield* Clock.currentTimeMillis) + 60_000
194 )
195 return access.token;
196 const grant = yield* post(
197 serverUrl,
198 "/api/auth/devices/refresh",
199 { refreshToken: saved.refreshToken },
200 Schema.toCodecJson(HostedAuthGrant),
201 );
202 yield* save(serverUrl, grant);
203 yield* deliver(serverUrl, grant.accessToken).pipe(Effect.ignore);
204 return grant.accessToken;
205 });
206
207 const accessToken = (server: string) => lock.withPermits(1)(resolveAccessToken(server));
208
209 const receive = (url: string) =>
210 lock
211 .withPermits(1)(
212 Effect.gen(function* () {
213 if (pending === null || !isHostedAuthCallback(url)) return;
214 const attempt = pending;
215 const now = yield* Clock.currentTimeMillis;
216 const proof = yield* Effect.try({
217 try: () => readHostedAuthCallback(url, attempt, now),
218 catch: fail,
219 });
220 pending = null;
221 const grant = yield* post(
222 attempt.server,
223 "/api/auth/devices/exchange",
224 proof,
225 Schema.toCodecJson(HostedAuthGrant),
226 );
227 yield* save(attempt.server, grant);
228 source = null;
229 yield* deliver(attempt.server, grant.accessToken).pipe(Effect.ignore);
230 state = { serverUrl: attempt.server, phase: "connected", error: null };
231 }),
232 )
233 .pipe(
234 Effect.catch(() =>
235 Effect.sync(() => {
236 state = { ...state, phase: "error", error: "Sign-in did not finish. Sign in again." };
237 }),
238 ),
239 );
240
241 return DesktopHostedAuth.of({
242 state: Effect.gen(function* () {
243 if (pending !== null && (yield* Clock.currentTimeMillis) >= pending.expiresAt) {
244 pending = null;
245 state = { ...state, phase: "error", error: "Sign-in expired. Sign in again." };
246 }
247 if (state.phase !== "idle") return state;
248 const saved = yield* read;
249 return saved === null
250 ? state
251 : { serverUrl: saved.serverUrl, phase: "connected", error: null };
252 }),
253 begin: (server) =>
254 lock.withPermits(1)(
255 Effect.gen(function* () {
256 if (!(yield* protectedStorageAvailable)) return yield* fail();
257 const now = yield* Clock.currentTimeMillis;
258 const attempt = yield* Effect.try({
259 try: () => createHostedAuthAttempt(server, now),
260 catch: fail,
261 });
262 const result = yield* post(
263 attempt.server,
264 "/api/auth/devices/start",
265 {
266 state: attempt.state,
267 codeChallenge: attempt.codeChallenge,
268 client: { label: "The Knife Desktop", deviceType: "desktop" },
269 },
270 HostedAuthDeviceStartResult,
271 );
272 const url = new URL(result.authorizationUrl);
273 if (
274 url.origin !== new URL(attempt.server).origin ||
275 url.protocol !== "https:" ||
276 url.username ||
277 url.password
278 )
279 return yield* fail();
280 pending = attempt;
281 state = { serverUrl: attempt.server, phase: "waiting", error: null };
282 if (!(yield* shell.openExternal(url.href))) {
283 pending = null;
284 state = {
285 ...state,
286 phase: "error",
287 error: "Couldn't open your browser. Sign in again.",
288 };
289 return yield* fail();
290 }
291 }).pipe(Effect.mapError(fail)),
292 ),
293 accessToken,
294 cancel: lock.withPermits(1)(
295 Effect.sync(() => {
296 if (pending === null) return;
297 pending = null;
298 state = { serverUrl: null, phase: "idle", error: null };
299 }),
300 ),
301 disconnect: lock.withPermits(1)(
302 Effect.gen(function* () {
303 const primary = (yield* pool.list).find((item) => item.id === PRIMARY_LOCAL_ENVIRONMENT_ID);
304 const config = primary === undefined ? Option.none() : yield* primary.currentConfig;
305 if (Option.isSome(config)) {
306 const localToken = yield* localAuth.getBearerToken;
307 const response = yield* Effect.scoped(
308 HttpClient.withScope(http).execute(
309 HttpClientRequest.make("DELETE")(
310 new URL("/api/self-hosted/connection", config.value.httpBaseUrl).href,
311 ).pipe(HttpClientRequest.setHeader("authorization", `Bearer ${localToken}`)),
312 ),
313 ).pipe(
314 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
315 Effect.mapError(fail),
316 );
317 if (response.status < 200 || response.status >= 300) return yield* fail();
318 }
319 yield* fs.remove(credentialPath, { force: true }).pipe(Effect.mapError(fail));
320 access = null;
321 source = null;
322 pending = null;
323 state = { serverUrl: null, phase: "idle", error: null };
324 }).pipe(Effect.mapError(fail)),
325 ),
326 receive,
327 configure: Effect.gen(function* () {
328 const run = Effect.runPromiseWith(yield* Effect.context<never>());
329 yield* app.on("open-url", (event: { preventDefault: () => void }, url: string) => {
330 if (!isHostedAuthCallback(url)) return;
331 event.preventDefault();
332 void run(receive(url));
333 });
334 yield* app.on("second-instance", (_event: unknown, argv: readonly string[]) => {
335 const url = argv.find(isHostedAuthCallback);
336 if (url) void run(receive(url));
337 });
338 yield* Effect.forkScoped(
339 app.whenReady.pipe(
340 Effect.andThen(
341 Effect.forever(
342 lock
343 .withPermits(1)(
344 Effect.gen(function* () {
345 const saved = yield* read;
346 if (saved !== null) {
347 const token = yield* resolveAccessToken(saved.serverUrl);
348 yield* deliver(saved.serverUrl, token);
349 }
350 }),
351 )
352 .pipe(Effect.ignore, Effect.andThen(Effect.sleep("1 minute"))),
353 ),
354 ),
355 ),
356 );
357 const initial = args.find(isHostedAuthCallback);
358 if (initial) yield* receive(initial);
359 }),
360 });
361});
362
363export const layer = Layer.effect(DesktopHostedAuth, make);