alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
262 lines (229 loc) • 8.15 kB
text/typescript
import {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { deflateRawSync, inflateRawSync } from "node:zlib";
import {
$hook,
$inject,
Alepha,
AlephaError,
type Static,
type TSchema,
} from "alepha";
import { SecretProvider } from "alepha/crypto";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import type {
Cookie,
CookiePrimitiveOptions,
Cookies,
} from "../primitives/$cookie.ts";
import { CookieParser } from "../services/CookieParser.ts";
export class ServerCookiesProvider {
protected readonly alepha = $inject(Alepha);
protected readonly log = $logger();
protected readonly cookieParser = $inject(CookieParser);
protected readonly dateTimeProvider = $inject(DateTimeProvider);
protected readonly secretProvider = $inject(SecretProvider);
// crypto constants
protected readonly ALGORITHM = "aes-256-gcm";
protected readonly IV_LENGTH = 16; // For GCM
protected readonly AUTH_TAG_LENGTH = 16;
protected readonly SIGNATURE_LENGTH = 32; // For SHA256
public readonly onRequest = $hook({
on: "server:onRequest",
handler: ({ request }) => {
request.cookies = {
req: this.cookieParser.parseRequestCookies(
request.headers.cookie ?? "",
),
res: {},
};
},
});
public readonly onAction = $hook({
on: "action:onRequest",
handler: ({ request }) => {
request.cookies = {
req: this.cookieParser.parseRequestCookies(
request.headers.cookie ?? "",
),
res: {},
};
},
});
public readonly onSend = $hook({
on: "server:onSend",
handler: ({ request }) => {
if (request.cookies && Object.keys(request.cookies.res).length > 0) {
const setCookieHeaders = this.cookieParser.serializeResponseCookies(
request.cookies.res,
request.url.protocol === "https:",
);
if (setCookieHeaders.length > 0) {
request.reply.headers["set-cookie"] = setCookieHeaders;
}
}
},
});
protected getCookiesFromContext(cookies?: Cookies): Cookies {
const contextCookies = this.alepha.store.get(
"alepha.http.request",
)?.cookies;
if (cookies) return cookies;
if (contextCookies) return contextCookies;
throw new AlephaError(
"Cookie context is not available. This method must be called within a server request cycle.",
);
}
/**
* Namespaces a cookie name with the app's `APP_NAME` (lowercased) so that
* two Alepha apps sharing a cookie jar do not collide.
*
* Cookies are scoped by host + path only — never by port — so two apps on
* `localhost:3000` and `localhost:4000` would otherwise both read/write a
* cookie literally named `tokens` and silently clobber each other (and,
* because each encrypts with its own `APP_SECRET`, the foreign cookie fails
* to decrypt and gets deleted, logging the user out).
*
* When `APP_NAME` is unset the name is returned unchanged, so existing
* single-app deployments keep their current cookie names. Same convention as
* the `APP_NAME` prefix used by R2 storage and server-timing.
*
* Pass `prefix = false` to skip the namespace entirely — used for
* `persist: "cookie"` atoms, whose browser-side counterpart cannot see
* `APP_NAME` and therefore always uses the bare name. See
* `CookiePrimitiveOptions.prefix`.
*/
protected prefixName(name: string, prefix = true): string {
if (!prefix) return name;
const app = this.alepha.env.APP_NAME;
if (!app) return name;
return `${String(app).toLowerCase()}.${name}`;
}
public getCookie<T extends TSchema>(
name: string,
options: CookiePrimitiveOptions<T>,
contextCookies?: Cookies,
): Static<T> | undefined {
const cookies = this.getCookiesFromContext(contextCookies);
const prefixed = options.prefix !== false;
let rawValue = cookies.req[this.prefixName(name, prefixed)];
if (!rawValue) return undefined;
try {
rawValue = decodeURIComponent(rawValue);
if (options.sign) {
const signature = rawValue.substring(0, this.SIGNATURE_LENGTH * 2);
const value = rawValue.substring(this.SIGNATURE_LENGTH * 2);
const expectedSignature = this.sign(value);
if (
!timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expectedSignature, "hex"),
)
) {
this.log.warn(`Invalid signature for cookie "${name}".`);
return undefined;
}
rawValue = value;
}
if (options.encrypt) {
rawValue = this.decrypt(rawValue);
}
if (options.compress) {
rawValue = inflateRawSync(Buffer.from(rawValue, "base64")).toString(
"utf8",
);
}
return this.alepha.codec.decode(options.schema, JSON.parse(rawValue));
} catch (error) {
this.log.warn(`Failed to parse cookie "${name}"`, error);
// corrupted or invalid cookie, instruct browser to delete it on next response
this.deleteCookie(name, cookies, prefixed);
return undefined;
}
}
public setCookie<T extends TSchema>(
name: string,
options: CookiePrimitiveOptions<T>,
data: Static<T>,
contextCookies?: Cookies,
): void {
const cookies = this.getCookiesFromContext(contextCookies);
let value = JSON.stringify(this.alepha.codec.decode(options.schema, data));
if (options.compress) {
value = deflateRawSync(value).toString("base64");
}
if (options.encrypt) {
value = this.encrypt(value);
}
if (options.sign) {
value = this.sign(value) + value;
}
const cookie: Cookie = {
value: encodeURIComponent(value),
path: options.path ?? "/",
// "lax" is correct here — "strict" would break OAuth flows because OAuth callbacks
// are top-level redirects from the IdP, and strict cookies are not sent on cross-site
// top-level navigations. "lax" is Chrome's own default and the industry standard.
sameSite: options.sameSite ?? "lax",
secure: options.secure ?? this.alepha.isProduction(),
httpOnly: options.httpOnly,
domain: options.domain,
};
if (options.ttl) {
cookie.maxAge = this.dateTimeProvider.duration(options.ttl).as("seconds");
}
cookies.res[this.prefixName(name, options.prefix !== false)] = cookie;
}
public deleteCookie<T extends TSchema>(
name: string,
contextCookies?: Cookies,
prefix = true,
): void {
const cookies = this.getCookiesFromContext(contextCookies);
cookies.res[this.prefixName(name, prefix)] = null;
}
// --- Crypto & Parsing ---
protected encrypt(text: string): string {
const iv = randomBytes(this.IV_LENGTH);
const cipher = createCipheriv(this.ALGORITHM, this.deriveKey(), iv);
const encrypted = Buffer.concat([
cipher.update(text, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}
protected decrypt(encryptedText: string): string {
const data = Buffer.from(encryptedText, "base64");
const iv = data.subarray(0, this.IV_LENGTH);
const authTag = data.subarray(
this.IV_LENGTH,
this.IV_LENGTH + this.AUTH_TAG_LENGTH,
);
const encrypted = data.subarray(this.IV_LENGTH + this.AUTH_TAG_LENGTH);
const decipher = createDecipheriv(this.ALGORITHM, this.deriveKey(), iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return decrypted.toString("utf8");
}
/**
* Derives a 32-byte key from APP_SECRET via SHA-256.
* Accepts any string length — no padding or truncation needed.
*/
protected deriveKey(): Buffer {
return createHash("sha256").update(this.secretProvider.secretKey).digest();
}
protected sign(data: string): string {
return createHmac("sha256", this.deriveKey()).update(data).digest("hex");
}
}