82 lines
2.6 KiB
TypeScript
82 lines
2.6 KiB
TypeScript
/**
|
|
* Runtime-neutral ACCESS_CODE session policy shared by Node routes and the
|
|
* Edge-compatible middleware verifier. Keep this module free of Node APIs.
|
|
*/
|
|
|
|
export const ACCESS_CODE_COOKIE_NAME = 'openmaic_access';
|
|
export const DEFAULT_ACCESS_CODE_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|
|
|
const MAX_ACCESS_CODE_SESSION_TTL_SECONDS = 60 * 60 * 24 * 365;
|
|
const HMAC_SHA256_HEX_LENGTH = 64;
|
|
|
|
export interface AccessTokenValidationOptions {
|
|
nowMs?: number;
|
|
ttlSeconds?: number;
|
|
}
|
|
|
|
export interface ParsedAccessToken {
|
|
issuedAtMs: number;
|
|
issuedAtText: string;
|
|
signatureHex: string;
|
|
}
|
|
|
|
export function resolveAccessCodeSessionTtlSeconds(value: string | undefined): number {
|
|
if (!value) return DEFAULT_ACCESS_CODE_SESSION_TTL_SECONDS;
|
|
|
|
const parsed = Number(value);
|
|
if (
|
|
!Number.isSafeInteger(parsed) ||
|
|
parsed <= 0 ||
|
|
parsed > MAX_ACCESS_CODE_SESSION_TTL_SECONDS
|
|
) {
|
|
return DEFAULT_ACCESS_CODE_SESSION_TTL_SECONDS;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function parseAccessToken(token: string): ParsedAccessToken | null {
|
|
const dotIndex = token.indexOf('.');
|
|
if (dotIndex <= 0 || dotIndex !== token.lastIndexOf('.')) return null;
|
|
|
|
const issuedAtText = token.slice(0, dotIndex);
|
|
const signatureHex = token.slice(dotIndex + 1);
|
|
if (!/^\d+$/.test(issuedAtText)) return null;
|
|
if (!new RegExp(`^[0-9a-f]{${HMAC_SHA256_HEX_LENGTH}}$`, 'i').test(signatureHex)) return null;
|
|
|
|
const issuedAtMs = Number(issuedAtText);
|
|
if (!Number.isSafeInteger(issuedAtMs) || issuedAtMs < 0) return null;
|
|
|
|
return { issuedAtMs, issuedAtText, signatureHex };
|
|
}
|
|
|
|
/**
|
|
* Parse a token and reject sessions issued in the future or whose TTL has
|
|
* elapsed. Expiration is exclusive: a token is invalid at issuedAt + TTL.
|
|
*/
|
|
export function parseFreshAccessToken(
|
|
token: string,
|
|
options: AccessTokenValidationOptions = {},
|
|
): ParsedAccessToken | null {
|
|
const parsed = parseAccessToken(token);
|
|
if (!parsed) return null;
|
|
|
|
const nowMs = options.nowMs ?? Date.now();
|
|
const ttlSeconds = options.ttlSeconds ?? DEFAULT_ACCESS_CODE_SESSION_TTL_SECONDS;
|
|
const ttlMs = ttlSeconds * 1000;
|
|
|
|
if (!Number.isFinite(nowMs) || !Number.isSafeInteger(ttlMs) || ttlMs <= 0) return null;
|
|
if (parsed.issuedAtMs > nowMs) return null;
|
|
if (nowMs - parsed.issuedAtMs >= ttlMs) return null;
|
|
|
|
return parsed;
|
|
}
|
|
|
|
export function hexToBytes(hex: string): Uint8Array | null {
|
|
if (hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null;
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|