feat(auth): port 3f auth infrastructure — Argon2 passwords + HS256 JWT tokens

This commit is contained in:
asepharyana
2026-09-02 22:50:38 +07:00
parent edc007619b
commit 7e8a160b0e
6 changed files with 216 additions and 1 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
> Plan lengkap migrasi in-place dari Rust (11 crate, clean architecture 4 lapis) ke monorepo
> TypeScript/Bun. Bahasa Indonesia, commit pakai Conventional Commits.
>
> **Status:** Fase 0–2 + 3a + 3b + 3e + 3c + 3d **selesai**. Berikutnya: **3f auth infrastructure**.
> **Status:** Fase 0–2 + 3a + 3b + 3e + 3c + 3d + 3f **selesai**. Berikutnya: **3g IPC + bgbash**.
> Base: `bun run check` bersih, 54 test hijau.
---
@@ -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);
}
}
+45
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -21,5 +21,8 @@
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.7.0"
},
"dependencies": {
"@node-rs/argon2": "^2.2.0"
}
}