From 7c5bf641cd5e0cbc318ac3c268e2be9e5d75d42d Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 02:45:09 +0700 Subject: [PATCH] feat(auth): Phase 2.2 VK ID OAuth 2.1 Authentication - PKCE flow, single-use state, encrypted token vault, HttpOnly session, /api/auth endpoints, UI badge, and comprehensive security tests --- docs/AUTH_SECURITY.md | 38 +++ docs/TOKEN_STORAGE.md | 26 +++ docs/VK_OAUTH_FLOW.md | 60 +++++ prisma/schema.prisma | 29 +++ src/app/api/auth/logout/route.ts | 24 ++ src/app/api/auth/me/route.ts | 33 +++ src/app/api/auth/vk/callback/route.ts | 89 ++++++++ src/app/api/auth/vk/start/route.ts | 51 +++++ src/app/api/giveaways/route.ts | 4 + src/app/layout.tsx | 15 +- src/components/auth/AuthButton.tsx | 90 ++++++++ src/integrations/vk/mock-oauth-client.ts | 94 ++++++++ src/integrations/vk/vk-oauth-client.ts | 267 ++++++++++++++++++++++ src/lib/auth/oauth-state.ts | 134 +++++++++++ src/lib/auth/session.ts | 138 +++++++++++ src/lib/auth/token-vault.ts | 72 ++++++ src/lib/repository/giveaway-repository.ts | 3 + src/lib/repository/memory-repository.ts | 2 + src/lib/repository/prisma-repository.ts | 4 + src/lib/repository/user-repository.ts | 191 ++++++++++++++++ tests/auth-security.test.ts | 172 ++++++++++++++ 21 files changed, 1531 insertions(+), 5 deletions(-) create mode 100644 docs/AUTH_SECURITY.md create mode 100644 docs/TOKEN_STORAGE.md create mode 100644 docs/VK_OAUTH_FLOW.md create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/vk/callback/route.ts create mode 100644 src/app/api/auth/vk/start/route.ts create mode 100644 src/components/auth/AuthButton.tsx create mode 100644 src/integrations/vk/mock-oauth-client.ts create mode 100644 src/integrations/vk/vk-oauth-client.ts create mode 100644 src/lib/auth/oauth-state.ts create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/auth/token-vault.ts create mode 100644 src/lib/repository/user-repository.ts create mode 100644 tests/auth-security.test.ts 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} + ) : ( +
+ +
+ )} + + {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(); + }); +});