1import type {
2 AuthClientMetadata,
3 AuthClientMetadataDeviceType,
4 AuthClientPresentationMetadata,
5} from "@t3tools/contracts";
6import type * as HttpServerRequest from "effect/http/HttpServerRequest";
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
8import * as NodeCrypto from "node:crypto";
9import * as Base64Url from "effect/encoding/Base64Url";
10import * as Result from "effect/Result";
11
12const SESSION_COOKIE_NAME = "t3_session";
13
14/**
15 * Cookies are scoped by host but *not* by port, so any two servers that can be
16 * live on one hostname at once need separate names — otherwise the second
17 * clobbers the first's session and both sides see "Invalid session token
18 * signature" until someone clears cookies by hand.
19 *
20 * Remote web servers use their persisted environment identity and omit the
21 * port, so the name survives state-directory moves and public port changes.
22 *
23 * Desktop scans upward from 3773 for a free port and binds
24 * 127.0.0.1, so a second instance lands on a different port and the same host.
25 */
26export function resolveSessionCookieName(input: {
27 readonly mode: "web" | "desktop";
28 readonly port: number;
29 readonly host: string | undefined;
30 readonly instanceKey: string;
31 readonly environmentId: string;
32 readonly development: boolean;
33}): string {
34 if (input.mode === "desktop") {
35 return `${SESSION_COOKIE_NAME}_${input.port}`;
36 }
37
38 const instanceHash = NodeCrypto.createHash("sha256")
39 .update(
40 !input.development && isRemoteReachableHost(input.host)
41 ? input.environmentId
42 : input.instanceKey,
43 )
44 .digest("hex")
45 .slice(0, 12);
46
47 if (!input.development && isRemoteReachableHost(input.host)) {
48 return `${SESSION_COOKIE_NAME}_${instanceHash}`;
49 }
50
51 // Cookies are scoped by host, not port. Loopback development servers need an
52 // instance-specific name or parallel agents overwrite each other's session,
53 // and a server that later reuses the port receives a token signed elsewhere.
54 return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
55}
56
57export function resolveLegacySessionCookieName(input: {
58 readonly mode: "web" | "desktop";
59 readonly host: string | undefined;
60 readonly development: boolean;
61}): string | undefined {
62 return input.mode === "web" && !input.development && isRemoteReachableHost(input.host)
63 ? SESSION_COOKIE_NAME
64 : undefined;
65}
66
67export function isRemoteReachableHost(host: string | undefined): boolean {
68 if (host === "0.0.0.0" || host === "::" || host === "[::]") {
69 return true;
70 }
71 if (!host || host.length === 0) {
72 return false;
73 }
74 return !(
75 host === "localhost" ||
76 host === "127.0.0.1" ||
77 host === "::1" ||
78 host === "[::1]" ||
79 host.startsWith("127.")
80 );
81}
82
83export function base64UrlEncode(input: string | Uint8Array): string {
84 return typeof input === "string"
85 ? Base64Url.encode(new TextEncoder().encode(input))
86 : Base64Url.encode(input);
87}
88
89export function base64UrlDecodeUtf8(input: string): string {
90 return Result.getOrThrow(Base64Url.decodeString(input));
91}
92
93export function signPayload(payload: string, secret: Uint8Array): string {
94 return NodeCrypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url");
95}
96
97export function timingSafeEqualBase64Url(left: string, right: string): boolean {
98 const leftBuffer = Buffer.from(left, "base64url");
99 const rightBuffer = Buffer.from(right, "base64url");
100 if (leftBuffer.length !== rightBuffer.length) {
101 return false;
102 }
103 return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer);
104}
105
106function normalizeNonEmptyString(value: string | null | undefined): string | undefined {
107 if (typeof value !== "string") {
108 return undefined;
109 }
110 const trimmed = value.trim();
111 return trimmed.length > 0 ? trimmed : undefined;
112}
113
114function normalizeIpAddress(value: string | null | undefined): string | undefined {
115 const normalized = normalizeNonEmptyString(value);
116 if (!normalized) {
117 return undefined;
118 }
119 return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
120}
121
122function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType {
123 if (!userAgent) {
124 return "unknown";
125 }
126
127 const normalized = userAgent.toLowerCase();
128 if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) {
129 return "bot";
130 }
131 if (/ipad|tablet/.test(normalized)) {
132 return "tablet";
133 }
134 if (/iphone|android.+mobile|mobile/.test(normalized)) {
135 return "mobile";
136 }
137 return "desktop";
138}
139
140function inferBrowser(userAgent: string | undefined): string | undefined {
141 if (!userAgent) {
142 return undefined;
143 }
144 const normalized = userAgent.toLowerCase();
145 if (/edg\//.test(normalized)) return "Edge";
146 if (/opr\//.test(normalized)) return "Opera";
147 if (/firefox\//.test(normalized)) return "Firefox";
148 if (/electron\//.test(normalized)) return "Electron";
149 if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome";
150 if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari";
151 return undefined;
152}
153
154function inferOs(userAgent: string | undefined): string | undefined {
155 if (!userAgent) {
156 return undefined;
157 }
158 const normalized = userAgent.toLowerCase();
159 if (/iphone|ipad|ipod/.test(normalized)) return "iOS";
160 if (/android/.test(normalized)) return "Android";
161 if (/mac os x|macintosh/.test(normalized)) return "macOS";
162 if (/windows nt/.test(normalized)) return "Windows";
163 if (/linux/.test(normalized)) return "Linux";
164 return undefined;
165}
166
167function readRemoteAddressFromSource(source: unknown): string | undefined {
168 if (!source || typeof source !== "object") {
169 return undefined;
170 }
171
172 const candidate = source as {
173 readonly remoteAddress?: string | null;
174 readonly socket?: {
175 readonly remoteAddress?: string | null;
176 };
177 };
178
179 return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress);
180}
181
182export function deriveAuthClientMetadata(input: {
183 readonly request: HttpServerRequest.HttpServerRequest;
184 readonly presented?: AuthClientPresentationMetadata;
185}): AuthClientMetadata {
186 const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]);
187 const ipAddress = readRemoteAddressFromSource(input.request.source);
188 const os = input.presented?.os ?? inferOs(userAgent);
189 const browser = inferBrowser(userAgent);
190 return {
191 ...(input.presented?.label ? { label: input.presented.label } : {}),
192 ...(ipAddress ? { ipAddress } : {}),
193 ...(userAgent ? { userAgent } : {}),
194 deviceType: input.presented?.deviceType ?? inferDeviceType(userAgent),
195 ...(os ? { os } : {}),
196 ...(browser ? { browser } : {}),
197 };
198}