generated from templates/typescript-library
start building base utilities
This commit is contained in:
parent
d6481527dd
commit
68f80be0dd
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 };
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user