feat(auth): Task 01 add persistent OAuthTransaction and Session Prisma stores
This commit is contained in:
parent
b2888950cd
commit
92f6d19225
7 changed files with 664 additions and 8 deletions
|
|
@ -0,0 +1,126 @@
|
|||
# Task 01: Persistent OAuth-state & Session Store Report
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Base Commit SHA:** `b2888950cdd2d948c15acf0004a0cdc91eeb70e6`
|
||||
**Status:** IMPLEMENTED / PASS
|
||||
**Assigned Agent:** Antigravity (Implementation Orchestrator)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Устранена проблема хранения OAuth-состояний и пользовательских сессий исключительно в памяти одного процесса Node.js (`MemoryOAuthTransactionStore` и `MemorySessionStore`), из-за которой в multi-instance / serverless среде или при перезапуске сервера происходили сбои аутентификации VK ID и сброс активных сессий пользователей.
|
||||
|
||||
Реализованы персистентные хранилища на базе PostgreSQL / Prisma:
|
||||
1. В `prisma/schema.prisma` добавлены модели `OAuthTransaction` (с полями `state`, `codeVerifier`, `redirectTarget`, `createdAt`, `expiresAt`) и `Session` (с полями `sessionId`, `userId`, `user`, `createdAt`, `expiresAt`), а также индексы по `expiresAt` и `userId`.
|
||||
2. Создана SQL-миграция `prisma/migrations/20260821120000_persistent_auth_stores/migration.sql`.
|
||||
3. В `src/lib/auth/oauth-state.ts` реализован `PrismaOAuthTransactionStore` с атомарной транзакционной операцией `consumeTransaction` (`$transaction` find + delete), предотвращающей race conditions и гарантирующей single-use семантику OAuth state.
|
||||
4. В `src/lib/auth/session.ts` реализован `PrismaSessionStore` со строгой валидацией TTL и каскадным удалением сессий при удалении пользователя.
|
||||
5. Настроены фабрики `createOAuthTransactionStore()` и `createSessionStore()`: при `STORAGE_DRIVER=memory` или `NODE_ENV=test` используются in-memory реализации, в остальных случаях — Prisma-драйверы.
|
||||
6. Снят фатальный запрет на запуск с `MULTI_INSTANCE=true` для Prisma-хранилищ.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modified Files
|
||||
|
||||
| File | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `prisma/schema.prisma` | DB Schema | Добавлены модели `OAuthTransaction` и `Session`, добавлена связь `sessions` в модель `User`. |
|
||||
| `prisma/migrations/20260821120000_persistent_auth_stores/migration.sql` | Migration | SQL-миграция создания таблиц и индексов для `OAuthTransaction` и `Session`. |
|
||||
| `src/lib/auth/oauth-state.ts` | Auth | Реализован `PrismaOAuthTransactionStore`, селектор `createOAuthTransactionStore`, функции установки стора. |
|
||||
| `src/lib/auth/session.ts` | Auth | Реализован `PrismaSessionStore`, селектор `createSessionStore`, функции установки стора. |
|
||||
| `tests/persistent-auth-stores.test.ts` | Tests (NEW) | Набор тестов на single-use конкурентность, multi-instance обмен, TTL, жизненный цикл и селекторы драйверов (11 тестов). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Migration & Execution Order
|
||||
|
||||
### SQL Migration Content:
|
||||
```sql
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuthTransaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"state" TEXT NOT NULL,
|
||||
"codeVerifier" TEXT NOT NULL,
|
||||
"redirectTarget" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OAuthTransaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuthTransaction_state_key" ON "OAuthTransaction"("state");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OAuthTransaction_expiresAt_idx" ON "OAuthTransaction"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_sessionId_key" ON "Session"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
```
|
||||
|
||||
### Порядок применения на существующей БД:
|
||||
1. Выполнить `npx prisma migrate deploy` или применить приведенный SQL-скрипт в PostgreSQL.
|
||||
2. Никаких изменений существующих данных `User`, `Giveaway`, `Participant` не требуется (обратно-совместимо).
|
||||
|
||||
---
|
||||
|
||||
## 4. Verification Evidence & Test Gate
|
||||
|
||||
Фактически выполненные команды:
|
||||
|
||||
```text
|
||||
npx prisma generate -> EXIT 0 (Prisma Client v5.22.0 generated with OAuthTransaction and Session models)
|
||||
npx tsc --noEmit -> EXIT 0 (Clean TypeScript check, 0 errors)
|
||||
npm test -> EXIT 0 (50 test suites, 295 passed, 0 failed)
|
||||
npm run lint -> EXIT 0 (Next.js ESLint passed clean)
|
||||
npm run build -> EXIT 0 (Next.js production build compiled successfully)
|
||||
```
|
||||
|
||||
### Summary of New Tests (`tests/persistent-auth-stores.test.ts`):
|
||||
- `concurrent consumeTransaction calls on same state yield exactly 1 success and N-1 UnauthorizedErrors` → **PASS**
|
||||
- `state created by instance A can be consumed by instance B sharing underlying state` → **PASS**
|
||||
- `expired OAuth state is rejected with UnauthorizedError` → **PASS**
|
||||
- `invalidateTransaction removes state explicitly` → **PASS**
|
||||
- `expired Session is rejected and returns null from getSession` → **PASS**
|
||||
- `createSession, getSession and destroySession work correctly` → **PASS**
|
||||
- `session survives re-creation of store instance when sharing storage` → **PASS**
|
||||
- `Memory stores throw fatal error when MULTI_INSTANCE=true` → **PASS**
|
||||
- `Prisma stores do NOT throw when MULTI_INSTANCE=true` → **PASS**
|
||||
- `createOAuthTransactionStore selects Memory in test/memory mode, Prisma in production mode` → **PASS**
|
||||
- `createSessionStore selects Memory in test/memory mode, Prisma in production mode` → **PASS**
|
||||
|
||||
---
|
||||
|
||||
## 5. Core Invariants & Security
|
||||
|
||||
- **Randomizer / Audit Proof Invariants:** `HMAC_SHA256_FY_V1`, `DeterministicHmacStream`, `executeDeterministicDrawV1`, `verifyDrawResult` сохранены без изменений.
|
||||
- **PKCE / State Invariants:** S256 code challenge, криптостойкие случайные токены (CSPRNG) сохранены.
|
||||
- **Single-Use Invariant:** Гарантируется как в памяти, так и в базе данных через атомарную транзакцию `$transaction`.
|
||||
|
||||
---
|
||||
|
||||
## 6. UNVERIFIED Assertions & Tech Debt
|
||||
|
||||
1. **UNVERIFIED: Live PostgreSQL CI execution for Prisma auth stores:**
|
||||
- В текущем тестовом окружении автоматизированные тесты Vitest выполняются с `STORAGE_DRIVER=memory` и `NODE_ENV=test`. Хотя `PrismaOAuthTransactionStore` и `PrismaSessionStore` скомпилированы и проверены, сквозной прогон с живой базой PostgreSQL в Vitest требует отдельного интеграционного сьюта.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Task 01: Persistent OAuth-state & Session Store
|
||||
|
||||
**Assigned to:** Antigravity (Implementation Orchestrator)
|
||||
**Priority:** HIGH
|
||||
**Date:** 2026-08-21
|
||||
**Base SHA:** `b2888950cdd2d948c15acf0004a0cdc91eeb70e6`
|
||||
|
||||
## Scope
|
||||
1. Add `OAuthTransaction` and `Session` models to `prisma/schema.prisma`.
|
||||
- `OAuthTransaction`: `id` (@id @default(cuid())), `state` (@unique), `codeVerifier`, `redirectTarget` (optional string), `createdAt`, `expiresAt`. Index on `expiresAt`.
|
||||
- `Session`: `id` (@id @default(cuid())), `sessionId` (@unique), `userId` (FK to `User`), `user` relation, `createdAt`, `expiresAt`. Index on `expiresAt`, `userId`.
|
||||
2. Generate migration SQL under `prisma/migrations/` (timestamped migration folder).
|
||||
3. Implement `PrismaOAuthTransactionStore` in `src/lib/auth/oauth-state.ts` implementing `IOAuthTransactionStore`.
|
||||
- Atomic single-use `consumeTransaction` (atomic delete/find).
|
||||
- TTL check after atomic consumption.
|
||||
4. Implement `PrismaSessionStore` in `src/lib/auth/session.ts` implementing `ISessionStore`.
|
||||
- `createSession`: persists session with `expiresAt = Date.now() + ttlMs`.
|
||||
- `getSession`: finds non-expired session by `sessionId`, loads user, returns `SessionUser` or `null`.
|
||||
- `destroySession`: deletes session by `sessionId`.
|
||||
- `clear`: deletes all sessions.
|
||||
5. Create store factory / default selector based on `STORAGE_DRIVER` & `NODE_ENV`:
|
||||
- `createOAuthTransactionStore()` & `createSessionStore()`: `STORAGE_DRIVER === 'memory' || process.env.NODE_ENV === 'test'` -> Memory, otherwise Prisma.
|
||||
- Remove fatal on `MULTI_INSTANCE` for Prisma stores; keep fatal guard for Memory stores.
|
||||
6. Tests in `tests/persistent-auth-stores.test.ts`:
|
||||
- Concurrent `consumeTransaction` on single state: exactly 1 success, N-1 fail / 401.
|
||||
- Multi-instance state consumption (two store instances sharing storage).
|
||||
- TTL expiry checks for both OAuth transaction and Session.
|
||||
- `destroySession` and session revival prevention.
|
||||
7. Run verification gate: `npm ci`, `npx prisma generate`, `npm test`, `npm run lint`, `npm run build`, `npx tsc --noEmit`.
|
||||
8. Write final report to `agents/antigravity/done/TASK-2026-08-21-01-persistent-auth-stores.md`.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "OAuthTransaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"state" TEXT NOT NULL,
|
||||
"codeVerifier" TEXT NOT NULL,
|
||||
"redirectTarget" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OAuthTransaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuthTransaction_state_key" ON "OAuthTransaction"("state");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OAuthTransaction_expiresAt_idx" ON "OAuthTransaction"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_sessionId_key" ON "Session"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -42,6 +42,7 @@ model User {
|
|||
|
||||
giveaways Giveaway[]
|
||||
credentials UserCredential?
|
||||
sessions Session[]
|
||||
}
|
||||
|
||||
model UserCredential {
|
||||
|
|
@ -169,3 +170,26 @@ model AuditRecord {
|
|||
drawnAt DateTime @default(now())
|
||||
verifiedAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model OAuthTransaction {
|
||||
id String @id @default(cuid())
|
||||
state String @unique
|
||||
codeVerifier String
|
||||
redirectTarget String?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionId String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([expiresAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomBytes, createHash } from 'crypto';
|
||||
import { UnauthorizedError, ValidationError } from '@/core/errors/http-errors';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export interface OAuthTransaction {
|
||||
state: string;
|
||||
|
|
@ -16,9 +17,9 @@ export interface IOAuthTransactionStore {
|
|||
}): Promise<{ state: string; codeVerifier: string; codeChallenge: string }>;
|
||||
consumeTransaction(state: string): Promise<{ codeVerifier: string; redirectTarget: string }>;
|
||||
invalidateTransaction(state: string): Promise<boolean>;
|
||||
clear(): void;
|
||||
size(): number;
|
||||
cleanupExpired(): number;
|
||||
clear(): void | Promise<void>;
|
||||
size(): number | Promise<number>;
|
||||
cleanupExpired(): number | Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -143,4 +144,125 @@ export class MemoryOAuthTransactionStore implements IOAuthTransactionStore {
|
|||
}
|
||||
}
|
||||
|
||||
export const defaultOAuthTransactionStore: IOAuthTransactionStore = new MemoryOAuthTransactionStore();
|
||||
export class PrismaOAuthTransactionStore implements IOAuthTransactionStore {
|
||||
private readonly defaultTtlMs: number;
|
||||
|
||||
constructor(options?: { defaultTtlMs?: number }) {
|
||||
this.defaultTtlMs = options?.defaultTtlMs ?? 10 * 60 * 1000; // 10 minutes
|
||||
}
|
||||
|
||||
public async createTransaction(options?: {
|
||||
redirectTarget?: string;
|
||||
ttlMs?: number;
|
||||
}): Promise<{ state: string; codeVerifier: string; codeChallenge: string }> {
|
||||
const state = generateOAuthState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
|
||||
const now = new Date();
|
||||
const ttl = options?.ttlMs ?? this.defaultTtlMs;
|
||||
const expiresAt = new Date(now.getTime() + ttl);
|
||||
|
||||
await prisma.oAuthTransaction.create({
|
||||
data: {
|
||||
state,
|
||||
codeVerifier,
|
||||
redirectTarget: options?.redirectTarget || '/',
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { state, codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically retrieves and removes the OAuth transaction in a single operation.
|
||||
* Guarantees exact-once consumption per state string in concurrent and multi-instance environments.
|
||||
*/
|
||||
public async consumeTransaction(state: string): Promise<{ codeVerifier: string; redirectTarget: string }> {
|
||||
if (!state || typeof state !== 'string') {
|
||||
throw new ValidationError('OAuth state parameter is missing or invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
const tx = await prisma.$transaction(async (txPrisma) => {
|
||||
const found = await txPrisma.oAuthTransaction.findUnique({
|
||||
where: { state },
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await txPrisma.oAuthTransaction.delete({
|
||||
where: { state },
|
||||
});
|
||||
|
||||
return found;
|
||||
});
|
||||
|
||||
if (!tx) {
|
||||
throw new UnauthorizedError('OAuth state not found or was already consumed (single-use constraint)');
|
||||
}
|
||||
|
||||
if (Date.now() > tx.expiresAt.getTime()) {
|
||||
throw new UnauthorizedError('OAuth state has expired');
|
||||
}
|
||||
|
||||
return {
|
||||
codeVerifier: tx.codeVerifier,
|
||||
redirectTarget: tx.redirectTarget || '/',
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof UnauthorizedError || error instanceof ValidationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error?.code === 'P2025') {
|
||||
throw new UnauthorizedError('OAuth state not found or was already consumed (single-use constraint)');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async invalidateTransaction(state: string): Promise<boolean> {
|
||||
if (!state || typeof state !== 'string') return false;
|
||||
try {
|
||||
const res = await prisma.oAuthTransaction.deleteMany({
|
||||
where: { state },
|
||||
});
|
||||
return res.count > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanupExpired(): Promise<number> {
|
||||
const res = await prisma.oAuthTransaction.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
await prisma.oAuthTransaction.deleteMany();
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
return await prisma.oAuthTransaction.count();
|
||||
}
|
||||
}
|
||||
|
||||
export function createOAuthTransactionStore(): IOAuthTransactionStore {
|
||||
const driver = process.env.STORAGE_DRIVER || (process.env.NODE_ENV === 'test' ? 'memory' : 'prisma');
|
||||
if (driver === 'memory') {
|
||||
return new MemoryOAuthTransactionStore();
|
||||
}
|
||||
return new PrismaOAuthTransactionStore();
|
||||
}
|
||||
|
||||
export let defaultOAuthTransactionStore: IOAuthTransactionStore = createOAuthTransactionStore();
|
||||
|
||||
export function setOAuthTransactionStore(store: IOAuthTransactionStore): void {
|
||||
defaultOAuthTransactionStore = store;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export const SESSION_COOKIE_NAME = 'randomayzer_session';
|
||||
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
|
|
@ -24,9 +25,9 @@ export interface ISessionStore {
|
|||
createSession(user: SessionUser, ttlMs?: number): Promise<string>;
|
||||
getSession(sessionId: string): Promise<SessionUser | null>;
|
||||
destroySession(sessionId: string): Promise<void>;
|
||||
cleanupExpired(): number;
|
||||
clear(): void;
|
||||
size(): number;
|
||||
cleanupExpired(): number | Promise<number>;
|
||||
clear(): void | Promise<void>;
|
||||
size(): number | Promise<number>;
|
||||
}
|
||||
|
||||
export class MemorySessionStore implements ISessionStore {
|
||||
|
|
@ -97,7 +98,99 @@ export class MemorySessionStore implements ISessionStore {
|
|||
}
|
||||
}
|
||||
|
||||
export const defaultSessionStore: ISessionStore = new MemorySessionStore();
|
||||
export class PrismaSessionStore implements ISessionStore {
|
||||
private readonly defaultTtlMs: number;
|
||||
|
||||
constructor(options?: { defaultTtlMs?: number }) {
|
||||
this.defaultTtlMs = options?.defaultTtlMs ?? SESSION_MAX_AGE_SECONDS * 1000;
|
||||
}
|
||||
|
||||
public async createSession(user: SessionUser, ttlMs?: number): Promise<string> {
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
const now = new Date();
|
||||
const ttl = ttlMs ?? this.defaultTtlMs;
|
||||
const expiresAt = new Date(now.getTime() + ttl);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
sessionId,
|
||||
userId: user.id,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public async getSession(sessionId: string): Promise<SessionUser | null> {
|
||||
if (!sessionId) return null;
|
||||
|
||||
const record = await prisma.session.findUnique({
|
||||
where: { sessionId },
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
if (Date.now() > record.expiresAt.getTime()) {
|
||||
await prisma.session.deleteMany({
|
||||
where: { sessionId },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!record.user) return null;
|
||||
|
||||
return {
|
||||
id: record.user.id,
|
||||
vkUserId: record.user.vkUserId,
|
||||
firstName: record.user.firstName ?? undefined,
|
||||
lastName: record.user.lastName ?? undefined,
|
||||
username: record.user.username ?? undefined,
|
||||
avatarUrl: record.user.avatarUrl ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public async destroySession(sessionId: string): Promise<void> {
|
||||
if (sessionId) {
|
||||
await prisma.session.deleteMany({
|
||||
where: { sessionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanupExpired(): Promise<number> {
|
||||
const res = await prisma.session.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
await prisma.session.deleteMany();
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
return await prisma.session.count();
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionStore(): ISessionStore {
|
||||
const driver = process.env.STORAGE_DRIVER || (process.env.NODE_ENV === 'test' ? 'memory' : 'prisma');
|
||||
if (driver === 'memory') {
|
||||
return new MemorySessionStore();
|
||||
}
|
||||
return new PrismaSessionStore();
|
||||
}
|
||||
|
||||
export let defaultSessionStore: ISessionStore = createSessionStore();
|
||||
|
||||
export function setSessionStore(store: ISessionStore): void {
|
||||
defaultSessionStore = store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session user from request cookie
|
||||
|
|
|
|||
221
tests/persistent-auth-stores.test.ts
Normal file
221
tests/persistent-auth-stores.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
MemoryOAuthTransactionStore,
|
||||
PrismaOAuthTransactionStore,
|
||||
createOAuthTransactionStore,
|
||||
generateOAuthState,
|
||||
generateCodeVerifier,
|
||||
generateCodeChallenge,
|
||||
} from '../src/lib/auth/oauth-state';
|
||||
import {
|
||||
MemorySessionStore,
|
||||
PrismaSessionStore,
|
||||
createSessionStore,
|
||||
SessionUser,
|
||||
} from '../src/lib/auth/session';
|
||||
import { UnauthorizedError } from '../src/core/errors/http-errors';
|
||||
|
||||
describe('Task 01: Persistent OAuth-State & Session Store', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
// ─── 1. Concurrent consumeTransaction on single state ────────────────────────
|
||||
describe('OAuth Single-Use Concurrency & Race Resistance', () => {
|
||||
it('concurrent consumeTransaction calls on same state yield exactly 1 success and N-1 UnauthorizedErrors', async () => {
|
||||
const store = new MemoryOAuthTransactionStore();
|
||||
const { state } = await store.createTransaction({
|
||||
redirectTarget: '/dashboard',
|
||||
ttlMs: 60000,
|
||||
});
|
||||
|
||||
const concurrentAttempts = Array.from({ length: 25 }, async () => {
|
||||
try {
|
||||
const res = await store.consumeTransaction(state);
|
||||
return { success: true, res };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(concurrentAttempts);
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
expect(successes).toHaveLength(1);
|
||||
expect(failures).toHaveLength(24);
|
||||
expect((successes[0] as any).res.redirectTarget).toBe('/dashboard');
|
||||
|
||||
failures.forEach(f => {
|
||||
expect(f.error).toBeInstanceOf(UnauthorizedError);
|
||||
expect((f.error as UnauthorizedError).message).toMatch(/single-use constraint|not found/i);
|
||||
});
|
||||
|
||||
// Subsequent read after race must also fail
|
||||
await expect(store.consumeTransaction(state)).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Multi-Instance OAuth Consumption ─────────────────────────────────────
|
||||
describe('Multi-Instance OAuth Transaction Handoff', () => {
|
||||
it('state created by instance A can be consumed by instance B sharing underlying state', async () => {
|
||||
// Create shared backing map for simulation
|
||||
const sharedMap = new Map<string, any>();
|
||||
|
||||
class SharedMemoryStore extends MemoryOAuthTransactionStore {
|
||||
constructor() {
|
||||
super();
|
||||
(this as any).store = sharedMap;
|
||||
}
|
||||
}
|
||||
|
||||
const instanceA = new SharedMemoryStore();
|
||||
const instanceB = new SharedMemoryStore();
|
||||
|
||||
// Instance A creates OAuth start transaction
|
||||
const { state, codeVerifier } = await instanceA.createTransaction({
|
||||
redirectTarget: '/giveaways/new',
|
||||
});
|
||||
|
||||
// Instance B consumes OAuth callback transaction
|
||||
const consumed = await instanceB.consumeTransaction(state);
|
||||
expect(consumed.codeVerifier).toBe(codeVerifier);
|
||||
expect(consumed.redirectTarget).toBe('/giveaways/new');
|
||||
|
||||
// Attempt to re-consume by instance A fails
|
||||
await expect(instanceA.consumeTransaction(state)).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. TTL Expiry Enforcement ───────────────────────────────────────────────
|
||||
describe('TTL Expiry & Invalidation Policy', () => {
|
||||
it('expired OAuth state is rejected with UnauthorizedError', async () => {
|
||||
const store = new MemoryOAuthTransactionStore({ defaultTtlMs: 1 });
|
||||
const { state } = await store.createTransaction({ ttlMs: -1000 }); // already expired
|
||||
|
||||
await expect(store.consumeTransaction(state)).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
it('invalidateTransaction removes state explicitly', async () => {
|
||||
const store = new MemoryOAuthTransactionStore();
|
||||
const { state } = await store.createTransaction();
|
||||
|
||||
const invalidated = await store.invalidateTransaction(state);
|
||||
expect(invalidated).toBe(true);
|
||||
|
||||
await expect(store.consumeTransaction(state)).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
it('expired Session is rejected and returns null from getSession', async () => {
|
||||
const sessionStore = new MemorySessionStore({ defaultTtlMs: 1 });
|
||||
const user: SessionUser = {
|
||||
id: 'usr_ttl_test',
|
||||
vkUserId: '123456',
|
||||
firstName: 'TTL',
|
||||
lastName: 'Test',
|
||||
};
|
||||
|
||||
const sessionId = await sessionStore.createSession(user, -5000); // expired 5s ago
|
||||
const session = await sessionStore.getSession(sessionId);
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Session Store Lifecycle & Destruction ────────────────────────────────
|
||||
describe('Session Store Lifecycle', () => {
|
||||
const testUser: SessionUser = {
|
||||
id: 'usr_session_lifecycle',
|
||||
vkUserId: '998877',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Organizer',
|
||||
username: 'alice_org',
|
||||
};
|
||||
|
||||
it('createSession, getSession and destroySession work correctly', async () => {
|
||||
const store = new MemorySessionStore();
|
||||
const sessionId = await store.createSession(testUser);
|
||||
|
||||
const retrieved = await store.getSession(sessionId);
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.id).toBe(testUser.id);
|
||||
expect(retrieved?.vkUserId).toBe(testUser.vkUserId);
|
||||
|
||||
await store.destroySession(sessionId);
|
||||
const afterDestroy = await store.getSession(sessionId);
|
||||
expect(afterDestroy).toBeNull();
|
||||
});
|
||||
|
||||
it('session survives re-creation of store instance when sharing storage', async () => {
|
||||
const sharedStoreMap = new Map<string, any>();
|
||||
|
||||
class SharedSessionStore extends MemorySessionStore {
|
||||
constructor() {
|
||||
super();
|
||||
(this as any).store = sharedStoreMap;
|
||||
}
|
||||
}
|
||||
|
||||
const storeProcess1 = new SharedSessionStore();
|
||||
const sessionId = await storeProcess1.createSession(testUser);
|
||||
|
||||
// Simulate process restart
|
||||
const storeProcess2 = new SharedSessionStore();
|
||||
const retrieved = await storeProcess2.getSession(sessionId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.id).toBe(testUser.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. MULTI_INSTANCE Configuration Policy & Driver Selector ────────────────
|
||||
describe('Driver Selector & MULTI_INSTANCE Guards', () => {
|
||||
it('Memory stores throw fatal error when MULTI_INSTANCE=true', () => {
|
||||
process.env.MULTI_INSTANCE = 'true';
|
||||
|
||||
expect(() => new MemoryOAuthTransactionStore()).toThrow(/MemoryOAuthTransactionStore cannot be used when MULTI_INSTANCE=true/);
|
||||
expect(() => new MemorySessionStore()).toThrow(/In-memory session store cannot be used with MULTI_INSTANCE=true/);
|
||||
});
|
||||
|
||||
it('Prisma stores do NOT throw when MULTI_INSTANCE=true', () => {
|
||||
process.env.MULTI_INSTANCE = 'true';
|
||||
|
||||
expect(() => new PrismaOAuthTransactionStore()).not.toThrow();
|
||||
expect(() => new PrismaSessionStore()).not.toThrow();
|
||||
});
|
||||
|
||||
it('createOAuthTransactionStore selects Memory in test/memory mode, Prisma in production mode', () => {
|
||||
delete process.env.STORAGE_DRIVER;
|
||||
(process.env as any).NODE_ENV = 'test';
|
||||
expect(createOAuthTransactionStore()).toBeInstanceOf(MemoryOAuthTransactionStore);
|
||||
|
||||
process.env.STORAGE_DRIVER = 'memory';
|
||||
(process.env as any).NODE_ENV = 'production';
|
||||
expect(createOAuthTransactionStore()).toBeInstanceOf(MemoryOAuthTransactionStore);
|
||||
|
||||
delete process.env.STORAGE_DRIVER;
|
||||
(process.env as any).NODE_ENV = 'production';
|
||||
expect(createOAuthTransactionStore()).toBeInstanceOf(PrismaOAuthTransactionStore);
|
||||
});
|
||||
|
||||
it('createSessionStore selects Memory in test/memory mode, Prisma in production mode', () => {
|
||||
delete process.env.STORAGE_DRIVER;
|
||||
(process.env as any).NODE_ENV = 'test';
|
||||
expect(createSessionStore()).toBeInstanceOf(MemorySessionStore);
|
||||
|
||||
process.env.STORAGE_DRIVER = 'memory';
|
||||
(process.env as any).NODE_ENV = 'production';
|
||||
expect(createSessionStore()).toBeInstanceOf(MemorySessionStore);
|
||||
|
||||
delete process.env.STORAGE_DRIVER;
|
||||
(process.env as any).NODE_ENV = 'production';
|
||||
expect(createSessionStore()).toBeInstanceOf(PrismaSessionStore);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue