From 68f80be0dd76190ea795ff7630feab09ee81c56d Mon Sep 17 00:00:00 2001 From: James Brumond Date: Sat, 26 Aug 2023 20:46:59 -0700 Subject: [PATCH] start building base utilities --- package-lock.json | 7 ++ package.json | 1 + readme.md | 139 ++++++++++++++++++++++++++++++++++++- src/cookies.ts | 42 +++++++++++ src/index.ts | 6 +- src/pkce-cookie.ts | 62 +++++++++++++++++ src/random-bytes.ts | 21 ++++++ src/session-credentials.ts | 79 +++++++++++++++++++++ src/session-token.ts | 50 +++++++++++++ 9 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 src/cookies.ts create mode 100644 src/pkce-cookie.ts create mode 100644 src/random-bytes.ts create mode 100644 src/session-credentials.ts create mode 100644 src/session-token.ts diff --git a/package-lock.json b/package-lock.json index 9942ccb..2fb9599 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,16 @@ "version": "0.1.0", "license": "ISC", "devDependencies": { + "@types/node": "^20.5.6", "typescript": "^5.1.3" } }, + "node_modules/@types/node": { + "version": "20.5.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.6.tgz", + "integrity": "sha512-Gi5wRGPbbyOTX+4Y2iULQ27oUPrefaB0PxGQJnfyWN3kvEDGM3mIB5M/gQLmitZf7A9FmLeaqxD3L1CXpm3VKQ==", + "dev": true + }, "node_modules/typescript": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", diff --git a/package.json b/package.json index 96138a0..1d410d5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "author": "James Brumond ", "license": "ISC", "devDependencies": { + "@types/node": "^20.5.6", "typescript": "^5.1.3" } } diff --git a/readme.md b/readme.md index ccdd95e..55f848c 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,143 @@ npm install --save-dev @js/types ## Usage + + +### Setup PKCE Provider + ```ts -import { } from '@js/oidc-login'; +import { create_pkce_cookie_provider } from '@js/oidc-login'; + +const pkce = create_pkce_cookie_provider({ + // Cookie name + name: 'pkce_verifier_code', + + // Use "Secure" cookie directive (only send over HTTPS)? + secure: true, + + // Cookie time-to-live (in seconds) + ttl: 300, + + // Number of bytes of random data to generate for the verification + // code (more is stronger, must be in range 32-96) + code_bytes: 48, +}); ``` + + + +### Preparing a PKCE Challenge + +```ts +const { + // This is a `Set-Cookie` header value containing the PKCE verifier + // code that should be sent to the client when redirecting to the + // OAuth2 provider to start the login process + set_cookie_header, + + // This is the PKCE challenge code that should be sent to the OAuth2 + // provider as a parameter in the redirect + pkce_code_challenge, +} = await pkce.prepare_pkce_challenge(); +``` + + + +### Verifying a PKCE Challenge + +```ts +// Retrieve the PKCE verifier from the cookie sent back by the client. +// This should be sent to the OAuth2 provider when fetching the token set +// in the login callback +const pkce_code_verifier = pkce.read_pkce_code_verifier(req.headers.cookie); + +// When responding to the login callback request, send the invalidator cookie +// to delete the previous set PKCE cookie +res.setHeader('set-cookie', pkce.invalidate_cookie); +``` + + + +### Setup Session Credentials Provider + +```ts +import { create_session_credentials_provider } from '@js/oidc-login'; + +const creds = create_session_credentials_provider({ + // Cookie name + name: 'session_token', + + // Use "Secure" cookie directive (only send over HTTPS)? + secure: true, + + // Cookie time-to-live (in seconds) + ttl: 3600, + + // Cryptographic hashing function to use for stored secrets + hash(secret: string) { + // SEE BELOW + return ''; + }, + + // Verification function for the hashing function above + verify_hash(secret: string, hashed_secret: string) { + // SEE BELOW + return true; + }, +}); +``` + +#### Using argon2 hashing + +```bash +npm install --save argon2 +``` + +```ts +import { hash, verify, argon2id } from 'argon2'; +import { create_session_credentials_provider } from '@js/oidc-login'; + +const creds = create_session_credentials_provider({ + name: 'session_token', + secure: true, + ttl: 3600, + hash(secret: string) { + return hash(password, { + type: argon2id, + // hash options... + }); + }, + verify_hash(secret: string, hashed_secret: string) { + return verify(hashed_secret, secret); + }, +}); +``` + + + +### Creating New Sessions + +```ts +const { + // This is a `Set-Cookie` header value containing the new session token + // that can be sent to a client to finish a web login process + set_cookie_header, + + // The hashed secret that should be stored for later verification + hashed_secret, + + // The session token details + session_token: { + // Unique prefix string that can be used to lookup the session + // for later verification + prefix, + + // The secret value generated for token + secret, + + // The complete, raw token string + token, + }, +} = await creds.prepare_new_session_credentials(); +``` + diff --git a/src/cookies.ts b/src/cookies.ts new file mode 100644 index 0000000..3a90285 --- /dev/null +++ b/src/cookies.ts @@ -0,0 +1,42 @@ + +export type SameSite = 'Strict' | 'Lax' | 'None'; + +export function set_cookie(name: string, value: string, expires: Date, secure: boolean, same_site: SameSite = 'Strict', path?: string) { + const cookie + = `${name}=${value}; ` + + `Expires=${expires.toUTCString()}; ` + + (path ? ` Path=${path}; ` : '') + + `HttpOnly; ` + + `SameSite=${same_site};` + + (secure ? ' Secure;' : '') + ; + + return cookie; +} + +export function invalidate_cookie(name: string, secure: boolean, path?: string) { + return set_cookie(name, 'invalidate', new Date(0), secure, 'Strict', path); +} + +export function parse_req_cookies(cookies: string) : Record { + const result: Record = { }; + + if (! cookies) { + return result; + } + + for (const cookie of cookies.split(';')) { + const index = cookie.indexOf('='); + + if (index < 0) { + continue; + } + + const name = cookie.slice(0, index).trim(); + const value = cookie.slice(index + 1).trim(); + + result[name] = decodeURIComponent(value); + } + + return result; +} diff --git a/src/index.ts b/src/index.ts index 4fa933d..1795398 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export function hello() : string { - return 'hello'; -} +export * from './pkce-cookie'; +export * from './session-credentials'; +export * from './session-token'; diff --git a/src/pkce-cookie.ts b/src/pkce-cookie.ts new file mode 100644 index 0000000..c76aea2 --- /dev/null +++ b/src/pkce-cookie.ts @@ -0,0 +1,62 @@ + +import { createHash } from 'crypto'; +import { random_bytes } from './random-bytes'; +import { invalidate_cookie, parse_req_cookies, set_cookie } from './cookies'; + +export interface PKCECookieConfig { + /** + * The name of the cookie to store the PKCE code challenge in + */ + name: string; + + /** + * Should the cookie only be sent over secure connections? + */ + secure: boolean; + + /** + * Time-to-live (in seconds) for the PKCE code challenge cookie + */ + ttl: number; + + /** + * Number of bytes of random data to generate for the verification + * code (more is stronger, must be in range 32-96) + */ + code_bytes: number; +} + +export type PKCECookieProvider = ReturnType; + +export function create_pkce_cookie_provider(conf: PKCECookieConfig) { + return Object.freeze({ + async prepare_pkce_challenge() { + const pkce_code_verifier = await random_bytes(conf.code_bytes, 'base64url'); + const pkce_code_challenge = sha256_base64_url(pkce_code_verifier); + const pkce_expire = new Date(Date.now() + (conf.ttl * 1000)); + + // "Lax" rather than "Strict", so the PKCE verifier will be included on the + // redirect to the login callback endpoint, which comes from the OpenID server, + // which is likely on a different site + const set_cookie_header = set_cookie(conf.name, pkce_code_verifier, pkce_expire, conf.secure, 'Lax'); + + return { + set_cookie_header, + pkce_code_challenge, + }; + }, + + read_pkce_code_verifier(cookies_header: string) { + const cookies = parse_req_cookies(cookies_header); + return cookies[conf.name]; + }, + + invalidate_cookie: invalidate_cookie(conf.name, conf.secure), + }); +} + +function sha256_base64_url(input: string) { + const hash = createHash('sha256'); + hash.update(input); + return hash.digest('base64url'); +} diff --git a/src/random-bytes.ts b/src/random-bytes.ts new file mode 100644 index 0000000..1f6cc1b --- /dev/null +++ b/src/random-bytes.ts @@ -0,0 +1,21 @@ + +import { randomBytes } from 'crypto'; + +export type BufferEncoded = T extends 'binary' ? Buffer : string; + +export function random_bytes(size: number, encoding?: T) : Promise> { + return new Promise>((resolve, reject) => { + randomBytes(size, (error, buffer) => { + if (error) { + return reject(error); + } + + if (encoding && encoding !== 'binary') { + resolve(buffer.toString(encoding) as BufferEncoded); + return; + } + + resolve(buffer as BufferEncoded); + }); + }); +} diff --git a/src/session-credentials.ts b/src/session-credentials.ts new file mode 100644 index 0000000..fbf9da9 --- /dev/null +++ b/src/session-credentials.ts @@ -0,0 +1,79 @@ + +import { SessionToken, generate_session_token, parse_session_token } from './session-token'; +import { invalidate_cookie, parse_req_cookies, set_cookie } from './cookies'; + +export interface SesionCookieConfig { + /** + * The name of the cookie to store the session token in + */ + name: string; + + /** + * Should the cookie only be sent over secure connections? + */ + secure: boolean; + + /** + * Time-to-live (in seconds) for the session token cookie + */ + ttl: number; + + /** + * Cryptographic hashing function to use to protect token secrets + * while stored. Good choices would be argon2, bcrypt, scrypt + */ + hash(secret: string) : string | Promise; + + /** + * Verification function for the provided hashing function + */ + verify_hash(secret: string, hashed_secret: string) : boolean | Promise; +} + +export interface ReadSessionCredentials { + bearer?: SessionToken; + cookie?: SessionToken; +} + +export interface SessionTokenWithSource extends SessionToken { + from: 'bearer' | 'cookie'; +} + +export type SessionCredentialProvider = ReturnType; + +export function create_session_credentials_provider(conf: SesionCookieConfig) { + return Object.freeze({ + async prepare_new_session_credentials() { + const session_token = await generate_session_token(); + const hashed_secret = await conf.hash(session_token.secret); + const session_expire = new Date(Date.now() + (conf.ttl * 1000)); + const set_cookie_header = set_cookie(conf.name, session_token.token, session_expire, conf.secure, 'Strict'); + + return { + set_cookie_header, + session_token, + hashed_secret, + }; + }, + + read_session_credentials(authorization_header: string, cookies_header: string) : SessionTokenWithSource { + if (authorization_header?.startsWith('Bearer ')) { + const token = parse_session_token(authorization_header.slice(7)); + return Object.assign(token, { from: 'bearer' as const }); + } + + const cookies = parse_req_cookies(cookies_header); + + if (cookies[conf.name]) { + const token = parse_session_token(cookies[conf.name]); + return Object.assign(token, { from: 'cookie' as const }); + } + }, + + invalidate_cookie: invalidate_cookie(conf.name, conf.secure), + + async verify_session_credentials(token: SessionToken, hashed_secret: string) : Promise { + return conf.verify_hash(token.secret, hashed_secret); + }, + }); +} diff --git a/src/session-token.ts b/src/session-token.ts new file mode 100644 index 0000000..7aea013 --- /dev/null +++ b/src/session-token.ts @@ -0,0 +1,50 @@ + +import { random_bytes } from './random-bytes'; + +/** + * + */ +export interface SessionToken { + /** + * The non-secret segment of the token; This should be used as an + * identifier for the session, and as such will likely be stored + * in plain-text as part of an index. This is a 12-byte, base64url + * encoded string (16 characters encoded) + */ + prefix: string; + + /** + * The secret segment of the token; This should be hashed and stored + * for later validation, similar to how you would treat a password. This + * is a 36-byte, base64url encoded string (48 characters encoded) + */ + secret: string; + + /** + * The full token value to be provided to the client in a cookie; A + * concatenation of the prefix and key segments. This string is 65 + * character long string (16 char prefix + 48 char secret + 1 char + * period ["."] delimiter) + */ + token: string; +} + +export async function generate_session_token() : Promise { + const bytes = await random_bytes(48); + const base64 = bytes.toString('base64url'); + const prefix = base64.slice(0, 16); + const secret = base64.slice(16); + const token = `${prefix}.${secret}`; + + return { prefix, secret, token }; +} + +export function parse_session_token(token: string) : SessionToken { + const [ prefix, secret, ...rest ] = token.split('.'); + + if (rest && rest.length || prefix?.length !== 16 || secret?.length !== 48) { + throw new Error('invalid session token'); + } + + return { prefix, secret, token }; +}