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",
|
"version": "0.1.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.5.6",
|
||||||
"typescript": "^5.1.3"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
"author": "James Brumond <https://jbrumond.me>",
|
"author": "James Brumond <https://jbrumond.me>",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.5.6",
|
||||||
"typescript": "^5.1.3"
|
"typescript": "^5.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
139
readme.md
139
readme.md
@ -19,6 +19,143 @@ npm install --save-dev @js/types
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Setup PKCE Provider
|
||||||
|
|
||||||
```ts
|
```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 {
|
export * from './pkce-cookie';
|
||||||
return 'hello';
|
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