Guests (Unauthenticated Sessions) Overview
Models
Section titled “Models”Guests
Section titled “Guests”import { pgTable, uuid } from "drizzle-orm/pg-core";
import { createdAt, updatedAt, archivedAt, randomId } from "#/helpers/cols";
import { users, usersArchive } from "#/models/users/schemas";
const guestsBaseCols = { id: randomId, createdAt, updatedAt,};
/** * Guests is essentially shorthand for "unauthenticated sessions" * Splitting them into a separate table makes it easier to adapt to the unique needs of different applications */export const guests = pgTable("guests", guestsBaseCols);
/** * Used to keep a record of unauthenticated sessions * * Uniquely, **entries do not have to come directly from the live table** * because guests are created on-the-fly on the server with just a token. * * If a guest creates an account or signs-in as a user, the relation can be saved to the archive table. * Useful for linking the user to the guest’s actions in logging, analytics, application monitoring, telemetry, etc. */export const guestsArchive = pgTable("guests_archive", { ...guestsBaseCols, userId: uuid("user_id").references(() => users.id), usersArchiveId: uuid("users_archive_id").references(() => usersArchive.id), archivedAt,});import { createInsertSchema, createSelectSchema } from "drizzle-valibot";import * as v from "valibot";import { guests, guestsArchive } from "./schemas";
const { id: guestId } = createSelectSchema(guests).entries;
const { id: guestsArchiveId, userId, createdAt, updatedAt,} = createInsertSchema(guestsArchive).entries;
export const InsertGuestsArchive = v.object({ id: guestsArchiveId, userId: v.nonNullish(userId), createdAt, updatedAt,});
export const SelectGuest = v.object({ id: guestId });import { eq, getTableColumns, sql } from "drizzle-orm";import { parse, type InferInput } from "valibot";
import { type QueryExecutor, type Tx } from "#/helpers/types";import { db } from "#/index";
import { guests } from "./schemas";import { SelectGuest } from "./validations";
// === SELECT ===
/** Filter out id. Since it is required for the select input, there's no reason to return it */// eslint-disable-next-line @typescript-eslint/no-unused-varsconst { id: idCol, ...guestReturnValues } = getTableColumns(guests);
const selectGuestStmt = ({ qx, label = "",}: { qx: QueryExecutor; label?: string;}) => qx .select(guestReturnValues) .from(guests) .where(eq(guests.id, sql.placeholder("id"))) .limit(1) .prepare(`select_guest_${label}`);
const selectGuestDefault = selectGuestStmt({ qx: db });
export async function selectGuest( input: InferInput<typeof SelectGuest>, viaTx?: { tx: Tx; label: string },) { const _selectGuest = viaTx ? selectGuestStmt({ qx: viaTx.tx, label: viaTx.label }) : selectGuestDefault;
const [data] = await _selectGuest.execute(parse(SelectGuest, input));
return data;}Internals
Section titled “Internals”import "server-only";
import { randomUUID } from "crypto";
import { jwtVerify, SignJWT } from "jose";import { extractSecretPairs, generateSaltedKey, getKeyFromSecretsMap,} from "./jwt-utils";
/** Load and transform the GUEST_TOKEN_SECRETS environment variable */const { secretsEntries, secretsMap } = extractSecretPairs({ secretPairsList: process.env.GUEST_TOKEN_SECRETS,});
/** * Generates a JSON Web Signature (JWS) token with a random uuid. * This implementation generates a unique salt per token that is stored in a custom `s` header. * Using a unique salt per token mitigates some risks of rainbow table attacks and limits exposure if the derived key is compromised */export async function generateGuestToken({ id } = { id: randomUUID() }) { const { kid, salt, key } = await generateSaltedKey({ secretsEntries, });
const token = await new SignJWT({ id }) .setProtectedHeader({ alg: "HS256", kid, s: salt }) .sign(key);
return { token, id };}
/** Verifies the signature of the given JWS token using {@link getKeyFromSecretsMap} and returns its payload */export async function extractGuestTokenPayload({ token }: { token: string }) { const { payload } = await jwtVerify( token, getKeyFromSecretsMap({ secretsMap }), ); return payload as { id: ReturnType<typeof randomUUID> };}import { hkdf, randomBytes } from "node:crypto";
import { type JWTHeaderParameters } from "jose";
/** Transform a comma-separated list of id:secret pairs into a 2D key-value array and a Map for easy lookup */export function extractSecretPairs({ secretPairsList,}: { secretPairsList: string | undefined;}) { const secretPairs = secretPairsList?.split(","); if (!secretPairs?.length) throw new Error("secret must be set");
const secretsEntries = secretPairs.map<[id: string, secret: string]>( (secretPair) => { const [id, secret] = secretPair.split(":"); if (!id || !secret) throw new Error( "must be in 'id:secret' format with non-empty id and secret", ); return [id, secret]; }, );
return { secretsEntries, secretsMap: new Map(secretsEntries) };}
/** * Derives an encryption key from the provided secret and salt. * Uses the HKDF function to ensure uniform randomness in the generated key, even if the secret is weak */export function deriveHashKey({ secret, salt,}: { secret: Parameters<typeof hkdf>[1]; salt: Parameters<typeof hkdf>[2];}) { return new Promise((resolve: (value: Uint8Array) => void, reject) => { hkdf( "sha256", secret, salt, "Generated in wunshot", 32, (err, derivedKey) => { if (err) reject(err); resolve(new Uint8Array(derivedKey)); }, ); });}
/** Generates a salt and uses {@link deriveHashKey} to generate a unique key */export async function generateSaltedKey({ secretsEntries,}: { secretsEntries: [id: string, secret: string][];}) { if (!secretsEntries[0]) throw new Error("secretEntries cannot be empty");
const [kid, secret] = secretsEntries[0]; const salt = randomBytes(32).toString("base64url"); const key = await deriveHashKey({ secret, salt });
return { kid, salt, key };}
/** * Generates the decryption key by using the provided `kid` and `s` headers of a given JWT. * The `s` header is a custom one used for managing a unique salt per token. * Looks up the corresponding secret from the given secretsMap and uses HKDF to derive the key. */export function getKeyFromSecretsMap({ secretsMap,}: { secretsMap: Map<string, string>;}) { return function getKey({ kid, s, }: JWTHeaderParameters & { s?: string; }) { if (!kid) throw new Error("`kid` claim is missing in the header parameters"); if (!s) throw new Error("`s` claim is missing in the header parameters");
const secret = secretsMap.get(kid); if (!secret) throw new Error("Cannot find decryption secret");
return deriveHashKey({ secret, salt: s }); };}import { archiveGuest } from "#/models/guests/cascades";
import { extractGuestTokenPayload } from "./guest-token";
/** Extract information from a guest token and send it to the archive table */export async function retireGuest( { token, userId, ipAddress, }: { token: string; userId: string; ipAddress: string; }, tx?: Parameters<typeof archiveGuest>[1],) { const { id } = await extractGuestTokenPayload({ token });
return archiveGuest({ id, userId, ipAddresses: [ipAddress] }, tx);}Sign Guest
Section titled “Sign Guest”