111 lines
3.2 KiB
TypeScript
111 lines
3.2 KiB
TypeScript
/**
|
|
* MWT / JWT decode — HS256 verification, cross-language parity with
|
|
* cores/mizan-python/src/mizan_core/mwt.py.
|
|
*
|
|
* Returns null on ANY failure (bad signature, expired, future nbf, wrong
|
|
* aud, malformed). Never throws.
|
|
*/
|
|
|
|
import { createHmac, timingSafeEqual } from 'crypto'
|
|
import type { Identity } from './identity'
|
|
|
|
export interface MwtPayload {
|
|
sub: string
|
|
staff: boolean
|
|
super: boolean
|
|
pkey: string
|
|
kid: string
|
|
aud: string
|
|
iat: number
|
|
exp: number
|
|
}
|
|
|
|
function base64urlDecode(input: string): Buffer | null {
|
|
if (!/^[A-Za-z0-9_-]*$/.test(input)) return null
|
|
return Buffer.from(input, 'base64url')
|
|
}
|
|
|
|
function constantTimeEqual(a: Buffer, b: Buffer): boolean {
|
|
if (a.length !== b.length) return false
|
|
return timingSafeEqual(a, b)
|
|
}
|
|
|
|
/**
|
|
* Decode and validate an MWT (HS256 JWT with Mizan claims).
|
|
* Returns MwtPayload on success, null on any failure.
|
|
*/
|
|
export function decodeMwt(
|
|
token: string,
|
|
secret: string,
|
|
audience: string = 'mizan',
|
|
): MwtPayload | null {
|
|
try {
|
|
const parts = token.split('.')
|
|
if (parts.length !== 3) return null
|
|
const [headerB64, payloadB64, signatureB64] = parts
|
|
|
|
const headerBytes = base64urlDecode(headerB64)
|
|
const payloadBytes = base64urlDecode(payloadB64)
|
|
const signatureBytes = base64urlDecode(signatureB64)
|
|
if (!headerBytes || !payloadBytes || !signatureBytes) return null
|
|
|
|
const header = JSON.parse(headerBytes.toString('utf-8'))
|
|
if (header.alg !== 'HS256') return null
|
|
|
|
// Recompute HMAC over `${headerB64}.${payloadB64}`
|
|
const expected = createHmac('sha256', secret)
|
|
.update(`${headerB64}.${payloadB64}`)
|
|
.digest()
|
|
if (!constantTimeEqual(expected, signatureBytes)) return null
|
|
|
|
const data = JSON.parse(payloadBytes.toString('utf-8'))
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
if (typeof data.exp !== 'number' || data.exp <= now) return null
|
|
if (data.nbf !== undefined && typeof data.nbf === 'number' && data.nbf > now) return null
|
|
if (data.aud !== audience) return null
|
|
|
|
const kid = typeof header.kid === 'string' ? header.kid : 'v1'
|
|
|
|
return {
|
|
sub: String(data.sub),
|
|
staff: Boolean(data.staff),
|
|
super: Boolean(data.super),
|
|
pkey: typeof data.pkey === 'string' ? data.pkey : '',
|
|
kid,
|
|
aud: audience,
|
|
iat: data.iat,
|
|
exp: data.exp,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode a Bearer JWT from an Authorization header value.
|
|
* Strips the "Bearer " prefix, then validates as an MWT.
|
|
*/
|
|
export function decodeJwtBearer(
|
|
authHeader: string,
|
|
secret: string,
|
|
audience: string = 'mizan',
|
|
): MwtPayload | null {
|
|
if (!authHeader) return null
|
|
const prefix = 'Bearer '
|
|
const token = authHeader.startsWith(prefix)
|
|
? authHeader.slice(prefix.length)
|
|
: authHeader
|
|
return decodeMwt(token, secret, audience)
|
|
}
|
|
|
|
/** Build an Identity from a decoded MWT payload. */
|
|
export function identityFromMwt(payload: MwtPayload): Identity {
|
|
return {
|
|
isAuthenticated: true,
|
|
isStaff: payload.staff,
|
|
isSuperuser: payload.super,
|
|
id: Number(payload.sub),
|
|
}
|
|
}
|