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:
parent
7acf4d2d4e
commit
7c5bf641cd
21 changed files with 1531 additions and 5 deletions
38
docs/AUTH_SECURITY.md
Normal file
38
docs/AUTH_SECURITY.md
Normal 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
26
docs/TOKEN_STORAGE.md
Normal 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
60
docs/VK_OAUTH_FLOW.md
Normal 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`
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
24
src/app/api/auth/logout/route.ts
Normal file
24
src/app/api/auth/logout/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/app/api/auth/me/route.ts
Normal file
33
src/app/api/auth/me/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
89
src/app/api/auth/vk/callback/route.ts
Normal file
89
src/app/api/auth/vk/callback/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
src/app/api/auth/vk/start/route.ts
Normal file
51
src/app/api/auth/vk/start/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<nav className="flex items-center gap-3">
|
||||
<Link
|
||||
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" />
|
||||
Дашборд
|
||||
<span className="hidden sm:inline">Дашборд</span>
|
||||
</Link>
|
||||
<Link
|
||||
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" />
|
||||
Новый розыгрыш
|
||||
<span className="hidden sm:inline">Новый розыгрыш</span>
|
||||
</Link>
|
||||
|
||||
<div className="ml-2 pl-2 border-l border-slate-800">
|
||||
<AuthButton />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -65,7 +70,7 @@ export default function RootLayout({
|
|||
<span>Provably Fair Engine • Криптографически доказуемый выбор</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Randomayzer Core v1.0 • Этап 1
|
||||
Randomayzer Core v1.0 • Этап 2.2 VK ID
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
90
src/components/auth/AuthButton.tsx
Normal file
90
src/components/auth/AuthButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/integrations/vk/mock-oauth-client.ts
Normal file
94
src/integrations/vk/mock-oauth-client.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
267
src/integrations/vk/vk-oauth-client.ts
Normal file
267
src/integrations/vk/vk-oauth-client.ts
Normal 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
134
src/lib/auth/oauth-state.ts
Normal 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
138
src/lib/auth/session.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
72
src/lib/auth/token-vault.ts
Normal file
72
src/lib/auth/token-vault.ts
Normal 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();
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
191
src/lib/repository/user-repository.ts
Normal file
191
src/lib/repository/user-repository.ts
Normal 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
172
tests/auth-security.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue