You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/helpers/jwt.ts

109 lines
3.5 KiB

import crypto from 'node:crypto'
/**
* Minimal RS256 JWT signing and verification.
*
* Wiki.js generates an RSA keypair during installation and keeps it in `config.auth.certs`, so
* tokens are signed with that key rather than with a shared secret. Only the RS256 algorithm is
* accepted on the way in — a token asking for `none`, or for an HMAC algorithm that would turn the
* public key into a signing secret, is rejected outright.
*/
export interface JwtClaims {
[claim: string]: any
/** Audience. Compared against the expected one during verification. */
aud?: string
/** Expiry, in seconds since the epoch. Required by `verifyJwt`. */
exp?: number
/** Issued at, in seconds since the epoch. */
iat?: number
}
function encodeSegment(value: object): string {
return Buffer.from(JSON.stringify(value)).toString('base64url')
}
function decodeSegment(segment: string): any {
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'))
}
/** Seconds since the epoch, the unit JWT uses for `iat` / `exp`. */
export function epochSeconds(instant: Temporal.Instant = Temporal.Now.instant()): number {
return Math.floor(instant.epochMilliseconds / 1000)
}
/**
* Sign a set of claims.
*
* @param privateKey A key object, or a PEM string for an unencrypted key. The installation key is
* passphrase-protected, so callers pass a `KeyObject` built with the passphrase.
*/
export function signJwt(claims: JwtClaims, privateKey: crypto.KeyObject | string): string {
const payload = `${encodeSegment({ alg: 'RS256', typ: 'JWT' })}.${encodeSegment(claims)}`
const signature = crypto.sign('RSA-SHA256', Buffer.from(payload), privateKey)
return `${payload}.${signature.toString('base64url')}`
}
/**
* Verify a token and return its claims.
*
* Throws with a specific message on every failure — a malformed token, a bad signature, an expired
* token or the wrong audience — so callers can log the reason without inspecting the token again.
*/
export function verifyJwt(
token: string,
publicKey: crypto.KeyObject | string,
{ audience }: { audience?: string } = {}
): JwtClaims {
const segments = token.split('.')
if (segments.length !== 3) {
throw new Error('Token is malformed.')
}
const [encodedHeader, encodedClaims, encodedSignature] = segments as [string, string, string]
let header: any
let claims: JwtClaims
try {
header = decodeSegment(encodedHeader)
claims = decodeSegment(encodedClaims)
} catch {
throw new Error('Token is malformed.')
}
if (header?.alg !== 'RS256') {
throw new Error('Token algorithm is not supported.')
}
if (!claims || typeof claims !== 'object') {
throw new Error('Token is malformed.')
}
let isValid = false
try {
isValid = crypto.verify(
'RSA-SHA256',
Buffer.from(`${encodedHeader}.${encodedClaims}`),
publicKey,
Buffer.from(encodedSignature, 'base64url')
)
} catch {
// -> A signature that is not even well-formed lands here rather than returning false
isValid = false
}
if (!isValid) {
throw new Error('Token signature is invalid.')
}
// -> A token with no expiry would be valid forever; treat its absence as a failure rather than as
// permission to skip the check
if (typeof claims.exp !== 'number') {
throw new Error('Token has no expiration.')
}
if (epochSeconds() >= claims.exp) {
throw new Error('Token has expired.')
}
if (audience && claims.aud !== audience) {
throw new Error('Token audience does not match.')
}
return claims
}