feat(auth): port 3f auth infrastructure — Argon2 passwords + HS256 JWT tokens
This commit is contained in:
1 parent
edc007619b
commit
7e8a160b0e
6 files changed
+216
-1
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "zesdex",
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
@@ -28,8 +31,48 @@
|
||||
"typescript": "^5.7.0",
|
||||
},
|
||||
},
|
||||
"apps/packages/infrastructure": {
|
||||
"name": "@zesdex/infrastructure",
|
||||
"version": "1.21.2",
|
||||
"dependencies": {
|
||||
"@zesdex/application": "workspace:*",
|
||||
"@zesdex/domain": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@node-rs/argon2": ["@node-rs/argon2@2.2.0", "", { "optionalDependencies": { "@node-rs/argon2-android-arm-eabi": "2.2.0", "@node-rs/argon2-android-arm64": "2.2.0", "@node-rs/argon2-darwin-arm64": "2.2.0", "@node-rs/argon2-darwin-x64": "2.2.0", "@node-rs/argon2-freebsd-x64": "2.2.0", "@node-rs/argon2-linux-arm-gnueabihf": "2.2.0", "@node-rs/argon2-linux-arm64-gnu": "2.2.0", "@node-rs/argon2-linux-arm64-musl": "2.2.0", "@node-rs/argon2-linux-x64-gnu": "2.2.0", "@node-rs/argon2-linux-x64-musl"Line truncated
|
||||
|
||||
"@node-rs/argon2-android-arm-eabi": ["@node-rs/argon2-android-arm-eabi@2.2.0", "", { "os": "android", "cpu": "arm" }, "sha512-xbJvpiTlYqKxq5XRkhQlZNiIRE4fZPFJdB46jOPED8pGkxgn1erMMdVCPWp1RcOxuChaiEOl8DVu5sVVu9ApVw=="],
|
||||
|
||||
"@node-rs/argon2-android-arm64": ["@node-rs/argon2-android-arm64@2.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-i47xYwh8GNKc0yNwFQz7Hqsx6pBZJsJ1hqRXmtThOXbjl6HoVlX7oGBpqq4s+sBrU8ba0kubAqJ8fBjtp4iwHQ=="],
|
||||
|
||||
"@node-rs/argon2-darwin-arm64": ["@node-rs/argon2-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zet1ekAQPUczRQ6cmFCpQyScg9346SnzYt7CRWPd4GCs8CBOOK34PrtbazdReHcBxUgSDnhMhDWnO0jmtJ15sQ=="],
|
||||
|
||||
"@node-rs/argon2-darwin-x64": ["@node-rs/argon2-darwin-x64@2.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JeOpmf5glG9MTS+qt6VZ3tUUZdQ3Kll/e18yUeBmDRSKQDN0AJs5ruzqV08DOclVUqXG0IF2DVhbE825RN96yA=="],
|
||||
|
||||
"@node-rs/argon2-freebsd-x64": ["@node-rs/argon2-freebsd-x64@2.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-w7R4yoAcOlZctOz7EHhf2hFHSuRyJcphMZAcyKRJ3Sxu8APjqifLepOkuXC1Gat08DYFTI51RMPj7sMU36KcGA=="],
|
||||
|
||||
"@node-rs/argon2-linux-arm-gnueabihf": ["@node-rs/argon2-linux-arm-gnueabihf@2.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-H5uGMLmj2vor3YDAX+LIyw4yGXEESXvr5voyRK+AuxdIvZ/B7mxma06IOmb+4n9t/MQ1Lbwu7gkyWt8f3wyrNA=="],
|
||||
|
||||
"@node-rs/argon2-linux-arm64-gnu": ["@node-rs/argon2-linux-arm64-gnu@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0A+tUMI7NwImaK7txceScl7d18GAw4hkt27NChZIZdD55w7qZ81LFcA4cEP9L6kgdubItlAqcD/qBR5XMCd5Q=="],
|
||||
|
||||
"@node-rs/argon2-linux-arm64-musl": ["@node-rs/argon2-linux-arm64-musl@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-i5s9msRRy+m/yul9M+1OF7fN/VqhJ+4IalPn1rhyrE3gwNcgfoT+A0DuAqR5gtXmDb3zx+QWIjtHCLhvkvu23g=="],
|
||||
|
||||
"@node-rs/argon2-linux-x64-gnu": ["@node-rs/argon2-linux-x64-gnu@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-R6oaRA2Iesbsexe06Aeydbdz617WD2lE6yDcb4t5T+0iGM1VyVpI+nNpcAtkcXyO6Dgmuhh+3RKbm/F2CSBt1w=="],
|
||||
|
||||
"@node-rs/argon2-linux-x64-musl": ["@node-rs/argon2-linux-x64-musl@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Paql63t7XusSTYMSfEQ0ex5edubWX/OhvLRYsC+oQxWiC+3x2WcYWp+fWVwdlAm+eJRsIOj6w3iG3yDy94ZL8A=="],
|
||||
|
||||
"@node-rs/argon2-win32-arm64-msvc": ["@node-rs/argon2-win32-arm64-msvc@2.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ysl+KaEeYVBKY3/J4w3VKD7KVHATpsJ/TMtxjmVyX8KEhwemH2qmlyZcVQb5fPy+Yk18g7cHu7LYuDffROo8pw=="],
|
||||
|
||||
"@node-rs/argon2-win32-ia32-msvc": ["@node-rs/argon2-win32-ia32-msvc@2.2.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-2Hp0RtOgM8jxcTsrtYlIkfk0PQXFnENyqvEuFTNCHMFSWhV8D/Ds2OrWSmjWTGQhLNWNp2JrX4XKsZM0+PB3NA=="],
|
||||
|
||||
"@node-rs/argon2-win32-x64-msvc": ["@node-rs/argon2-win32-x64-msvc@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cfcNjVqLpgOU/9l2mLOFduuXTxvGH5qoV38AhcOz9oQaC7GDmsFbAgcuizVNuOtpSyQr5HoQez3f2X2jYgGMsg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||
|
||||
"@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],
|
||||
@@ -38,6 +81,8 @@
|
||||
|
||||
"@zesdex/domain": ["@zesdex/domain@workspace:apps/packages/domain"],
|
||||
|
||||
"@zesdex/infrastructure": ["@zesdex/infrastructure@workspace:apps/packages/infrastructure"],
|
||||
|
||||
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
@@ -21,5 +21,8 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.0"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user