1// @effect-diagnostics nodeBuiltinImport:off - Desktop login proofs never cross into the renderer.
2import * as NodeCrypto from "node:crypto";
3
4import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl";
5
6export function createHostedAuthAttempt(server: string, now: number) {
7 const verifier = NodeCrypto.randomBytes(32).toString("base64url");
8 return {
9 server: hostedServerUrl(server),
10 state: NodeCrypto.randomBytes(32).toString("base64url"),
11 verifier,
12 codeChallenge: NodeCrypto.createHash("sha256").update(verifier).digest("base64url"),
13 expiresAt: now + 10 * 60_000,
14 };
15}
16
17export type HostedAuthAttempt = ReturnType<typeof createHostedAuthAttempt>;
18
19export function isHostedAuthCallback(value: string): boolean {
20 try {
21 const url = new URL(value);
22 return (
23 url.protocol === "t3code:" &&
24 url.host === "app" &&
25 !url.username &&
26 !url.password &&
27 url.pathname === "/self-hosted/callback"
28 );
29 } catch {
30 return false;
31 }
32}
33
34export function readHostedAuthCallback(value: string, pending: HostedAuthAttempt, now: number) {
35 if (!isHostedAuthCallback(value)) throw new Error("This sign-in link is invalid.");
36 const url = new URL(value);
37 const code = url.searchParams.get("code");
38 const state = url.searchParams.get("state");
39 const server = url.searchParams.get("server");
40 if (
41 url.hash ||
42 url.searchParams.size !== 3 ||
43 !code ||
44 code.length > 512 ||
45 !state ||
46 !server ||
47 now >= pending.expiresAt ||
48 hostedServerUrl(server) !== pending.server
49 ) {
50 throw new Error("This sign-in link has expired or belongs to another sign-in. Sign in again.");
51 }
52 const actual = Buffer.from(state);
53 const expected = Buffer.from(pending.state);
54 if (actual.length !== expected.length || !NodeCrypto.timingSafeEqual(actual, expected)) {
55 throw new Error("This sign-in link belongs to another sign-in. Sign in again.");
56 }
57 return { code, codeVerifier: pending.verifier };
58}