From 9278c3063216e4a07b51251279d62f1c0c8db928 Mon Sep 17 00:00:00 2001 From: augustinemartins Date: Thu, 20 Aug 2026 22:14:53 +0000 Subject: [PATCH] feat(auth): add rotating refresh sessions, reuse detection, and logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace stateless logout with short-lived access + opaque rotating refresh tokens backed by a refresh_tokens table with family linkage. Replaying a consumed token revokes the whole family and its session. Adds /auth/refresh, /auth/logout, and /auth/logout/all with JSON-body or httpOnly-cookie transport and a documented CSRF policy. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- .env.example | 5 +- ROADMAP.md | 2 +- docs/API.md | 52 ++- docs/AUTH_POLICY.md | 25 +- docs/security/refresh-token-rotation.md | 99 +++++ .../migration.sql | 34 ++ prisma/schema.prisma | 21 + src/config/env.ts | 4 + src/config/jwt.ts | 15 +- src/config/swagger.ts | 2 +- src/controllers/auth.controller.ts | 317 ++++++++++++++- src/docs/schemas.ts | 56 ++- src/routes/v1/auth.routes.ts | 18 +- src/schemas/auth.schema.ts | 11 + src/services/refresh-token.service.ts | 337 ++++++++++++++++ src/types/session.types.ts | 3 + src/utils/cookies.ts | 37 ++ tests/auth.controller.test.ts | 205 +++++++++- tests/refresh-token.service.test.ts | 379 ++++++++++++++++++ 19 files changed, 1577 insertions(+), 45 deletions(-) create mode 100644 docs/security/refresh-token-rotation.md create mode 100644 prisma/migrations/20260820130000_refresh_token_rotation/migration.sql create mode 100644 src/services/refresh-token.service.ts create mode 100644 src/utils/cookies.ts create mode 100644 tests/refresh-token.service.test.ts diff --git a/.env.example b/.env.example index 4250ddb..93403b8 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,10 @@ STELLAR_NETWORK=testnet JWT_SECRET= JWT_ISSUER=learnault-api JWT_AUDIENCE=learnault-clients -JWT_EXPIRES_IN=1d +# Access-token lifetime in seconds (short-lived; refreshed via opaque token). +JWT_ACCESS_TTL_SECONDS=900 +# Opaque refresh-token lifetime in seconds (default 30 days). +REFRESH_TOKEN_TTL_SECONDS=2592000 # 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 diff --git a/ROADMAP.md b/ROADMAP.md index 7ee1735..840d0ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,7 @@ Status baseline: 17 July 2026. The present schema supports users, flat modules, ## Current implementation inventory - [x] Express/TypeScript service, versioned router, Prisma/PostgreSQL setup, security headers, logging, and error middleware exist. -- [~] JWT registration/login exists; logout is stateless and refresh, verification, recovery, session, and wallet provisioning flows are absent. +- [~] JWT registration/login exists with rotating refresh sessions, reuse detection, and logout; verification, recovery, session management, and wallet provisioning flows are partly present. - [~] User routes exist, but user persistence helpers currently return mock users. - [~] Flat module list/detail/start/complete routes exist without Course, LearningPath, Lesson, Quiz, Question, Attempt, Enrollment, or detailed Progress models. - [~] Reward, credential, referral, offline-sync, notification, webhook, employer, Stellar, and Soroban services/routes exist at varying levels of completeness. diff --git a/docs/API.md b/docs/API.md index 6188353..f2a63d1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -9,8 +9,8 @@ The Learnault API is a JSON REST API for a decentralized learn-to-earn platform | Item | Value | |------|-------| | Base URL (production) | `https://api.learnault.io/api/v1` | -| Base URL (local) | `http://localhost:3000/api/v1` | -| Auth scheme | JWT Bearer (`Authorization: Bearer `) | +| Base URL (local) | `http://localhost:3000/api/v1` || Auth scheme | JWT Bearer (`Authorization: Bearer `) | +| Refresh scheme | Opaque rotating refresh token (`refreshToken` body or `refresh_token` cookie) | | Content-Type | `application/json` | --- @@ -134,7 +134,10 @@ Register a new user. Queues a verification email. ```json { "message": "User registered successfully", - "token": "", + "accessToken": "", + "refreshToken": "", + "expiresIn": 900, + "tokenType": "Bearer", "user": { "id": "...", "email": "...", "username": "...", "role": "learner" } } ``` @@ -151,11 +154,50 @@ Rate-limited (10 req / 15 min). --- +### `POST /auth/refresh` + +Rotates a refresh token for a new access/refresh pair. The presented token +is consumed; a replayed (already-rotated) token revokes the whole session +family and returns `401 REFRESH_REUSE_DETECTED`. + +**Request body:** `{ "refreshToken": "" }` β€” or send the token +via an httpOnly `refresh_token` cookie. + +```json +{ + "message": "Token refreshed successfully", + "accessToken": "", + "refreshToken": "", + "expiresIn": 900, + "tokenType": "Bearer" +} +``` + +**Responses:** 200 (rotated), 400 (missing token), 401 (`REFRESH_INVALID`, +`REFRESH_EXPIRED`, `REFRESH_REVOKED`, or `REFRESH_REUSE_DETECTED`) + +--- + ### `POST /auth/logout` -Stateless β€” no session is stored server-side. Returns a reminder to clear the token client-side. +Logs out the current session by revoking its refresh-token family. Idempotent. + +**Request body:** `{ "refreshToken": "" }` β€” or via the +httpOnly `refresh_token` cookie. + +**Response:** `200 { "message": "Logged out successfully", "revokedCount": 1 }` + +--- + +### `POST /auth/logout/all` + +Logs out every session for the user identified by the refresh token. +Idempotent. + +**Request body:** `{ "refreshToken": "" }` β€” or via the +httpOnly `refresh_token` cookie. -**Response:** `200 { "message": "Logged out successfully. Please clear your token client-side." }` +**Response:** `200 { "message": "All sessions logged out", "revokedCount": 3 }` --- diff --git a/docs/AUTH_POLICY.md b/docs/AUTH_POLICY.md index 7872053..73a193d 100644 --- a/docs/AUTH_POLICY.md +++ b/docs/AUTH_POLICY.md @@ -81,12 +81,27 @@ 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 +## 6. Refresh token rotation & transport + +Access tokens are short-lived (default 15 minutes); refresh tokens are +opaque and rotated on every use. The full design β€” family linkage, reuse +detection, logout, transport, and the CSRF policy β€” is documented in +[`docs/security/refresh-token-rotation.md`](security/refresh-token-rotation.md). + +| Token | Transport | Lifetime | Storage | +| --- | --- | --- | --- | +| Access token (JWT) | `Authorization: Bearer ` header | 15 min (`JWT_ACCESS_TTL_SECONDS`) | client memory only | +| Refresh token (opaque) | JSON body `refreshToken`, or httpOnly `refresh_token` cookie | 30 days (`REFRESH_TOKEN_TTL_SECONDS`) | SHA-256 hash only (`refresh_tokens.tokenHash`) | + +- Refresh tokens are single-use: each successful `POST /auth/refresh` + consumes the presented token and returns a new one in the same family. +- Presenting an already-consumed (ROTATED) token is treated as theft and + revokes the entire family plus its parent session (reuse detection). +- `POST /auth/logout` revokes the current session; `POST /auth/logout/all` + revokes every session for the identified user. + +## 7. 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 diff --git a/docs/security/refresh-token-rotation.md b/docs/security/refresh-token-rotation.md new file mode 100644 index 0000000..a4e31fe --- /dev/null +++ b/docs/security/refresh-token-rotation.md @@ -0,0 +1,99 @@ +# Refresh Token Rotation, Transport & CSRF Policy + +Reference implementation for **API Roadmap Phase 1: Implement Refresh +Rotation and Logout API** (#130). This replaces the old stateless logout with +stateful, rotating refresh sessions. + +## 1. Token model + +| Token | Type | Lifetime | Issued at | Sent back | +| --- | --- | --- | --- | --- | +| Access token | JWT (HS256, `kid`-pinned) | 15 min (`JWT_ACCESS_TTL_SECONDS`) | login, register, OTP-login, refresh | `Authorization: Bearer ` | +| Refresh token | opaque, 64-char base64url, 256-bit | 30 days (`REFRESH_TOKEN_TTL_SECONDS`) | login, register, OTP-login, refresh | JSON body `refreshToken` or `refresh_token` cookie | + +The raw refresh token is **never persisted**. Only its SHA-256 hash is stored +in `refresh_tokens.tokenHash` (a unique index). This means a database leak +does not expose usable refresh tokens. + +## 2. Rotation & family linkage + +Every login/register/OTP-login creates a `Session` and a new **rotation +family** (a `familyId` plus the session's first `RefreshToken` row). + +A successful `POST /auth/refresh` runs atomically: + +1. Hash the presented refresh token and look it up. +2. Reject unless the row is `ACTIVE`, unexpired, and its session is + unrevoked and unexpired. +3. Claim the token with a conditional update (`ACTIVE` β†’ `ROTATED`); if + `count === 0`, another request already consumed it β€” treat as reuse. +4. Mint a new `RefreshToken` in the **same family** (`ACTIVE`, new expiry), + and advance the session's access token and `lastUsedAt` in one + transaction. + +The family is the set of rows sharing a `familyId`: one `ROTATED` (consumed) +token per rotation plus the single current `ACTIVE` token. + +## 3. Reuse (replay) detection + +Presenting a refresh token whose status is already `ROTATED` β€” or losing the +rotation race β€” is a strong signal of token theft. The service responds by +revoking the **entire family** and its parent session, and returns +`401 REFRESH_REUSE_DETECTED`. The victim must log in again; the attacker's +copy of the token is now worthless. + +## 4. Logout + +| Endpoint | Input | Effect | +| --- | --- | --- | +| `POST /auth/logout` | refresh token | revokes the session + its family (logout current) | +| `POST /auth/logout/all` | refresh token | revokes **every** session for the identified user (logout all) | + +Both are idempotent and return `revokedCount`. Unknown tokens are a neutral +no-op (`revokedCount: 0`) so the response does not leak token validity. + +## 5. Transport + +- **Access token** β€” always `Authorization: Bearer `. This keeps + every protected route unchanged and avoids CSRF exposure for state-changing + API calls, because browsers never attach the header automatically. +- **Refresh token** β€” the client chooses one of: + 1. **JSON body** (`{ "refreshToken": "..." }`), the default for native + apps and server-to-server clients. + 2. **httpOnly cookie** named `refresh_token`, recommended for browser + clients that want the refresh token out of JavaScript's reach. + +The refresh and logout endpoints accept the token from either source, body +first, then cookie. + +## 6. CSRF policy + +- **Body transport (recommended for APIs):** not CSRF-exposed. A cross-site + attacker cannot force a victim's browser to submit a JSON body with the + `Authorization` header; `SameSite` does not apply because no cookie is used. +- **Cookie transport (optional for browsers):** the cookie is sent + automatically, so CSRF protection is required. Clients using cookie + transport MUST: + 1. Set `SameSite=Strict` (or `Lax` with an explicit CSRF token), and + 2. Reject cross-site requests at the edge, e.g. verify `Origin` / + `Sec-Fetch-Site` before forwarding `/auth/refresh` and `/auth/logout`. + +The server does not set cookies itself; it only *reads* an existing +`refresh_token` cookie. This keeps the API contract transport-agnostic and +leaves cookie lifecycle (and its CSRF obligations) to the client/edge. + +## 7. Failure matrix + +| Condition | Result | +| --- | --- | +| Unknown token | `401 REFRESH_INVALID` | +| `ROTATED` token replayed | revoke family β†’ `401 REFRESH_REUSE_DETECTED` | +| `REVOKED` token/session | `401 REFRESH_REVOKED` | +| Expired token or session | `401 REFRESH_EXPIRED` | +| Missing token | `400 refreshToken is required` | + +## 8. Verification + +Covered by `tests/refresh-token.service.test.ts` (rotation, reuse, race, +expiry, revocation, logout) and `tests/auth.controller.test.ts` (transport via +body and cookie, error codes). diff --git a/prisma/migrations/20260820130000_refresh_token_rotation/migration.sql b/prisma/migrations/20260820130000_refresh_token_rotation/migration.sql new file mode 100644 index 0000000..9de722a --- /dev/null +++ b/prisma/migrations/20260820130000_refresh_token_rotation/migration.sql @@ -0,0 +1,34 @@ +-- Rotating refresh tokens with family linkage and reuse detection. +-- +-- Each row is one opaque refresh token (stored as a SHA-256 hash only β€” the +-- raw token is never persisted). Rows sharing a `familyId` form a rotation +-- family: a successful refresh consumes the presented token (ACTIVE β†’ ROTATED) +-- and mints a new ACTIVE row in the same family. Presenting a ROTATED token is +-- treated as theft and revokes the entire family plus its parent session. + +CREATE TABLE "refresh_tokens" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "familyId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- Uniquely index the token hash for O(1) lookup during refresh. +CREATE UNIQUE INDEX "refresh_tokens_tokenHash_key" ON "refresh_tokens"("tokenHash"); + +-- Fast family/session revocation during reuse detection and logout. +CREATE INDEX "refresh_tokens_familyId_status_idx" ON "refresh_tokens"("familyId", "status"); +CREATE INDEX "refresh_tokens_sessionId_status_idx" ON "refresh_tokens"("sessionId", "status"); + +-- Cascade so session deletion (account deletion finalization) also removes +-- its refresh-token family. +ALTER TABLE "refresh_tokens" + ADD CONSTRAINT "refresh_tokens_sessionId_fkey" + FOREIGN KEY ("sessionId") REFERENCES "sessions"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fcae08..617d684 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -182,12 +182,33 @@ model Session { revokedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + refreshTokens RefreshToken[] @@index([userId, isRevoked]) @@index([userId, isRevoked, lastUsedAt]) @@map("sessions") } +// One row per opaque refresh token. Rows sharing a `familyId` form a rotation +// family: each successful refresh consumes the presented token (ACTIVE β†’ +// ROTATED) and mints a new ACTIVE row in the same family. Presenting an already +// ROTATED token is treated as theft and revokes the entire family + session. +model RefreshToken { + id String @id @default(uuid()) + sessionId String + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + familyId String + tokenHash String @unique + status String @default("ACTIVE") // ACTIVE, ROTATED, REVOKED + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([familyId, status]) + @@index([sessionId, status]) + @@map("refresh_tokens") +} + model AuditLog { id String @id @default(uuid()) userId String? diff --git a/src/config/env.ts b/src/config/env.ts index 73d1fe5..c2c1c2d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -28,6 +28,10 @@ export const env = { // SMS provider selection β€” only "mock" is implemented until a real provider is integrated SMS_PROVIDER: process.env.SMS_PROVIDER || 'mock', + // Token lifetimes + ACCESS_TOKEN_TTL_SECONDS: parseInt(process.env.JWT_ACCESS_TTL_SECONDS || '900', 10), + REFRESH_TOKEN_TTL_SECONDS: parseInt(process.env.REFRESH_TOKEN_TTL_SECONDS || '2592000', 10), // 30 days + // Account lifecycle configurations DELETION_COOLING_OFF_DAYS: parseInt(process.env.DELETION_COOLING_OFF_DAYS || '30', 10), EXPORT_TTL_DAYS: parseInt(process.env.EXPORT_TTL_DAYS || '7', 10), diff --git a/src/config/jwt.ts b/src/config/jwt.ts index d69ca22..df5a5b5 100644 --- a/src/config/jwt.ts +++ b/src/config/jwt.ts @@ -6,7 +6,13 @@ import { JWTPayload, signToken, verifyToken } from '../utils/jwt' 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' +// Access tokens are short-lived (default 15 minutes); clients exchange an +// opaque refresh token (see services/refresh-token.service.ts) for a fresh one. +const ACCESS_TOKEN_TTL_SECONDS = (() => { + const parsed = parseInt(process.env.JWT_ACCESS_TTL_SECONDS || '', 10) + + return Number.isInteger(parsed) && parsed > 0 ? parsed : 15 * 60 +})() const ACTIVE_KEY_ID = process.env.JWT_KEY_ID || 'default' const IS_TEST_ENV = process.env.NODE_ENV === 'test' @@ -88,7 +94,7 @@ export function issueAccessToken( algorithm: ALGORITHM, issuer: ISSUER, audience: AUDIENCE, - expiresIn: EXPIRES_IN, + expiresIn: ACCESS_TOKEN_TTL_SECONDS, keyid: ACTIVE_KEY_ID, } as SignOptions) } @@ -122,7 +128,10 @@ export const jwtConfig = { algorithm: ALGORITHM, issuer: ISSUER, audience: AUDIENCE, - expiresIn: EXPIRES_IN, + expiresIn: ACCESS_TOKEN_TTL_SECONDS, activeKeyId: ACTIVE_KEY_ID, retiredKeyIds: Array.from(RETIRED_KEYS.keys()), } + +/** Access-token lifetime in seconds, for the `expiresIn` response field. */ +export const accessTokenTtlSeconds = ACCESS_TOKEN_TTL_SECONDS diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 9b90d95..a4216ab 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -48,7 +48,7 @@ const options: swaggerJsdoc.Options = { ], tags: [ { name: 'Health', description: 'Service health check' }, - { name: 'Auth', description: 'Registration, login, email verification, password reset, phone OTP' }, + { name: 'Auth', description: 'Registration, login, refresh rotation, logout, email verification, password reset, phone OTP' }, { name: 'Users', description: 'User profile management' }, { name: 'Modules', description: 'Learning module catalogue and progress tracking' }, { name: 'Credentials', description: 'On-chain verifiable credentials' }, diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 3221c97..f4e3ad7 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,12 +1,13 @@ import crypto from 'crypto' import { Request, Response } from 'express' import prisma from '../config/database' -import { issueAccessToken } from '../config/jwt' -import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema, otpRequestSchema, otpVerifySchema } from '../schemas/auth.schema' +import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema, otpRequestSchema, otpVerifySchema, refreshTokenSchema } 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 { refreshTokenService } from '../services/refresh-token.service' import { comparePassword, hashPassword, needsRehash } from '../utils/password' +import { parseCookieHeader } from '../utils/cookies' import logger from '../utils/logger' const VERIFICATION_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours @@ -180,11 +181,15 @@ export class AuthController { logger.error('[Auth] Failed to queue verification email:', err) ) - const token = this.generateToken(user.id, user.role) + const session = await refreshTokenService.issueSession({ + userId: user.id, + role: user.role, + ...this.clientContext(req), + }) res.status(201).json({ message: 'User registered successfully', - token, + ...this.tokenPayload(session), user: { id: user.id, email: user.email, @@ -511,11 +516,15 @@ export class AuthController { data: { ...passwordUpdate, lastLoginAt: new Date() } }) - const token = this.generateToken(user.id, user.role) + const session = await refreshTokenService.issueSession({ + userId: user.id, + role: user.role, + ...this.clientContext(req), + }) res.status(200).json({ message: 'Login successful', - token, + ...this.tokenPayload(session), user: { id: user.id, email: user.email, @@ -529,23 +538,251 @@ export class AuthController { } } + /** + * @openapi + * /auth/refresh: + * post: + * operationId: authRefresh + * summary: Rotate a refresh token for a new access/refresh pair + * description: > + * Consumes the presented refresh token and issues a new short-lived + * access token plus a new opaque refresh token in the same family. + * Presenting a refresh token that has already been rotated (replay) + * revokes the entire family and returns 401 REFRESH_REUSE_DETECTED. + * + * The refresh token may be sent in the JSON body (`refreshToken`) + * or via an httpOnly `refresh_token` cookie. + * tags: [Auth] + * security: [] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RefreshTokenInput' + * responses: + * 200: + * description: Token rotated successfully. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TokenResponse' + * 400: + * description: Validation failed or refresh token missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Invalid, expired, revoked, or replayed refresh token. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async refresh(req: Request, res: Response): Promise { + try { + const validation = refreshTokenSchema.safeParse(req.body ?? {}) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const refreshToken = this.readRefreshToken(req) + if (!refreshToken) { + res.status(400).json({ error: 'refreshToken is required' }) + + return + } + + const result = await refreshTokenService.rotate(refreshToken, this.clientContext(req)) + + switch (result.kind) { + case 'ok': + res.status(200).json({ + message: 'Token refreshed successfully', + ...this.tokenPayload(result), + }) + + return + + case 'reuse': + res.status(401).json({ + error: 'Refresh token reuse detected; the session has been revoked', + code: 'REFRESH_REUSE_DETECTED', + }) + + return + + case 'expired': + res.status(401).json({ error: 'Refresh token expired', code: 'REFRESH_EXPIRED' }) + + return + + case 'revoked': + res.status(401).json({ error: 'Refresh token revoked', code: 'REFRESH_REVOKED' }) + + return + + case 'invalid': + res.status(401).json({ error: 'Invalid refresh token', code: 'REFRESH_INVALID' }) + + return + } + } catch (error) { + logger.error('[Auth] refresh error:', error) + res.status(500).json({ error: 'Internal server error during refresh' }) + } + } + /** * @openapi * /auth/logout: * post: * operationId: authLogout - * summary: Log out (stateless β€” client must discard the token) + * summary: Log out the current session * description: > - * The server has no session state; this endpoint simply returns a - * reminder to clear the token client-side. + * Revokes the session identified by the presented refresh token, so + * that token (and any token in its family) can no longer be used. + * The refresh token may be sent in the JSON body (`refreshToken`) or + * via an httpOnly `refresh_token` cookie. Idempotent. * tags: [Auth] * security: [] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RefreshTokenInput' * responses: * 200: - * description: Logged out successfully. + * description: Session revoked (or already revoked). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LogoutResponse' + * 400: + * description: Validation failed or refresh token missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ async logout(req: Request, res: Response): Promise { - res.status(200).json({ message: 'Logged out successfully. Please clear your token client-side.' }) + try { + const validation = refreshTokenSchema.safeParse(req.body ?? {}) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const refreshToken = this.readRefreshToken(req) + if (!refreshToken) { + res.status(400).json({ error: 'refreshToken is required' }) + + return + } + + const result = await refreshTokenService.revokeByRefreshToken(refreshToken, this.clientContext(req)) + + res.status(200).json({ + message: 'Logged out successfully', + revokedCount: result.revokedCount, + }) + } catch (error) { + logger.error('[Auth] logout error:', error) + res.status(500).json({ error: 'Internal server error during logout' }) + } + } + + /** + * @openapi + * /auth/logout/all: + * post: + * operationId: authLogoutAll + * summary: Log out all sessions for the user + * description: > + * Revokes every session (and their refresh-token families) for the + * user identified by the presented refresh token. The refresh token + * may be sent in the JSON body (`refreshToken`) or via an httpOnly + * `refresh_token` cookie. Idempotent. + * tags: [Auth] + * security: [] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RefreshTokenInput' + * responses: + * 200: + * description: All sessions revoked. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LogoutResponse' + * 400: + * description: Validation failed or refresh token missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async logoutAll(req: Request, res: Response): Promise { + try { + const validation = refreshTokenSchema.safeParse(req.body ?? {}) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const refreshToken = this.readRefreshToken(req) + if (!refreshToken) { + res.status(400).json({ error: 'refreshToken is required' }) + + return + } + + const result = await refreshTokenService.revokeAllByRefreshToken(refreshToken, this.clientContext(req)) + + res.status(200).json({ + message: 'All sessions logged out', + revokedCount: result.revokedCount, + }) + } catch (error) { + logger.error('[Auth] logoutAll error:', error) + res.status(500).json({ error: 'Internal server error during logout' }) + } } /** @@ -1048,11 +1285,15 @@ export class AuthController { data: { lastLoginAt: new Date() } }) - const token = this.generateToken(user.id, user.role) + const session = await refreshTokenService.issueSession({ + userId: user.id, + role: user.role, + ...this.clientContext(req), + }) res.status(200).json({ message: 'Login successful', - token, + ...this.tokenPayload(session), user: { id: user.id, email: user.email, @@ -1098,8 +1339,54 @@ export class AuthController { return null } - private generateToken(userId: string, role: string): string { - return issueAccessToken({ id: userId, role }) + private clientContext(req: Request): { userAgent?: string; ipAddress?: string } { + return { + ipAddress: this.getClientIp(req), + userAgent: this.getUserAgent(req), + } + } + + private getClientIp(req: Request): string { + return (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() + || (req.headers['x-real-ip'] as string) + || req.socket.remoteAddress + || 'unknown' + } + + private getUserAgent(req: Request): string | undefined { + return req.headers['user-agent'] + } + + /** + * Resolve the refresh token from the JSON body first, then from the + * httpOnly `refresh_token` cookie. Returns null when neither is present. + */ + private readRefreshToken(req: Request): string | null { + const bodyToken = req.body?.refreshToken + if (typeof bodyToken === 'string' && bodyToken.length > 0) { + return bodyToken + } + + const cookieToken = parseCookieHeader(req.headers.cookie)['refresh_token'] + if (cookieToken && cookieToken.length > 0) { + return cookieToken + } + + return null + } + + private tokenPayload(result: { accessToken: string; refreshToken: string; expiresIn: number }): { + accessToken: string + refreshToken: string + expiresIn: number + tokenType: string + } { + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + expiresIn: result.expiresIn, + tokenType: 'Bearer', + } } private isRateLimited(key: string, windowMs: number): boolean { diff --git a/src/docs/schemas.ts b/src/docs/schemas.ts index 77c0d8a..536f737 100644 --- a/src/docs/schemas.ts +++ b/src/docs/schemas.ts @@ -116,13 +116,63 @@ * properties: * message: * type: string - * example: User registered successfully - * token: + * example: Login successful + * accessToken: + * type: string + * description: Short-lived JWT; pass as Authorization Bearer token. + * refreshToken: + * type: string + * description: Opaque token used to obtain a new access/refresh pair. + * expiresIn: + * type: integer + * description: Access-token lifetime in seconds. + * example: 900 + * tokenType: * type: string - * description: JWT; pass as Authorization Bearer token. + * example: Bearer * user: * $ref: '#/components/schemas/AuthUser' * + * TokenResponse: + * type: object + * description: Response from POST /auth/refresh after a successful rotation. + * properties: + * message: + * type: string + * example: Token refreshed successfully + * accessToken: + * type: string + * description: New short-lived JWT. + * refreshToken: + * type: string + * description: New opaque refresh token; the presented one is now consumed. + * expiresIn: + * type: integer + * example: 900 + * tokenType: + * type: string + * example: Bearer + * + * RefreshTokenInput: + * type: object + * properties: + * refreshToken: + * type: string + * description: > + * Opaque refresh token. Optional in the JSON body when sent via the + * httpOnly `refresh_token` cookie instead. + * + * LogoutResponse: + * type: object + * properties: + * message: + * type: string + * example: Logged out successfully + * revokedCount: + * type: integer + * description: Number of sessions revoked (0 when the token was unknown). + * example: 1 + * * VerifyEmailInput: * type: object * required: [token] diff --git a/src/routes/v1/auth.routes.ts b/src/routes/v1/auth.routes.ts index 1131cdd..083f617 100644 --- a/src/routes/v1/auth.routes.ts +++ b/src/routes/v1/auth.routes.ts @@ -20,13 +20,27 @@ router.post('/register', authLimiter, authController.register.bind(authControlle */ router.post('/login', authLimiter, authController.login.bind(authController)) +/** + * @route POST /api/v1/auth/refresh + * @desc Rotate a refresh token for a new access/refresh pair + * @access Public (refresh-token possession) + */ +router.post('/refresh', authController.refresh.bind(authController)) + /** * @route POST /api/v1/auth/logout - * @desc Logout user - * @access Public + * @desc Logout current session (revoke its refresh-token family) + * @access Public (refresh-token possession) */ router.post('/logout', authController.logout.bind(authController)) +/** + * @route POST /api/v1/auth/logout/all + * @desc Logout all sessions for the user identified by the refresh token + * @access Public (refresh-token possession) + */ +router.post('/logout/all', authController.logoutAll.bind(authController)) + /** * @route POST /api/v1/auth/verify-email * @desc Verify email with token diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 09238c3..c478552 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -52,6 +52,16 @@ export const otpVerifySchema = z.object({ deviceId: z.string().min(1).optional(), }) +/** + * Refresh / logout bodies carry an opaque refresh token. The token is + * optional here because it may also arrive via the `refresh_token` httpOnly + * cookie; the controller resolves one of the two and rejects when neither is + * present. + */ +export const refreshTokenSchema = z.object({ + refreshToken: z.string().min(1, 'refreshToken is required').optional(), +}) + export type RegisterInput = z.infer; export type LoginInput = z.infer; export type VerifyEmailInput = z.infer; @@ -60,3 +70,4 @@ export type ForgotPasswordInput = z.infer; export type ResetPasswordInput = z.infer; export type OtpRequestInput = z.infer; export type OtpVerifyInput = z.infer; +export type RefreshTokenInput = z.infer; diff --git a/src/services/refresh-token.service.ts b/src/services/refresh-token.service.ts new file mode 100644 index 0000000..0606076 --- /dev/null +++ b/src/services/refresh-token.service.ts @@ -0,0 +1,337 @@ +import crypto from 'crypto' +import prisma from '../config/database' +import { accessTokenTtlSeconds, issueAccessToken } from '../config/jwt' +import { env } from '../config/env' +import { auditService } from './audit.service' +import { SessionAuditAction } from '../types/session.types' + +// ── Token helpers ───────────────────────────────────────────────────────── + +const REFRESH_TOKEN_BYTES = 48 // β†’ 64 base64url characters + +/** Generate an opaque, unguessable refresh token (never persisted raw). */ +export function generateOpaqueToken(): string { + return crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('base64url') +} + +/** SHA-256 hash of a raw opaque token β€” the only form ever stored. */ +export function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex') +} + +// ── Result types ────────────────────────────────────────────────────────── + +export interface IssueSessionResult { + sessionId: string + accessToken: string + refreshToken: string + expiresIn: number +} + +export type RotateResult = + | { kind: 'ok'; accessToken: string; refreshToken: string; expiresIn: number } + /** Unknown token β€” cannot be mapped to any session. */ + | { kind: 'invalid' } + /** A previously-rotated token was presented again: the family was revoked. */ + | { kind: 'reuse' } + /** Token or session was already revoked (or the session was logged out). */ + | { kind: 'revoked' } + /** Token or session has passed its absolute expiry. */ + | { kind: 'expired' } + +export interface RevokeResult { + revokedCount: number +} + +interface RefreshContext { + ipAddress?: string + userAgent?: string +} + +interface RefreshTokenRow { + id: string + sessionId: string + familyId: string + status: string + expiresAt: Date + session: { + id: string + userId: string + isRevoked: boolean + expiresAt: Date + user: { id: string; role: string } + } +} + +// ── Service ─────────────────────────────────────────────────────────────── + +export class RefreshTokenService { + private readonly ttlMs = env.REFRESH_TOKEN_TTL_SECONDS * 1000 + + /** + * Create a new session and its first refresh token (a new rotation family). + * Used by login/register/OTP-login. The access token is short-lived; the + * opaque refresh token is returned exactly once and only its hash is stored. + */ + async issueSession(params: { + userId: string + role: string + userAgent?: string + ipAddress?: string + }): Promise { + const accessToken = issueAccessToken({ id: params.userId, role: params.role }) + const refreshToken = generateOpaqueToken() + const tokenHash = hashToken(refreshToken) + const now = new Date() + const expiresAt = new Date(now.getTime() + this.ttlMs) + + // Explicit ids let the session + token creation commit in a single + // transaction array without an interactive-transaction round trip. + const sessionId = crypto.randomUUID() + const refreshTokenId = crypto.randomUUID() + const familyId = crypto.randomUUID() + + await prisma.$transaction([ + prisma.session.create({ + data: { + id: sessionId, + userId: params.userId, + token: accessToken, + userAgent: params.userAgent ?? null, + ipAddress: params.ipAddress ?? null, + expiresAt, + }, + }), + prisma.refreshToken.create({ + data: { + id: refreshTokenId, + sessionId, + familyId, + tokenHash, + status: 'ACTIVE', + expiresAt, + }, + }), + ]) + + return { + sessionId, + accessToken, + refreshToken, + expiresIn: accessTokenTtlSeconds, + } + } + + /** + * Consume a refresh token and atomically rotate it. + * + * Rotation semantics: + * β€’ ACTIVE + unexpired + live session β†’ consumed (ACTIVEβ†’ROTATED) and a new + * ACTIVE token is minted in the same family; a fresh access token is issued. + * β€’ ROTATED (already used once) β†’ reuse/theft: the whole family and + * its parent session are revoked. + * β€’ REVOKED β†’ family/session already revoked. + * β€’ expired β†’ no rotation; the token is dead. + * + * The ACTIVEβ†’ROTATED transition uses a conditional `updateMany` so that + * concurrent refreshes of the same token cannot both win β€” the loser is + * treated exactly like a replay. + */ + async rotate(rawToken: string, ctx: RefreshContext = {}): Promise { + const tokenHash = hashToken(rawToken) + const found = (await prisma.refreshToken.findUnique({ + where: { tokenHash }, + include: { + session: { + select: { + id: true, + userId: true, + isRevoked: true, + expiresAt: true, + user: { select: { id: true, role: true } }, + }, + }, + }, + })) as RefreshTokenRow | null + + if (!found) { + return { kind: 'invalid' } + } + + const now = new Date() + + // Replay signal: this token was already consumed by a previous rotation. + if (found.status === 'ROTATED') { + await this.revokeFamily(found.familyId, found.sessionId, found.session.userId, ctx) + + return { kind: 'reuse' } + } + + if (found.status === 'REVOKED') { + return { kind: 'revoked' } + } + + if (found.expiresAt <= now) { + return { kind: 'expired' } + } + + if (found.session.isRevoked) { + return { kind: 'revoked' } + } + + if (found.session.expiresAt <= now) { + return { kind: 'expired' } + } + + // Atomic claim β€” only one concurrent refresh of this token wins. + const claimed = await prisma.refreshToken.updateMany({ + where: { id: found.id, status: 'ACTIVE' }, + data: { status: 'ROTATED' }, + }) + + if (claimed.count === 0) { + // We lost the race: someone else rotated this token first β†’ replay. + await this.revokeFamily(found.familyId, found.sessionId, found.session.userId, ctx) + + return { kind: 'reuse' } + } + + const accessToken = issueAccessToken({ id: found.session.userId, role: found.session.user.role }) + const refreshToken = generateOpaqueToken() + const newTokenHash = hashToken(refreshToken) + const newRefreshTokenId = crypto.randomUUID() + const newExpiresAt = new Date(now.getTime() + this.ttlMs) + + await prisma.$transaction([ + prisma.refreshToken.create({ + data: { + id: newRefreshTokenId, + sessionId: found.sessionId, + familyId: found.familyId, + tokenHash: newTokenHash, + status: 'ACTIVE', + expiresAt: newExpiresAt, + }, + }), + prisma.session.update({ + where: { id: found.sessionId }, + data: { token: accessToken, lastUsedAt: now }, + }), + ]) + + return { + kind: 'ok', + accessToken, + refreshToken, + expiresIn: accessTokenTtlSeconds, + } + } + + /** + * Revoke the single session identified by the given refresh token + * (logout-current). Idempotent: unknown or already-revoked tokens are a + * no-op rather than an error, so the endpoint does not leak token validity. + */ + async revokeByRefreshToken(rawToken: string, ctx: RefreshContext = {}): Promise { + const tokenHash = hashToken(rawToken) + const found = await prisma.refreshToken.findUnique({ + where: { tokenHash }, + select: { + sessionId: true, + familyId: true, + session: { select: { userId: true } }, + }, + }) + + if (!found) { + return { revokedCount: 0 } + } + + await this.revokeFamily(found.familyId, found.sessionId, found.session.userId, ctx, SessionAuditAction.SESSION_LOGGED_OUT) + + return { revokedCount: 1 } + } + + /** + * Revoke every session belonging to the user identified by the given + * refresh token (logout-all). Idempotent and neutral on unknown tokens. + */ + async revokeAllByRefreshToken(rawToken: string, ctx: RefreshContext = {}): Promise { + const tokenHash = hashToken(rawToken) + const found = await prisma.refreshToken.findUnique({ + where: { tokenHash }, + select: { session: { select: { userId: true } } }, + }) + + if (!found) { + return { revokedCount: 0 } + } + + return this.revokeAllForUser(found.session.userId, ctx) + } + + /** Revoke all sessions (and their refresh-token families) for a user. */ + async revokeAllForUser(userId: string, ctx: RefreshContext = {}): Promise { + const now = new Date() + + const sessions = await prisma.session.findMany({ + where: { userId, isRevoked: false }, + select: { id: true }, + }) + const sessionIds = sessions.map(s => s.id) + + if (sessionIds.length === 0) { + return { revokedCount: 0 } + } + + await prisma.$transaction([ + prisma.session.updateMany({ + where: { id: { in: sessionIds }, isRevoked: false }, + data: { isRevoked: true, revokedAt: now }, + }), + prisma.refreshToken.updateMany({ + where: { sessionId: { in: sessionIds }, status: { not: 'REVOKED' } }, + data: { status: 'REVOKED' }, + }), + auditService.op({ + userId, + action: SessionAuditAction.SESSION_ALL_LOGGED_OUT, + metadata: { revokedCount: sessionIds.length }, + ...ctx, + }), + ]) + + return { revokedCount: sessionIds.length } + } + + /** + * Revoke an entire rotation family and its parent session. This is the + * single enforcement point for both reuse detection and logout-current. + */ + private async revokeFamily( + familyId: string, + sessionId: string, + userId: string, + ctx: RefreshContext, + action: string = SessionAuditAction.REFRESH_REUSE_DETECTED + ): Promise { + await prisma.$transaction([ + prisma.refreshToken.updateMany({ + where: { familyId, status: { not: 'REVOKED' } }, + data: { status: 'REVOKED' }, + }), + prisma.session.updateMany({ + where: { id: sessionId, isRevoked: false }, + data: { isRevoked: true, revokedAt: new Date() }, + }), + auditService.op({ + userId, + action, + metadata: { familyId, sessionId }, + ...ctx, + }), + ]) + } +} + +export const refreshTokenService = new RefreshTokenService() diff --git a/src/types/session.types.ts b/src/types/session.types.ts index ad5a947..346ac98 100644 --- a/src/types/session.types.ts +++ b/src/types/session.types.ts @@ -61,6 +61,9 @@ export type RevokeAllResult = { export const SessionAuditAction = { SESSION_REVOKED: 'SESSION_REVOKED', SESSION_ALL_REVOKED: 'SESSION_ALL_REVOKED', + SESSION_LOGGED_OUT: 'SESSION_LOGGED_OUT', + SESSION_ALL_LOGGED_OUT: 'SESSION_ALL_LOGGED_OUT', + REFRESH_REUSE_DETECTED: 'REFRESH_REUSE_DETECTED', } as const export type SessionAuditActionValue = (typeof SessionAuditAction)[keyof typeof SessionAuditAction] diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts new file mode 100644 index 0000000..71afda8 --- /dev/null +++ b/src/utils/cookies.ts @@ -0,0 +1,37 @@ +/** + * Minimal RFC 6265 cookie-header parser. + * + * Used to read the optional httpOnly `refresh_token` cookie without pulling in + * a cookie-parsing dependency. This intentionally handles only the simple + * `name=value; name2=value2` shape a browser sends β€” it does not implement + * cookie attributes (Path/Domain/SameSite/Expires), which never appear on the + * request side anyway. + */ +export function parseCookieHeader(header: string | undefined): Record { + const cookies: Record = {} + + if (!header) { + return cookies + } + + for (const part of header.split(';')) { + const separator = part.indexOf('=') + if (separator === -1) { + continue + } + + const name = part.slice(0, separator).trim() + if (!name) { + continue + } + + const value = part.slice(separator + 1).trim() + try { + cookies[name] = decodeURIComponent(value) + } catch { + cookies[name] = value + } + } + + return cookies +} diff --git a/tests/auth.controller.test.ts b/tests/auth.controller.test.ts index d3304f4..492fb81 100644 --- a/tests/auth.controller.test.ts +++ b/tests/auth.controller.test.ts @@ -5,6 +5,7 @@ import prisma from '../src/config/database' import bcrypt from 'bcryptjs' import { emailService } from '../src/services/email.service' import { otpService } from '../src/services/otp.service' +import { refreshTokenService } from '../src/services/refresh-token.service' const mockTokenHash = 'abc123def456hash' const mockRawToken = 'aaabbbcccddd00112233445566778899aabbccddeeff00112233445566778899' @@ -81,6 +82,15 @@ vi.mock('../src/services/otp.service', async () => { } }) +vi.mock('../src/services/refresh-token.service', () => ({ + refreshTokenService: { + issueSession: vi.fn(), + rotate: vi.fn(), + revokeByRefreshToken: vi.fn(), + revokeAllByRefreshToken: vi.fn(), + }, +})) + describe('AuthController', () => { let authController: AuthController let mockRequest: Partial @@ -101,6 +111,13 @@ describe('AuthController', () => { otpPhoneCounts.clear() otpDeviceCounts.clear() vi.clearAllMocks() + + vi.mocked(refreshTokenService.issueSession).mockResolvedValue({ + sessionId: 'session-1', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expiresIn: 900, + }) }) describe('register', () => { @@ -148,9 +165,15 @@ describe('AuthController', () => { expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ message: 'User registered successfully', - token: 'mock_token', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expiresIn: 900, + tokenType: 'Bearer', }) ) + expect(refreshTokenService.issueSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: '1', role: 'LEARNER' }) + ) }) it('should return 400 for invalid input', async () => { @@ -631,9 +654,15 @@ describe('AuthController', () => { expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ message: 'Login successful', - token: 'mock_token', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expiresIn: 900, + tokenType: 'Bearer', }) ) + expect(refreshTokenService.issueSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: '1', role: 'LEARNER' }) + ) }) it('should return 403 with ACCOUNT_DEACTIVATED for deactivated accounts', async () => { @@ -804,19 +833,170 @@ describe('AuthController', () => { }) }) + describe('refresh', () => { + it('rotates a valid refresh token and returns a new access/refresh pair', async () => { + mockRequest.body = { refreshToken: 'old-refresh-token' } + + vi.mocked(refreshTokenService.rotate).mockResolvedValue({ + kind: 'ok', + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresIn: 900, + }) + + await authController.refresh(mockRequest as Request, mockResponse as Response) + + expect(refreshTokenService.rotate).toHaveBeenCalledWith( + 'old-refresh-token', + expect.objectContaining({ ipAddress: '127.0.0.1' }) + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresIn: 900, + tokenType: 'Bearer', + }) + ) + }) + + it('reads the refresh token from the httpOnly cookie when the body is absent', async () => { + mockRequest.body = {} + mockRequest.headers = { cookie: 'refresh_token=cookie-refresh-token' } + + vi.mocked(refreshTokenService.rotate).mockResolvedValue({ + kind: 'ok', + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresIn: 900, + }) + + await authController.refresh(mockRequest as Request, mockResponse as Response) + + expect(refreshTokenService.rotate).toHaveBeenCalledWith( + 'cookie-refresh-token', + expect.anything() + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) + + it('returns 400 when no refresh token is provided', async () => { + mockRequest.body = {} + + await authController.refresh(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'refreshToken is required' }) + expect(refreshTokenService.rotate).not.toHaveBeenCalled() + }) + + it('returns 401 REFRESH_REUSE_DETECTED when a replayed token is detected', async () => { + mockRequest.body = { refreshToken: 'replayed-token' } + + vi.mocked(refreshTokenService.rotate).mockResolvedValue({ kind: 'reuse' }) + + await authController.refresh(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'REFRESH_REUSE_DETECTED' }) + ) + }) + + it('returns 401 for invalid, expired, and revoked tokens', async () => { + const cases = [ + { kind: 'invalid' as const, code: 'REFRESH_INVALID' }, + { kind: 'expired' as const, code: 'REFRESH_EXPIRED' }, + { kind: 'revoked' as const, code: 'REFRESH_REVOKED' }, + ] + + for (const c of cases) { + vi.clearAllMocks() + mockRequest.body = { refreshToken: 'some-token' } + vi.mocked(refreshTokenService.rotate).mockResolvedValue({ kind: c.kind }) + + await authController.refresh(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: c.code }) + ) + } + }) + }) + describe('logout', () => { - it('should return success message', async () => { - await authController.logout( - mockRequest as Request, - mockResponse as Response + it('revokes the session identified by the refresh token', async () => { + mockRequest.body = { refreshToken: 'current-refresh-token' } + + vi.mocked(refreshTokenService.revokeByRefreshToken).mockResolvedValue({ revokedCount: 1 }) + + await authController.logout(mockRequest as Request, mockResponse as Response) + + expect(refreshTokenService.revokeByRefreshToken).toHaveBeenCalledWith( + 'current-refresh-token', + expect.anything() ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Logged out successfully', + revokedCount: 1, + }) + }) + + it('reads the refresh token from the cookie when the body is absent', async () => { + mockRequest.body = {} + mockRequest.headers = { cookie: 'refresh_token=cookie-refresh-token' } + + vi.mocked(refreshTokenService.revokeByRefreshToken).mockResolvedValue({ revokedCount: 1 }) + + await authController.logout(mockRequest as Request, mockResponse as Response) + expect(refreshTokenService.revokeByRefreshToken).toHaveBeenCalledWith( + 'cookie-refresh-token', + expect.anything() + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) + + it('returns 400 when no refresh token is provided', async () => { + mockRequest.body = {} + + await authController.logout(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'refreshToken is required' }) + }) + }) + + describe('logoutAll', () => { + it('revokes all sessions for the user identified by the refresh token', async () => { + mockRequest.body = { refreshToken: 'any-refresh-token' } + + vi.mocked(refreshTokenService.revokeAllByRefreshToken).mockResolvedValue({ revokedCount: 3 }) + + await authController.logoutAll(mockRequest as Request, mockResponse as Response) + + expect(refreshTokenService.revokeAllByRefreshToken).toHaveBeenCalledWith( + 'any-refresh-token', + expect.anything() + ) expect(mockResponse.status).toHaveBeenCalledWith(200) expect(mockResponse.json).toHaveBeenCalledWith({ - message: - 'Logged out successfully. Please clear your token client-side.', + message: 'All sessions logged out', + revokedCount: 3, }) }) + + it('returns 400 when no refresh token is provided', async () => { + mockRequest.body = {} + + await authController.logoutAll(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'refreshToken is required' }) + }) }) describe('resetPassword', () => { @@ -1036,7 +1216,14 @@ describe('AuthController', () => { expect(mockResponse.status).toHaveBeenCalledWith(200) expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Login successful', token: 'mock_token' }) + expect.objectContaining({ + message: 'Login successful', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + }) + ) + expect(refreshTokenService.issueSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user1', role: 'LEARNER' }) ) }) diff --git a/tests/refresh-token.service.test.ts b/tests/refresh-token.service.test.ts new file mode 100644 index 0000000..9c1bbc9 --- /dev/null +++ b/tests/refresh-token.service.test.ts @@ -0,0 +1,379 @@ +/** + * tests/refresh-token.service.test.ts + * + * Unit tests for the rotating refresh-token service (#130). + * + * Coverage checklist (per acceptance criteria): + * βœ“ IssueSession β€” creates session + first token, stores only a hash + * βœ“ Rotate β€” consumes ACTIVE token, mints new token in the same family + * βœ“ Reuse β€” replaying a ROTATED token revokes the whole family + * βœ“ Race β€” concurrent rotation (updateMany count 0) is treated as reuse + * βœ“ Expiry β€” expired token/session is rejected without rotating + * βœ“ Revocation β€” revoked token/session is rejected; logout-current/all revoke + * βœ“ Logout β€” logout-current revokes one family, logout-all revokes the user + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import prisma from '../src/config/database' +import { issueAccessToken } from '../src/config/jwt' +import { + RefreshTokenService, + generateOpaqueToken, + hashToken, +} from '../src/services/refresh-token.service' +import { SessionAuditAction } from '../src/types/session.types' + +// ── Module mocks ───────────────────────────────────────────────────────── + +vi.mock('../src/config/database', () => ({ + default: { + session: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + refreshToken: { + create: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + $transaction: vi.fn(async (ops: any[]) => Promise.all(ops)), + }, +})) + +vi.mock('../src/config/jwt', () => ({ + accessTokenTtlSeconds: 900, + issueAccessToken: vi.fn(), +})) + +vi.mock('../src/config/env', () => ({ + env: { REFRESH_TOKEN_TTL_SECONDS: 2592000 }, +})) + +vi.mock('../src/services/audit.service', () => ({ + auditService: { op: vi.fn(() => ({})), record: vi.fn() }, +})) + +// ── Helpers ───────────────────────────────────────────────────────────── + +const FUTURE = () => new Date(Date.now() + 3600_000) +const PAST = () => new Date(Date.now() - 1000) + +function foundRow(overrides: Record = {}) { + const future = FUTURE() + + return { + id: 'rt-1', + sessionId: 'sess-1', + familyId: 'family-1', + status: 'ACTIVE', + expiresAt: future, + session: { + id: 'sess-1', + userId: 'user-1', + isRevoked: false, + expiresAt: future, + user: { id: 'user-1', role: 'learner' }, + }, + ...overrides, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe('RefreshTokenService', () => { + let service: RefreshTokenService + + beforeEach(() => { + vi.clearAllMocks() + service = new RefreshTokenService() + + vi.mocked(issueAccessToken).mockImplementation(({ id }: { id: string }) => `access-token-${id}`) + vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.session.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.session.create).mockResolvedValue({ id: 'sess-1' } as any) + vi.mocked(prisma.refreshToken.create).mockResolvedValue({} as any) + vi.mocked(prisma.session.update).mockResolvedValue({} as any) + }) + + describe('helpers', () => { + it('generates an opaque 64-character base64url token', () => { + expect(generateOpaqueToken()).toMatch(/^[A-Za-z0-9_-]{64}$/) + }) + + it('hashes a token to a 64-char hex digest', () => { + expect(hashToken('hello')).toMatch(/^[0-9a-f]{64}$/) + expect(hashToken('hello')).toBe(hashToken('hello')) + }) + }) + + describe('issueSession', () => { + it('creates a session and first refresh token atomically, storing only the hash', async () => { + const result = await service.issueSession({ + userId: 'user-1', + role: 'learner', + userAgent: 'vitest', + ipAddress: '1.2.3.4', + }) + + expect(issueAccessToken).toHaveBeenCalledWith({ id: 'user-1', role: 'learner' }) + expect(prisma.$transaction).toHaveBeenCalled() + + const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown[] + expect(txOps).toHaveLength(2) + + expect(prisma.session.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: 'user-1', + token: 'access-token-user-1', + userAgent: 'vitest', + ipAddress: '1.2.3.4', + }), + }) + + // Only the hash of the refresh token is persisted β€” never the raw value. + const createArgs = vi.mocked(prisma.refreshToken.create).mock.calls[0][0] as any + expect(createArgs.data.tokenHash).not.toBe(result.refreshToken) + expect(createArgs.data.tokenHash).toBe(hashToken(result.refreshToken)) + expect(createArgs.data.status).toBe('ACTIVE') + + expect(result).toMatchObject({ + sessionId: expect.any(String), + accessToken: 'access-token-user-1', + expiresIn: 900, + }) + expect(result.refreshToken).toMatch(/^[A-Za-z0-9_-]{64}$/) + }) + }) + + describe('rotate', () => { + it('rotates an ACTIVE token: consumes it and mints a new token in the same family', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(foundRow() as any) + + const result = await service.rotate('raw-refresh-token', { ipAddress: '1.2.3.4' }) + + // Looked up by hash + expect(prisma.refreshToken.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { tokenHash: hashToken('raw-refresh-token') } }) + ) + + // Atomic claim + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { id: 'rt-1', status: 'ACTIVE' }, + data: { status: 'ROTATED' }, + }) + + // New token is minted in the same family and the session is advanced + const createArgs = vi.mocked(prisma.refreshToken.create).mock.calls[0][0] as any + expect(createArgs.data).toMatchObject({ + sessionId: 'sess-1', + familyId: 'family-1', + status: 'ACTIVE', + }) + expect(prisma.session.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sess-1' }, + data: expect.objectContaining({ token: 'access-token-user-1' }), + }) + ) + + expect(result).toMatchObject({ + kind: 'ok', + accessToken: 'access-token-user-1', + expiresIn: 900, + }) + if (result.kind === 'ok') { + expect(result.refreshToken).toMatch(/^[A-Za-z0-9_-]{64}$/) + } + }) + + it('rejects an unknown token as invalid without any writes', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(null) + + const result = await service.rotate('unknown') + + expect(result).toEqual({ kind: 'invalid' }) + expect(prisma.refreshToken.updateMany).not.toHaveBeenCalled() + }) + + it('rejects an expired token without rotating', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + foundRow({ expiresAt: PAST() }) as any + ) + + const result = await service.rotate('expired-token') + + expect(result).toEqual({ kind: 'expired' }) + expect(prisma.refreshToken.updateMany).not.toHaveBeenCalled() + expect(prisma.refreshToken.create).not.toHaveBeenCalled() + }) + + it('rejects a REVOKED token', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + foundRow({ status: 'REVOKED' }) as any + ) + + expect(await service.rotate('revoked-token')).toEqual({ kind: 'revoked' }) + }) + + it('returns revoked without rotating when the session is already revoked', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + foundRow({ session: { id: 'sess-1', userId: 'user-1', isRevoked: true, expiresAt: FUTURE(), user: { id: 'user-1', role: 'learner' } } }) as any + ) + + const result = await service.rotate('token-of-revoked-session') + + expect(result).toEqual({ kind: 'revoked' }) + expect(prisma.refreshToken.updateMany).not.toHaveBeenCalled() + expect(prisma.refreshToken.create).not.toHaveBeenCalled() + }) + + it('rejects an expired session', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + foundRow({ session: { id: 'sess-1', userId: 'user-1', isRevoked: false, expiresAt: PAST(), user: { id: 'user-1', role: 'learner' } } }) as any + ) + + expect(await service.rotate('token-of-expired-session')).toEqual({ kind: 'expired' }) + }) + + it('detects replay of a ROTATED token and revokes the entire family', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + foundRow({ status: 'ROTATED' }) as any + ) + + const result = await service.rotate('replayed-token') + + expect(result).toEqual({ kind: 'reuse' }) + // Family revoked… + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { familyId: 'family-1', status: { not: 'REVOKED' } }, + data: { status: 'REVOKED' }, + }) + ) + // …and the parent session revoked + expect(prisma.session.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'sess-1', isRevoked: false }, + data: expect.objectContaining({ isRevoked: true }), + }) + ) + }) + + it('treats a lost rotation race as reuse and revokes the family', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(foundRow() as any) + // Another request claimed the ACTIVEβ†’ROTATED transition first. + vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 } as any) + + const result = await service.rotate('raced-token') + + expect(result).toEqual({ kind: 'reuse' }) + expect(prisma.session.updateMany).toHaveBeenCalled() + }) + }) + + describe('revokeByRefreshToken (logout current)', () => { + it('revokes the session and family for a known token', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue({ + sessionId: 'sess-1', + familyId: 'family-1', + session: { userId: 'user-1' }, + } as any) + + const result = await service.revokeByRefreshToken('current-token', { ipAddress: '1.2.3.4' }) + + expect(result).toEqual({ revokedCount: 1 }) + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { familyId: 'family-1', status: { not: 'REVOKED' } } }) + ) + expect(prisma.session.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'sess-1', isRevoked: false } }) + ) + }) + + it('is a neutral no-op for an unknown token', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(null) + + const result = await service.revokeByRefreshToken('unknown') + + expect(result).toEqual({ revokedCount: 0 }) + expect(prisma.refreshToken.updateMany).not.toHaveBeenCalled() + }) + }) + + describe('revokeAllByRefreshToken / revokeAllForUser (logout all)', () => { + it('revokes every session and their refresh tokens for the identified user', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue({ + session: { userId: 'user-1' }, + } as any) + vi.mocked(prisma.session.findMany).mockResolvedValue([{ id: 'sess-1' }, { id: 'sess-2' }] as any) + + const result = await service.revokeAllByRefreshToken('any-token') + + expect(result).toEqual({ revokedCount: 2 }) + expect(prisma.session.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: { in: ['sess-1', 'sess-2'] }, isRevoked: false } }) + ) + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { sessionId: { in: ['sess-1', 'sess-2'] }, status: { not: 'REVOKED' } }, + }) + ) + }) + + it('returns 0 when the user has no active sessions', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue({ + session: { userId: 'user-1' }, + } as any) + vi.mocked(prisma.session.findMany).mockResolvedValue([] as any) + + const result = await service.revokeAllByRefreshToken('any-token') + + expect(result).toEqual({ revokedCount: 0 }) + }) + + it('is a neutral no-op for an unknown token', async () => { + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(null) + + expect(await service.revokeAllByRefreshToken('unknown')).toEqual({ revokedCount: 0 }) + }) + }) + + describe('audit actions', () => { + it('uses REFRESH_REUSE_DETECTED when a family is revoked due to replay', async () => { + const { auditService } = await import('../src/services/audit.service') + + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + foundRow({ status: 'ROTATED' }) as any + ) + + await service.rotate('replayed-token') + + expect(auditService.op).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: SessionAuditAction.REFRESH_REUSE_DETECTED, + }) + ) + }) + + it('uses SESSION_LOGGED_OUT for logout-current', async () => { + const { auditService } = await import('../src/services/audit.service') + + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue({ + sessionId: 'sess-1', + familyId: 'family-1', + session: { userId: 'user-1' }, + } as any) + + await service.revokeByRefreshToken('current-token') + + expect(auditService.op).toHaveBeenCalledWith( + expect.objectContaining({ action: SessionAuditAction.SESSION_LOGGED_OUT }) + ) + }) + }) +})