Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
hashPasswordArgon2id,
|
||||
randomToken,
|
||||
sha256Token,
|
||||
signAccessToken,
|
||||
verifyAccessToken,
|
||||
verifyPasswordArgon2id
|
||||
} from './index.js';
|
||||
|
||||
describe('auth primitives', () => {
|
||||
it('hashes and verifies Argon2id passwords with PHC metadata', async () => {
|
||||
const secret = crypto.randomUUID();
|
||||
const hash = await hashPasswordArgon2id(secret, {
|
||||
salt: Buffer.alloc(16, 7),
|
||||
memoryKiB: 1024,
|
||||
passes: 1
|
||||
});
|
||||
|
||||
expect(hash).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/);
|
||||
await expect(verifyPasswordArgon2id(secret, hash)).resolves.toBe(true);
|
||||
await expect(verifyPasswordArgon2id(crypto.randomUUID(), hash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('hashes refresh tokens without retaining token material', () => {
|
||||
const token = randomToken();
|
||||
const digest = sha256Token(token);
|
||||
|
||||
expect(token).not.toContain(digest);
|
||||
expect(digest).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('signs and verifies expiring access tokens', () => {
|
||||
const secret = 'test-only-access-token-secret-min-32-bytes';
|
||||
const token = signAccessToken(
|
||||
{
|
||||
sub: 'usr_test',
|
||||
username: 'operator',
|
||||
roles: ['admin'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret,
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 60,
|
||||
now: new Date('2026-06-21T00:00:00.000Z')
|
||||
}
|
||||
);
|
||||
|
||||
expect(
|
||||
verifyAccessToken(token, {
|
||||
secret,
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
now: new Date('2026-06-21T00:00:30.000Z')
|
||||
})?.sub
|
||||
).toBe('usr_test');
|
||||
|
||||
expect(
|
||||
verifyAccessToken(token, {
|
||||
secret,
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
now: new Date('2026-06-21T00:02:00.000Z')
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
export const AUTH_COOKIE_NAME = 'lisglosips_session';
|
||||
export const REFRESH_COOKIE_NAME = 'lisglosips_refresh';
|
||||
export const ACCESS_TOKEN_TYPE = 'Bearer';
|
||||
export const PASSWORD_ALGO_ARGON2ID = 'argon2id';
|
||||
|
||||
export type PermissionKey =
|
||||
| 'dashboard.view'
|
||||
| 'customers.view'
|
||||
| 'customers.manage'
|
||||
| 'customer_gateways.view'
|
||||
| 'customer_gateways.manage'
|
||||
| 'vendors.view'
|
||||
| 'vendors.manage'
|
||||
| 'vendor_gateways.view'
|
||||
| 'vendor_gateways.manage'
|
||||
| 'line_groups.view'
|
||||
| 'line_groups.manage'
|
||||
| 'recharges.view'
|
||||
| 'recharges.manage'
|
||||
| 'cdr.view'
|
||||
| 'recordings.play'
|
||||
| 'quality.view'
|
||||
| 'quality.manage'
|
||||
| 'users.view'
|
||||
| 'users.manage'
|
||||
| 'roles.view'
|
||||
| 'roles.manage'
|
||||
| 'audit.view';
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const ARGON2_MEMORY_KIB = 19_456;
|
||||
const ARGON2_PASSES = 2;
|
||||
const ARGON2_PARALLELISM = 1;
|
||||
const ARGON2_TAG_LENGTH = 32;
|
||||
const ARGON2_SALT_LENGTH = 16;
|
||||
|
||||
type NodeArgon2Sync = (
|
||||
algorithm: 'argon2id',
|
||||
options: {
|
||||
message: string | Buffer;
|
||||
nonce: Buffer;
|
||||
parallelism: number;
|
||||
tagLength: number;
|
||||
memory: number;
|
||||
passes: number;
|
||||
}
|
||||
) => Buffer;
|
||||
|
||||
type WasmArgon2idHash = (params: {
|
||||
password: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
parallelism: number;
|
||||
passes: number;
|
||||
memorySize: number;
|
||||
tagLength: number;
|
||||
}) => Uint8Array;
|
||||
type WasmImports = Record<string, Record<string, unknown>>;
|
||||
type WasmInstantiatedSource = {
|
||||
instance: {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
type WasmGlobal = {
|
||||
WebAssembly: {
|
||||
instantiate(bytes: Buffer, imports: WasmImports): Promise<WasmInstantiatedSource>;
|
||||
};
|
||||
};
|
||||
|
||||
let wasmArgon2id: Promise<WasmArgon2idHash> | null = null;
|
||||
|
||||
function b64url(input: Buffer | string): string {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
function fromB64url(input: string): Buffer {
|
||||
return Buffer.from(input, 'base64url');
|
||||
}
|
||||
|
||||
export interface Argon2idPasswordHashOptions {
|
||||
memoryKiB?: number;
|
||||
passes?: number;
|
||||
parallelism?: number;
|
||||
tagLength?: number;
|
||||
salt?: Buffer;
|
||||
}
|
||||
|
||||
async function loadWasmArgon2id(): Promise<WasmArgon2idHash> {
|
||||
if (!wasmArgon2id) {
|
||||
wasmArgon2id = (async () => {
|
||||
const baseDir = dirname(fileURLToPath(import.meta.url));
|
||||
const setupModule = (await import(pathToFileURL(join(baseDir, '../vendor/argon2id/lib/setup.js')).href)) as {
|
||||
default: (
|
||||
getSIMD: (imports: WasmImports) => Promise<WasmInstantiatedSource>,
|
||||
getNonSIMD: (imports: WasmImports) => Promise<WasmInstantiatedSource>
|
||||
) => Promise<WasmArgon2idHash>;
|
||||
};
|
||||
const wasm = (globalThis as unknown as WasmGlobal).WebAssembly;
|
||||
const instantiate = async (fileName: string, imports: WasmImports): Promise<WasmInstantiatedSource> =>
|
||||
wasm.instantiate(await readFile(join(baseDir, '../vendor/argon2id/dist', fileName)), imports);
|
||||
|
||||
return setupModule.default(
|
||||
(imports) => instantiate('simd.wasm', imports),
|
||||
(imports) => instantiate('no-simd.wasm', imports)
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
return wasmArgon2id;
|
||||
}
|
||||
|
||||
async function computeArgon2idDigest(params: {
|
||||
password: string;
|
||||
salt: Buffer;
|
||||
parallelism: number;
|
||||
tagLength: number;
|
||||
memory: number;
|
||||
passes: number;
|
||||
}): Promise<Buffer> {
|
||||
const nodeArgon2 = (crypto as unknown as { argon2Sync?: NodeArgon2Sync }).argon2Sync;
|
||||
if (typeof nodeArgon2 === 'function') {
|
||||
return nodeArgon2('argon2id', {
|
||||
message: params.password,
|
||||
nonce: params.salt,
|
||||
parallelism: params.parallelism,
|
||||
tagLength: params.tagLength,
|
||||
memory: params.memory,
|
||||
passes: params.passes
|
||||
});
|
||||
}
|
||||
|
||||
const wasmHash = await loadWasmArgon2id();
|
||||
return Buffer.from(
|
||||
wasmHash({
|
||||
password: Buffer.from(params.password, 'utf8'),
|
||||
salt: params.salt,
|
||||
parallelism: params.parallelism,
|
||||
tagLength: params.tagLength,
|
||||
memorySize: params.memory,
|
||||
passes: params.passes
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function hashPasswordArgon2id(password: string, options: Argon2idPasswordHashOptions = {}): Promise<string> {
|
||||
if (password.length === 0) {
|
||||
throw new Error('Password must not be empty.');
|
||||
}
|
||||
|
||||
const memory = options.memoryKiB ?? ARGON2_MEMORY_KIB;
|
||||
const passes = options.passes ?? ARGON2_PASSES;
|
||||
const parallelism = options.parallelism ?? ARGON2_PARALLELISM;
|
||||
const tagLength = options.tagLength ?? ARGON2_TAG_LENGTH;
|
||||
const salt = options.salt ?? crypto.randomBytes(ARGON2_SALT_LENGTH);
|
||||
const digest = await computeArgon2idDigest({
|
||||
password,
|
||||
salt,
|
||||
parallelism,
|
||||
tagLength,
|
||||
memory,
|
||||
passes
|
||||
});
|
||||
|
||||
return `$argon2id$v=19$m=${memory},t=${passes},p=${parallelism}$${b64url(salt)}$${b64url(digest)}`;
|
||||
}
|
||||
|
||||
export async function verifyPasswordArgon2id(password: string, storedHash: string): Promise<boolean> {
|
||||
const parts = storedHash.split('$');
|
||||
if (parts.length !== 6 || parts[1] !== 'argon2id' || parts[2] !== 'v=19') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = Object.fromEntries(
|
||||
parts[3].split(',').map((pair) => {
|
||||
const [key, value] = pair.split('=');
|
||||
return [key, Number(value)];
|
||||
})
|
||||
);
|
||||
|
||||
if (!params.m || !params.t || !params.p) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const salt = fromB64url(parts[4]);
|
||||
const expected = fromB64url(parts[5]);
|
||||
const actual = await computeArgon2idDigest({
|
||||
password,
|
||||
salt,
|
||||
parallelism: params.p,
|
||||
tagLength: expected.length,
|
||||
memory: params.m,
|
||||
passes: params.t
|
||||
});
|
||||
|
||||
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
export function randomToken(byteLength = 48): string {
|
||||
return crypto.randomBytes(byteLength).toString('base64url');
|
||||
}
|
||||
|
||||
export function sha256Token(token: string): string {
|
||||
return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
roles: string[];
|
||||
typ: 'access';
|
||||
}
|
||||
|
||||
export interface SignedAccessTokenPayload extends AccessTokenPayload {
|
||||
iss: string;
|
||||
aud: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export function signAccessToken(
|
||||
payload: AccessTokenPayload,
|
||||
options: {
|
||||
secret: string;
|
||||
issuer: string;
|
||||
audience: string;
|
||||
ttlSeconds: number;
|
||||
now?: Date;
|
||||
}
|
||||
): string {
|
||||
const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
||||
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const body = b64url(
|
||||
JSON.stringify({
|
||||
...payload,
|
||||
iss: options.issuer,
|
||||
aud: options.audience,
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + options.ttlSeconds
|
||||
} satisfies SignedAccessTokenPayload)
|
||||
);
|
||||
const signature = crypto.createHmac('sha256', options.secret).update(`${header}.${body}`).digest('base64url');
|
||||
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
secret: string;
|
||||
issuer: string;
|
||||
audience: string;
|
||||
now?: Date;
|
||||
}
|
||||
): SignedAccessTokenPayload | null {
|
||||
const [header, body, signature] = token.split('.');
|
||||
if (!header || !body || !signature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expected = crypto.createHmac('sha256', options.secret).update(`${header}.${body}`).digest('base64url');
|
||||
if (expected.length !== signature.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(fromB64url(body).toString('utf8')) as SignedAccessTokenPayload;
|
||||
const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
||||
|
||||
if (payload.typ !== 'access' || payload.iss !== options.issuer || payload.aud !== options.audience || payload.exp <= nowSeconds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
Reference in New Issue
Block a user