Skip to content
18 changes: 17 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
95 changes: 95 additions & 0 deletions docs/AUTH_POLICY.md
Original file line number Diff line number Diff line change
@@ -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.
128 changes: 128 additions & 0 deletions src/config/jwt.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const raw = process.env.JWT_PREVIOUS_KEYS
const keys = new Map<string, string>()

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<SignOptions, 'algorithm' | 'expiresIn' | 'keyid'> = {}
): 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()),
}
17 changes: 5 additions & 12 deletions src/controllers/account.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 = `\
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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 })
}
}
29 changes: 13 additions & 16 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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' })

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading