Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 47 additions & 5 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`) |
| Base URL (local) | `http://localhost:3000/api/v1` || Auth scheme | JWT Bearer (`Authorization: Bearer <token>`) |
| Refresh scheme | Opaque rotating refresh token (`refreshToken` body or `refresh_token` cookie) |
| Content-Type | `application/json` |

---
Expand Down Expand Up @@ -134,7 +134,10 @@ Register a new user. Queues a verification email.
```json
{
"message": "User registered successfully",
"token": "<jwt>",
"accessToken": "<jwt>",
"refreshToken": "<opaque-token>",
"expiresIn": 900,
"tokenType": "Bearer",
"user": { "id": "...", "email": "...", "username": "...", "role": "learner" }
}
```
Expand All @@ -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": "<opaque-token>" }` — or send the token
via an httpOnly `refresh_token` cookie.

```json
{
"message": "Token refreshed successfully",
"accessToken": "<new-jwt>",
"refreshToken": "<new-opaque-token>",
"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": "<opaque-token>" }` — 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": "<opaque-token>" }` — 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 }`

---

Expand Down
25 changes: 20 additions & 5 deletions docs/AUTH_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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
Expand Down
99 changes: 99 additions & 0 deletions docs/security/refresh-token-rotation.md
Original file line number Diff line number Diff line change
@@ -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 <token>` |
| 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 <accessToken>`. 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).
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 21 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
4 changes: 4 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
15 changes: 12 additions & 3 deletions src/config/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/config/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading
Loading