diff --git a/docs/AUTH_SECURITY.md b/docs/AUTH_SECURITY.md
new file mode 100644
index 0000000..26a5e95
--- /dev/null
+++ b/docs/AUTH_SECURITY.md
@@ -0,0 +1,38 @@
+# Authentication & Authorization Security Policy
+
+This document details the security mitigations, CSRF defenses, session controls, and token isolation policies in **Randomayzer**.
+
+---
+
+## 1. CSRF & State Parameter Defenses
+
+- **Unpredictable State**: Every OAuth flow generates 32 bytes of cryptographic randomness via `crypto.randomBytes(32).toString('base64url')`.
+- **Strict Single-Use**: The moment `consumeTransaction(state)` is called in the callback handler, the transaction is immediately deleted from storage. Even if a duplicate or replayed request arrives, it is immediately rejected with HTTP 401.
+- **Short TTL**: OAuth transactions automatically expire after 10 minutes.
+
+---
+
+## 2. PKCE (Proof Key for Code Exchange)
+
+- **Standard**: RFC 7636 (OAuth 2.1 mandatory).
+- **Code Verifier**: 48 random bytes encoded as base64url (64 characters).
+- **Code Challenge**: `BASE64URL(SHA256(codeVerifier))`.
+- **Method**: `S256` (plain is strictly forbidden).
+
+---
+
+## 3. Session Security
+
+- **Cookie Name**: `randomayzer_session`
+- **Attributes**:
+ - `HttpOnly`: Client-side JavaScript (`document.cookie`) cannot read the session cookie, preventing XSS-based session extraction.
+ - `Secure`: Transmitted only over HTTPS in production.
+ - `SameSite=Lax`: Prevents cross-site CSRF on third-party link navigations while permitting normal user navigation.
+ - `Max-Age`: 30 days (2,592,000 seconds).
+
+---
+
+## 4. Token Leakage Prevention
+
+- `/api/auth/me` returns only safe, sanitized user metadata (`id`, `vkUserId`, `firstName`, `lastName`, `username`, `avatarUrl`).
+- Tokens are **never** rendered in JSON responses, headers, URL parameters, logs, or persistent audit records.
diff --git a/docs/TOKEN_STORAGE.md b/docs/TOKEN_STORAGE.md
new file mode 100644
index 0000000..fb65954
--- /dev/null
+++ b/docs/TOKEN_STORAGE.md
@@ -0,0 +1,26 @@
+# Token Storage & Encryption-at-Rest Architecture
+
+This document describes how user access and refresh tokens are protected at rest.
+
+---
+
+## 1. Zero Plaintext Invariant
+
+Tokens issued by VK ID are **never** stored in plaintext in the database or caches.
+
+---
+
+## 2. AES-256-GCM Token Vault (`src/lib/auth/token-vault.ts`)
+
+- **Algorithm**: Authenticated Encryption with Associated Data (`AES-256-GCM`).
+- **Key Derivation**: 256-bit key derived via `SHA-256` from `TOKEN_ENCRYPTION_KEY` or `AUTH_SECRET`.
+- **Initialization Vector (IV)**: 12 bytes (96 bits) of fresh cryptographic randomness generated per encryption operation.
+- **Authentication Tag**: 16 bytes (128 bits) ensuring ciphertext integrity against tampering.
+- **Format**: `iv_hex:authTag_hex:ciphertext_hex`
+
+---
+
+## 3. Token Rotation & Refresh Semantics
+
+- If VK ID responds with a `refresh_token`, it is encrypted and saved alongside the access token in `UserCredential`.
+- Invalidation: Calling `/api/auth/logout` destroys the user session and local credential cache.
diff --git a/docs/VK_OAUTH_FLOW.md b/docs/VK_OAUTH_FLOW.md
new file mode 100644
index 0000000..277d41a
--- /dev/null
+++ b/docs/VK_OAUTH_FLOW.md
@@ -0,0 +1,60 @@
+# VK ID OAuth 2.1 Authentication Flow
+
+This document specifies the authorization flow, endpoints, and security contracts for organizer login via VK ID.
+
+---
+
+## Sequence Diagram
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User as Organizer (Browser)
+ participant App as Randomayzer Frontend
+ participant Server as Randomayzer API
+ participant Vault as Token Vault / DB
+ participant VKID as VK ID OAuth 2.1 (id.vk.com)
+
+ User->>App: Click "Войти через VK ID"
+ App->>Server: GET /api/auth/vk/start?redirectTarget=/
+ Server->>Server: Generate PKCE (verifier + S256 challenge) & State (32 bytes)
+ Server->>Server: Save OAuthTransaction (10m TTL, single-use)
+ Server-->>User: 302 Redirect to VK ID Auth URL
+ User->>VKID: Authorize App & Grant Permissions
+ VKID-->>User: 302 Redirect to /api/auth/vk/callback?code=AUTH_CODE&state=STATE
+ User->>Server: GET /api/auth/vk/callback?code=AUTH_CODE&state=STATE
+ Server->>Server: Validate & Consume single-use State
+ Server->>VKID: POST /oauth2/auth (grant_type=authorization_code, code, code_verifier)
+ VKID-->>Server: 200 OK (access_token, refresh_token, user_id, expires_in)
+ Server->>Vault: Encrypt access_token & refresh_token with AES-256-GCM
+ Server->>Server: Fetch Profile (users.get) & Upsert User/Organizer
+ Server->>Server: Create Session & set HttpOnly Secure Cookie
+ Server-->>User: 302 Redirect to redirectTarget
+ User->>Server: GET /api/auth/me (Cookie: randomayzer_session)
+ Server-->>User: 200 OK (safe profile without tokens)
+```
+
+---
+
+## Official Endpoints
+
+1. **Authorization Start**: `https://id.vk.com/auth`
+ - Parameters:
+ - `response_type=code`
+ - `client_id` (VK App ID)
+ - `redirect_uri` (`https://randomayzer.domain/api/auth/vk/callback`)
+ - `state` (Cryptographically random, single-use, 10 min TTL)
+ - `code_challenge` (BASE64URL(SHA256(code_verifier)))
+ - `code_challenge_method=S256`
+ - `scope=wall,groups,offline`
+
+2. **Token Exchange**: `https://id.vk.com/oauth2/auth`
+ - Method: POST `application/x-www-form-urlencoded`
+ - Parameters:
+ - `grant_type=authorization_code`
+ - `code`
+ - `code_verifier`
+ - `client_id`
+ - `client_secret`
+ - `redirect_uri`
+ - `state`
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0aec025..c1f7a44 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -30,6 +30,32 @@ enum ParticipantSource {
COMBINED
}
+model User {
+ id String @id @default(cuid())
+ vkUserId String @unique
+ firstName String?
+ lastName String?
+ username String?
+ avatarUrl String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ giveaways Giveaway[]
+ credentials UserCredential?
+}
+
+model UserCredential {
+ id String @id @default(cuid())
+ userId String @unique
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ encryptedAccessToken String
+ encryptedRefreshToken String?
+ expiresAt DateTime?
+ scope String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
model Giveaway {
id String @id @default(cuid())
platform Platform @default(VK)
@@ -47,6 +73,8 @@ model Giveaway {
winnersCount Int @default(1)
reserveWinnersCount Int @default(0)
seed String?
+ organizerId String?
+ organizer User? @relation(fields: [organizerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
drawnAt DateTime?
@@ -57,6 +85,7 @@ model Giveaway {
auditRecord AuditRecord?
@@index([platform, platformOwnerId, platformPostId])
+ @@index([organizerId])
}
model Participant {
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..cdcc7d4
--- /dev/null
+++ b/src/app/api/auth/logout/route.ts
@@ -0,0 +1,24 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { defaultSessionStore, clearSessionCookie, SESSION_COOKIE_NAME } from '@/lib/auth/session';
+import { handleApiError } from '@/core/errors/http-errors';
+
+export const dynamic = 'force-dynamic';
+
+export async function POST(req: NextRequest) {
+ try {
+ const sessionId = req.cookies.get(SESSION_COOKIE_NAME)?.value;
+ if (sessionId) {
+ await defaultSessionStore.destroySession(sessionId);
+ }
+
+ const response = NextResponse.json({
+ success: true,
+ message: 'Logged out successfully',
+ });
+
+ clearSessionCookie(response);
+ return response;
+ } catch (error: any) {
+ return handleApiError(error);
+ }
+}
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
new file mode 100644
index 0000000..15b8384
--- /dev/null
+++ b/src/app/api/auth/me/route.ts
@@ -0,0 +1,33 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSessionFromRequest } from '@/lib/auth/session';
+import { handleApiError } from '@/core/errors/http-errors';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: NextRequest) {
+ try {
+ const user = await getSessionFromRequest(req);
+
+ if (!user) {
+ return NextResponse.json({
+ authenticated: false,
+ user: null,
+ });
+ }
+
+ // Return safe user profile only (NO access token, refresh token, or secrets)
+ return NextResponse.json({
+ authenticated: true,
+ user: {
+ id: user.id,
+ vkUserId: user.vkUserId,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ username: user.username,
+ avatarUrl: user.avatarUrl,
+ },
+ });
+ } catch (error: any) {
+ return handleApiError(error);
+ }
+}
diff --git a/src/app/api/auth/vk/callback/route.ts b/src/app/api/auth/vk/callback/route.ts
new file mode 100644
index 0000000..70b519f
--- /dev/null
+++ b/src/app/api/auth/vk/callback/route.ts
@@ -0,0 +1,89 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { defaultOAuthTransactionStore } from '@/lib/auth/oauth-state';
+import { getOAuthClient } from '../start/route';
+import { defaultTokenVault } from '@/lib/auth/token-vault';
+import { defaultUserRepository } from '@/lib/repository/user-repository';
+import { defaultSessionStore, setSessionCookie } from '@/lib/auth/session';
+import { handleApiError, ValidationError, UnauthorizedError } from '@/core/errors/http-errors';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: NextRequest) {
+ try {
+ const { searchParams } = new URL(req.url);
+ const code = searchParams.get('code');
+ const state = searchParams.get('state');
+ const errorParam = searchParams.get('error');
+ const errorDescription = searchParams.get('error_description');
+
+ const origin = req.nextUrl.origin || 'http://localhost:3000';
+
+ // 1. Handle user cancellation or VK authorization rejection
+ if (errorParam) {
+ const target = `/?auth_error=${encodeURIComponent(errorDescription || errorParam)}`;
+ return NextResponse.redirect(`${origin}${target}`);
+ }
+
+ if (!code) {
+ throw new ValidationError('Authorization code is missing from callback query');
+ }
+
+ if (!state) {
+ throw new ValidationError('OAuth state parameter is missing from callback query');
+ }
+
+ // 2. Validate and consume single-use state transaction (recovers codeVerifier and redirectTarget)
+ const { codeVerifier, redirectTarget } = await defaultOAuthTransactionStore.consumeTransaction(state);
+
+ const clientId = process.env.VK_APP_ID || process.env.NEXT_PUBLIC_VK_APP_ID || '51990000';
+ const clientSecret = process.env.VK_CLIENT_SECRET;
+ const redirectUri = process.env.VK_REDIRECT_URI || `${origin}/api/auth/vk/callback`;
+
+ // 3. Exchange code for access token via dedicated VkOAuthClient
+ const oauthClient = getOAuthClient();
+ const tokenResponse = await oauthClient.exchangeCode({
+ code,
+ codeVerifier,
+ clientId,
+ clientSecret,
+ redirectUri,
+ state,
+ });
+
+ // 4. Encrypt sensitive tokens at rest before storing
+ const encryptedAccessToken = await defaultTokenVault.encrypt(tokenResponse.access_token);
+ const encryptedRefreshToken = tokenResponse.refresh_token
+ ? await defaultTokenVault.encrypt(tokenResponse.refresh_token)
+ : undefined;
+
+ // 5. Retrieve organizer profile from VK API
+ const userProfile = await oauthClient.getUserProfile(
+ tokenResponse.access_token,
+ tokenResponse.user_id
+ );
+
+ // 6. Upsert user in repository
+ const sessionUser = await defaultUserRepository.upsertUserWithTokens({
+ vkUserId: String(tokenResponse.user_id),
+ firstName: userProfile.firstName,
+ lastName: userProfile.lastName,
+ username: userProfile.username,
+ avatarUrl: userProfile.avatarUrl,
+ encryptedAccessToken,
+ encryptedRefreshToken,
+ expiresIn: tokenResponse.expires_in,
+ scope: tokenResponse.scope,
+ });
+
+ // 7. Create secure session and set HttpOnly cookie
+ const sessionId = await defaultSessionStore.createSession(sessionUser);
+
+ const destination = redirectTarget.startsWith('/') ? redirectTarget : '/';
+ const response = NextResponse.redirect(`${origin}${destination}`);
+ setSessionCookie(response, sessionId);
+
+ return response;
+ } catch (error: any) {
+ return handleApiError(error);
+ }
+}
diff --git a/src/app/api/auth/vk/start/route.ts b/src/app/api/auth/vk/start/route.ts
new file mode 100644
index 0000000..cbfc638
--- /dev/null
+++ b/src/app/api/auth/vk/start/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { defaultOAuthTransactionStore } from '@/lib/auth/oauth-state';
+import { defaultVkOAuthClient, IVkOAuthClient } from '@/integrations/vk/vk-oauth-client';
+import { MockVkOAuthClient } from '@/integrations/vk/mock-oauth-client';
+import { handleApiError, ValidationError } from '@/core/errors/http-errors';
+
+export const dynamic = 'force-dynamic';
+
+export function getOAuthClient(): IVkOAuthClient {
+ if (process.env.USE_VK_MOCK === 'true' || (process.env.NODE_ENV === 'test' && !process.env.VK_APP_ID)) {
+ return new MockVkOAuthClient();
+ }
+ return defaultVkOAuthClient;
+}
+
+export async function GET(req: NextRequest) {
+ try {
+ const { searchParams } = new URL(req.url);
+ const redirectTarget = searchParams.get('redirectTarget') || '/';
+
+ const clientId = process.env.VK_APP_ID || process.env.NEXT_PUBLIC_VK_APP_ID || '51990000';
+ if (!clientId) {
+ throw new ValidationError('VK_APP_ID is not configured in server environment');
+ }
+
+ // Determine absolute redirect URI
+ const origin = req.nextUrl.origin || 'http://localhost:3000';
+ const redirectUri = process.env.VK_REDIRECT_URI || `${origin}/api/auth/vk/callback`;
+
+ // 1. Create secure OAuth transaction with PKCE and State
+ const { state, codeChallenge } = await defaultOAuthTransactionStore.createTransaction({
+ redirectTarget,
+ ttlMs: 10 * 60 * 1000, // 10 min TTL
+ });
+
+ // 2. Build authorization URL
+ const oauthClient = getOAuthClient();
+ const authUrl = oauthClient.buildAuthorizationUrl({
+ clientId,
+ redirectUri,
+ state,
+ codeChallenge,
+ scope: 'wall,groups,offline',
+ });
+
+ // 3. Redirect user to VK ID
+ return NextResponse.redirect(authUrl);
+ } catch (error: any) {
+ return handleApiError(error);
+ }
+}
diff --git a/src/app/api/giveaways/route.ts b/src/app/api/giveaways/route.ts
index 8100baa..3842382 100644
--- a/src/app/api/giveaways/route.ts
+++ b/src/app/api/giveaways/route.ts
@@ -5,6 +5,7 @@ import { handleApiError } from '@/core/errors/http-errors';
import { generalApiRateLimiter } from '@/lib/rate-limiter';
import { IdempotencyStore } from '@/lib/idempotency';
import { resolveClientIp } from '@/lib/client-ip';
+import { getSessionFromRequest } from '@/lib/auth/session';
export async function GET(req: NextRequest) {
try {
@@ -43,6 +44,8 @@ export async function POST(req: NextRequest) {
}
}
+ const sessionUser = await getSessionFromRequest(req);
+
const giveaway = await GiveawayStore.create({
sourceUrl: validated.sourceUrl,
post: validated.post,
@@ -50,6 +53,7 @@ export async function POST(req: NextRequest) {
winnersCount: validated.winnersCount,
reserveWinnersCount: validated.reserveWinnersCount,
seed: validated.seed,
+ organizerId: sessionUser?.id,
});
const responseBody = {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index a4340ff..3e96d16 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import './globals.css';
import Link from 'next/link';
import { Gift, ShieldCheck, PlusCircle, LayoutDashboard } from 'lucide-react';
+import { AuthButton } from '@/components/auth/AuthButton';
export const metadata: Metadata = {
title: 'Randomayzer — Доказуемые розыгрыши ВКонтакте',
@@ -36,18 +37,22 @@ export default function RootLayout({
@@ -65,7 +70,7 @@ export default function RootLayout({
Provably Fair Engine • Криптографически доказуемый выбор
- Randomayzer Core v1.0 • Этап 1
+ Randomayzer Core v1.0 • Этап 2.2 VK ID
diff --git a/src/components/auth/AuthButton.tsx b/src/components/auth/AuthButton.tsx
new file mode 100644
index 0000000..18d4dc7
--- /dev/null
+++ b/src/components/auth/AuthButton.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { LogIn, LogOut, User as UserIcon } from 'lucide-react';
+import Image from 'next/image';
+
+interface AuthUser {
+ id: string;
+ vkUserId: string;
+ firstName?: string;
+ lastName?: string;
+ username?: string;
+ avatarUrl?: string;
+}
+
+export function AuthButton() {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch('/api/auth/me')
+ .then(res => res.json())
+ .then(data => {
+ if (data.authenticated && data.user) {
+ setUser(data.user);
+ } else {
+ setUser(null);
+ }
+ })
+ .catch(() => setUser(null))
+ .finally(() => setLoading(false));
+ }, []);
+
+ const handleLogout = async () => {
+ try {
+ await fetch('/api/auth/logout', { method: 'POST' });
+ setUser(null);
+ window.location.reload();
+ } catch (e) {
+ console.error('Logout error', e);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (user) {
+ const fullName = `${user.firstName || ''} ${user.lastName || ''}`.trim() || `VK ${user.vkUserId}`;
+ return (
+
+ {user.avatarUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ {fullName}
+
+
+
+ );
+ }
+
+ return (
+
+
+ Войти через VK ID
+
+ );
+}
diff --git a/src/integrations/vk/mock-oauth-client.ts b/src/integrations/vk/mock-oauth-client.ts
new file mode 100644
index 0000000..e4cc47f
--- /dev/null
+++ b/src/integrations/vk/mock-oauth-client.ts
@@ -0,0 +1,94 @@
+import { IVkOAuthClient, VkOAuthTokenResponse } from './vk-oauth-client';
+import { VkAuthError, VkValidationError } from './vk-errors';
+
+export class MockVkOAuthClient implements IVkOAuthClient {
+ public shouldFailExchange = false;
+ public shouldFailRefresh = false;
+
+ public buildAuthorizationUrl(params: {
+ clientId: string;
+ redirectUri: string;
+ state: string;
+ codeChallenge: string;
+ scope?: string;
+ }): string {
+ const query = new URLSearchParams({
+ response_type: 'code',
+ client_id: params.clientId,
+ redirect_uri: params.redirectUri,
+ state: params.state,
+ code_challenge: params.codeChallenge,
+ code_challenge_method: 'S256',
+ });
+ return `https://id.vk.com/auth?${query.toString()}`;
+ }
+
+ public async exchangeCode(params: {
+ code: string;
+ codeVerifier: string;
+ clientId: string;
+ clientSecret?: string;
+ redirectUri: string;
+ state?: string;
+ }): Promise {
+ if (!params.code) {
+ throw new VkValidationError('Authorization code is missing');
+ }
+ if (!params.codeVerifier) {
+ throw new VkValidationError('PKCE code_verifier is missing');
+ }
+ if (this.shouldFailExchange || params.code === 'invalid_code') {
+ throw new VkAuthError('VK OAuth token exchange failed: invalid_grant');
+ }
+
+ return {
+ access_token: `mock_vk_access_token_${params.code}`,
+ token_type: 'Bearer',
+ expires_in: 86400,
+ user_id: 12345678,
+ refresh_token: `mock_vk_refresh_token_${params.code}`,
+ scope: 'wall,groups,offline',
+ };
+ }
+
+ public async refreshToken(params: {
+ refreshToken: string;
+ clientId: string;
+ clientSecret?: string;
+ }): Promise {
+ if (!params.refreshToken) {
+ throw new VkValidationError('Refresh token is required');
+ }
+ if (this.shouldFailRefresh || params.refreshToken === 'invalid_refresh') {
+ throw new VkAuthError('VK OAuth token refresh failed: invalid_grant');
+ }
+
+ return {
+ access_token: `mock_refreshed_access_token_${Date.now()}`,
+ token_type: 'Bearer',
+ expires_in: 86400,
+ user_id: 12345678,
+ refresh_token: `mock_new_refresh_token_${Date.now()}`,
+ scope: 'wall,groups,offline',
+ };
+ }
+
+ public async getUserProfile(
+ _accessToken: string,
+ userId: number | string
+ ): Promise<{
+ id: string;
+ firstName: string;
+ lastName: string;
+ username?: string;
+ avatarUrl?: string;
+ }> {
+ return {
+ id: String(userId),
+ firstName: 'Иван',
+ lastName: 'Организаторов',
+ username: 'organizer_ivan',
+ avatarUrl: 'https://sun9-1.userapi.com/s/v1/ig2/mock_avatar.jpg',
+ };
+ }
+}
diff --git a/src/integrations/vk/vk-oauth-client.ts b/src/integrations/vk/vk-oauth-client.ts
new file mode 100644
index 0000000..e0614d2
--- /dev/null
+++ b/src/integrations/vk/vk-oauth-client.ts
@@ -0,0 +1,267 @@
+import { VkAuthError, VkValidationError, VkNetworkError } from './vk-errors';
+
+export interface VkOAuthTokenResponse {
+ access_token: string;
+ token_type?: string;
+ expires_in: number;
+ user_id: number;
+ state?: string;
+ scope?: string;
+ refresh_token?: string;
+ id_token?: string;
+ email?: string;
+ phone?: string;
+}
+
+export interface IVkOAuthClient {
+ buildAuthorizationUrl(params: {
+ clientId: string;
+ redirectUri: string;
+ state: string;
+ codeChallenge: string;
+ scope?: string;
+ }): string;
+
+ exchangeCode(params: {
+ code: string;
+ codeVerifier: string;
+ clientId: string;
+ clientSecret?: string;
+ redirectUri: string;
+ state?: string;
+ }): Promise;
+
+ refreshToken(params: {
+ refreshToken: string;
+ clientId: string;
+ clientSecret?: string;
+ }): Promise;
+
+ getUserProfile(
+ accessToken: string,
+ userId: number | string
+ ): Promise<{
+ id: string;
+ firstName: string;
+ lastName: string;
+ username?: string;
+ avatarUrl?: string;
+ }>;
+}
+
+export class VkOAuthClient implements IVkOAuthClient {
+ public static readonly DEFAULT_AUTH_URL = 'https://id.vk.com/auth';
+ public static readonly DEFAULT_TOKEN_URL = 'https://id.vk.com/oauth2/auth';
+ public static readonly DEFAULT_API_BASE = 'https://api.vk.com/method/';
+
+ private readonly authUrl: string;
+ private readonly tokenUrl: string;
+ private readonly apiBase: string;
+
+ constructor(options?: { authUrl?: string; tokenUrl?: string; apiBase?: string }) {
+ this.authUrl = options?.authUrl || process.env.VK_ID_AUTH_URL || VkOAuthClient.DEFAULT_AUTH_URL;
+ this.tokenUrl = options?.tokenUrl || process.env.VK_ID_TOKEN_URL || VkOAuthClient.DEFAULT_TOKEN_URL;
+ this.apiBase = options?.apiBase || process.env.VK_API_BASE_URL || VkOAuthClient.DEFAULT_API_BASE;
+ }
+
+ public buildAuthorizationUrl(params: {
+ clientId: string;
+ redirectUri: string;
+ state: string;
+ codeChallenge: string;
+ scope?: string;
+ }): string {
+ const query = new URLSearchParams({
+ response_type: 'code',
+ client_id: params.clientId,
+ redirect_uri: params.redirectUri,
+ state: params.state,
+ code_challenge: params.codeChallenge,
+ code_challenge_method: 'S256',
+ });
+
+ if (params.scope) {
+ query.append('scope', params.scope);
+ }
+
+ return `${this.authUrl}?${query.toString()}`;
+ }
+
+ public async exchangeCode(params: {
+ code: string;
+ codeVerifier: string;
+ clientId: string;
+ clientSecret?: string;
+ redirectUri: string;
+ state?: string;
+ }): Promise {
+ if (!params.code) {
+ throw new VkValidationError('Authorization code is missing');
+ }
+ if (!params.codeVerifier) {
+ throw new VkValidationError('PKCE code_verifier is missing');
+ }
+
+ const formBody = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code: params.code,
+ code_verifier: params.codeVerifier,
+ client_id: params.clientId,
+ redirect_uri: params.redirectUri,
+ });
+
+ if (params.clientSecret) {
+ formBody.append('client_secret', params.clientSecret);
+ }
+ if (params.state) {
+ formBody.append('state', params.state);
+ }
+
+ try {
+ const response = await fetch(this.tokenUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Accept': 'application/json',
+ 'User-Agent': 'Randomayzer-OAuth/1.0 (+https://github.com/ochenstarik-ui/randomayzer)',
+ },
+ body: formBody.toString(),
+ });
+
+ const data = await response.json().catch(() => null);
+
+ if (!response.ok || !data || data.error) {
+ const errorDesc = data?.error_description || data?.error_msg || data?.error || 'Token exchange failed';
+ throw new VkAuthError(`VK OAuth token exchange error (${response.status}): ${errorDesc}`, {
+ errorCode: response.status,
+ });
+ }
+
+ if (!data.access_token) {
+ throw new VkAuthError('VK OAuth response missing access_token');
+ }
+
+ return {
+ access_token: data.access_token,
+ token_type: data.token_type || 'Bearer',
+ expires_in: Number(data.expires_in || 0),
+ user_id: Number(data.user_id || 0),
+ refresh_token: data.refresh_token,
+ id_token: data.id_token,
+ email: data.email,
+ phone: data.phone,
+ scope: data.scope,
+ };
+ } catch (err: unknown) {
+ if (err instanceof VkAuthError || err instanceof VkValidationError) {
+ throw err;
+ }
+ const message = err instanceof Error ? err.message : 'Network error';
+ throw new VkNetworkError(`Failed to connect to VK OAuth token endpoint: ${message}`);
+ }
+ }
+
+ public async refreshToken(params: {
+ refreshToken: string;
+ clientId: string;
+ clientSecret?: string;
+ }): Promise {
+ if (!params.refreshToken) {
+ throw new VkValidationError('Refresh token is required');
+ }
+
+ const formBody = new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: params.refreshToken,
+ client_id: params.clientId,
+ });
+
+ if (params.clientSecret) {
+ formBody.append('client_secret', params.clientSecret);
+ }
+
+ try {
+ const response = await fetch(this.tokenUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Accept': 'application/json',
+ 'User-Agent': 'Randomayzer-OAuth/1.0',
+ },
+ body: formBody.toString(),
+ });
+
+ const data = await response.json().catch(() => null);
+
+ if (!response.ok || !data || data.error) {
+ const errorDesc = data?.error_description || data?.error_msg || data?.error || 'Token refresh failed';
+ throw new VkAuthError(`VK OAuth token refresh error (${response.status}): ${errorDesc}`);
+ }
+
+ return {
+ access_token: data.access_token,
+ token_type: data.token_type || 'Bearer',
+ expires_in: Number(data.expires_in || 0),
+ user_id: Number(data.user_id || 0),
+ refresh_token: data.refresh_token || params.refreshToken,
+ id_token: data.id_token,
+ email: data.email,
+ phone: data.phone,
+ scope: data.scope,
+ };
+ } catch (err: unknown) {
+ if (err instanceof VkAuthError) throw err;
+ const message = err instanceof Error ? err.message : 'Network error';
+ throw new VkNetworkError(`Failed to refresh VK token: ${message}`);
+ }
+ }
+
+ public async getUserProfile(
+ accessToken: string,
+ userId: number | string
+ ): Promise<{
+ id: string;
+ firstName: string;
+ lastName: string;
+ username?: string;
+ avatarUrl?: string;
+ }> {
+ const url = `${this.apiBase}users.get`;
+ const formBody = new URLSearchParams({
+ user_ids: String(userId),
+ fields: 'photo_100,photo_200,screen_name',
+ access_token: accessToken,
+ v: '5.199',
+ });
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Accept': 'application/json',
+ },
+ body: formBody.toString(),
+ });
+
+ const data = await response.json().catch(() => null);
+ const user = data?.response?.[0];
+
+ if (!user) {
+ return {
+ id: String(userId),
+ firstName: 'VK Organizer',
+ lastName: String(userId),
+ };
+ }
+
+ return {
+ id: String(user.id),
+ firstName: user.first_name || 'Organizer',
+ lastName: user.last_name || '',
+ username: user.screen_name || undefined,
+ avatarUrl: user.photo_100 || user.photo_200 || undefined,
+ };
+ }
+}
+
+export const defaultVkOAuthClient: IVkOAuthClient = new VkOAuthClient();
diff --git a/src/lib/auth/oauth-state.ts b/src/lib/auth/oauth-state.ts
new file mode 100644
index 0000000..2635eeb
--- /dev/null
+++ b/src/lib/auth/oauth-state.ts
@@ -0,0 +1,134 @@
+import { randomBytes, createHash } from 'crypto';
+import { UnauthorizedError, ValidationError } from '@/core/errors/http-errors';
+
+export interface OAuthTransaction {
+ state: string;
+ codeVerifier: string;
+ redirectTarget: string;
+ createdAt: number;
+ expiresAt: number;
+ used: boolean;
+}
+
+export interface IOAuthTransactionStore {
+ createTransaction(options?: {
+ redirectTarget?: string;
+ ttlMs?: number;
+ }): Promise<{ state: string; codeVerifier: string; codeChallenge: string }>;
+ consumeTransaction(state: string): Promise<{ codeVerifier: string; redirectTarget: string }>;
+ clear(): void;
+ size(): number;
+ cleanupExpired(): number;
+}
+
+/**
+ * Generates a cryptographically random PKCE code_verifier (64 bytes base64url).
+ */
+export function generateCodeVerifier(): string {
+ return randomBytes(48).toString('base64url');
+}
+
+/**
+ * Derives a PKCE S256 code_challenge from a code_verifier (BASE64URL(SHA256(verifier))).
+ */
+export function generateCodeChallenge(verifier: string): string {
+ return createHash('sha256').update(verifier, 'utf8').digest('base64url');
+}
+
+/**
+ * Generates an unpredictable random state string for OAuth CSRF protection.
+ */
+export function generateOAuthState(): string {
+ return randomBytes(32).toString('base64url');
+}
+
+export class MemoryOAuthTransactionStore implements IOAuthTransactionStore {
+ private store = new Map();
+ private readonly defaultTtlMs: number;
+ private readonly maxTransactions: number;
+ private opCounter = 0;
+
+ constructor(options?: { defaultTtlMs?: number; maxTransactions?: number }) {
+ this.defaultTtlMs = options?.defaultTtlMs ?? 10 * 60 * 1000; // 10 minutes
+ this.maxTransactions = options?.maxTransactions ?? 10000;
+ }
+
+ 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 = Date.now();
+ const ttl = options?.ttlMs ?? this.defaultTtlMs;
+
+ this.opCounter++;
+ if (this.opCounter % 50 === 0 || this.store.size >= this.maxTransactions) {
+ this.cleanupExpired();
+ }
+
+ this.store.set(state, {
+ state,
+ codeVerifier,
+ redirectTarget: options?.redirectTarget || '/',
+ createdAt: now,
+ expiresAt: now + ttl,
+ used: false,
+ });
+
+ return { state, codeVerifier, codeChallenge };
+ }
+
+ 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');
+ }
+
+ const tx = this.store.get(state);
+
+ if (!tx) {
+ throw new UnauthorizedError('OAuth state not found or was already consumed (single-use constraint)');
+ }
+
+ // Immediately remove from store to guarantee strict single-use semantics
+ this.store.delete(state);
+
+ if (tx.used) {
+ throw new UnauthorizedError('OAuth state was previously used');
+ }
+
+ if (Date.now() > tx.expiresAt) {
+ throw new UnauthorizedError('OAuth state has expired');
+ }
+
+ return {
+ codeVerifier: tx.codeVerifier,
+ redirectTarget: tx.redirectTarget,
+ };
+ }
+
+ public cleanupExpired(): number {
+ const now = Date.now();
+ let count = 0;
+ for (const [k, v] of this.store.entries()) {
+ if (now > v.expiresAt || v.used) {
+ this.store.delete(k);
+ count++;
+ }
+ }
+ return count;
+ }
+
+ public clear(): void {
+ this.store.clear();
+ this.opCounter = 0;
+ }
+
+ public size(): number {
+ return this.store.size;
+ }
+}
+
+export const defaultOAuthTransactionStore: IOAuthTransactionStore = new MemoryOAuthTransactionStore();
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
new file mode 100644
index 0000000..d963ad3
--- /dev/null
+++ b/src/lib/auth/session.ts
@@ -0,0 +1,138 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { randomBytes } from 'crypto';
+
+export const SESSION_COOKIE_NAME = 'randomayzer_session';
+export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
+
+export interface SessionUser {
+ id: string;
+ vkUserId: string;
+ firstName?: string;
+ lastName?: string;
+ username?: string;
+ avatarUrl?: string;
+}
+
+interface SessionRecord {
+ sessionId: string;
+ user: SessionUser;
+ createdAt: number;
+ expiresAt: number;
+}
+
+export interface ISessionStore {
+ createSession(user: SessionUser, ttlMs?: number): Promise;
+ getSession(sessionId: string): Promise;
+ destroySession(sessionId: string): Promise;
+ cleanupExpired(): number;
+ clear(): void;
+ size(): number;
+}
+
+export class MemorySessionStore implements ISessionStore {
+ private store = new Map();
+ 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 {
+ const sessionId = randomBytes(32).toString('hex');
+ const now = Date.now();
+ const ttl = ttlMs ?? this.defaultTtlMs;
+
+ this.store.set(sessionId, {
+ sessionId,
+ user,
+ createdAt: now,
+ expiresAt: now + ttl,
+ });
+
+ return sessionId;
+ }
+
+ public async getSession(sessionId: string): Promise {
+ if (!sessionId) return null;
+ const record = this.store.get(sessionId);
+ if (!record) return null;
+
+ if (Date.now() > record.expiresAt) {
+ this.store.delete(sessionId);
+ return null;
+ }
+
+ return record.user;
+ }
+
+ public async destroySession(sessionId: string): Promise {
+ if (sessionId) {
+ this.store.delete(sessionId);
+ }
+ }
+
+ public cleanupExpired(): number {
+ const now = Date.now();
+ let count = 0;
+ for (const [k, v] of this.store.entries()) {
+ if (now > v.expiresAt) {
+ this.store.delete(k);
+ count++;
+ }
+ }
+ return count;
+ }
+
+ public clear(): void {
+ this.store.clear();
+ }
+
+ public size(): number {
+ return this.store.size;
+ }
+}
+
+export const defaultSessionStore: ISessionStore = new MemorySessionStore();
+
+/**
+ * Extracts session user from request cookie
+ */
+export async function getSessionFromRequest(
+ req: NextRequest,
+ sessionStore: ISessionStore = defaultSessionStore
+): Promise {
+ const sessionId = req.cookies.get(SESSION_COOKIE_NAME)?.value;
+ if (!sessionId) return null;
+ return sessionStore.getSession(sessionId);
+}
+
+/**
+ * Sets session cookie on a NextResponse
+ */
+export function setSessionCookie(res: NextResponse, sessionId: string): void {
+ const isProd = process.env.NODE_ENV === 'production';
+ res.cookies.set({
+ name: SESSION_COOKIE_NAME,
+ value: sessionId,
+ httpOnly: true,
+ secure: isProd,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: SESSION_MAX_AGE_SECONDS,
+ });
+}
+
+/**
+ * Clears session cookie on a NextResponse
+ */
+export function clearSessionCookie(res: NextResponse): void {
+ res.cookies.set({
+ name: SESSION_COOKIE_NAME,
+ value: '',
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ maxAge: 0,
+ });
+}
diff --git a/src/lib/auth/token-vault.ts b/src/lib/auth/token-vault.ts
new file mode 100644
index 0000000..32a5485
--- /dev/null
+++ b/src/lib/auth/token-vault.ts
@@ -0,0 +1,72 @@
+import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
+
+export interface ITokenVault {
+ encrypt(plaintext: string): Promise;
+ decrypt(ciphertext: string): Promise;
+}
+
+export class AesGcmTokenVault implements ITokenVault {
+ private readonly key: Buffer;
+ private static readonly ALGORITHM = 'aes-256-gcm';
+ private static readonly IV_LENGTH = 12; // Standard 96-bit IV for GCM
+ private static readonly AUTH_TAG_LENGTH = 16;
+
+ constructor(secretKey?: string) {
+ const rawSecret =
+ secretKey ||
+ process.env.TOKEN_ENCRYPTION_KEY ||
+ process.env.AUTH_SECRET ||
+ 'dev-encryption-key-do-not-use-in-production-randomayzer-2026';
+
+ if (process.env.NODE_ENV === 'production' && !process.env.TOKEN_ENCRYPTION_KEY) {
+ console.warn(
+ '[SECURITY WARNING] TOKEN_ENCRYPTION_KEY is not set in production. Using fallback secret.'
+ );
+ }
+
+ // Derive strict 32-byte (256-bit) key via SHA-256
+ this.key = createHash('sha256').update(rawSecret, 'utf8').digest();
+ }
+
+ public async encrypt(plaintext: string): Promise {
+ if (!plaintext) return '';
+
+ const iv = randomBytes(AesGcmTokenVault.IV_LENGTH);
+ const cipher = createCipheriv(AesGcmTokenVault.ALGORITHM, this.key, iv, {
+ authTagLength: AesGcmTokenVault.AUTH_TAG_LENGTH,
+ });
+
+ let encrypted = cipher.update(plaintext, 'utf8', 'hex');
+ encrypted += cipher.final('hex');
+
+ const authTag = cipher.getAuthTag();
+
+ // Format: iv:authTag:ciphertext
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
+ }
+
+ public async decrypt(encryptedPayload: string): Promise {
+ if (!encryptedPayload) return '';
+
+ const parts = encryptedPayload.split(':');
+ if (parts.length !== 3) {
+ throw new Error('Malformed encrypted payload format. Expected iv:tag:ciphertext');
+ }
+
+ const [ivHex, tagHex, cipherHex] = parts;
+ const iv = Buffer.from(ivHex, 'hex');
+ const authTag = Buffer.from(tagHex, 'hex');
+
+ const decipher = createDecipheriv(AesGcmTokenVault.ALGORITHM, this.key, iv, {
+ authTagLength: AesGcmTokenVault.AUTH_TAG_LENGTH,
+ });
+ decipher.setAuthTag(authTag);
+
+ let decrypted = decipher.update(cipherHex, 'hex', 'utf8');
+ decrypted += decipher.final('utf8');
+
+ return decrypted;
+ }
+}
+
+export const defaultTokenVault: ITokenVault = new AesGcmTokenVault();
diff --git a/src/lib/repository/giveaway-repository.ts b/src/lib/repository/giveaway-repository.ts
index b7954e0..aab469c 100644
--- a/src/lib/repository/giveaway-repository.ts
+++ b/src/lib/repository/giveaway-repository.ts
@@ -19,6 +19,7 @@ export interface GiveawayWithRelations {
winnersCount: number;
reserveWinnersCount: number;
seed: string | null;
+ organizerId?: string | null;
createdAt: string;
updatedAt: string;
drawnAt: string | null;
@@ -42,6 +43,7 @@ export interface GiveawaySummary {
status: GiveawayStatusType;
winnersCount: number;
reserveWinnersCount: number;
+ organizerId?: string | null;
createdAt: string;
updatedAt: string;
drawnAt: string | null;
@@ -68,6 +70,7 @@ export interface CreateGiveawayInput {
winnersCount?: number;
reserveWinnersCount?: number;
seed?: string;
+ organizerId?: string;
}
export interface IGiveawayRepository {
diff --git a/src/lib/repository/memory-repository.ts b/src/lib/repository/memory-repository.ts
index b8b42d8..e90a414 100644
--- a/src/lib/repository/memory-repository.ts
+++ b/src/lib/repository/memory-repository.ts
@@ -38,6 +38,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
winnersCount: input.winnersCount || 1,
reserveWinnersCount: input.reserveWinnersCount || 0,
seed: input.seed || null,
+ organizerId: input.organizerId || null,
createdAt: now,
updatedAt: now,
drawnAt: null,
@@ -98,6 +99,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
status: gw.status,
winnersCount: gw.winnersCount,
reserveWinnersCount: gw.reserveWinnersCount,
+ organizerId: gw.organizerId || null,
createdAt: gw.createdAt,
updatedAt: gw.updatedAt,
drawnAt: gw.drawnAt,
diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts
index 9e583cb..493c870 100644
--- a/src/lib/repository/prisma-repository.ts
+++ b/src/lib/repository/prisma-repository.ts
@@ -86,6 +86,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
winnersCount: raw.winnersCount,
reserveWinnersCount: raw.reserveWinnersCount,
seed: raw.seed,
+ organizerId: raw.organizerId || null,
createdAt: raw.createdAt.toISOString(),
updatedAt: raw.updatedAt.toISOString(),
drawnAt: raw.drawnAt ? raw.drawnAt.toISOString() : null,
@@ -114,6 +115,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
winnersCount: input.winnersCount || 1,
reserveWinnersCount: input.reserveWinnersCount || 0,
seed: input.seed,
+ organizerId: input.organizerId || null,
},
include: {
participants: true,
@@ -178,6 +180,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
status: true,
winnersCount: true,
reserveWinnersCount: true,
+ organizerId: true,
createdAt: true,
updatedAt: true,
drawnAt: true,
@@ -209,6 +212,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
status: item.status as GiveawayStatusType,
winnersCount: item.winnersCount,
reserveWinnersCount: item.reserveWinnersCount,
+ organizerId: item.organizerId || null,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
drawnAt: item.drawnAt ? item.drawnAt.toISOString() : null,
diff --git a/src/lib/repository/user-repository.ts b/src/lib/repository/user-repository.ts
new file mode 100644
index 0000000..ce4dc44
--- /dev/null
+++ b/src/lib/repository/user-repository.ts
@@ -0,0 +1,191 @@
+import { prisma } from '../prisma';
+import { SessionUser } from '../auth/session';
+
+export interface UpsertUserParams {
+ vkUserId: string;
+ firstName?: string;
+ lastName?: string;
+ username?: string;
+ avatarUrl?: string;
+ encryptedAccessToken: string;
+ encryptedRefreshToken?: string;
+ expiresIn?: number;
+ scope?: string;
+}
+
+export interface IUserRepository {
+ upsertUserWithTokens(params: UpsertUserParams): Promise;
+ getUserById(id: string): Promise;
+ getUserByVkId(vkUserId: string): Promise;
+ getUserCredentials(userId: string): Promise<{
+ encryptedAccessToken: string;
+ encryptedRefreshToken?: string | null;
+ expiresAt?: Date | null;
+ scope?: string | null;
+ } | null>;
+}
+
+export class PrismaUserRepository implements IUserRepository {
+ public async upsertUserWithTokens(params: UpsertUserParams): Promise {
+ const expiresAt = params.expiresIn ? new Date(Date.now() + params.expiresIn * 1000) : null;
+
+ const user = await prisma.user.upsert({
+ where: { vkUserId: params.vkUserId },
+ update: {
+ firstName: params.firstName,
+ lastName: params.lastName,
+ username: params.username,
+ avatarUrl: params.avatarUrl,
+ credentials: {
+ upsert: {
+ create: {
+ encryptedAccessToken: params.encryptedAccessToken,
+ encryptedRefreshToken: params.encryptedRefreshToken,
+ expiresAt,
+ scope: params.scope,
+ },
+ update: {
+ encryptedAccessToken: params.encryptedAccessToken,
+ encryptedRefreshToken: params.encryptedRefreshToken,
+ expiresAt,
+ scope: params.scope,
+ },
+ },
+ },
+ },
+ create: {
+ vkUserId: params.vkUserId,
+ firstName: params.firstName,
+ lastName: params.lastName,
+ username: params.username,
+ avatarUrl: params.avatarUrl,
+ credentials: {
+ create: {
+ encryptedAccessToken: params.encryptedAccessToken,
+ encryptedRefreshToken: params.encryptedRefreshToken,
+ expiresAt,
+ scope: params.scope,
+ },
+ },
+ },
+ });
+
+ return {
+ id: user.id,
+ vkUserId: user.vkUserId,
+ firstName: user.firstName || undefined,
+ lastName: user.lastName || undefined,
+ username: user.username || undefined,
+ avatarUrl: user.avatarUrl || undefined,
+ };
+ }
+
+ public async getUserById(id: string): Promise {
+ const user = await prisma.user.findUnique({ where: { id } });
+ if (!user) return null;
+ return {
+ id: user.id,
+ vkUserId: user.vkUserId,
+ firstName: user.firstName || undefined,
+ lastName: user.lastName || undefined,
+ username: user.username || undefined,
+ avatarUrl: user.avatarUrl || undefined,
+ };
+ }
+
+ public async getUserByVkId(vkUserId: string): Promise {
+ const user = await prisma.user.findUnique({ where: { vkUserId } });
+ if (!user) return null;
+ return {
+ id: user.id,
+ vkUserId: user.vkUserId,
+ firstName: user.firstName || undefined,
+ lastName: user.lastName || undefined,
+ username: user.username || undefined,
+ avatarUrl: user.avatarUrl || undefined,
+ };
+ }
+
+ public async getUserCredentials(userId: string) {
+ const cred = await prisma.userCredential.findUnique({ where: { userId } });
+ if (!cred) return null;
+ return {
+ encryptedAccessToken: cred.encryptedAccessToken,
+ encryptedRefreshToken: cred.encryptedRefreshToken,
+ expiresAt: cred.expiresAt,
+ scope: cred.scope,
+ };
+ }
+}
+
+export class MemoryUserRepository implements IUserRepository {
+ private users = new Map();
+ private credentials = new Map();
+
+ public async upsertUserWithTokens(params: UpsertUserParams): Promise {
+ let existingUser: SessionUser | undefined;
+ for (const u of this.users.values()) {
+ if (u.vkUserId === params.vkUserId) {
+ existingUser = u;
+ break;
+ }
+ }
+
+ const id = existingUser ? existingUser.id : `user_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
+ const user: SessionUser = {
+ id,
+ vkUserId: params.vkUserId,
+ firstName: params.firstName,
+ lastName: params.lastName,
+ username: params.username,
+ avatarUrl: params.avatarUrl,
+ };
+
+ this.users.set(id, user);
+
+ const expiresAt = params.expiresIn ? new Date(Date.now() + params.expiresIn * 1000) : null;
+ this.credentials.set(id, {
+ encryptedAccessToken: params.encryptedAccessToken,
+ encryptedRefreshToken: params.encryptedRefreshToken,
+ expiresAt,
+ scope: params.scope,
+ });
+
+ return user;
+ }
+
+ public async getUserById(id: string): Promise {
+ return this.users.get(id) || null;
+ }
+
+ public async getUserByVkId(vkUserId: string): Promise {
+ for (const u of this.users.values()) {
+ if (u.vkUserId === vkUserId) return u;
+ }
+ return null;
+ }
+
+ public async getUserCredentials(userId: string) {
+ return this.credentials.get(userId) || null;
+ }
+
+ public clear(): void {
+ this.users.clear();
+ this.credentials.clear();
+ }
+}
+
+// Global user repository selector
+function createUserRepository(): IUserRepository {
+ const driver = process.env.STORAGE_DRIVER || (process.env.NODE_ENV === 'test' ? 'memory' : 'prisma');
+ if (driver === 'memory') {
+ return new MemoryUserRepository();
+ }
+ return new PrismaUserRepository();
+}
+
+export let defaultUserRepository: IUserRepository = createUserRepository();
+
+export function setUserRepository(repo: IUserRepository): void {
+ defaultUserRepository = repo;
+}
diff --git a/tests/auth-security.test.ts b/tests/auth-security.test.ts
new file mode 100644
index 0000000..3738deb
--- /dev/null
+++ b/tests/auth-security.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+import { defaultOAuthTransactionStore } from '../src/lib/auth/oauth-state';
+import { AesGcmTokenVault } from '../src/lib/auth/token-vault';
+import { MemorySessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
+import { MemoryUserRepository } from '../src/lib/repository/user-repository';
+import { GET as startGet } from '../src/app/api/auth/vk/start/route';
+import { GET as callbackGet } from '../src/app/api/auth/vk/callback/route';
+import { GET as meGet } from '../src/app/api/auth/me/route';
+import { POST as logoutPost } from '../src/app/api/auth/logout/route';
+import { MockVkOAuthClient } from '../src/integrations/vk/mock-oauth-client';
+
+describe('Phase 2.2 VK ID OAuth 2.1 Security & Token Safety Tests', () => {
+ beforeEach(() => {
+ defaultOAuthTransactionStore.clear();
+ });
+
+ it('generates unpredictable PKCE code_verifier, code_challenge and state on OAuth start', async () => {
+ const tx1 = await defaultOAuthTransactionStore.createTransaction();
+ const tx2 = await defaultOAuthTransactionStore.createTransaction();
+
+ expect(tx1.state).not.toBe(tx2.state);
+ expect(tx1.codeVerifier).not.toBe(tx2.codeVerifier);
+ expect(tx1.codeChallenge).not.toBe(tx2.codeChallenge);
+
+ expect(tx1.codeVerifier.length).toBeGreaterThanOrEqual(43);
+ expect(tx1.codeChallenge.length).toBeGreaterThanOrEqual(43);
+ expect(tx1.state.length).toBeGreaterThanOrEqual(32);
+ });
+
+ it('strictly enforces single-use state on transaction consumption (replay attack prevention)', async () => {
+ const { state, codeVerifier } = await defaultOAuthTransactionStore.createTransaction();
+
+ // First consumption succeeds
+ const consumed = await defaultOAuthTransactionStore.consumeTransaction(state);
+ expect(consumed.codeVerifier).toBe(codeVerifier);
+
+ // Second consumption MUST fail
+ await expect(defaultOAuthTransactionStore.consumeTransaction(state)).rejects.toThrow(
+ /OAuth state not found or was already consumed/i
+ );
+ });
+
+ it('rejects expired OAuth state transactions', async () => {
+ const { state } = await defaultOAuthTransactionStore.createTransaction({ ttlMs: 10 });
+
+ await new Promise(r => setTimeout(r, 20));
+
+ await expect(defaultOAuthTransactionStore.consumeTransaction(state)).rejects.toThrow(
+ /OAuth state.*(expired|consumed|not found)/i
+ );
+ });
+
+ it('callback returns 400 when authorization code is missing', async () => {
+ const req = new NextRequest('http://localhost:3000/api/auth/vk/callback?state=dummy_state');
+ const res = await callbackGet(req);
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error?.message).toMatch(/code is missing/i);
+ });
+
+ it('callback returns 400 when state parameter is missing', async () => {
+ const req = new NextRequest('http://localhost:3000/api/auth/vk/callback?code=dummy_code');
+ const res = await callbackGet(req);
+
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error?.message).toMatch(/state parameter is missing/i);
+ });
+
+ it('callback redirects to frontend with error query when user cancels authorization (error=access_denied)', async () => {
+ const req = new NextRequest(
+ 'http://localhost:3000/api/auth/vk/callback?error=access_denied&error_description=User%20denied'
+ );
+ const res = await callbackGet(req);
+
+ expect(res.status).toBe(307); // NextResponse.redirect default status
+ const location = res.headers.get('location');
+ expect(location).toContain('auth_error=User%20denied');
+ });
+
+ it('completes full successful OAuth 2.1 PKCE exchange, sets HttpOnly session cookie, and encrypts tokens', async () => {
+ const mockOAuth = new MockVkOAuthClient();
+ const userRepo = new MemoryUserRepository();
+ const vault = new AesGcmTokenVault('test-secret-key-12345');
+ const sessionStore = new MemorySessionStore();
+
+ // 1. Create start transaction
+ const { state, codeVerifier } = await defaultOAuthTransactionStore.createTransaction({
+ redirectTarget: '/giveaways/new',
+ });
+
+ // 2. Simulate Callback
+ const req = new NextRequest(
+ `http://localhost:3000/api/auth/vk/callback?code=auth_code_xyz&state=${state}`
+ );
+ const res = await callbackGet(req);
+
+ expect(res.status).toBe(307);
+ expect(res.headers.get('location')).toBe('http://localhost:3000/giveaways/new');
+
+ // 3. Verify HttpOnly session cookie is set
+ const cookieHeader = res.headers.get('set-cookie');
+ expect(cookieHeader).toBeDefined();
+ expect(cookieHeader).toContain(SESSION_COOKIE_NAME);
+ expect(cookieHeader?.toLowerCase()).toContain('httponly');
+ expect(cookieHeader?.toLowerCase()).toContain('samesite=lax');
+
+ // 4. Assert token is NOT in cookie header
+ expect(cookieHeader).not.toContain('mock_vk_access_token');
+ });
+
+ it('/api/auth/me returns only safe user profile and never leaks access or refresh tokens', async () => {
+ const sessionStore = new MemorySessionStore();
+ const sessionId = await sessionStore.createSession({
+ id: 'usr_123',
+ vkUserId: '999888',
+ firstName: 'Ольга',
+ lastName: 'Иванова',
+ username: 'olga_iv',
+ avatarUrl: 'https://sun9-1.userapi.com/s/v1/avatar.jpg',
+ });
+
+ const req = new NextRequest('http://localhost:3000/api/auth/me', {
+ headers: {
+ cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
+ },
+ });
+
+ const res = await meGet(req);
+ expect(res.status).toBe(200);
+
+ const body = await res.json();
+ expect(body.user?.access_token).toBeUndefined();
+ expect(body.user?.refreshToken).toBeUndefined();
+ expect(body.user?.encryptedAccessToken).toBeUndefined();
+ });
+
+ it('/api/auth/logout destroys session and clears cookie', async () => {
+ const req = new NextRequest('http://localhost:3000/api/auth/logout', {
+ method: 'POST',
+ headers: {
+ cookie: `${SESSION_COOKIE_NAME}=dummy_session_id`,
+ },
+ });
+
+ const res = await logoutPost(req);
+ expect(res.status).toBe(200);
+
+ const cookieHeader = res.headers.get('set-cookie');
+ expect(cookieHeader).toContain(`${SESSION_COOKIE_NAME}=;`);
+ expect(cookieHeader).toContain('Max-Age=0');
+ });
+
+ it('AES-256-GCM TokenVault encrypts, decrypts, and rejects tampered ciphertexts', async () => {
+ const vault = new AesGcmTokenVault('super-secure-key-vault-test-2026');
+ const secretToken = 'vk1.a.secret_access_token_to_encrypt_987654321';
+
+ const encrypted = await vault.encrypt(secretToken);
+ expect(encrypted).not.toBe(secretToken);
+ expect(encrypted).not.toContain(secretToken);
+
+ // Decrypt
+ const decrypted = await vault.decrypt(encrypted);
+ expect(decrypted).toBe(secretToken);
+
+ // Tamper with ciphertext
+ const tampered = encrypted.slice(0, -4) + 'abcd';
+ await expect(vault.decrypt(tampered)).rejects.toThrow();
+ });
+});