feat(auth): port 3f auth infrastructure — Argon2 passwords + HS256 JWT tokens
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Auth infrastructure implementations — Argon2 passwords + HS256 JWT.
|
||||
* Mirrors `apps/infrastructure/src/auth/mod.rs`.
|
||||
*/
|
||||
export { Argon2PasswordService } from "./password.ts";
|
||||
export { Hs256TokenService, createToken, verifyToken } from "./jwt.ts";
|
||||
export type { JwtClaims } from "./jwt.ts";
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* JWT token utilities for HMAC-SHA256 (HS256) signing and verification.
|
||||
* Mirrors `apps/infrastructure/src/auth/jwt.rs`.
|
||||
*
|
||||
* Uses `node:crypto` HMAC — no external JWT library needed.
|
||||
* Implements compact JWT encoding/decoding per RFC 7515.
|
||||
*/
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { TokenService } from "@zesdex/application";
|
||||
|
||||
/** Standard JWT claims. */
|
||||
export interface JwtClaims {
|
||||
sub: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
/** Token purpose: "access" or "refresh". */
|
||||
typ: "access" | "refresh";
|
||||
/** Optional session binding. */
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
/** JWT header (always HS256). */
|
||||
interface JwtHeader {
|
||||
alg: "HS256";
|
||||
typ: "JWT";
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY_SECS = 3600; // 1 hour
|
||||
const REFRESH_TOKEN_EXPIRY_SECS = 604800; // 7 days
|
||||
|
||||
function base64url(data: string | Buffer): string {
|
||||
const str = typeof data === "string" ? data : data.toString("base64");
|
||||
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): Buffer {
|
||||
let s = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (s.length % 4) s += "=";
|
||||
return Buffer.from(s, "base64");
|
||||
}
|
||||
|
||||
function sign(secret: string, data: string): string {
|
||||
return base64url(createHmac("sha256", secret).update(data).digest());
|
||||
}
|
||||
|
||||
function encodeJson(obj: object): string {
|
||||
return base64url(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/** Create a JWT token with the given claims and secret. */
|
||||
export function createToken(secret: string, claims: JwtClaims): string {
|
||||
const header: JwtHeader = { alg: "HS256", typ: "JWT" };
|
||||
const encHeader = encodeJson(header);
|
||||
const encPayload = encodeJson(claims);
|
||||
const sig = sign(secret, `${encHeader}.${encPayload}`);
|
||||
return `${encHeader}.${encPayload}.${sig}`;
|
||||
}
|
||||
|
||||
/** Verify a JWT token and return its claims. Throws if invalid/expired. */
|
||||
export function verifyToken(secret: string, token: string): JwtClaims {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new Error("Invalid JWT: expected 3 parts");
|
||||
|
||||
const [encHeader, encPayload, sig] = parts as [string, string, string];
|
||||
|
||||
// Verify signature (constant-time)
|
||||
const expectedSig = sign(secret, `${encHeader}.${encPayload}`);
|
||||
const sigBuf = Buffer.from(sig, "base64");
|
||||
const expectedBuf = Buffer.from(expectedSig, "base64");
|
||||
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
||||
throw new Error("Invalid JWT signature");
|
||||
}
|
||||
|
||||
// Decode header
|
||||
const header: JwtHeader = JSON.parse(base64urlDecode(encHeader).toString("utf8"));
|
||||
if (header.alg !== "HS256") throw new Error(`Unsupported JWT algorithm: ${header.alg}`);
|
||||
|
||||
// Decode payload
|
||||
const claims: JwtClaims = JSON.parse(base64urlDecode(encPayload).toString("utf8"));
|
||||
|
||||
// Validate required claims
|
||||
if (!claims.sub || typeof claims.exp !== "number" || typeof claims.typ !== "string") {
|
||||
throw new Error("Invalid JWT: missing required claims (sub, exp, typ)");
|
||||
}
|
||||
|
||||
// Validate expiry with 60s leeway
|
||||
const nowSecs = Math.floor(Date.now() / 1000);
|
||||
if (claims.exp + 60 < nowSecs) {
|
||||
throw new Error("JWT token expired");
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete TokenService implementing HS256 JWT.
|
||||
* Generates access + refresh token pairs.
|
||||
*/
|
||||
export class Hs256TokenService implements TokenService {
|
||||
constructor(private readonly secret: string) {}
|
||||
|
||||
/** Generate an [access, refresh] token pair for the given subject. */
|
||||
generateTokens(sub: string): [string, string] {
|
||||
const nowSecs = Math.floor(Date.now() / 1000);
|
||||
const access: JwtClaims = {
|
||||
sub,
|
||||
exp: nowSecs + ACCESS_TOKEN_EXPIRY_SECS,
|
||||
iat: nowSecs,
|
||||
typ: "access",
|
||||
};
|
||||
const refresh: JwtClaims = {
|
||||
sub,
|
||||
exp: nowSecs + REFRESH_TOKEN_EXPIRY_SECS,
|
||||
iat: nowSecs,
|
||||
typ: "refresh",
|
||||
};
|
||||
return [createToken(this.secret, access), createToken(this.secret, refresh)];
|
||||
}
|
||||
|
||||
/** Verify an access token and return the subject. Throws if invalid/expired/wrong type. */
|
||||
verifyAccessToken(token: string): string {
|
||||
const claims = verifyToken(this.secret, token);
|
||||
if (claims.typ !== "access") throw new Error(`Expected access token, got ${claims.typ}`);
|
||||
return claims.sub;
|
||||
}
|
||||
|
||||
/** Verify a refresh token and return the subject. Throws if invalid/expired/wrong type. */
|
||||
verifyRefreshToken(token: string): string {
|
||||
const claims = verifyToken(this.secret, token);
|
||||
if (claims.typ !== "refresh") throw new Error(`Expected refresh token, got ${claims.typ}`);
|
||||
return claims.sub;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Argon2 password hashing and verification.
|
||||
* Mirrors `apps/infrastructure/src/auth/password.rs`.
|
||||
*
|
||||
* Uses @node-rs/argon2 (Argon2id) for password hashing, matching the Rust argon2 crate.
|
||||
*/
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import type { PasswordService } from "@zesdex/application";
|
||||
|
||||
/** Argon2id password hashing service. */
|
||||
export class Argon2PasswordService implements PasswordService {
|
||||
/**
|
||||
* Hash a plaintext password using Argon2id with a random salt.
|
||||
* The PHC-encoded hash string is returned.
|
||||
*/
|
||||
async hash(password: string): Promise<string> {
|
||||
return hash(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a plaintext password against a previously-hashed PHC string.
|
||||
* Returns `true` if the password matches.
|
||||
*/
|
||||
async verify(password: string, hashStr: string): Promise<boolean> {
|
||||
return verify(hashStr, password);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user