diff --git a/.env.example b/.env.example index afb31b0..4250ddb 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,23 @@ PORT=5000 NODE_ENV=development STELLAR_NETWORK=testnet -JWT_SECRET=your_jwt_secret + +# JWT — required outside NODE_ENV=test; the app refuses to start without it. +# Generate with: openssl rand -base64 48 +JWT_SECRET= +JWT_ISSUER=learnault-api +JWT_AUDIENCE=learnault-clients +JWT_EXPIRES_IN=1d +# Identifies which secret above is the active signing key. +JWT_KEY_ID=default +# Retired keys kept only so already-issued tokens keep verifying until they +# expire: "old-kid:old-secret,older-kid:older-secret" +JWT_PREVIOUS_KEYS= + +# bcrypt cost factor for password hashes (10-15, default 12). Raising this +# upgrades new hashes immediately; existing hashes upgrade lazily on next login. +BCRYPT_SALT_ROUNDS=12 + # Database Configuration DATABASE_URL="postgresql://username:password@localhost:5432/learnault_db?schema=public" diff --git a/docs/AUTH_POLICY.md b/docs/AUTH_POLICY.md new file mode 100644 index 0000000..7872053 --- /dev/null +++ b/docs/AUTH_POLICY.md @@ -0,0 +1,95 @@ +# Authentication & Account Security Policy + +Reference implementation for **API Roadmap Phase 1: Harden Authentication +and Account Security Policy**. This is the policy matrix requested as +verification evidence for that issue. + +## 1. Account status vs. access + +| Status | Login (`/auth/login`, `/auth/otp/verify`) | Authenticated routes (`authenticate` + `authorize`) | `requireActiveAccount` routes | +| -------------------- | :---: | :---: | :---: | +| `ACTIVE` | ✅ | ✅ | ✅ | +| `DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | +| `PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | +| `DELETED` | ❌ 401 (indistinguishable from bad credentials) | ❌ 401 `Account not found` | ❌ 401 `Account not found` | + +Status is always re-read from the database at request time — a JWT issued +before a status change stays *cryptographically* valid until it expires, +but no longer grants access once the account is no longer `ACTIVE`. + +`authorize(...roles)` and `requireActiveAccount` both enforce this table; +`authorize` additionally re-checks the caller's **role** against the +database rather than trusting the JWT's `role` claim, so a role change +(or a status change) takes effect on the very next request instead of +waiting for the old token to expire. + +## 2. Operations requiring a verified email + +| Operation | Verified email required? | +| -------------------------------------------- | :---: | +| Register / log in | No — verification happens post-signup | +| Browse modules, view own profile/credentials | No | +| `POST /rewards/withdraw` (moves funds out) | **Yes** — `requireVerifiedEmail` | +| `/employer/*` (accesses candidate PII) | **Yes** — `requireVerifiedEmail` | + +Rationale: verification is not required to *use* the platform, only for +operations that move value out of it or expose other users' data to an +unverified identity. `requireVerifiedEmail` (in `auth.middleware.ts`) is +the single enforcement point; extending coverage to another route means +adding it to that route's middleware chain, not duplicating the check. + +## 3. Password / hashing policy + +- Strength (enforced in `auth.schema.ts` via `isStrongPassword`): 8+ + characters, at least one uppercase, one lowercase, one number, one + symbol. +- Hashing: bcrypt via `utils/password.ts`, cost factor from + `BCRYPT_SALT_ROUNDS` (default 12, valid range 10-15). +- Upgradable cost: `needsRehash()` compares a stored hash's embedded cost + against the current configured cost. On successful login, a + below-current-cost hash is transparently re-hashed and persisted — no + forced reset, no user-visible change. +- No PIN concept exists in this codebase yet (no wallet-PIN feature is + implemented); this policy applies to the password field only. + +## 4. JWT policy + +- Algorithm is pinned to `HS256`; tokens are rejected if the header claims + anything else (`config/jwt.ts`, `verifyAccessToken`). +- Every token carries and every verification checks `iss` (`JWT_ISSUER`) + and `aud` (`JWT_AUDIENCE`). +- Every token is signed with an explicit `kid` header (`JWT_KEY_ID`). + Verification reads the `kid` and resolves the matching secret — the + active one, or one listed in `JWT_PREVIOUS_KEYS` — so a key can be + rotated by introducing a new `JWT_KEY_ID`/`JWT_SECRET` pair and moving + the old pair into `JWT_PREVIOUS_KEYS` until its tokens expire. +- `JWT_SECRET` has **no fallback** outside `NODE_ENV=test`; the process + throws on boot instead of signing with a guessable default. + +## 5. Rate limits (`rate-limit.middleware.ts`, `env.ts`) + +| Limiter | Applies to | Default | +| --- | --- | --- | +| `authLimiter` | `/auth/register`, `/auth/login`, `/auth/resend-verification`, `/auth/forgot-password`, `/auth/reset-password` | 10 / 15 min / IP | +| `otpLimiter` | `/auth/otp/request`, `/auth/otp/verify` | 5 / 15 min / IP | +| Per-account OTP limits | `otp.service.ts` + `auth.controller.ts` (phone + device scoped) | 5/hr per phone, 10/hr per device | +| `employerLimiter` | `/employer/*` | 500 / 15 min / IP | +| `authenticatedLimiter` | authenticated, non-employer traffic | 1000 / 15 min / IP | +| `generalLimiter` | everything else | 100 / 15 min / IP | + +All limiters set `X-RateLimit-Limit`, `X-RateLimit-Remaining`, +`X-RateLimit-Reset`, and — on a 429 — `Retry-After`, so clients get stable +retry information rather than a bare error. + +## 6. Explicitly out of scope here + +- **Refresh token rotation** — blocked by + [#130](https://github.com/learnault/learnault-api/issues/130), which is + not yet implemented. No refresh endpoint exists to rate-limit or harden + yet; this will need a follow-up once #130 lands. +- **PIN policy** — no PIN feature exists in the codebase. +- `user.controller.ts#changePassword` — its `validatePassword` / + `updateUserPassword` helpers are pre-existing stubs (`mockUser`, `throw + new Error('Not implemented')`) unrelated to this change; it isn't wired + to Prisma yet, so there's nothing here to harden without building that + feature from scratch. diff --git a/src/config/jwt.ts b/src/config/jwt.ts new file mode 100644 index 0000000..d69ca22 --- /dev/null +++ b/src/config/jwt.ts @@ -0,0 +1,128 @@ +import { Algorithm, JsonWebTokenError, SignOptions, VerifyOptions } from 'jsonwebtoken' +import { JWTPayload, signToken, verifyToken } from '../utils/jwt' + +// Centralized JWT policy: one algorithm, one issuer/audience pair, and an +// explicit key id (kid) so tokens are self-describing during key rotation. +const ALGORITHM: Algorithm = 'HS256' +const ISSUER = process.env.JWT_ISSUER || 'learnault-api' +const AUDIENCE = process.env.JWT_AUDIENCE || 'learnault-clients' +const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d' +const ACTIVE_KEY_ID = process.env.JWT_KEY_ID || 'default' +const IS_TEST_ENV = process.env.NODE_ENV === 'test' + +// A hardcoded fallback secret is only ever tolerated in the test runner. +// Anywhere else, missing config is a startup failure, not a silent weak key. +function loadActiveSecret(): string { + const secret = process.env.JWT_SECRET + + if (secret && secret.trim().length > 0) { + return secret + } + + if (IS_TEST_ENV) { + return 'test-only-insecure-secret-do-not-use-in-prod' + } + + throw new Error( + 'JWT_SECRET environment variable is required (NODE_ENV != test). ' + + 'Refusing to start with an insecure fallback secret.' + ) +} + +// JWT_PREVIOUS_KEYS="kid1:secret1,kid2:secret2" — retired signing keys kept +// around only so tokens issued before a rotation can still be verified. +function loadRetiredKeys(): Map { + const raw = process.env.JWT_PREVIOUS_KEYS + const keys = new Map() + + if (!raw) { + return keys + } + + for (const entry of raw.split(',')) { + const [kid, secret] = entry.split(':').map((part: string) => part?.trim()) + + if (kid && secret) { + keys.set(kid, secret) + } + } + + return keys +} + +const ACTIVE_SECRET = loadActiveSecret() +const RETIRED_KEYS = loadRetiredKeys() + +export interface AccessTokenClaims extends JWTPayload { + id: string; + role: string; +} + +/** Reads the unverified `kid` header so we know which key to check against. */ +function readKeyId(token: string): string | undefined { + const headerSegment = token.split('.')[0] + + if (!headerSegment) { + return undefined + } + + try { + const header = JSON.parse(Buffer.from(headerSegment, 'base64url').toString('utf8')) + + return typeof header.kid === 'string' ? header.kid : undefined + } catch { + return undefined + } +} + +/** + * Sign an access token under the current active key, issuer, audience and + * algorithm. `expiresIn` and `algorithm` may not be overridden by callers. + */ +export function issueAccessToken( + claims: AccessTokenClaims, + options: Omit = {} +): string { + return signToken(claims, ACTIVE_SECRET, { + ...options, + algorithm: ALGORITHM, + issuer: ISSUER, + audience: AUDIENCE, + expiresIn: EXPIRES_IN, + keyid: ACTIVE_KEY_ID, + } as SignOptions) +} + +/** + * Verify an access token. Resolves the signing key from the token's `kid` + * header (falling back to the active key for tokens minted before this + * change), and always pins algorithm/issuer/audience — a token that used a + * different algorithm or was minted for another audience is rejected. + */ +export function verifyAccessToken(token: string, options: VerifyOptions = {}): AccessTokenClaims { + const kid = readKeyId(token) + const secret = !kid || kid === ACTIVE_KEY_ID ? ACTIVE_SECRET : RETIRED_KEYS.get(kid) + + if (!secret) { + // Same error type jwt.verify() itself throws for a bad signature, so + // callers (e.g. authenticate()) treat this as "invalid token" (401) + // rather than an unexpected server error (500). + throw new JsonWebTokenError('Unknown or retired signing key') + } + + return verifyToken(token, secret, { + ...options, + algorithms: [ALGORITHM], + issuer: ISSUER, + audience: AUDIENCE, + }) as AccessTokenClaims +} + +export const jwtConfig = { + algorithm: ALGORITHM, + issuer: ISSUER, + audience: AUDIENCE, + expiresIn: EXPIRES_IN, + activeKeyId: ACTIVE_KEY_ID, + retiredKeyIds: Array.from(RETIRED_KEYS.keys()), +} diff --git a/src/controllers/account.controller.ts b/src/controllers/account.controller.ts index a38352b..05b9758 100644 --- a/src/controllers/account.controller.ts +++ b/src/controllers/account.controller.ts @@ -1,7 +1,6 @@ import { Request, Response } from 'express' -import bcrypt from 'bcryptjs' -import jwt from 'jsonwebtoken' import prisma from '../config/database' +import { issueAccessToken } from '../config/jwt' import logger from '../utils/logger' import { deactivateSchema, @@ -14,11 +13,9 @@ import { dataExportService } from '../services/data-export.service' import { accountLifecycleService } from '../services/account-lifecycle.service' import { auditService } from '../services/audit.service' import { emailService } from '../services/email.service' +import { comparePassword } from '../utils/password' import { AccountStatus, AuditAction, ExportStatus, RequestContext } from '../types/account.types' -const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret' -const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d' - function buildDeletionRequestedEmail(username: string, scheduledFor: Date): { subject: string; body: string } { const subject = 'Your account deletion request' const body = `\ @@ -577,7 +574,7 @@ export class AccountController { return null } - const isMatch = await bcrypt.compare(password, user.password) + const isMatch = await comparePassword(password, user.password) if (!isMatch) { await auditService.record({ userId, @@ -607,7 +604,7 @@ export class AccountController { return null } - const isMatch = await bcrypt.compare(password, user.password) + const isMatch = await comparePassword(password, user.password) if (!isMatch) { return null } @@ -629,10 +626,6 @@ export class AccountController { } private generateToken(userId: string, role: string): string { - return jwt.sign( - { id: userId, role }, - JWT_SECRET as string, - { expiresIn: JWT_EXPIRES_IN as any } - ) + return issueAccessToken({ id: userId, role }) } } diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 0b74a46..3221c97 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,17 +1,14 @@ import crypto from 'crypto' import { Request, Response } from 'express' -import bcrypt from 'bcryptjs' -import jwt from 'jsonwebtoken' import prisma from '../config/database' +import { issueAccessToken } from '../config/jwt' import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema, otpRequestSchema, otpVerifySchema } from '../schemas/auth.schema' import { UserRole } from '../types/user.types' import { emailService } from '../services/email.service' import { otpService, normalizePhone, OtpPurpose } from '../services/otp.service' +import { comparePassword, hashPassword, needsRehash } from '../utils/password' import logger from '../utils/logger' -const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret' -const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d' - const VERIFICATION_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours const PASSWORD_RESET_TOKEN_EXPIRY_MS = 30 * 60 * 1000 // 30 minutes const RESEND_COOLDOWN_MS = 60 * 1000 // 1 minute @@ -154,8 +151,7 @@ export class AuthController { return } - const salt = await bcrypt.genSalt(10) - const hashedPassword = await bcrypt.hash(password, salt) + const hashedPassword = await hashPassword(password) const user = await prisma.user.create({ data: { @@ -490,7 +486,7 @@ export class AuthController { return } - const isMatch = await bcrypt.compare(password, user.password) + const isMatch = await comparePassword(password, user.password) if (!isMatch) { res.status(401).json({ error: 'Invalid credentials' }) @@ -504,9 +500,15 @@ export class AuthController { return } + // Transparently upgrade the stored hash if it was made under a + // weaker cost factor than the current configuration. + const passwordUpdate = needsRehash(user.password) + ? { password: await hashPassword(password) } + : {} + await prisma.user.update({ where: { id: user.id }, - data: { lastLoginAt: new Date() } + data: { ...passwordUpdate, lastLoginAt: new Date() } }) const token = this.generateToken(user.id, user.role) @@ -730,8 +732,7 @@ export class AuthController { return } - const salt = await bcrypt.genSalt(10) - const hashedPassword = await bcrypt.hash(newPassword, salt) + const hashedPassword = await hashPassword(newPassword) const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || (req.headers['x-real-ip'] as string) @@ -1098,11 +1099,7 @@ export class AuthController { } private generateToken(userId: string, role: string): string { - return jwt.sign( - { id: userId, role }, - JWT_SECRET as string, - { expiresIn: JWT_EXPIRES_IN as any } - ) + return issueAccessToken({ id: userId, role }) } private isRateLimited(key: string, windowMs: number): boolean { diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 4d976f6..9f53053 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from 'express' import jwt from 'jsonwebtoken' import prisma from '../config/database' +import { verifyAccessToken } from '../config/jwt' export type UserRole = 'learner' | 'employer'; @@ -22,14 +23,10 @@ declare global { } } -const JWT_SECRET = process.env.JWT_SECRET as string - -if (!JWT_SECRET) { - throw new Error('JWT_SECRET environment variable is required') -} - /** * Strict authentication — rejects requests without a valid JWT. + * Delegates to verifyAccessToken(), which pins algorithm, issuer, + * audience, and resolves the signing key via the token's kid header. */ export const authenticate = ( req: Request, @@ -47,7 +44,7 @@ export const authenticate = ( const token = authHeader.split(' ')[1] try { - const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload + const decoded = verifyAccessToken(token) as unknown as JwtPayload req.user = decoded next() } catch (err) { @@ -83,7 +80,7 @@ export const optionalAuthenticate = ( const token = authHeader.split(' ')[1] try { - const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload + const decoded = verifyAccessToken(token) as unknown as JwtPayload req.user = decoded } catch { /** */ } @@ -144,25 +141,108 @@ export const requireActiveAccount = async ( } /** - * Role-based authorization — must be used after `authenticate`. - * Restricts access to users with one of the specified roles. + * Verified-email gate — must be used after `authenticate`. + * Blocks operations that the platform's verification policy (see + * docs/AUTH_POLICY.md) requires a confirmed email address for. */ -export const authorize = (...roles: UserRole[]) => { - return (req: Request, res: Response, next: NextFunction): void => { - if (!req.user) { - res.status(401).json({ message: 'Authentication required' }) +export const requireVerifiedEmail = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + if (!req.user) { + res.status(401).json({ message: 'Authentication required' }) + + return + } + + try { + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { isVerified: true }, + }) + + if (!user) { + res.status(401).json({ message: 'Account not found' }) return } - if (!roles.includes(req.user.role)) { + if (!user.isVerified) { res.status(403).json({ - message: `Access denied. Requires one of the following roles: ${roles.join(', ')}`, + message: 'This action requires a verified email address', + code: 'EMAIL_NOT_VERIFIED', }) return } next() + } catch { + res.status(500).json({ message: 'Internal server error during verification check' }) + } +} + +/** + * Role-based authorization — must be used after `authenticate`. + * Re-reads the user's role and status from the database rather than + * trusting the JWT claim: a role change or account status change must + * take effect immediately, not only once the old token expires. + */ +export const authorize = (...roles: UserRole[]) => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.user) { + res.status(401).json({ message: 'Authentication required' }) + + return + } + + try { + const current = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { role: true, status: true }, + }) + + if (!current || current.status === 'DELETED') { + res.status(401).json({ message: 'Account not found' }) + + return + } + + if (current.status === 'DEACTIVATED') { + res.status(403).json({ + message: 'Account is deactivated', + code: 'ACCOUNT_DEACTIVATED', + }) + + return + } + + if (current.status === 'PENDING_DELETION') { + res.status(403).json({ + message: 'Account is scheduled for deletion', + code: 'ACCOUNT_PENDING_DELETION', + }) + + return + } + + const persistedRole = current.role as UserRole + + if (!roles.includes(persistedRole)) { + res.status(403).json({ + message: `Access denied. Requires one of the following roles: ${roles.join(', ')}`, + }) + + return + } + + // Keep the persisted role in sync for downstream handlers, + // in case it drifted from the (now-stale) JWT claim. + req.user.role = persistedRole + next() + } catch { + res.status(500).json({ message: 'Internal server error during authorization' }) + } } -} \ No newline at end of file +} diff --git a/src/routes/v1/auth.routes.ts b/src/routes/v1/auth.routes.ts index 627388c..1131cdd 100644 --- a/src/routes/v1/auth.routes.ts +++ b/src/routes/v1/auth.routes.ts @@ -11,7 +11,7 @@ const authController = new AuthController() * @desc Register a new user * @access Public */ -router.post('/register', authController.register.bind(authController)) +router.post('/register', authLimiter, authController.register.bind(authController)) /** * @route POST /api/v1/auth/login @@ -53,7 +53,7 @@ router.post('/forgot-password', authLimiter, authController.forgotPassword.bind( * @desc Reset password with token * @access Public */ -router.post('/reset-password', authController.resetPassword.bind(authController)) +router.post('/reset-password', authLimiter, authController.resetPassword.bind(authController)) /** * @route POST /api/v1/auth/otp/request diff --git a/src/routes/v1/employer.routes.ts b/src/routes/v1/employer.routes.ts index ed5eb11..e9ec361 100644 --- a/src/routes/v1/employer.routes.ts +++ b/src/routes/v1/employer.routes.ts @@ -1,11 +1,13 @@ import { Router } from 'express' import { contactCandidate, getCandidateProfile, searchTalent } from '../../controllers/employer.controller' -import { authenticate, authorize } from '../../middleware/auth.middleware' +import { authenticate, authorize, requireVerifiedEmail } from '../../middleware/auth.middleware' import { employerLimiter } from '../../middleware/rate-limit.middleware' const router: Router = Router() -router.use(authenticate, authorize('employer'), employerLimiter) +// requireVerifiedEmail: employer actions touch candidate PII, so the +// employer's own email must be confirmed — see docs/AUTH_POLICY.md. +router.use(authenticate, authorize('employer'), requireVerifiedEmail, employerLimiter) // GET /employer/search - search talent with filters router.get('/search', searchTalent) diff --git a/src/routes/v1/rewards.routes.ts b/src/routes/v1/rewards.routes.ts index 8e7e293..3c4df0d 100644 --- a/src/routes/v1/rewards.routes.ts +++ b/src/routes/v1/rewards.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express' import { RewardController } from '../../controllers/reward.controller' -import { authenticate } from '../../middleware/auth.middleware' +import { authenticate, requireVerifiedEmail } from '../../middleware/auth.middleware' const router: Router = Router() const rewardController = new RewardController() @@ -39,11 +39,12 @@ router.get( * @body walletAddress - Stellar wallet address (required) * @body amount - Amount to withdraw in XLM (required) * @body memo - Optional memo for the transaction - * @access Private (requires authentication) + * @access Private (requires authentication + verified email — see docs/AUTH_POLICY.md) */ router.post( '/withdraw', authenticate, + requireVerifiedEmail, rewardController.withdraw.bind(rewardController), ) diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 1c500bd..09238c3 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -1,9 +1,20 @@ import { z } from 'zod' import { UserRole } from '../types/user.types' +import { isStrongPassword } from '../utils/password' + +// Shared password policy: 8+ chars, upper, lower, number, symbol. Applied +// wherever a new credential is set (register, reset) so the rule can't +// drift between entry points. +const strongPassword = z + .string() + .min(8, 'Password must be at least 8 characters long') + .refine(isStrongPassword, { + message: 'Password must include an uppercase letter, a lowercase letter, a number, and a symbol', + }) export const registerSchema = z.object({ email: z.string().email('Invalid email address'), - password: z.string().min(8, 'Password must be at least 8 characters long'), + password: strongPassword, username: z.string().min(3, 'Username must be at least 3 characters long'), role: z.nativeEnum(UserRole).optional().default(UserRole.LEARNER), }) @@ -27,7 +38,7 @@ export const forgotPasswordSchema = z.object({ export const resetPasswordSchema = z.object({ token: z.string().min(1, 'Token is required'), - newPassword: z.string().min(8, 'Password must be at least 8 characters long'), + newPassword: strongPassword, }) export const otpRequestSchema = z.object({ diff --git a/src/utils/password.ts b/src/utils/password.ts index 3d5a264..12b6eca 100644 --- a/src/utils/password.ts +++ b/src/utils/password.ts @@ -1,4 +1,15 @@ -import bcrypt from 'bcrypt' +// bcryptjs, not bcrypt: it's the package actually listed as a runtime +// dependency and already used by the auth/account controllers. +import bcrypt from 'bcryptjs' + +// Cost factor is read from env so it can be raised over time (e.g. as +// hardware gets faster) without a code change; existing hashes carry their +// own cost and are upgraded lazily via needsRehash()/currentHashCost(). +export function getConfiguredSaltRounds(): number { + const parsed = parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) + + return Number.isInteger(parsed) && parsed >= 10 && parsed <= 15 ? parsed : 12 +} /** * Basic strength check: 8+ characters, upper, lower, number, symbol. @@ -21,7 +32,7 @@ return ( export async function hashPassword( password: string, - saltRounds = 10 + saltRounds = getConfiguredSaltRounds() ): Promise { return bcrypt.hash(password, saltRounds) } @@ -32,3 +43,17 @@ export async function comparePassword( ): Promise { return bcrypt.compare(password, hashed) } + +/** Cost factor embedded in a bcrypt hash (`$2b$$...`), or null if unreadable. */ +export function currentHashCost(hashed: string): number | null { + const match = /^\$2[aby]?\$(\d{2})\$/.exec(hashed) + + return match ? parseInt(match[1], 10) : null +} + +/** True when a stored hash was made with a weaker cost than today's config. */ +export function needsRehash(hashed: string): boolean { + const cost = currentHashCost(hashed) + + return cost === null || cost < getConfiguredSaltRounds() +} diff --git a/tests/auth.controller.test.ts b/tests/auth.controller.test.ts index dabfd40..d3304f4 100644 --- a/tests/auth.controller.test.ts +++ b/tests/auth.controller.test.ts @@ -29,6 +29,12 @@ vi.mock('../src/config/database', () => ({ accountDeletionRequest: { findFirst: vi.fn(), }, + session: { + updateMany: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, $transaction: vi.fn((args: any[]) => Promise.all(args)), }, })) @@ -163,6 +169,23 @@ describe('AuthController', () => { ) }) + it('should return 400 for a password that meets length but not complexity', async () => { + mockRequest.body = { + email: 'test@example.com', + // 8+ chars but no uppercase/symbol — fails isStrongPassword + password: 'alllowercase123', + username: 'testuser', + } + + await authController.register(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Validation failed' }) + ) + expect(prisma.user.create).not.toHaveBeenCalled() + }) + it('should return 409 if user already exists', async () => { mockRequest.body = { email: 'exists@example.com', @@ -720,6 +743,65 @@ describe('AuthController', () => { error: 'Invalid credentials', }) }) + + it('transparently upgrades a hash stored below the current bcrypt cost', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + // cost 10 — below the default configured cost of 12 + password: '$2b$10$abcdefghijklmnopqrstuv', + username: 'testuser', + role: 'LEARNER', + status: 'ACTIVE', + }) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(prisma.user.update as any).mockResolvedValue({}) + + await authController.login( + mockRequest as Request, + mockResponse as Response + ) + + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: '1' }, + data: expect.objectContaining({ password: 'hashed_password' }), + }) + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) + + it('does not rewrite a hash that already meets the current bcrypt cost', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + // cost 12 — matches the default configured cost, no upgrade needed + password: '$2b$12$abcdefghijklmnopqrstuv', + username: 'testuser', + role: 'LEARNER', + status: 'ACTIVE', + }) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(prisma.user.update as any).mockResolvedValue({}) + + await authController.login( + mockRequest as Request, + mockResponse as Response + ) + + const updateCallArgs = (prisma.user.update as any).mock.calls[0][0] + expect(updateCallArgs.data).not.toHaveProperty('password') + }) }) describe('logout', () => { @@ -737,6 +819,56 @@ describe('AuthController', () => { }) }) + describe('resetPassword', () => { + const validToken = 'a'.repeat(64) + + it('should return 400 for a new password that fails the strength policy', async () => { + mockRequest.body = { token: validToken, newPassword: 'alllowercase123' } + + await authController.resetPassword(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('should reset the password, revoke sessions, and revoke pending tokens on success', async () => { + mockRequest.body = { token: validToken, newPassword: 'NewStr0ng!Pass' } + mockRequest.headers = { 'user-agent': 'vitest' } + mockRequest.socket = { remoteAddress: '127.0.0.1' } as any + + ;(prisma.verificationToken.findFirst as any).mockResolvedValue({ + id: 'vt1', + userId: 'u1', + status: 'PENDING', + type: 'PASSWORD_RESET', + expiresAt: new Date(Date.now() + 60000), + }) + + await authController.resetPassword(mockRequest as Request, mockResponse as Response) + + expect(prisma.$transaction).toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Password reset successful' }) + }) + + it('should return 400 for an expired token', async () => { + mockRequest.body = { token: validToken, newPassword: 'NewStr0ng!Pass' } + + ;(prisma.verificationToken.findFirst as any).mockResolvedValue({ + id: 'vt1', + userId: 'u1', + status: 'PENDING', + type: 'PASSWORD_RESET', + expiresAt: new Date(Date.now() - 60000), + }) + + await authController.resetPassword(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Token expired' }) + }) + }) + describe('requestOtp', () => { it('should reject a malformed phone number', async () => { mockRequest.body = { phone: '08012345678' } diff --git a/tests/unit/auth.middleware.test.ts b/tests/unit/auth.middleware.test.ts index e0da93b..17c0c1f 100644 --- a/tests/unit/auth.middleware.test.ts +++ b/tests/unit/auth.middleware.test.ts @@ -1,18 +1,34 @@ import { NextFunction, Request, Response } from 'express' -// JWT_SECRET must be set before auth.middleware is imported because the module -// throws at load time if the variable is missing. vi.stubEnv + dynamic import -// is the correct Vitest pattern for this scenario. -import { describe, expect, it, vi } from 'vitest' +// JWT_SECRET/ISSUER/AUDIENCE must be set before auth.middleware (and the +// config/jwt module it wraps) are imported, since config/jwt reads them at +// module load time. vi.stubEnv + dynamic import is the correct Vitest +// pattern for this scenario. +import { describe, expect, it, vi, beforeEach } from 'vitest' import jwt from 'jsonwebtoken' const JWT_SECRET = 'test-secret-key' +const JWT_ISSUER = 'learnault-api' +const JWT_AUDIENCE = 'learnault-clients' +const JWT_KEY_ID = 'test-key' -// Stub the env variable BEFORE the module is imported vi.stubEnv('JWT_SECRET', JWT_SECRET) +vi.stubEnv('JWT_ISSUER', JWT_ISSUER) +vi.stubEnv('JWT_AUDIENCE', JWT_AUDIENCE) +vi.stubEnv('JWT_KEY_ID', JWT_KEY_ID) + +const findUniqueMock = vi.fn() + +vi.mock('../../src/config/database', () => ({ + default: { + user: { + findUnique: (...args: unknown[]) => findUniqueMock(...args), + }, + }, +})) // Dynamically import AFTER stubbing so the module-level guard sees the value -const { authenticate, optionalAuthenticate, authorize } = await import( +const { authenticate, optionalAuthenticate, authorize, requireActiveAccount, requireVerifiedEmail } = await import( '../../src/middleware/auth.middleware' ) @@ -20,9 +36,15 @@ const { authenticate, optionalAuthenticate, authorize } = await import( function makeToken ( payload: Record, - expiresIn: string | number = '1h', + overrides: { expiresIn?: string | number; issuer?: string; audience?: string; keyid?: string; algorithm?: jwt.Algorithm } = {}, ): string { - return jwt.sign(payload, JWT_SECRET, { expiresIn } as jwt.SignOptions) + return jwt.sign(payload, JWT_SECRET, { + expiresIn: overrides.expiresIn ?? '1h', + issuer: overrides.issuer ?? JWT_ISSUER, + audience: overrides.audience ?? JWT_AUDIENCE, + keyid: overrides.keyid ?? JWT_KEY_ID, + algorithm: overrides.algorithm ?? 'HS256', + } as jwt.SignOptions) } function makeMocks () { @@ -36,6 +58,10 @@ function makeMocks () { return { req, res, next } } +beforeEach(() => { + findUniqueMock.mockReset() +}) + // ── authenticate ────────────────────────────────────────────────────────────── describe('authenticate', () => { @@ -76,7 +102,7 @@ describe('authenticate', () => { it('returns 401 with "Token has expired" for an expired token', () => { const { req, res, next } = makeMocks() - const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, -1) + const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { expiresIn: -1 }) req.headers = { authorization: `Bearer ${token}` } authenticate(req as Request, res as Response, next) @@ -96,6 +122,55 @@ describe('authenticate', () => { expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token' }) expect(next).not.toHaveBeenCalled() }) + + it('returns 401 for a token signed with the wrong issuer', () => { + const { req, res, next } = makeMocks() + const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { issuer: 'someone-else' }) + req.headers = { authorization: `Bearer ${token}` } + + authenticate(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token' }) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 401 for a token signed with the wrong audience', () => { + const { req, res, next } = makeMocks() + const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { audience: 'someone-else' }) + req.headers = { authorization: `Bearer ${token}` } + + authenticate(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token' }) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 401 for a token signed with an unrecognized key id', () => { + const { req, res, next } = makeMocks() + const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { keyid: 'unknown-key' }) + req.headers = { authorization: `Bearer ${token}` } + + authenticate(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token' }) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 401 for a token signed with a different algorithm', () => { + const { req, res, next } = makeMocks() + // none-algorithm/HS384 forgery attempt — must be rejected even though + // jsonwebtoken itself can produce it. + const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { algorithm: 'HS384' }) + req.headers = { authorization: `Bearer ${token}` } + + authenticate(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) }) // ── optionalAuthenticate ────────────────────────────────────────────────────── @@ -135,7 +210,7 @@ describe('optionalAuthenticate', () => { it('calls next() without blocking when token is expired', () => { const { req, res, next } = makeMocks() - const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, -1) + const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { expiresIn: -1 }) req.headers = { authorization: `Bearer ${token}` } optionalAuthenticate(req as Request, res as Response, next) @@ -145,33 +220,130 @@ describe('optionalAuthenticate', () => { }) }) +// ── requireActiveAccount ───────────────────────────────────────────────────── + +describe('requireActiveAccount', () => { + it('returns 401 when req.user is not set', async () => { + const { req, res, next } = makeMocks() + + await requireActiveAccount(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('calls next() for an ACTIVE account', async () => { + const { req, res, next } = makeMocks(); + (req as any).user = { id: 'u1' } + findUniqueMock.mockResolvedValue({ status: 'ACTIVE' }) + + await requireActiveAccount(req as Request, res as Response, next) + + expect(next).toHaveBeenCalledOnce() + }) + + it('returns 403 ACCOUNT_DEACTIVATED for a deactivated account', async () => { + const { req, res, next } = makeMocks(); + (req as any).user = { id: 'u1' } + findUniqueMock.mockResolvedValue({ status: 'DEACTIVATED' }) + + await requireActiveAccount(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' })) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 403 ACCOUNT_PENDING_DELETION for a pending-deletion account', async () => { + const { req, res, next } = makeMocks(); + (req as any).user = { id: 'u1' } + findUniqueMock.mockResolvedValue({ status: 'PENDING_DELETION' }) + + await requireActiveAccount(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ACCOUNT_PENDING_DELETION' })) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 401 "Account not found" for a deleted or missing account', async () => { + const { req, res, next } = makeMocks(); + (req as any).user = { id: 'u1' } + findUniqueMock.mockResolvedValue(null) + + await requireActiveAccount(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ message: 'Account not found' }) + expect(next).not.toHaveBeenCalled() + }) +}) + +// ── requireVerifiedEmail ───────────────────────────────────────────────────── + +describe('requireVerifiedEmail', () => { + it('returns 401 when req.user is not set', async () => { + const { req, res, next } = makeMocks() + + await requireVerifiedEmail(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('calls next() when the email is verified', async () => { + const { req, res, next } = makeMocks(); + (req as any).user = { id: 'u1' } + findUniqueMock.mockResolvedValue({ isVerified: true }) + + await requireVerifiedEmail(req as Request, res as Response, next) + + expect(next).toHaveBeenCalledOnce() + }) + + it('returns 403 EMAIL_NOT_VERIFIED when the email is unverified', async () => { + const { req, res, next } = makeMocks(); + (req as any).user = { id: 'u1' } + findUniqueMock.mockResolvedValue({ isVerified: false }) + + await requireVerifiedEmail(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'EMAIL_NOT_VERIFIED' })) + expect(next).not.toHaveBeenCalled() + }) +}) + // ── authorize ───────────────────────────────────────────────────────────────── describe('authorize', () => { - it('calls next() when user has a matching role', () => { + it('calls next() when the persisted role matches', async () => { const { req, res, next } = makeMocks(); (req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } + findUniqueMock.mockResolvedValue({ role: 'learner', status: 'ACTIVE' }) - authorize('learner')(req as Request, res as Response, next) + await authorize('learner')(req as Request, res as Response, next) expect(next).toHaveBeenCalledOnce() expect(res.status).not.toHaveBeenCalled() }) - it('calls next() when user role matches one of multiple allowed roles', () => { + it('calls next() when the persisted role matches one of multiple allowed roles', async () => { const { req, res, next } = makeMocks(); (req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } + findUniqueMock.mockResolvedValue({ role: 'employer', status: 'ACTIVE' }) - authorize('learner', 'employer')(req as Request, res as Response, next) + await authorize('learner', 'employer')(req as Request, res as Response, next) expect(next).toHaveBeenCalledOnce() }) - it('returns 403 when user role is not in the allowed list', () => { + it('returns 403 when the persisted role is not in the allowed list', async () => { const { req, res, next } = makeMocks(); (req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } + findUniqueMock.mockResolvedValue({ role: 'learner', status: 'ACTIVE' }) - authorize('employer')(req as Request, res as Response, next) + await authorize('employer')(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(403) expect(res.json).toHaveBeenCalledWith( @@ -180,13 +352,49 @@ describe('authorize', () => { expect(next).not.toHaveBeenCalled() }) - it('returns 401 when req.user is not set', () => { + it('returns 401 when req.user is not set', async () => { const { req, res, next } = makeMocks() - authorize('learner')(req as Request, res as Response, next) + await authorize('learner')(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(401) expect(res.json).toHaveBeenCalledWith({ message: 'Authentication required' }) expect(next).not.toHaveBeenCalled() }) -}) \ No newline at end of file + + it('uses the current persisted role even when the JWT claim is stale', async () => { + const { req, res, next } = makeMocks(); + // Token still claims 'learner', but the account was promoted to + // 'employer' in the database after the token was issued. + (req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } + findUniqueMock.mockResolvedValue({ role: 'employer', status: 'ACTIVE' }) + + await authorize('employer')(req as Request, res as Response, next) + + expect(next).toHaveBeenCalledOnce() + expect((req as any).user.role).toBe('employer') + }) + + it('returns 403 ACCOUNT_DEACTIVATED even if the JWT role would otherwise pass', async () => { + const { req, res, next } = makeMocks(); + (req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } + findUniqueMock.mockResolvedValue({ role: 'employer', status: 'DEACTIVATED' }) + + await authorize('employer')(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' })) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 401 "Account not found" for a deleted account', async () => { + const { req, res, next } = makeMocks(); + (req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } + findUniqueMock.mockResolvedValue(null) + + await authorize('employer')(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/employer.routes.test.ts b/tests/unit/employer.routes.test.ts index 414d592..f6f9693 100644 --- a/tests/unit/employer.routes.test.ts +++ b/tests/unit/employer.routes.test.ts @@ -1,5 +1,4 @@ import express from 'express' -import jwt from 'jsonwebtoken' import request from 'supertest' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,14 +8,24 @@ vi.mock('../../src/controllers/employer.controller', () => ({ contactCandidate: vi.fn((_req, res) => res.status(201).json({ ok: true })), })) -import employerRoutes from '../../src/routes/v1/employer.routes' +// authorize()/requireVerifiedEmail now read the caller's role, status, and +// verification flag from the database rather than trusting the JWT claim +// alone — mock that lookup so this route test doesn't need a real DB. +const findUniqueMock = vi.fn() -function makeToken(role: 'learner' | 'employer') { - const secret = process.env.JWT_SECRET as string +vi.mock('../../src/config/database', () => ({ + default: { + user: { + findUnique: (...args: unknown[]) => findUniqueMock(...args), + }, + }, +})) - return jwt.sign({ id: 'user-1', email: 'user@example.com', role }, secret, { - expiresIn: '1h', - }) +const { issueAccessToken } = await import('../../src/config/jwt') +const employerRoutes = (await import('../../src/routes/v1/employer.routes')).default + +function makeToken (role: 'learner' | 'employer') { + return issueAccessToken({ id: 'user-1', role }) } describe('employer.routes', () => { @@ -35,6 +44,8 @@ describe('employer.routes', () => { }) it('restricts access to employer accounts only', async () => { + findUniqueMock.mockResolvedValue({ role: 'learner', status: 'ACTIVE', isVerified: true }) + const app = express() app.use(express.json()) app.use('/employer', employerRoutes) @@ -47,6 +58,8 @@ describe('employer.routes', () => { }) it('applies employer rate limiter and allows employer role', async () => { + findUniqueMock.mockResolvedValue({ role: 'employer', status: 'ACTIVE', isVerified: true }) + const app = express() app.use(express.json()) app.use('/employer', employerRoutes) @@ -58,4 +71,18 @@ describe('employer.routes', () => { expect(response.status).toBe(200) expect(response.headers['x-ratelimit-limit']).toBeDefined() }) + + it('rejects an employer with an unverified email', async () => { + findUniqueMock.mockResolvedValue({ role: 'employer', status: 'ACTIVE', isVerified: false }) + + const app = express() + app.use(express.json()) + app.use('/employer', employerRoutes) + + const response = await request(app) + .get('/employer/search') + .set('Authorization', `Bearer ${makeToken('employer')}`) + + expect(response.status).toBe(403) + }) }) diff --git a/tests/unit/jwt-config.test.ts b/tests/unit/jwt-config.test.ts new file mode 100644 index 0000000..a4121e0 --- /dev/null +++ b/tests/unit/jwt-config.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' + +// config/jwt.ts reads all of its configuration from process.env at module +// load time, so every scenario below needs its own fresh module instance +// (vi.resetModules + dynamic import) after stubbing the env it cares about. + +describe('config/jwt — key rotation and token pinning', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('throws on load outside NODE_ENV=test when JWT_SECRET is missing', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('JWT_SECRET', '') + + await expect(import('../../src/config/jwt')).rejects.toThrow(/JWT_SECRET/) + }) + + it('falls back to a fixed test-only secret when NODE_ENV=test and JWT_SECRET is missing', async () => { + vi.stubEnv('NODE_ENV', 'test') + vi.stubEnv('JWT_SECRET', '') + + const { issueAccessToken, verifyAccessToken } = await import('../../src/config/jwt') + const token = issueAccessToken({ id: 'u1', role: 'learner' }) + const claims = verifyAccessToken(token) + + expect(claims.id).toBe('u1') + }) + + it('issues tokens with the pinned issuer, audience, algorithm, and active kid', async () => { + vi.stubEnv('NODE_ENV', 'test') + vi.stubEnv('JWT_SECRET', 'primary-secret') + vi.stubEnv('JWT_ISSUER', 'learnault-api') + vi.stubEnv('JWT_AUDIENCE', 'learnault-clients') + vi.stubEnv('JWT_KEY_ID', 'key-2026-01') + + const { issueAccessToken } = await import('../../src/config/jwt') + const jwtModule = (await import('jsonwebtoken')).default + const token = issueAccessToken({ id: 'u1', role: 'learner' }) + const decoded = jwtModule.decode(token, { complete: true }) + + expect(decoded?.header.alg).toBe('HS256') + expect(decoded?.header.kid).toBe('key-2026-01') + expect((decoded?.payload as any).iss).toBe('learnault-api') + expect((decoded?.payload as any).aud).toBe('learnault-clients') + }) + + it('still verifies a token signed under a retired key listed in JWT_PREVIOUS_KEYS', async () => { + vi.stubEnv('NODE_ENV', 'test') + vi.stubEnv('JWT_SECRET', 'old-secret') + vi.stubEnv('JWT_KEY_ID', 'key-old') + + // Mint a token under the "old" active key/config. + const oldModule = await import('../../src/config/jwt') + const oldToken = oldModule.issueAccessToken({ id: 'u1', role: 'learner' }) + + // Rotate: new active key, old key demoted to JWT_PREVIOUS_KEYS. + vi.resetModules() + vi.stubEnv('JWT_SECRET', 'new-secret') + vi.stubEnv('JWT_KEY_ID', 'key-new') + vi.stubEnv('JWT_PREVIOUS_KEYS', 'key-old:old-secret') + + const rotatedModule = await import('../../src/config/jwt') + const claims = rotatedModule.verifyAccessToken(oldToken) + + expect(claims.id).toBe('u1') + + const newToken = rotatedModule.issueAccessToken({ id: 'u2', role: 'employer' }) + expect(rotatedModule.verifyAccessToken(newToken).id).toBe('u2') + }) + + it('rejects a token whose key id is neither active nor listed as retired', async () => { + vi.stubEnv('NODE_ENV', 'test') + vi.stubEnv('JWT_SECRET', 'new-secret') + vi.stubEnv('JWT_KEY_ID', 'key-new') + vi.stubEnv('JWT_PREVIOUS_KEYS', '') + + const { issueAccessToken, verifyAccessToken } = await import('../../src/config/jwt') + const jwtModule = (await import('jsonwebtoken')).default + const forged = jwtModule.sign({ id: 'attacker' }, 'guessed-secret', { + keyid: 'never-registered', + issuer: 'learnault-api', + audience: 'learnault-clients', + algorithm: 'HS256', + }) + + expect(() => verifyAccessToken(forged)).toThrow() + // sanity: legitimate tokens from the same module still verify fine + expect(() => verifyAccessToken(issueAccessToken({ id: 'u1', role: 'learner' }))).not.toThrow() + }) +}) diff --git a/tests/unit/password.test.ts b/tests/unit/password.test.ts new file mode 100644 index 0000000..ffc207a --- /dev/null +++ b/tests/unit/password.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { + isStrongPassword, + hashPassword, + comparePassword, + currentHashCost, + needsRehash, + getConfiguredSaltRounds, +} from '../../src/utils/password' + +describe('isStrongPassword', () => { + it('accepts a password with upper, lower, number, and symbol', () => { + expect(isStrongPassword('Str0ng!Pass')).toBe(true) + }) + + it.each<[string, boolean]>([ + ['Short1!', false], // 7 chars — below the minimum length + ['alllowercase1!', false], + ['ALLUPPERCASE1!', false], + ['NoNumbers!', false], + ['NoSymbols123', false], + ['Ab1!', false], // too short + ])('rejects %s', (candidate, expected) => { + expect(isStrongPassword(candidate)).toBe(expected) + }) +}) + +describe('getConfiguredSaltRounds', () => { + beforeEach(() => vi.unstubAllEnvs()) + afterEach(() => vi.unstubAllEnvs()) + + it('defaults to 12 when unset', () => { + vi.stubEnv('BCRYPT_SALT_ROUNDS', '') + expect(getConfiguredSaltRounds()).toBe(12) + }) + + it('honors a valid configured value', () => { + vi.stubEnv('BCRYPT_SALT_ROUNDS', '13') + expect(getConfiguredSaltRounds()).toBe(13) + }) + + it('falls back to 12 for an out-of-range value', () => { + vi.stubEnv('BCRYPT_SALT_ROUNDS', '99') + expect(getConfiguredSaltRounds()).toBe(12) + }) +}) + +describe('hashPassword / comparePassword', () => { + it('round-trips: a hash verifies against its own plaintext', async () => { + const hash = await hashPassword('Str0ng!Pass', 10) + + await expect(comparePassword('Str0ng!Pass', hash)).resolves.toBe(true) + await expect(comparePassword('wrong', hash)).resolves.toBe(false) + }) +}) + +describe('currentHashCost / needsRehash', () => { + beforeEach(() => vi.unstubAllEnvs()) + afterEach(() => vi.unstubAllEnvs()) + + it('reads the cost factor embedded in a bcrypt hash', async () => { + const hash = await hashPassword('Str0ng!Pass', 10) + + expect(currentHashCost(hash)).toBe(10) + }) + + it('returns null for a non-bcrypt string', () => { + expect(currentHashCost('not-a-hash')).toBeNull() + }) + + it('flags a hash below the configured cost as needing a rehash', async () => { + vi.stubEnv('BCRYPT_SALT_ROUNDS', '12') + const weakHash = await hashPassword('Str0ng!Pass', 10) + + expect(needsRehash(weakHash)).toBe(true) + }) + + it('does not flag a hash at or above the configured cost', async () => { + vi.stubEnv('BCRYPT_SALT_ROUNDS', '10') + const currentHash = await hashPassword('Str0ng!Pass', 10) + + expect(needsRehash(currentHash)).toBe(false) + }) +})