Skip to content
Source
Sign inSign up
Open History to see the last commit

utils.ts

185 lines6.2 KB

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 Digest is effectful; the cookie name stays a plain function.
8import * as NodeCrypto from "node:crypto";
9import * as NodeNet from "node:net";
10import { normalizeHostname } from "@t3tools/shared/hostClassification";
11import * as Mac from "effect/crypto/Mac";
12import * as Base64Url from "effect/encoding/Base64Url";
13import * as Result from "effect/Result";
14​
15const SESSION_COOKIE_NAME = "t3_session";
16​
17/** Cookies span ports; use separate names for servers sharing a hostname. */
18export function resolveSessionCookieName(input: {
19 readonly mode: "web" | "desktop";
20 readonly port: number;
21 readonly host: string | undefined;
22 readonly instanceKey: string;
23 readonly environmentId: string;
24 readonly development: boolean;
25}): string {
26 if (input.mode === "desktop") {
27 return `${SESSION_COOKIE_NAME}_${input.port}`;
28 }
29​
30 const instanceHash = NodeCrypto.createHash("sha256")
31 .update(
32 !input.development && isRemoteReachableHost(input.host)
33 ? input.environmentId
34 : input.instanceKey,
35 )
36 .digest("hex")
37 .slice(0, 12);
38​
39 if (!input.development && isRemoteReachableHost(input.host)) {
40 return `${SESSION_COOKIE_NAME}_${instanceHash}`;
41 }
42​
43 return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
45​
46export function resolveLegacySessionCookieName(input: {
47 readonly mode: "web" | "desktop";
48 readonly host: string | undefined;
49 readonly development: boolean;
50}): string | undefined {
51 return input.mode === "web" && !input.development && isRemoteReachableHost(input.host)
52 ? SESSION_COOKIE_NAME
53 : undefined;
55​
56/**
57 * The loopback address a server given `host` listens on, or undefined when it may listen beyond
58 * this machine. Unset means 127.0.0.1. Only a loopback literal or `localhost` counts: Node listens
59 * on every interface for an empty host, and any other name resolves through DNS.
60 */
61export function loopbackBindAddress(host: string | undefined): string | undefined {
62 if (host === undefined) return "127.0.0.1";
63 const address = normalizeHostname(host);
64 if (address === "localhost") return "127.0.0.1";
65 return (NodeNet.isIPv4(address) && address.startsWith("127.")) || address === "::1"
66 ? address
67 : undefined;
69​
70export const isRemoteReachableHost = (host: string | undefined): boolean =>
71 loopbackBindAddress(host) === undefined;
72​
73export function base64UrlEncode(input: string | Uint8Array): string {
74 return typeof input === "string"
75 ? Base64Url.encode(new TextEncoder().encode(input))
76 : Base64Url.encode(input);
78​
79export function base64UrlDecodeUtf8(input: string): string {
80 return Result.getOrThrow(Base64Url.decodeString(input));
82​
83export function timingSafeEqualBase64Url(left: string, right: string): boolean {
84 const leftBytes = Base64Url.decode(left);
85 const rightBytes = Base64Url.decode(right);
86 return (
87 Result.isSuccess(leftBytes) &&
88 Result.isSuccess(rightBytes) &&
89 Mac.timingSafeEqual(leftBytes.success, rightBytes.success)
90 );
92​
93function normalizeNonEmptyString(value: string | null | undefined): string | undefined {
94 if (typeof value !== "string") {
95 return undefined;
96 }
97 const trimmed = value.trim();
98 return trimmed.length > 0 ? trimmed : undefined;
100​
101function normalizeIpAddress(value: string | null | undefined): string | undefined {
102 const normalized = normalizeNonEmptyString(value);
103 if (!normalized) {
104 return undefined;
105 }
106 return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
108​
109function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType {
110 if (!userAgent) {
111 return "unknown";
112 }
113​
114 const normalized = userAgent.toLowerCase();
115 if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) {
116 return "bot";
117 }
118 if (/ipad|tablet/.test(normalized)) {
119 return "tablet";
120 }
121 if (/iphone|android.+mobile|mobile/.test(normalized)) {
122 return "mobile";
123 }
124 return "desktop";
126​
127function inferBrowser(userAgent: string | undefined): string | undefined {
128 if (!userAgent) {
129 return undefined;
130 }
131 const normalized = userAgent.toLowerCase();
132 if (/edg(?:e|a|ios)?\//.test(normalized)) return "Edge";
133 if (/opr\/|opios\//.test(normalized)) return "Opera";
134 if (/firefox\/|fxios\//.test(normalized)) return "Firefox";
135 if (/electron\//.test(normalized)) return "Electron";
136 if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome";
137 if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari";
138 return undefined;
140​
141function inferOs(userAgent: string | undefined): string | undefined {
142 if (!userAgent) {
143 return undefined;
144 }
145 const normalized = userAgent.toLowerCase();
146 if (/iphone|ipad|ipod/.test(normalized)) return "iOS";
147 if (/android/.test(normalized)) return "Android";
148 if (/mac os x|macintosh/.test(normalized)) return "macOS";
149 if (/windows nt/.test(normalized)) return "Windows";
150 if (/linux/.test(normalized)) return "Linux";
151 return undefined;
153​
154function readRemoteAddressFromSource(source: unknown): string | undefined {
155 if (!source || typeof source !== "object") {
156 return undefined;
157 }
158​
159 const candidate = source as {
160 readonly remoteAddress?: string | null;
161 readonly socket?: {
162 readonly remoteAddress?: string | null;
163 };
164 };
165​
166 return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress);
168​
169export function deriveAuthClientMetadata(input: {
170 readonly request: HttpServerRequest.HttpServerRequest;
171 readonly presented?: AuthClientPresentationMetadata;
172}): AuthClientMetadata {
173 const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]);
174 const ipAddress = readRemoteAddressFromSource(input.request.source);
175 const os = input.presented?.os ?? inferOs(userAgent);
176 const browser = inferBrowser(userAgent);
177 return {
178 ...(input.presented?.label ? { label: input.presented.label } : {}),
179 ...(ipAddress ? { ipAddress } : {}),
180 ...(userAgent ? { userAgent } : {}),
181 deviceType: input.presented?.deviceType ?? inferDeviceType(userAgent),
182 ...(os ? { os } : {}),
183 ...(browser ? { browser } : {}),
184 };