| 1 | // @effect-diagnostics nodeBuiltinImport:off - Desktop login proofs never cross into the renderer. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | |
| 4 | import { normalizeHostedServer as hostedServerUrl } from "@t3tools/shared/hostedServerUrl"; |
| 5 | |
| 6 | export 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 | |
| 17 | export type HostedAuthAttempt = ReturnType<typeof createHostedAuthAttempt>; |
| 18 | |
| 19 | export 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 | |
| 34 | export 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 | } |