Skip to main content
Module

std/http/cookie_map.ts

Deno standard library
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.// This module is browser compatible.
/** Provides a iterable map interfaces for managing cookies server side. * * @example * To access the keys in a request and have any set keys available for creating * a response: * * ```ts * import { * CookieMap, * mergeHeaders * } from "https://deno.land/std@$STD_VERSION/http/cookie_map.ts"; * * const request = new Request("https://localhost/", { * headers: { "cookie": "foo=bar; bar=baz;"} * }); * * const cookies = new CookieMap(request, { secure: true }); * console.log(cookies.get("foo")); // logs "bar" * cookies.set("session", "1234567", { secure: true }); * * const response = new Response("hello", { * headers: mergeHeaders({ * "content-type": "text/plain", * }, cookies), * }); * ``` * * @example * To have automatic management of cryptographically signed cookies, you can use * the {@linkcode SecureCookieMap} instead of {@linkcode CookieMap}. The biggest * difference is that the methods operate async in order to be able to support * async signing and validation of cookies: * * ```ts * import { * SecureCookieMap, * mergeHeaders, * type KeyRing, * } from "https://deno.land/std@$STD_VERSION/http/cookie_map.ts"; * * const request = new Request("https://localhost/", { * headers: { "cookie": "foo=bar; bar=baz;"} * }); * * // The keys must implement the `KeyRing` interface. * declare const keys: KeyRing; * * const cookies = new SecureCookieMap(request, { keys, secure: true }); * console.log(await cookies.get("foo")); // logs "bar" * // the cookie will be automatically signed using the supplied key ring. * await cookies.set("session", "1234567"); * * const response = new Response("hello", { * headers: mergeHeaders({ * "content-type": "text/plain", * }, cookies), * }); * ``` * * In addition, if you have a {@linkcode Response} or {@linkcode Headers} for a * response at construction of the cookies object, they can be passed and any * set cookies will be added directly to those headers: * * ```ts * import { CookieMap } from "https://deno.land/std@$STD_VERSION/http/cookie_map.ts"; * * const request = new Request("https://localhost/", { * headers: { "cookie": "foo=bar; bar=baz;"} * }); * * const response = new Response("hello", { * headers: { "content-type": "text/plain" }, * }); * * const cookies = new CookieMap(request, { response }); * console.log(cookies.get("foo")); // logs "bar" * cookies.set("session", "1234567"); * ``` * * @module */
export interface CookieMapOptions { /** The {@linkcode Response} or the headers that will be used with the * response. When provided, `Set-Cookie` headers will be set in the headers * when cookies are set or deleted in the map. * * An alternative way to extract the headers is to pass the cookie map to the * {@linkcode mergeHeaders} function to merge various sources of the * headers to be provided when creating or updating a response. */ response?: Headered | Headers; /** A flag that indicates if the request and response are being handled over * a secure (e.g. HTTPS/TLS) connection. * * @default {false} */ secure?: boolean;}
export interface CookieMapSetDeleteOptions { /** The domain to scope the cookie for. */ domain?: string; /** When the cookie expires. */ expires?: Date; /** A flag that indicates if the cookie is valid over HTTP only. */ httpOnly?: boolean; /** Do not error when signing and validating cookies over an insecure * connection. */ ignoreInsecure?: boolean; /** Overwrite an existing value. */ overwrite?: boolean; /** The path the cookie is valid for. */ path?: string; /** Override the flag that was set when the instance was created. */ secure?: boolean; /** Set the same-site indicator for a cookie. */ sameSite?: "strict" | "lax" | "none" | boolean;}
/** An object which contains a `headers` property which has a value of an * instance of {@linkcode Headers}, like {@linkcode Request} and * {@linkcode Response}. */export interface Headered { headers: Headers;}
export interface Mergeable { [cookieMapHeadersInitSymbol](): [string, string][];}
export interface SecureCookieMapOptions { /** Keys which will be used to validate and sign cookies. The key ring should * implement the {@linkcode KeyRing} interface. */ keys?: KeyRing;
/** The {@linkcode Response} or the headers that will be used with the * response. When provided, `Set-Cookie` headers will be set in the headers * when cookies are set or deleted in the map. * * An alternative way to extract the headers is to pass the cookie map to the * {@linkcode mergeHeaders} function to merge various sources of the * headers to be provided when creating or updating a response. */ response?: Headered | Headers;
/** A flag that indicates if the request and response are being handled over * a secure (e.g. HTTPS/TLS) connection. * * @default {false} */ secure?: boolean;}
export interface SecureCookieMapGetOptions { /** Overrides the flag that was set when the instance was created. */ signed?: boolean;}
export interface SecureCookieMapSetDeleteOptions { /** The domain to scope the cookie for. */ domain?: string; /** When the cookie expires. */ expires?: Date; /** A flag that indicates if the cookie is valid over HTTP only. */ httpOnly?: boolean; /** Do not error when signing and validating cookies over an insecure * connection. */ ignoreInsecure?: boolean; /** Overwrite an existing value. */ overwrite?: boolean; /** The path the cookie is valid for. */ path?: string; /** Override the flag that was set when the instance was created. */ secure?: boolean; /** Set the same-site indicator for a cookie. */ sameSite?: "strict" | "lax" | "none" | boolean; /** Override the default behavior of signing the cookie. */ signed?: boolean;}
type CookieAttributes = SecureCookieMapSetDeleteOptions;
// deno-lint-ignore no-control-regexconst FIELD_CONTENT_REGEXP = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;const KEY_REGEXP = /(?:^|;) *([^=]*)=[^;]*/g;const SAME_SITE_REGEXP = /^(?:lax|none|strict)$/i;
const matchCache: Record<string, RegExp> = {};function getPattern(name: string): RegExp { if (name in matchCache) { return matchCache[name]; }
return matchCache[name] = new RegExp( `(?:^|;) *${name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")}=([^;]*)`, );}
function pushCookie(values: string[], cookie: Cookie) { if (cookie.overwrite) { for (let i = values.length - 1; i >= 0; i--) { if (values[i].indexOf(`${cookie.name}=`) === 0) { values.splice(i, 1); } } } values.push(cookie.toHeaderValue());}
function validateCookieProperty( key: string, value: string | undefined | null,) { if (value && !FIELD_CONTENT_REGEXP.test(value)) { throw new TypeError(`The "${key}" of the cookie (${value}) is invalid.`); }}
/** An internal abstraction to manage cookies. */class Cookie implements CookieAttributes { domain?: string; expires?: Date; httpOnly = true; maxAge?: number; name: string; overwrite = false; path = "/"; sameSite: "strict" | "lax" | "none" | boolean = false; secure = false; signed?: boolean; value: string;
constructor( name: string, value: string | null, attributes: CookieAttributes, ) { validateCookieProperty("name", name); this.name = name; validateCookieProperty("value", value); this.value = value ?? ""; Object.assign(this, attributes); if (!this.value) { this.expires = new Date(0); this.maxAge = undefined; }
validateCookieProperty("path", this.path); validateCookieProperty("domain", this.domain); if ( this.sameSite && typeof this.sameSite === "string" && !SAME_SITE_REGEXP.test(this.sameSite) ) { throw new TypeError( `The "sameSite" of the cookie ("${this.sameSite}") is invalid.`, ); } }
toHeaderValue(): string { let value = this.toString(); if (this.maxAge) { this.expires = new Date(Date.now() + (this.maxAge * 1000)); } if (this.path) { value += `; path=${this.path}`; } if (this.expires) { value += `; expires=${this.expires.toUTCString()}`; } if (this.domain) { value += `; domain=${this.domain}`; } if (this.sameSite) { value += `; samesite=${ this.sameSite === true ? "strict" : this.sameSite.toLowerCase() }`; } if (this.secure) { value += "; secure"; } if (this.httpOnly) { value += "; httponly"; } return value; }
toString(): string { return `${this.name}=${this.value}`; }}
/** Symbol which is used in {@link mergeHeaders} to extract a * `[string | string][]` from an instance to generate the final set of * headers. */export const cookieMapHeadersInitSymbol = Symbol.for( "Deno.std.cookieMap.headersInit",);
function isMergeable(value: unknown): value is Mergeable { return value != null && typeof value === "object" && cookieMapHeadersInitSymbol in value;}
/** Allows merging of various sources of headers into a final set of headers * which can be used in a {@linkcode Response}. * * Note, that unlike when passing a `Response` or {@linkcode Headers} used in a * response to {@linkcode CookieMap} or {@linkcode SecureCookieMap}, merging * will not ensure that there are no other `Set-Cookie` headers from other * sources, it will simply append the various headers together. */export function mergeHeaders( ...sources: (Headered | HeadersInit | Mergeable)[]): Headers { const headers = new Headers(); for (const source of sources) { let entries: Iterable<[string, string]>; if (source instanceof Headers) { entries = source; } else if ("headers" in source && source.headers instanceof Headers) { entries = source.headers; } else if (isMergeable(source)) { entries = source[cookieMapHeadersInitSymbol](); } else if (Array.isArray(source)) { entries = source as [string, string][]; } else { entries = Object.entries(source); } for (const [key, value] of entries) { headers.append(key, value); } } return headers;}
const keys = Symbol("#keys");const requestHeaders = Symbol("#requestHeaders");const responseHeaders = Symbol("#responseHeaders");const isSecure = Symbol("#secure");const requestKeys = Symbol("#requestKeys");
/** An internal abstract class which provides common functionality for * {@link CookieMap} and {@link SecureCookieMap}. */abstract class CookieMapBase implements Mergeable { [keys]?: string[]; [requestHeaders]: Headers; [responseHeaders]: Headers; [isSecure]: boolean;
[requestKeys](): string[] { if (this[keys]) { return this[keys]; } const result = this[keys] = [] as string[]; const header = this[requestHeaders].get("cookie"); if (!header) { return result; } let matches: RegExpExecArray | null; while ((matches = KEY_REGEXP.exec(header))) { const [, key] = matches; result.push(key); } return result; }
constructor(request: Headers | Headered, options: CookieMapOptions) { this[requestHeaders] = "headers" in request ? request.headers : request; const { secure = false, response = new Headers() } = options; this[responseHeaders] = "headers" in response ? response.headers : response; this[isSecure] = secure; }
/** A method used by {@linkcode mergeHeaders} to be able to merge * headers from various sources when forming a {@linkcode Response}. */ [cookieMapHeadersInitSymbol](): [string, string][] { const init: [string, string][] = []; for (const [key, value] of this[responseHeaders]) { if (key === "set-cookie") { init.push([key, value]); } } return init; }
[Symbol.for("Deno.customInspect")]() { return `${this.constructor.name} []`; }
[Symbol.for("nodejs.util.inspect.custom")]( depth: number, // deno-lint-ignore no-explicit-any options: any, inspect: (value: unknown, options?: unknown) => string, ) { if (depth < 0) { return options.stylize(`[${this.constructor.name}]`, "special"); }
const newOptions = Object.assign({}, options, { depth: options.depth === null ? null : options.depth - 1, }); return `${options.stylize(this.constructor.name, "special")} ${ inspect([], newOptions) }`; }}
/** * Provides a way to manage cookies in a request and response on the server * as a single iterable collection. * * The methods and properties align to {@linkcode Map}. When constructing a * {@linkcode Request} or {@linkcode Headers} from the request need to be * provided, as well as optionally the {@linkcode Response} or `Headers` for the * response can be provided. Alternatively the {@linkcode mergeHeaders} * function can be used to generate a final set of headers for sending in the * response. */export class CookieMap extends CookieMapBase { /** Contains the number of valid cookies in the request headers. */ get size(): number { return [...this].length; }
constructor(request: Headers | Headered, options: CookieMapOptions = {}) { super(request, options); }
/** Deletes all the cookies from the {@linkcode Request} in the response. */ clear(options: CookieMapSetDeleteOptions = {}) { for (const key of this.keys()) { this.set(key, null, options); } }
/** Set a cookie to be deleted in the response. * * This is a convenience function for `set(key, null, options?)`. */ delete(key: string, options: CookieMapSetDeleteOptions = {}): boolean { this.set(key, null, options); return true; }
/** Return the value of a matching key present in the {@linkcode Request}. If * the key is not present `undefined` is returned. */ get(key: string): string | undefined { const headerValue = this[requestHeaders].get("cookie"); if (!headerValue) { return undefined; } const match = headerValue.match(getPattern(key)); if (!match) { return undefined; } const [, value] = match; return value; }
/** Returns `true` if the matching key is present in the {@linkcode Request}, * otherwise `false`. */ has(key: string): boolean { const headerValue = this[requestHeaders].get("cookie"); if (!headerValue) { return false; } return getPattern(key).test(headerValue); }
/** Set a named cookie in the response. The optional * {@linkcode CookieMapSetDeleteOptions} are applied to the cookie being set. */ set( key: string, value: string | null, options: CookieMapSetDeleteOptions = {}, ): this { const resHeaders = this[responseHeaders]; const values: string[] = []; for (const [key, value] of resHeaders) { if (key === "set-cookie") { values.push(value); } } const secure = this[isSecure];
if (!secure && options.secure && !options.ignoreInsecure) { throw new TypeError( "Cannot send secure cookie over unencrypted connection.", ); }
const cookie = new Cookie(key, value, options); cookie.secure = options.secure ?? secure; pushCookie(values, cookie);
resHeaders.delete("set-cookie"); for (const value of values) { resHeaders.append("set-cookie", value); } return this; }
/** Iterate over the cookie keys and values that are present in the * {@linkcode Request}. This is an alias of the `[Symbol.iterator]` method * present on the class. */ entries(): IterableIterator<[string, string]> { return this[Symbol.iterator](); }
/** Iterate over the cookie keys that are present in the * {@linkcode Request}. */ *keys(): IterableIterator<string> { for (const [key] of this) { yield key; } }
/** Iterate over the cookie values that are present in the * {@linkcode Request}. */ *values(): IterableIterator<string> { for (const [, value] of this) { yield value; } }
/** Iterate over the cookie keys and values that are present in the * {@linkcode Request}. */ *[Symbol.iterator](): IterableIterator<[string, string]> { const keys = this[requestKeys](); for (const key of keys) { const value = this.get(key); if (value) { yield [key, value]; } } }}
/** Types of data that can be signed cryptographically. */export type Data = string | number[] | ArrayBuffer | Uint8Array;
/** An interface which describes the methods that {@linkcode SecureCookieMap} * uses to sign and verify cookies. */export interface KeyRing { /** Given a set of data and a digest, return the key index of the key used * to sign the data. The index is 0 based. A non-negative number indices the * digest is valid and a key was found. */ indexOf(data: Data, digest: string): Promise<number> | number; /** Sign the data, returning a string based digest of the data. */ sign(data: Data): Promise<string> | string; /** Verifies the digest matches the provided data, indicating the data was * signed by the keyring and has not been tampered with. */ verify(data: Data, digest: string): Promise<boolean> | boolean;}
/** Provides an way to manage cookies in a request and response on the server * as a single iterable collection, as well as the ability to sign and verify * cookies to prevent tampering. * * The methods and properties align to {@linkcode Map}, but due to the need to * support asynchronous cryptographic keys, all the APIs operate async. When * constructing a {@linkcode Request} or {@linkcode Headers} from the request * need to be provided, as well as optionally the {@linkcode Response} or * `Headers` for the response can be provided. Alternatively the * {@linkcode mergeHeaders} function can be used to generate a final set * of headers for sending in the response. * * On construction, the optional set of keys implementing the * {@linkcode KeyRing} interface. While it is optional, if you don't plan to use * keys, you might want to consider using just the {@linkcode CookieMap}. * * @example */export class SecureCookieMap extends CookieMapBase { #keyRing?: KeyRing;
/** Is set to a promise which resolves with the number of cookies in the * {@linkcode Request}. */ get size(): Promise<number> { return (async () => { let size = 0; for await (const _ of this) { size++; } return size; })(); }
constructor( request: Headers | Headered, options: SecureCookieMapOptions = {}, ) { super(request, options); const { keys } = options; this.#keyRing = keys; }
/** Sets all cookies in the {@linkcode Request} to be deleted in the * response. */ async clear(options: SecureCookieMapSetDeleteOptions) { for await (const key of this.keys()) { await this.set(key, null, options); } }
/** Set a cookie to be deleted in the response. * * This is a convenience function for `set(key, null, options?)`. */ async delete( key: string, options: SecureCookieMapSetDeleteOptions = {}, ): Promise<boolean> { await this.set(key, null, options); return true; }
/** Get the value of a cookie from the {@linkcode Request}. * * If the cookie is signed, and the signature is invalid, `undefined` will be * returned and the cookie will be set to be deleted in the response. If the * cookie is using an "old" key from the keyring, the cookie will be re-signed * with the current key and be added to the response to be updated. */ async get( key: string, options: SecureCookieMapGetOptions = {}, ): Promise<string | undefined> { const signed = options.signed ?? !!this.#keyRing; const nameSig = `${key}.sig`;
const header = this[requestHeaders].get("cookie"); if (!header) { return; } const match = header.match(getPattern(key)); if (!match) { return; } const [, value] = match; if (!signed) { return value; } const digest = await this.get(nameSig, { signed: false }); if (!digest) { return; } const data = `${key}=${value}`; if (!this.#keyRing) { throw new TypeError("key ring required for signed cookies"); } const index = await this.#keyRing.indexOf(data, digest);
if (index < 0) { await this.delete(nameSig, { path: "/", signed: false }); } else { if (index) { await this.set(nameSig, await this.#keyRing.sign(data), { signed: false, }); } return value; } }
/** Returns `true` if the key is in the {@linkcode Request}. * * If the cookie is signed, and the signature is invalid, `false` will be * returned and the cookie will be set to be deleted in the response. If the * cookie is using an "old" key from the keyring, the cookie will be re-signed * with the current key and be added to the response to be updated. */ async has( key: string, options: SecureCookieMapGetOptions = {}, ): Promise<boolean> { const signed = options.signed ?? !!this.#keyRing; const nameSig = `${key}.sig`;
const header = this[requestHeaders].get("cookie"); if (!header) { return false; } const match = header.match(getPattern(key)); if (!match) { return false; } if (!signed) { return true; } const digest = await this.get(nameSig, { signed: false }); if (!digest) { return false; } const [, value] = match; const data = `${key}=${value}`; if (!this.#keyRing) { throw new TypeError("key ring required for signed cookies"); } const index = await this.#keyRing.indexOf(data, digest);
if (index < 0) { await this.delete(nameSig, { path: "/", signed: false }); return false; } else { if (index) { await this.set(nameSig, await this.#keyRing.sign(data), { signed: false, }); } return true; } }
/** Set a cookie in the response headers. * * If there was a keyring set, cookies will be automatically signed, unless * overridden by the passed options. Cookies can be deleted by setting the * value to `null`. */ async set( key: string, value: string | null, options: SecureCookieMapSetDeleteOptions = {}, ): Promise<this> { const resHeaders = this[responseHeaders]; const headers: string[] = []; for (const [key, value] of resHeaders.entries()) { if (key === "set-cookie") { headers.push(value); } } const secure = this[isSecure]; const signed = options.signed ?? !!this.#keyRing;
if (!secure && options.secure && !options.ignoreInsecure) { throw new TypeError( "Cannot send secure cookie over unencrypted connection.", ); }
const cookie = new Cookie(key, value, options); cookie.secure = options.secure ?? secure; pushCookie(headers, cookie);
if (signed) { if (!this.#keyRing) { throw new TypeError("keys required for signed cookies."); } cookie.value = await this.#keyRing.sign(cookie.toString()); cookie.name += ".sig"; pushCookie(headers, cookie); }
resHeaders.delete("set-cookie"); for (const header of headers) { resHeaders.append("set-cookie", header); } return this; }
/** Iterate over the {@linkcode Request} cookies, yielding up a tuple * containing the key and value of each cookie. * * If a key ring was provided, only properly signed cookie keys and values are * returned. */ entries(): AsyncIterableIterator<[string, string]> { return this[Symbol.asyncIterator](); }
/** Iterate over the request's cookies, yielding up the key of each cookie. * * If a keyring was provided, only properly signed cookie keys are * returned. */ async *keys(): AsyncIterableIterator<string> { for await (const [key] of this) { yield key; } }
/** Iterate over the request's cookies, yielding up the value of each cookie. * * If a keyring was provided, only properly signed cookie values are * returned. */ async *values(): AsyncIterableIterator<string> { for await (const [, value] of this) { yield value; } }
/** Iterate over the {@linkcode Request} cookies, yielding up a tuple * containing the key and value of each cookie. * * If a key ring was provided, only properly signed cookie keys and values are * returned. */ async *[Symbol.asyncIterator](): AsyncIterableIterator<[string, string]> { const keys = this[requestKeys](); for (const key of keys) { const value = await this.get(key); if (value) { yield [key, value]; } } }}