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

This commit is contained in:
Ochenstarik 2026-08-18 02:45:09 +07:00
parent 7acf4d2d4e
commit 7c5bf641cd
21 changed files with 1531 additions and 5 deletions

38
docs/AUTH_SECURITY.md Normal file
View file

@ -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.

26
docs/TOKEN_STORAGE.md Normal file
View file

@ -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.

60
docs/VK_OAUTH_FLOW.md Normal file
View file

@ -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`

View file

@ -30,6 +30,32 @@ enum ParticipantSource {
COMBINED 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 { model Giveaway {
id String @id @default(cuid()) id String @id @default(cuid())
platform Platform @default(VK) platform Platform @default(VK)
@ -47,6 +73,8 @@ model Giveaway {
winnersCount Int @default(1) winnersCount Int @default(1)
reserveWinnersCount Int @default(0) reserveWinnersCount Int @default(0)
seed String? seed String?
organizerId String?
organizer User? @relation(fields: [organizerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
drawnAt DateTime? drawnAt DateTime?
@ -57,6 +85,7 @@ model Giveaway {
auditRecord AuditRecord? auditRecord AuditRecord?
@@index([platform, platformOwnerId, platformPostId]) @@index([platform, platformOwnerId, platformPostId])
@@index([organizerId])
} }
model Participant { model Participant {

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -5,6 +5,7 @@ import { handleApiError } from '@/core/errors/http-errors';
import { generalApiRateLimiter } from '@/lib/rate-limiter'; import { generalApiRateLimiter } from '@/lib/rate-limiter';
import { IdempotencyStore } from '@/lib/idempotency'; import { IdempotencyStore } from '@/lib/idempotency';
import { resolveClientIp } from '@/lib/client-ip'; import { resolveClientIp } from '@/lib/client-ip';
import { getSessionFromRequest } from '@/lib/auth/session';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
@ -43,6 +44,8 @@ export async function POST(req: NextRequest) {
} }
} }
const sessionUser = await getSessionFromRequest(req);
const giveaway = await GiveawayStore.create({ const giveaway = await GiveawayStore.create({
sourceUrl: validated.sourceUrl, sourceUrl: validated.sourceUrl,
post: validated.post, post: validated.post,
@ -50,6 +53,7 @@ export async function POST(req: NextRequest) {
winnersCount: validated.winnersCount, winnersCount: validated.winnersCount,
reserveWinnersCount: validated.reserveWinnersCount, reserveWinnersCount: validated.reserveWinnersCount,
seed: validated.seed, seed: validated.seed,
organizerId: sessionUser?.id,
}); });
const responseBody = { const responseBody = {

View file

@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import Link from 'next/link'; import Link from 'next/link';
import { Gift, ShieldCheck, PlusCircle, LayoutDashboard } from 'lucide-react'; import { Gift, ShieldCheck, PlusCircle, LayoutDashboard } from 'lucide-react';
import { AuthButton } from '@/components/auth/AuthButton';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Randomayzer — Доказуемые розыгрыши ВКонтакте', title: 'Randomayzer — Доказуемые розыгрыши ВКонтакте',
@ -36,18 +37,22 @@ export default function RootLayout({
<nav className="flex items-center gap-3"> <nav className="flex items-center gap-3">
<Link <Link
href="/" href="/"
className="flex items-center gap-2 px-3.5 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-lg transition-colors" className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-lg transition-colors"
> >
<LayoutDashboard className="w-4 h-4" /> <LayoutDashboard className="w-4 h-4" />
Дашборд <span className="hidden sm:inline">Дашборд</span>
</Link> </Link>
<Link <Link
href="/giveaways/new" href="/giveaways/new"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-md shadow-blue-600/25 active:scale-95" className="flex items-center gap-2 px-3.5 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-md shadow-blue-600/25 active:scale-95"
> >
<PlusCircle className="w-4 h-4" /> <PlusCircle className="w-4 h-4" />
Новый розыгрыш <span className="hidden sm:inline">Новый розыгрыш</span>
</Link> </Link>
<div className="ml-2 pl-2 border-l border-slate-800">
<AuthButton />
</div>
</nav> </nav>
</div> </div>
</header> </header>
@ -65,7 +70,7 @@ export default function RootLayout({
<span>Provably Fair Engine Криптографически доказуемый выбор</span> <span>Provably Fair Engine Криптографически доказуемый выбор</span>
</div> </div>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
Randomayzer Core v1.0 Этап 1 Randomayzer Core v1.0 Этап 2.2 VK ID
</p> </p>
</div> </div>
</footer> </footer>

View file

@ -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<AuthUser | null>(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 (
<div className="w-24 h-9 bg-slate-800/50 animate-pulse rounded-lg" />
);
}
if (user) {
const fullName = `${user.firstName || ''} ${user.lastName || ''}`.trim() || `VK ${user.vkUserId}`;
return (
<div className="flex items-center gap-3 bg-slate-800/40 border border-slate-700/60 rounded-lg py-1 px-2.5">
{user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt={fullName}
width={28}
height={28}
className="w-7 h-7 rounded-full object-cover border border-blue-500/40"
/>
) : (
<div className="w-7 h-7 rounded-full bg-blue-600/30 flex items-center justify-center text-blue-400">
<UserIcon className="w-3.5 h-3.5" />
</div>
)}
<span className="text-xs font-medium text-slate-200 hidden sm:inline max-w-[120px] truncate">
{fullName}
</span>
<button
onClick={handleLogout}
title="Выйти"
className="text-slate-400 hover:text-rose-400 p-1 hover:bg-slate-700/50 rounded transition-colors"
>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
);
}
return (
<a
href="/api/auth/vk/start"
className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold bg-[#0077ff]/15 hover:bg-[#0077ff]/25 text-[#0077ff] border border-[#0077ff]/30 rounded-lg transition-all active:scale-95 shadow-sm"
>
<LogIn className="w-3.5 h-3.5" />
<span>Войти через VK ID</span>
</a>
);
}

View file

@ -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<VkOAuthTokenResponse> {
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<VkOAuthTokenResponse> {
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',
};
}
}

View file

@ -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<VkOAuthTokenResponse>;
refreshToken(params: {
refreshToken: string;
clientId: string;
clientSecret?: string;
}): Promise<VkOAuthTokenResponse>;
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<VkOAuthTokenResponse> {
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<VkOAuthTokenResponse> {
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();

134
src/lib/auth/oauth-state.ts Normal file
View file

@ -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<string, OAuthTransaction>();
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();

138
src/lib/auth/session.ts Normal file
View file

@ -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<string>;
getSession(sessionId: string): Promise<SessionUser | null>;
destroySession(sessionId: string): Promise<void>;
cleanupExpired(): number;
clear(): void;
size(): number;
}
export class MemorySessionStore implements ISessionStore {
private store = new Map<string, SessionRecord>();
private readonly defaultTtlMs: number;
constructor(options?: { defaultTtlMs?: number }) {
this.defaultTtlMs = options?.defaultTtlMs ?? SESSION_MAX_AGE_SECONDS * 1000;
}
public async createSession(user: SessionUser, ttlMs?: number): Promise<string> {
const sessionId = randomBytes(32).toString('hex');
const now = 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<SessionUser | null> {
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<void> {
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<SessionUser | null> {
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,
});
}

View file

@ -0,0 +1,72 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
export interface ITokenVault {
encrypt(plaintext: string): Promise<string>;
decrypt(ciphertext: string): Promise<string>;
}
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<string> {
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<string> {
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();

View file

@ -19,6 +19,7 @@ export interface GiveawayWithRelations {
winnersCount: number; winnersCount: number;
reserveWinnersCount: number; reserveWinnersCount: number;
seed: string | null; seed: string | null;
organizerId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
drawnAt: string | null; drawnAt: string | null;
@ -42,6 +43,7 @@ export interface GiveawaySummary {
status: GiveawayStatusType; status: GiveawayStatusType;
winnersCount: number; winnersCount: number;
reserveWinnersCount: number; reserveWinnersCount: number;
organizerId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
drawnAt: string | null; drawnAt: string | null;
@ -68,6 +70,7 @@ export interface CreateGiveawayInput {
winnersCount?: number; winnersCount?: number;
reserveWinnersCount?: number; reserveWinnersCount?: number;
seed?: string; seed?: string;
organizerId?: string;
} }
export interface IGiveawayRepository { export interface IGiveawayRepository {

View file

@ -38,6 +38,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
winnersCount: input.winnersCount || 1, winnersCount: input.winnersCount || 1,
reserveWinnersCount: input.reserveWinnersCount || 0, reserveWinnersCount: input.reserveWinnersCount || 0,
seed: input.seed || null, seed: input.seed || null,
organizerId: input.organizerId || null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
drawnAt: null, drawnAt: null,
@ -98,6 +99,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
status: gw.status, status: gw.status,
winnersCount: gw.winnersCount, winnersCount: gw.winnersCount,
reserveWinnersCount: gw.reserveWinnersCount, reserveWinnersCount: gw.reserveWinnersCount,
organizerId: gw.organizerId || null,
createdAt: gw.createdAt, createdAt: gw.createdAt,
updatedAt: gw.updatedAt, updatedAt: gw.updatedAt,
drawnAt: gw.drawnAt, drawnAt: gw.drawnAt,

View file

@ -86,6 +86,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
winnersCount: raw.winnersCount, winnersCount: raw.winnersCount,
reserveWinnersCount: raw.reserveWinnersCount, reserveWinnersCount: raw.reserveWinnersCount,
seed: raw.seed, seed: raw.seed,
organizerId: raw.organizerId || null,
createdAt: raw.createdAt.toISOString(), createdAt: raw.createdAt.toISOString(),
updatedAt: raw.updatedAt.toISOString(), updatedAt: raw.updatedAt.toISOString(),
drawnAt: raw.drawnAt ? raw.drawnAt.toISOString() : null, drawnAt: raw.drawnAt ? raw.drawnAt.toISOString() : null,
@ -114,6 +115,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
winnersCount: input.winnersCount || 1, winnersCount: input.winnersCount || 1,
reserveWinnersCount: input.reserveWinnersCount || 0, reserveWinnersCount: input.reserveWinnersCount || 0,
seed: input.seed, seed: input.seed,
organizerId: input.organizerId || null,
}, },
include: { include: {
participants: true, participants: true,
@ -178,6 +180,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
status: true, status: true,
winnersCount: true, winnersCount: true,
reserveWinnersCount: true, reserveWinnersCount: true,
organizerId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
drawnAt: true, drawnAt: true,
@ -209,6 +212,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
status: item.status as GiveawayStatusType, status: item.status as GiveawayStatusType,
winnersCount: item.winnersCount, winnersCount: item.winnersCount,
reserveWinnersCount: item.reserveWinnersCount, reserveWinnersCount: item.reserveWinnersCount,
organizerId: item.organizerId || null,
createdAt: item.createdAt.toISOString(), createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(), updatedAt: item.updatedAt.toISOString(),
drawnAt: item.drawnAt ? item.drawnAt.toISOString() : null, drawnAt: item.drawnAt ? item.drawnAt.toISOString() : null,

View file

@ -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<SessionUser>;
getUserById(id: string): Promise<SessionUser | null>;
getUserByVkId(vkUserId: string): Promise<SessionUser | null>;
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<SessionUser> {
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<SessionUser | null> {
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<SessionUser | null> {
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<string, SessionUser>();
private credentials = new Map<string, any>();
public async upsertUserWithTokens(params: UpsertUserParams): Promise<SessionUser> {
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<SessionUser | null> {
return this.users.get(id) || null;
}
public async getUserByVkId(vkUserId: string): Promise<SessionUser | null> {
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;
}

172
tests/auth-security.test.ts Normal file
View file

@ -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();
});
});