Skip to content

Guests (Unauthenticated Sessions) Overview

#/models/guests/schemas.ts
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,
});
#/models/guests/validations.ts
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 });
#/models/guests/queries.ts
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-vars
const { 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;
}
#/models/guests/cascades.ts
#/ops/guests/internals/guest-token.ts
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> };
}
#/ops/auth/internals/jwt-utils.ts
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 });
};
}
#/ops/guests/internals/retire-guest.ts
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);
}
#/ops/guests/sign-guest.ts