import type { AuthClientMetadata, AuthClientMetadataDeviceType, AuthClientPresentationMetadata,} from "@t3tools/contracts";import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";import * as NodeCrypto from "node:crypto";import * as Encoding from "effect/Encoding";import * as Result from "effect/Result"; const SESSION_COOKIE_NAME = "t3_session"; /** * Cookies are scoped by host but *not* by port, so any two servers that can be * live on one hostname at once need separate names — otherwise the second * clobbers the first's session and both sides see "Invalid session token * signature" until someone clears cookies by hand. * * Remote web servers use their persisted environment identity and omit the * port, so the name survives state-directory moves and public port changes. * * Desktop scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. */export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; readonly host: string | undefined; readonly instanceKey: string; readonly environmentId: string; readonly development: boolean;}): string { if (input.mode === "desktop") { return `${SESSION_COOKIE_NAME}_${input.port}`; } const instanceHash = NodeCrypto.createHash("sha256") .update( !input.development && isRemoteReachableHost(input.host) ? input.environmentId : input.instanceKey, ) .digest("hex") .slice(0, 12); if (!input.development && isRemoteReachableHost(input.host)) { return `${SESSION_COOKIE_NAME}_${instanceHash}`; } // Cookies are scoped by host, not port. Loopback development servers need an // instance-specific name or parallel agents overwrite each other's session, // and a server that later reuses the port receives a token signed elsewhere. return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;} export function resolveLegacySessionCookieName(input: { readonly mode: "web" | "desktop"; readonly host: string | undefined; readonly development: boolean;}): string | undefined { return input.mode === "web" && !input.development && isRemoteReachableHost(input.host) ? SESSION_COOKIE_NAME : undefined;} export function isRemoteReachableHost(host: string | undefined): boolean { if (host === "0.0.0.0" || host === "::" || host === "[::]") { return true; } if (!host || host.length === 0) { return false; } return !( host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.startsWith("127.") );} export function base64UrlEncode(input: string | Uint8Array): string { return typeof input === "string" ? Encoding.encodeBase64Url(new TextEncoder().encode(input)) : Encoding.encodeBase64Url(input);} export function base64UrlDecodeUtf8(input: string): string { return Result.getOrThrow(Encoding.decodeBase64UrlString(input));} export function signPayload(payload: string, secret: Uint8Array): string { return NodeCrypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url");} export function timingSafeEqualBase64Url(left: string, right: string): boolean { const leftBuffer = Buffer.from(left, "base64url"); const rightBuffer = Buffer.from(right, "base64url"); if (leftBuffer.length !== rightBuffer.length) { return false; } return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer);} function normalizeNonEmptyString(value: string | null | undefined): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined;} function normalizeIpAddress(value: string | null | undefined): string | undefined { const normalized = normalizeNonEmptyString(value); if (!normalized) { return undefined; } return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;} function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType { if (!userAgent) { return "unknown"; } const normalized = userAgent.toLowerCase(); if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) { return "bot"; } if (/ipad|tablet/.test(normalized)) { return "tablet"; } if (/iphone|android.+mobile|mobile/.test(normalized)) { return "mobile"; } return "desktop";} function inferBrowser(userAgent: string | undefined): string | undefined { if (!userAgent) { return undefined; } const normalized = userAgent.toLowerCase(); if (/edg\//.test(normalized)) return "Edge"; if (/opr\//.test(normalized)) return "Opera"; if (/firefox\//.test(normalized)) return "Firefox"; if (/electron\//.test(normalized)) return "Electron"; if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome"; if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari"; return undefined;} function inferOs(userAgent: string | undefined): string | undefined { if (!userAgent) { return undefined; } const normalized = userAgent.toLowerCase(); if (/iphone|ipad|ipod/.test(normalized)) return "iOS"; if (/android/.test(normalized)) return "Android"; if (/mac os x|macintosh/.test(normalized)) return "macOS"; if (/windows nt/.test(normalized)) return "Windows"; if (/linux/.test(normalized)) return "Linux"; return undefined;} function readRemoteAddressFromSource(source: unknown): string | undefined { if (!source || typeof source !== "object") { return undefined; } const candidate = source as { readonly remoteAddress?: string | null; readonly socket?: { readonly remoteAddress?: string | null; }; }; return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress);} export function deriveAuthClientMetadata(input: { readonly request: HttpServerRequest.HttpServerRequest; readonly presented?: AuthClientPresentationMetadata;}): AuthClientMetadata { const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); const ipAddress = readRemoteAddressFromSource(input.request.source); const os = input.presented?.os ?? inferOs(userAgent); const browser = inferBrowser(userAgent); return { ...(input.presented?.label ? { label: input.presented.label } : {}), ...(ipAddress ? { ipAddress } : {}), ...(userAgent ? { userAgent } : {}), deviceType: input.presented?.deviceType ?? inferDeviceType(userAgent), ...(os ? { os } : {}), ...(browser ? { browser } : {}), };}
import type {
AuthClientMetadata,
AuthClientMetadataDeviceType,
AuthClientPresentationMetadata,
} from "@t3tools/contracts";
import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
import * as NodeCrypto from "node:crypto";
import * as Encoding from "effect/Encoding";
import * as Result from "effect/Result";
const SESSION_COOKIE_NAME = "t3_session";
/**
* Cookies are scoped by host but *not* by port, so any two servers that can be
* live on one hostname at once need separate names — otherwise the second
* clobbers the first's session and both sides see "Invalid session token
* signature" until someone clears cookies by hand.
*
* Remote web servers use their persisted environment identity and omit the
* port, so the name survives state-directory moves and public port changes.
*
* Desktop scans upward from 3773 for a free port and binds
* 127.0.0.1, so a second instance lands on a different port and the same host.
*/
export function resolveSessionCookieName(input: {
readonly mode: "web" | "desktop";
readonly port: number;
readonly host: string | undefined;
readonly instanceKey: string;
readonly environmentId: string;
readonly development: boolean;
}): string {
if (input.mode === "desktop") {
return `${SESSION_COOKIE_NAME}_${input.port}`;
}
const instanceHash = NodeCrypto.createHash("sha256")
.update(
!input.development && isRemoteReachableHost(input.host)
? input.environmentId
: input.instanceKey,
)
.digest("hex")
.slice(0, 12);
if (!input.development && isRemoteReachableHost(input.host)) {
return `${SESSION_COOKIE_NAME}_${instanceHash}`;
}
// Cookies are scoped by host, not port. Loopback development servers need an
// instance-specific name or parallel agents overwrite each other's session,
// and a server that later reuses the port receives a token signed elsewhere.
return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
}
export function resolveLegacySessionCookieName(input: {
readonly mode: "web" | "desktop";
readonly host: string | undefined;
readonly development: boolean;
}): string | undefined {
return input.mode === "web" && !input.development && isRemoteReachableHost(input.host)
? SESSION_COOKIE_NAME
: undefined;
}
export function isRemoteReachableHost(host: string | undefined): boolean {
if (host === "0.0.0.0" || host === "::" || host === "[::]") {
return true;
}
if (!host || host.length === 0) {
return false;
}
return !(
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "[::1]" ||
host.startsWith("127.")
);
}
export function base64UrlEncode(input: string | Uint8Array): string {
return typeof input === "string"
? Encoding.encodeBase64Url(new TextEncoder().encode(input))
: Encoding.encodeBase64Url(input);
}
export function base64UrlDecodeUtf8(input: string): string {
return Result.getOrThrow(Encoding.decodeBase64UrlString(input));
}
export function signPayload(payload: string, secret: Uint8Array): string {
return NodeCrypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url");
}
export function timingSafeEqualBase64Url(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left, "base64url");
const rightBuffer = Buffer.from(right, "base64url");
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function normalizeNonEmptyString(value: string | null | undefined): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeIpAddress(value: string | null | undefined): string | undefined {
const normalized = normalizeNonEmptyString(value);
if (!normalized) {
return undefined;
}
return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
}
function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType {
if (!userAgent) {
return "unknown";
}
const normalized = userAgent.toLowerCase();
if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) {
return "bot";
}
if (/ipad|tablet/.test(normalized)) {
return "tablet";
}
if (/iphone|android.+mobile|mobile/.test(normalized)) {
return "mobile";
}
return "desktop";
}
function inferBrowser(userAgent: string | undefined): string | undefined {
if (!userAgent) {
return undefined;
}
const normalized = userAgent.toLowerCase();
if (/edg\//.test(normalized)) return "Edge";
if (/opr\//.test(normalized)) return "Opera";
if (/firefox\//.test(normalized)) return "Firefox";
if (/electron\//.test(normalized)) return "Electron";
if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome";
if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari";
return undefined;
}
function inferOs(userAgent: string | undefined): string | undefined {
if (!userAgent) {
return undefined;
}
const normalized = userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(normalized)) return "iOS";
if (/android/.test(normalized)) return "Android";
if (/mac os x|macintosh/.test(normalized)) return "macOS";
if (/windows nt/.test(normalized)) return "Windows";
if (/linux/.test(normalized)) return "Linux";
return undefined;
}
function readRemoteAddressFromSource(source: unknown): string | undefined {
if (!source || typeof source !== "object") {
return undefined;
}
const candidate = source as {
readonly remoteAddress?: string | null;
readonly socket?: {
readonly remoteAddress?: string | null;
};
};
return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress);
}
export function deriveAuthClientMetadata(input: {
readonly request: HttpServerRequest.HttpServerRequest;
readonly presented?: AuthClientPresentationMetadata;
}): AuthClientMetadata {
const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]);
const ipAddress = readRemoteAddressFromSource(input.request.source);
const os = input.presented?.os ?? inferOs(userAgent);
const browser = inferBrowser(userAgent);
return {
...(input.presented?.label ? { label: input.presented.label } : {}),
...(ipAddress ? { ipAddress } : {}),
...(userAgent ? { userAgent } : {}),
deviceType: input.presented?.deviceType ?? inferDeviceType(userAgent),
...(os ? { os } : {}),
...(browser ? { browser } : {}),
};
}