feat(auth): port 3f auth infrastructure — Argon2 passwords + HS256 JWT tokens
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
> Plan lengkap migrasi in-place dari Rust (11 crate, clean architecture 4 lapis) ke monorepo
|
> Plan lengkap migrasi in-place dari Rust (11 crate, clean architecture 4 lapis) ke monorepo
|
||||||
> TypeScript/Bun. Bahasa Indonesia, commit pakai Conventional Commits.
|
> 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.
|
> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,5 +21,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.0",
|
"@types/bun": "^1.2.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@node-rs/argon2": "^2.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user