generated from templates/typescript-library
	start building base utilities
This commit is contained in:
		
							
								
								
									
										7
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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", | ||||
|   | ||||
| @@ -18,6 +18,7 @@ | ||||
|   "author": "James Brumond <https://jbrumond.me>", | ||||
|   "license": "ISC", | ||||
|   "devDependencies": { | ||||
|     "@types/node": "^20.5.6", | ||||
|     "typescript": "^5.1.3" | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										139
									
								
								readme.md
									
									
									
									
									
								
							
							
						
						
									
										139
									
								
								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 '<hashed secret>'; | ||||
| 	}, | ||||
|  | ||||
| 	// 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(); | ||||
| ``` | ||||
|  | ||||
|   | ||||
							
								
								
									
										42
									
								
								src/cookies.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/cookies.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string, string> { | ||||
| 	const result: Record<string, string> = { }; | ||||
|  | ||||
| 	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; | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
|  | ||||
| export function hello() : string { | ||||
| 	return 'hello'; | ||||
| } | ||||
| export * from './pkce-cookie'; | ||||
| export * from './session-credentials'; | ||||
| export * from './session-token'; | ||||
|   | ||||
							
								
								
									
										62
									
								
								src/pkce-cookie.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/pkce-cookie.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<typeof create_pkce_cookie_provider>; | ||||
|  | ||||
| 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'); | ||||
| } | ||||
							
								
								
									
										21
									
								
								src/random-bytes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/random-bytes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
|  | ||||
| import { randomBytes } from 'crypto'; | ||||
|  | ||||
| export type BufferEncoded<T extends BufferEncoding> = T extends 'binary' ? Buffer : string; | ||||
|  | ||||
| export function random_bytes<T extends BufferEncoding = 'binary'>(size: number, encoding?: T) : Promise<BufferEncoded<T>> { | ||||
| 	return new Promise<BufferEncoded<T>>((resolve, reject) => { | ||||
| 		randomBytes(size, (error, buffer) => { | ||||
| 			if (error) { | ||||
| 				return reject(error); | ||||
| 			} | ||||
|  | ||||
| 			if (encoding && encoding !== 'binary') { | ||||
| 				resolve(buffer.toString(encoding) as BufferEncoded<T>); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			resolve(buffer as BufferEncoded<T>); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										79
									
								
								src/session-credentials.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/session-credentials.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string>; | ||||
|  | ||||
| 	/** | ||||
| 	 * Verification function for the provided hashing function | ||||
| 	 */ | ||||
| 	verify_hash(secret: string, hashed_secret: string) : boolean | Promise<boolean>; | ||||
| } | ||||
|  | ||||
| export interface ReadSessionCredentials { | ||||
| 	bearer?: SessionToken; | ||||
| 	cookie?: SessionToken; | ||||
| } | ||||
|  | ||||
| export interface SessionTokenWithSource extends SessionToken { | ||||
| 	from: 'bearer' | 'cookie'; | ||||
| } | ||||
|  | ||||
| export type SessionCredentialProvider = ReturnType<typeof create_session_credentials_provider>; | ||||
|  | ||||
| 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<boolean> { | ||||
| 			return conf.verify_hash(token.secret, hashed_secret); | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										50
									
								
								src/session-token.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/session-token.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<SessionToken> { | ||||
| 	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 }; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user