mirror of
https://github.com/denoland/std.git
synced 2024-11-22 04:59:05 +00:00
466 lines
12 KiB
TypeScript
466 lines
12 KiB
TypeScript
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
|
|
// Structured similarly to Go's cookie.go
|
|
// https://github.com/golang/go/blob/master/src/net/http/cookie.go
|
|
// This module is browser compatible.
|
|
|
|
/**
|
|
* Represents an HTTP Cookie.
|
|
*
|
|
* @see {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-4.2.1}
|
|
*/
|
|
export interface Cookie {
|
|
/** Name of the cookie. */
|
|
name: string;
|
|
/** Value of the cookie. */
|
|
value: string;
|
|
/**
|
|
* The cookie's `Expires` attribute, either as an explicit date or UTC
|
|
* milliseconds. If `undefined`, the cookie will expire when the client's
|
|
* session ends.
|
|
*
|
|
* @example <caption>Explicit date:</caption>
|
|
*
|
|
* ```ts
|
|
* import { Cookie } from "@std/http/cookie";
|
|
* const cookie: Cookie = {
|
|
* name: 'name',
|
|
* value: 'value',
|
|
* // expires on Fri Dec 30 2022
|
|
* expires: new Date('2022-12-31')
|
|
* }
|
|
* ```
|
|
*
|
|
* @example <caption>UTC milliseconds</caption>
|
|
*
|
|
* ```ts
|
|
* import { Cookie } from "@std/http/cookie";
|
|
* const cookie: Cookie = {
|
|
* name: 'name',
|
|
* value: 'value',
|
|
* // expires 10 seconds from now
|
|
* expires: Date.now() + 10000
|
|
* }
|
|
* ```
|
|
*/
|
|
expires?: Date | number;
|
|
/**
|
|
* The cookie's `Max-Age` attribute, in seconds. Must be a non-negative
|
|
* integer. A cookie with a `maxAge` of `0` expires immediately.
|
|
*/
|
|
maxAge?: number;
|
|
/**
|
|
* The cookie's `Domain` attribute. Specifies those hosts to which the cookie
|
|
* will be sent.
|
|
*/
|
|
domain?: string;
|
|
/**
|
|
* The cookie's `Path` attribute. A cookie with a path will only be included
|
|
* in the `Cookie` request header if the requested URL matches that path.
|
|
*/
|
|
path?: string;
|
|
/**
|
|
* The cookie's `Secure` attribute. If `true`, the cookie will only be
|
|
* included in the `Cookie` request header if the connection uses SSL and
|
|
* HTTPS.
|
|
*
|
|
* @default {false}
|
|
*/
|
|
secure?: boolean;
|
|
/**
|
|
* The cookie's `HTTPOnly` attribute. If `true`, the cookie cannot be accessed via JavaScript.
|
|
*
|
|
* @default {false}
|
|
*/
|
|
httpOnly?: boolean;
|
|
/**
|
|
* The cookie's `Partitioned` attribute.
|
|
* If `true`, the cookie will be only be included in the `Cookie` request header if
|
|
* the domain it is embedded by matches the domain the cookie was originally set from.
|
|
*
|
|
* Warning: This is an attribute that has not been fully standardized yet.
|
|
* It may change in the future without following the semver semantics of the package.
|
|
* Clients may ignore the attribute until they understand it.
|
|
*
|
|
* @default {false}
|
|
*/
|
|
partitioned?: boolean;
|
|
/**
|
|
* Allows servers to assert that a cookie ought not to
|
|
* be sent along with cross-site requests.
|
|
*/
|
|
sameSite?: "Strict" | "Lax" | "None";
|
|
/**
|
|
* Additional key value pairs with the form "key=value".
|
|
*
|
|
* @default {[]}
|
|
*/
|
|
unparsed?: string[];
|
|
}
|
|
|
|
const FIELD_CONTENT_REGEXP = /^(?=[\x20-\x7E]*$)[^()@<>,;:\\"\[\]?={}\s]+$/;
|
|
|
|
function toString(cookie: Cookie): string {
|
|
if (!cookie.name) {
|
|
return "";
|
|
}
|
|
const out: string[] = [];
|
|
validateName(cookie.name);
|
|
validateValue(cookie.name, cookie.value);
|
|
out.push(`${cookie.name}=${cookie.value}`);
|
|
|
|
// Fallback for invalid Set-Cookie
|
|
// ref: https://www.rfc-editor.org/rfc/rfc6265.html#section-3.1
|
|
if (cookie.name.startsWith("__Secure")) {
|
|
cookie.secure = true;
|
|
}
|
|
if (cookie.name.startsWith("__Host")) {
|
|
cookie.path = "/";
|
|
cookie.secure = true;
|
|
delete cookie.domain;
|
|
}
|
|
|
|
if (cookie.secure) {
|
|
out.push("Secure");
|
|
}
|
|
if (cookie.httpOnly) {
|
|
out.push("HttpOnly");
|
|
}
|
|
if (cookie.partitioned) {
|
|
out.push("Partitioned");
|
|
}
|
|
if (typeof cookie.maxAge === "number" && Number.isInteger(cookie.maxAge)) {
|
|
if (cookie.maxAge < 0) {
|
|
throw new RangeError(
|
|
`Cannot serialize cookie as Max-Age must be >= 0: received ${cookie.maxAge}`,
|
|
);
|
|
}
|
|
out.push(`Max-Age=${cookie.maxAge}`);
|
|
}
|
|
if (cookie.domain) {
|
|
validateDomain(cookie.domain);
|
|
out.push(`Domain=${cookie.domain}`);
|
|
}
|
|
if (cookie.sameSite) {
|
|
out.push(`SameSite=${cookie.sameSite}`);
|
|
}
|
|
if (cookie.path) {
|
|
validatePath(cookie.path);
|
|
out.push(`Path=${cookie.path}`);
|
|
}
|
|
if (cookie.expires) {
|
|
const { expires } = cookie;
|
|
const date = typeof expires === "number" ? new Date(expires) : expires;
|
|
out.push(`Expires=${date.toUTCString()}`);
|
|
}
|
|
if (cookie.unparsed) {
|
|
out.push(cookie.unparsed.join("; "));
|
|
}
|
|
return out.join("; ");
|
|
}
|
|
|
|
/**
|
|
* Validate Cookie Name.
|
|
* @param name Cookie name.
|
|
*/
|
|
function validateName(name: string | undefined | null) {
|
|
if (name && !FIELD_CONTENT_REGEXP.test(name)) {
|
|
throw new SyntaxError(`Invalid cookie name: "${name}"`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Path Value.
|
|
* See {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1.2.4}.
|
|
* @param path Path value.
|
|
*/
|
|
function validatePath(path: string | null) {
|
|
if (path === null) {
|
|
return;
|
|
}
|
|
for (let i = 0; i < path.length; i++) {
|
|
const c = path.charAt(i);
|
|
if (
|
|
c < String.fromCharCode(0x20) || c > String.fromCharCode(0x7E) ||
|
|
c === ";"
|
|
) {
|
|
throw new SyntaxError(
|
|
`Cookie path "${path}" contains invalid character: "${c}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Cookie Value.
|
|
* See {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1}.
|
|
* @param value Cookie value.
|
|
*/
|
|
function validateValue(name: string, value: string | null) {
|
|
if (value === null) return;
|
|
for (let i = 0; i < value.length; i++) {
|
|
const c = value.charAt(i);
|
|
if (
|
|
c < String.fromCharCode(0x21) || c === String.fromCharCode(0x22) ||
|
|
c === String.fromCharCode(0x2c) || c === String.fromCharCode(0x3b) ||
|
|
c === String.fromCharCode(0x5c) || c === String.fromCharCode(0x7f)
|
|
) {
|
|
throw new SyntaxError(
|
|
"RFC2616 cookie '" + name + "' cannot contain character '" + c + "'",
|
|
);
|
|
}
|
|
if (c > String.fromCharCode(0x80)) {
|
|
throw new SyntaxError(
|
|
"RFC2616 cookie '" + name +
|
|
"' can only have US-ASCII chars as value: It contains 0x" +
|
|
c.charCodeAt(0).toString(16),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Cookie Domain.
|
|
* See {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1.2.3}.
|
|
* @param domain Cookie domain.
|
|
*/
|
|
function validateDomain(domain: string) {
|
|
const char1 = domain.charAt(0);
|
|
const charN = domain.charAt(domain.length - 1);
|
|
if (char1 === "-" || charN === "." || charN === "-") {
|
|
throw new SyntaxError(
|
|
"Invalid first/last char in cookie domain: " + domain,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse cookies of a header
|
|
*
|
|
* @example Usage
|
|
* ```ts
|
|
* import { getCookies } from "@std/http/cookie";
|
|
* import { assertEquals } from "@std/assert";
|
|
*
|
|
* const headers = new Headers();
|
|
* headers.set("Cookie", "full=of; tasty=chocolate");
|
|
*
|
|
* const cookies = getCookies(headers);
|
|
* assertEquals(cookies, { full: "of", tasty: "chocolate" });
|
|
* ```
|
|
*
|
|
* @param headers The headers instance to get cookies from
|
|
* @return Object with cookie names as keys
|
|
*/
|
|
export function getCookies(headers: Headers): Record<string, string> {
|
|
const cookie = headers.get("Cookie");
|
|
if (cookie !== null) {
|
|
const out: Record<string, string> = {};
|
|
const c = cookie.split(";");
|
|
for (const kv of c) {
|
|
const [cookieKey, ...cookieVal] = kv.split("=");
|
|
if (cookieKey === undefined) {
|
|
throw new SyntaxError("Cookie cannot start with '='");
|
|
}
|
|
const key = cookieKey.trim();
|
|
out[key] = cookieVal.join("=");
|
|
}
|
|
return out;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Set the cookie header properly in the headers
|
|
*
|
|
* @example Usage
|
|
* ```ts
|
|
* import { Cookie, setCookie } from "@std/http/cookie";
|
|
* import { assertEquals } from "@std/assert";
|
|
*
|
|
* const headers = new Headers();
|
|
* const cookie: Cookie = { name: "Space", value: "Cat" };
|
|
* setCookie(headers, cookie);
|
|
*
|
|
* const cookieHeader = headers.get("set-cookie");
|
|
*
|
|
* assertEquals(cookieHeader, "Space=Cat");
|
|
* ```
|
|
*
|
|
* @param headers The headers instance to set the cookie to
|
|
* @param cookie Cookie to set
|
|
*/
|
|
export function setCookie(headers: Headers, cookie: Cookie) {
|
|
// Parsing cookie headers to make consistent set-cookie header
|
|
// ref: https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1.1
|
|
const v = toString(cookie);
|
|
if (v) {
|
|
headers.append("Set-Cookie", v);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the cookie header with empty value in the headers to delete it.
|
|
*
|
|
* The attributes (`path`, `domain`, `secure`, `httpOnly`, `partitioned`) need
|
|
* to match the values when the cookie was set.
|
|
*
|
|
* > Note: Deleting a `Cookie` will set its expiration date before now. Forcing
|
|
* > the browser to delete it.
|
|
*
|
|
* @example Usage
|
|
* ```ts
|
|
* import { deleteCookie } from "@std/http/cookie";
|
|
* import { assertEquals } from "@std/assert";
|
|
*
|
|
* const headers = new Headers();
|
|
* deleteCookie(headers, "deno");
|
|
*
|
|
* const cookieHeader = headers.get("set-cookie");
|
|
*
|
|
* assertEquals(cookieHeader, "deno=; Expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
|
* ```
|
|
*
|
|
* @param headers The headers instance to delete the cookie from
|
|
* @param name Name of cookie
|
|
* @param attributes Additional cookie attributes
|
|
*/
|
|
export function deleteCookie(
|
|
headers: Headers,
|
|
name: string,
|
|
attributes?: Pick<
|
|
Cookie,
|
|
"path" | "domain" | "secure" | "httpOnly" | "partitioned"
|
|
>,
|
|
) {
|
|
setCookie(headers, {
|
|
name: name,
|
|
value: "",
|
|
expires: new Date(0),
|
|
...attributes,
|
|
});
|
|
}
|
|
|
|
function parseSetCookie(value: string): Cookie | null {
|
|
const attrs = value
|
|
.split(";")
|
|
.map((attr) => {
|
|
const [key, ...values] = attr.trim().split("=");
|
|
return [key!, values.join("=")] as const;
|
|
});
|
|
|
|
if (!attrs[0]) {
|
|
return null;
|
|
}
|
|
|
|
const cookie: Cookie = {
|
|
name: attrs[0][0],
|
|
value: attrs[0][1],
|
|
};
|
|
|
|
for (const [key, value] of attrs.slice(1)) {
|
|
switch (key.toLocaleLowerCase()) {
|
|
case "expires":
|
|
cookie.expires = new Date(value);
|
|
break;
|
|
case "max-age":
|
|
cookie.maxAge = Number(value);
|
|
if (cookie.maxAge < 0) {
|
|
// deno-lint-ignore no-console
|
|
console.warn(
|
|
"Max-Age must be an integer superior or equal to 0. Cookie ignored.",
|
|
);
|
|
return null;
|
|
}
|
|
break;
|
|
case "domain":
|
|
cookie.domain = value;
|
|
break;
|
|
case "path":
|
|
cookie.path = value;
|
|
break;
|
|
case "secure":
|
|
cookie.secure = true;
|
|
break;
|
|
case "httponly":
|
|
cookie.httpOnly = true;
|
|
break;
|
|
case "samesite":
|
|
cookie.sameSite = value as NonNullable<Cookie["sameSite"]>;
|
|
break;
|
|
default:
|
|
if (!Array.isArray(cookie.unparsed)) {
|
|
cookie.unparsed = [];
|
|
}
|
|
cookie.unparsed.push([key, value].join("="));
|
|
}
|
|
}
|
|
if (cookie.name.startsWith("__Secure-")) {
|
|
/** This requirement is mentioned in https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie but not the RFC. */
|
|
if (!cookie.secure) {
|
|
// deno-lint-ignore no-console
|
|
console.warn(
|
|
"Cookies with names starting with `__Secure-` must be set with the secure flag. Cookie ignored.",
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
if (cookie.name.startsWith("__Host-")) {
|
|
if (!cookie.secure) {
|
|
// deno-lint-ignore no-console
|
|
console.warn(
|
|
"Cookies with names starting with `__Host-` must be set with the secure flag. Cookie ignored.",
|
|
);
|
|
return null;
|
|
}
|
|
if (cookie.domain !== undefined) {
|
|
// deno-lint-ignore no-console
|
|
console.warn(
|
|
"Cookies with names starting with `__Host-` must not have a domain specified. Cookie ignored.",
|
|
);
|
|
return null;
|
|
}
|
|
if (cookie.path !== "/") {
|
|
// deno-lint-ignore no-console
|
|
console.warn(
|
|
"Cookies with names starting with `__Host-` must have path be `/`. Cookie has been ignored.",
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
return cookie;
|
|
}
|
|
|
|
/**
|
|
* Parse set-cookies of a header
|
|
*
|
|
* @example Usage
|
|
* ```ts
|
|
* import { getSetCookies } from "@std/http/cookie";
|
|
* import { assertEquals } from "@std/assert";
|
|
*
|
|
* const headers = new Headers([
|
|
* ["Set-Cookie", "lulu=meow; Secure; Max-Age=3600"],
|
|
* ["Set-Cookie", "booya=kasha; HttpOnly; Path=/"],
|
|
* ]);
|
|
*
|
|
* const cookies = getSetCookies(headers);
|
|
*
|
|
* assertEquals(cookies[0], {
|
|
* name: "lulu",
|
|
* value: "meow",
|
|
* secure: true,
|
|
* maxAge: 3600
|
|
* });
|
|
* ```
|
|
*
|
|
* @param headers The headers instance to get set-cookies from
|
|
* @return List of cookies
|
|
*/
|
|
export function getSetCookies(headers: Headers): Cookie[] {
|
|
return headers.getSetCookie()
|
|
/** Parse each `set-cookie` header separately */
|
|
.map(parseSetCookie)
|
|
/** Skip empty cookies */
|
|
.filter(Boolean) as Cookie[];
|
|
}
|