diff --git a/TODO.md b/TODO.md index ea4dcda..6eca64d 100644 --- a/TODO.md +++ b/TODO.md @@ -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. --- diff --git a/apps/packages/infrastructure/src/auth/index.ts b/apps/packages/infrastructure/src/auth/index.ts new file mode 100644 index 0000000..b371cfb --- /dev/null +++ b/apps/packages/infrastructure/src/auth/index.ts @@ -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"; diff --git a/apps/packages/infrastructure/src/auth/jwt.ts b/apps/packages/infrastructure/src/auth/jwt.ts new file mode 100644 index 0000000..4be1aa1 --- /dev/null +++ b/apps/packages/infrastructure/src/auth/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; + } +} diff --git a/apps/packages/infrastructure/src/auth/password.ts b/apps/packages/infrastructure/src/auth/password.ts new file mode 100644 index 0000000..b680493 --- /dev/null +++ b/apps/packages/infrastructure/src/auth/password.ts @@ -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 { + 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 { + return verify(hashStr, password); + } +} diff --git a/bun.lock b/bun.lock index 2bb6bb9..d86eba0 100644 --- a/bun.lock +++ b/bun.lock @@ -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": "2.2.0", "@node-rs/argon2-win32-arm64-msvc": "2.2.0", "@node-rs/argon2-win32-ia32-msvc": "2.2.0", "@node-rs/argon2-win32-x64-msvc": "2.2.0" } }, "sha512-iBItNNiiim2nzhmUY4uxA4nYaNBatj8Ae/YnlwQzRYRFiBysm2scfsyjO+ktH+7VHl1O24UwFd2EeihV4IJzxw=="], + + "@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=="], diff --git a/package.json b/package.json index 121e8b1..56bb51a 100644 --- a/package.json +++ b/package.json @@ -21,5 +21,8 @@ "devDependencies": { "@types/bun": "^1.2.0", "typescript": "^5.7.0" + }, + "dependencies": { + "@node-rs/argon2": "^2.2.0" } } \ No newline at end of file