From 602fa50216895a7030d09cbf9ba21a50e7460983 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Feb 2026 11:19:58 -0800 Subject: [PATCH] feat(auth): add @bytelyst/auth package - createJwtUtils() factory with configurable issuer and expiry (jose) - extractAuth() middleware for Fastify request auth extraction - requireRole() guard with multi-role support - hashPassword() / verifyPassword() via bcryptjs - getCurrentUser() helper for Next.js API routes (generic TUser) - AuthPayload, TokenPayload, JwtUtils types - NO dependency on @bytelyst/config (reads JWT_SECRET from process.env directly) - Peer deps: jose >=5.0.0, bcryptjs >=2.4.0 --- packages/auth/package.json | 22 ++++++++++ packages/auth/src/index.ts | 10 +++++ packages/auth/src/jwt.ts | 70 ++++++++++++++++++++++++++++++++ packages/auth/src/middleware.ts | 57 ++++++++++++++++++++++++++ packages/auth/src/password.ts | 18 ++++++++ packages/auth/src/server-auth.ts | 26 ++++++++++++ packages/auth/src/types.ts | 33 +++++++++++++++ packages/auth/tsconfig.json | 9 ++++ 8 files changed, 245 insertions(+) create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/index.ts create mode 100644 packages/auth/src/jwt.ts create mode 100644 packages/auth/src/middleware.ts create mode 100644 packages/auth/src/password.ts create mode 100644 packages/auth/src/server-auth.ts create mode 100644 packages/auth/src/types.ts create mode 100644 packages/auth/tsconfig.json diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 00000000..78337b5e --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,22 @@ +{ + "name": "@bytelyst/auth", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "peerDependencies": { + "jose": ">=5.0.0", + "bcryptjs": ">=2.4.0" + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 00000000..7a1747f5 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,10 @@ +export { createJwtUtils } from "./jwt.js"; +export { extractAuth, requireRole } from "./middleware.js"; +export { hashPassword, verifyPassword } from "./password.js"; +export { getCurrentUser } from "./server-auth.js"; +export type { + TokenPayload, + AuthPayload, + JwtUtilsOptions, + JwtUtils, +} from "./types.js"; diff --git a/packages/auth/src/jwt.ts b/packages/auth/src/jwt.ts new file mode 100644 index 00000000..c2da14ac --- /dev/null +++ b/packages/auth/src/jwt.ts @@ -0,0 +1,70 @@ +/** + * JWT utilities — configurable issuer and expiry. + * Uses jose library for standards-compliant JWT handling. + */ + +import { SignJWT, jwtVerify } from "jose"; +import type { JwtUtils, JwtUtilsOptions, TokenPayload } from "./types.js"; + +function getSecret(): Uint8Array { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error("JWT_SECRET must be set"); + return new TextEncoder().encode(secret); +} + +/** + * Create a JWT utility set with the given issuer and expiry configuration. + * + * @example + * ```ts + * const jwt = createJwtUtils({ issuer: "lysnrai", accessTokenExpiry: "1h" }); + * const token = await jwt.createAccessToken({ sub: "u1", email: "a@b.com", role: "admin" }); + * const payload = await jwt.verifyToken(token); + * ``` + */ +export function createJwtUtils(options: JwtUtilsOptions): JwtUtils { + const { + issuer, + accessTokenExpiry = "1h", + refreshTokenExpiry = "30d", + } = options; + + return { + async createAccessToken(payload) { + return new SignJWT({ + ...payload, + productId: payload.productId || issuer, + type: "access", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(accessTokenExpiry) + .setIssuer(issuer) + .sign(getSecret()); + }, + + async createRefreshToken(payload) { + return new SignJWT({ + sub: payload.sub, + productId: payload.productId || issuer, + type: "refresh", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(refreshTokenExpiry) + .setIssuer(issuer) + .sign(getSecret()); + }, + + async verifyToken(token: string) { + try { + const { payload } = await jwtVerify(token, getSecret(), { + issuer, + }); + return payload as unknown as TokenPayload; + } catch { + return null; + } + }, + }; +} diff --git a/packages/auth/src/middleware.ts b/packages/auth/src/middleware.ts new file mode 100644 index 00000000..0f889a11 --- /dev/null +++ b/packages/auth/src/middleware.ts @@ -0,0 +1,57 @@ +/** + * Fastify auth middleware — validates JWT tokens from Authorization headers. + */ + +import { jwtVerify } from "jose"; +import type { AuthPayload } from "./types.js"; + +function getSecret(): Uint8Array { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error("JWT_SECRET must be set"); + return new TextEncoder().encode(secret); +} + +/** + * Extract and verify auth payload from an Authorization header. + * Works with any request-like object that has headers.authorization. + * + * @throws Error with message "Unauthorized" if no valid Bearer token + * @throws Error with message "Invalid or expired token" if verification fails + */ +export async function extractAuth(req: { + headers: { authorization?: string }; +}): Promise { + const auth = req.headers.authorization; + if (!auth?.startsWith("Bearer ")) { + throw Object.assign(new Error("Unauthorized"), { statusCode: 401 }); + } + const token = auth.slice(7); + try { + const { payload } = await jwtVerify(token, getSecret()); + const p = payload as unknown as AuthPayload; + if (p.type !== "access") throw new Error("Not an access token"); + return p; + } catch { + throw Object.assign(new Error("Invalid or expired token"), { + statusCode: 401, + }); + } +} + +/** + * Require specific roles. Extracts auth first, then checks role. + * + * @throws Error with statusCode 403 if role doesn't match + */ +export async function requireRole( + req: { headers: { authorization?: string } }, + ...roles: string[] +): Promise { + const payload = await extractAuth(req); + if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { + throw Object.assign(new Error("Insufficient permissions"), { + statusCode: 403, + }); + } + return payload; +} diff --git a/packages/auth/src/password.ts b/packages/auth/src/password.ts new file mode 100644 index 00000000..2c46f2cd --- /dev/null +++ b/packages/auth/src/password.ts @@ -0,0 +1,18 @@ +/** + * Password hashing utilities using bcryptjs. + */ + +import bcrypt from "bcryptjs"; + +const SALT_ROUNDS = 12; + +export async function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, SALT_ROUNDS); +} + +export async function verifyPassword( + plain: string, + hash: string, +): Promise { + return bcrypt.compare(plain, hash); +} diff --git a/packages/auth/src/server-auth.ts b/packages/auth/src/server-auth.ts new file mode 100644 index 00000000..1336b1c8 --- /dev/null +++ b/packages/auth/src/server-auth.ts @@ -0,0 +1,26 @@ +/** + * Server-side auth helpers for Next.js API routes. + */ + +import type { TokenPayload } from "./types.js"; + +/** + * Get the current user from an Authorization header value. + * Pairs with a verifyToken function and a getUserById function. + * + * @param authHeader - The Authorization header value (e.g., "Bearer xxx") + * @param verifyToken - Function to verify the JWT and return a payload + * @param getUserById - Function to look up the user by their ID + * @returns The user object or null if auth fails + */ +export async function getCurrentUser( + authHeader: string | null, + verifyToken: (token: string) => Promise, + getUserById: (id: string) => Promise, +): Promise { + if (!authHeader?.startsWith("Bearer ")) return null; + const token = authHeader.slice(7); + const payload = await verifyToken(token); + if (!payload || payload.type !== "access") return null; + return getUserById(payload.sub); +} diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts new file mode 100644 index 00000000..df036859 --- /dev/null +++ b/packages/auth/src/types.ts @@ -0,0 +1,33 @@ +export interface TokenPayload { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: "access" | "refresh"; + [key: string]: unknown; +} + +export interface AuthPayload { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: string; +} + +export interface JwtUtilsOptions { + issuer: string; + accessTokenExpiry?: string; + refreshTokenExpiry?: string; +} + +export interface JwtUtils { + createAccessToken(payload: { + sub: string; + email: string; + role: string; + productId?: string; + }): Promise; + createRefreshToken(payload: { sub: string; productId?: string }): Promise; + verifyToken(token: string): Promise; +} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 00000000..5edad813 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +}