start building base utilities
All checks were successful
Build and test / build-and-test (18.x) (push) Successful in 15s
Build and test / build-and-test (20.x) (push) Successful in 15s

This commit is contained in:
James Brumond 2023-08-26 20:46:59 -07:00
parent d6481527dd
commit 68f80be0dd
Signed by: james
GPG Key ID: E8F2FC44BAA3357A
9 changed files with 403 additions and 4 deletions

7
package-lock.json generated
View File

@ -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",

View File

@ -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
View File

@ -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
View 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;
}

View File

@ -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
View 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
View 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>);
});
});
}

View 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
View 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 };
}