160 lines
4.0 KiB
TypeScript
160 lines
4.0 KiB
TypeScript
import * as jose from 'jose'
|
|
import { JWTExpired } from 'jose/errors'
|
|
|
|
/**
|
|
* JWE Key Management Algorithm
|
|
*
|
|
* @see {@link https://github.com/panva/jose/issues/210#jwe-alg}
|
|
*/
|
|
const ALG = 'dir' // Direct Encryption Mode with a shared secret
|
|
|
|
/**
|
|
* JWE Content Encryption Algorithm
|
|
*
|
|
* @see {@link https://github.com/panva/jose/issues/210#jwe-enc}
|
|
*/
|
|
const ENC = 'A256GCM' // Requires a 256 bit (32 byte) secret
|
|
const BYTE_LENGTH = 32
|
|
/** Digest algorithm */
|
|
const HKDF_ALGORITHM: HkdfParams = {
|
|
hash: 'SHA-256',
|
|
/** Additional information to derive the encryption key */
|
|
info: new TextEncoder().encode('session jwe cek'),
|
|
name: 'HKDF',
|
|
salt: new Uint8Array(0),
|
|
}
|
|
|
|
async function hdkf(secret: string) {
|
|
const ikm = new TextEncoder().encode(secret)
|
|
const length = BYTE_LENGTH << 3
|
|
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits'])
|
|
return new Uint8Array(await crypto.subtle.deriveBits(HKDF_ALGORITHM, key, length))
|
|
}
|
|
|
|
export type EncryptionKey = jose.CryptoKey | jose.KeyObject | jose.JWK | Uint8Array
|
|
|
|
/**
|
|
* Create an encryption key from a shared secret or return the existing encryption key.
|
|
*/
|
|
export async function createEncryptionKey(secret: EncryptionKey | string): Promise<EncryptionKey> {
|
|
if (typeof secret === 'string') {
|
|
return hdkf(secret)
|
|
}
|
|
|
|
return secret
|
|
}
|
|
|
|
export interface CookiePayload extends jose.JWTPayload {}
|
|
|
|
export interface DecryptResult<Payload> extends Partial<jose.JWTDecryptResult<Payload>> {
|
|
/**
|
|
* Indicates that the JWT has expired.
|
|
*/
|
|
expired: jose.errors.JWTExpired | undefined
|
|
}
|
|
|
|
/**
|
|
* Decrypt and validate the JWE string
|
|
*/
|
|
export async function jweDecrypt<Payload>(
|
|
jwt: string,
|
|
key: EncryptionKey,
|
|
options?: jose.JWTDecryptOptions
|
|
): Promise<DecryptResult<Payload>> {
|
|
let expired
|
|
let result
|
|
|
|
try {
|
|
result = await jose.jwtDecrypt<Payload>(jwt, key, options)
|
|
} catch (error) {
|
|
if (error instanceof JWTExpired) {
|
|
expired = error
|
|
} else {
|
|
// Ignore other errors when decrypting the cookie, eg;
|
|
// when the cookie is invalid.
|
|
console.error(error)
|
|
}
|
|
}
|
|
|
|
return { expired, ...result }
|
|
}
|
|
|
|
/**
|
|
* Encrypt the cookie payload as a JWE string
|
|
*
|
|
* @returns the JWE string and the max age of the session cookie.
|
|
*/
|
|
export async function jweEncrypt(
|
|
payload: CookiePayload,
|
|
key: EncryptionKey,
|
|
duration?: MaxAgeDuration
|
|
): Promise<{ jwe: string; maxAge?: number }> {
|
|
const now = epoch()
|
|
const iat = payload.iat ?? now
|
|
const jwt = new jose.EncryptJWT(payload)
|
|
.setIssuedAt(iat)
|
|
.setProtectedHeader({ enc: ENC, alg: ALG })
|
|
|
|
let maxAge
|
|
|
|
if (duration) {
|
|
const exp = calculateExpiration(iat, now, duration)
|
|
maxAge = Math.max(0, exp - now)
|
|
jwt.setExpirationTime(exp)
|
|
}
|
|
|
|
const jwe = await jwt.encrypt(key)
|
|
|
|
return { jwe, maxAge }
|
|
}
|
|
|
|
/**
|
|
* Generates a random byte hex string, encoded with base64.
|
|
*
|
|
* See [Generating random values](https://thecopenhagenbook.com/random-values)
|
|
*/
|
|
export function generateId(length = 20): string {
|
|
const bytes = new Uint8Array(length)
|
|
crypto.getRandomValues(bytes)
|
|
// TODO: return bytes.toBase64()
|
|
return btoa(Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''))
|
|
}
|
|
|
|
/**
|
|
* Time since unix epoch in seconds.
|
|
*/
|
|
export function epoch(date: Date = new Date()): number {
|
|
return Math.floor(date.getTime() / 1000)
|
|
}
|
|
|
|
export interface MaxAgeDuration {
|
|
/**
|
|
* Duration a session will be valid for,
|
|
* after which it will have to be re-authenticated.
|
|
*/
|
|
absolute: number
|
|
|
|
/**
|
|
* Duration a session will be considered active,
|
|
* during which the session max age can be extended.
|
|
*/
|
|
inactivity?: number
|
|
}
|
|
|
|
/**
|
|
* Calculate the expiration of the session cookie.
|
|
*
|
|
* Either the:
|
|
* - last updated time + inactivity duration
|
|
* - created time + absolute duration
|
|
*
|
|
* whichever is sooner
|
|
*/
|
|
function calculateExpiration(createdAt: number, updatedAt: number, duration: MaxAgeDuration) {
|
|
if (duration.inactivity === undefined) {
|
|
return createdAt + duration.absolute
|
|
}
|
|
|
|
return Math.min(updatedAt + duration.inactivity, createdAt + duration.absolute)
|
|
}
|