feat(vk): Phase 2.3 Authenticated VK Organizer Integration - VkAuthContextResolver, single-flight token refresh mutex, controlled user token fallback, runtime capabilities, and test suite
This commit is contained in:
parent
b5467f617e
commit
d6f087c21e
22 changed files with 1096 additions and 64 deletions
108
claude_review/CLAUDE_C3_FINAL_VERIFICATION_b5467f6.md
Normal file
108
claude_review/CLAUDE_C3_FINAL_VERIFICATION_b5467f6.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Randomayzer — Claude C-3 Final Security Verification
|
||||||
|
|
||||||
|
**Reviewed commit:** `b5467f617e061c864333b362c6b84481469de890`
|
||||||
|
**Scope:** только проверка закрытия blockers из C-2/G-4, не полный аудит.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Anonymous `GET /api/giveaways` — было 200+утечка, теперь?
|
||||||
|
|
||||||
|
**401.** Подтверждено кодом (`requireAuthenticatedUser(req)` вызывается до любого чтения из store) и живым тестом, который был прогнан изолированно:
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Claude PoC Reproduction: anonymous GET /api/giveaways is rejected with 401 Unauthorized
|
||||||
|
```
|
||||||
|
Тело ответа `body.giveaways` — `undefined`, ничего не утекает.
|
||||||
|
|
||||||
|
## 2. Authenticated listing — scoped на уровне repository/SQL?
|
||||||
|
|
||||||
|
**Да.** `GiveawayStore.listSummaries(organizerId)` → `listGiveawaysSummary(organizerId)`:
|
||||||
|
- Prisma: `where: organizerId ? { organizerId } : undefined` — фильтрация в самом SQL-запросе, не постфактум.
|
||||||
|
- Memory-репозиторий: тот же контракт (`listGiveaways(organizerId)` фильтрует внутри репозитория).
|
||||||
|
|
||||||
|
Живой прогон теста `tests/giveaway-listing-idor.test.ts` (5/5 passed):
|
||||||
|
- User A видит только свой giveaway, JSON ответа не содержит ни слова "Bob" / чужого URL.
|
||||||
|
- User B — симметрично.
|
||||||
|
- Пустой аккаунт → `[]`, без ошибок.
|
||||||
|
- Отдельный repository-level тест напрямую подтверждает `listGiveawaysSummary(userId)` фильтрует по `organizerId`.
|
||||||
|
|
||||||
|
## 3. Prisma migration
|
||||||
|
|
||||||
|
**Реально существует:** `prisma/migrations/20260818120000_ownership_invariant/migration.sql`.
|
||||||
|
|
||||||
|
Проверено содержимое:
|
||||||
|
- `DO $$ ... IF EXISTS (SELECT 1 FROM "Giveaway" WHERE "organizerId" IS NULL) THEN RAISE EXCEPTION ...` — миграция **абортится**, если есть legacy NULL-записи, а не назначает их случайному пользователю.
|
||||||
|
- `ALTER TABLE "Giveaway" ALTER COLUMN "organizerId" SET NOT NULL;`
|
||||||
|
- `DROP CONSTRAINT ... ADD CONSTRAINT ... FOREIGN KEY (organizerId) REFERENCES "User"(id) ON DELETE RESTRICT ON UPDATE CASCADE;`
|
||||||
|
- `CREATE INDEX ... ON "Giveaway"("organizerId")`.
|
||||||
|
|
||||||
|
Поведение при legacy `organizerId=NULL` соответствует требованию: миграция требует ручной data remediation, не авто-назначения.
|
||||||
|
|
||||||
|
## 4. Atomic OAuth state consumption
|
||||||
|
|
||||||
|
`MemoryOAuthTransactionStore.consumeTransaction(state)`:
|
||||||
|
```ts
|
||||||
|
const tx = this.store.get(state);
|
||||||
|
if (!tx) throw new UnauthorizedError(...);
|
||||||
|
this.store.delete(state); // ← между get и delete нет await
|
||||||
|
```
|
||||||
|
Между `get` и `delete` нет `await` — в однопоточном event loop Node.js это гарантирует атомарность синхронного участка даже при параллельном вызове `Promise.all`.
|
||||||
|
|
||||||
|
Существующий тест `tests/oauth-concurrency.test.ts` (100 concurrent на один state) — прогнан живьём:
|
||||||
|
```
|
||||||
|
✓ 100 concurrent consumeTransaction attempts on the same state result in exactly 1 success and 99 failures
|
||||||
|
```
|
||||||
|
Ровно 1 success, 99 failures, победитель получил корректный `codeVerifier`, повторный consume после — отклонён.
|
||||||
|
|
||||||
|
## 5. Production trusted origin
|
||||||
|
|
||||||
|
- `getAppBaseUrl()` / `getVkRedirectUri()` — fail-fast `throw`, если `APP_BASE_URL`/`VK_REDIRECT_URI` отсутствуют в проде или не HTTPS. Подтверждено тестами (`origin-and-csrf-gate.test.ts`, 4 подтеста).
|
||||||
|
- `validateCsrfOrigin` в проде сравнивает `Origin`/`Referer` со строго конфигурируемым `getTrustedHost()` (из `APP_BASE_URL`), **никогда** не читает `Host` или `X-Forwarded-Host` в production-ветке кода. Grep по всем API-роутам подтверждает: `req.headers.get('host')` и `x-forwarded-host` нигде не используются для построения redirect-целей — везде используется `getAppBaseUrl()`.
|
||||||
|
- Встроенный тест: evil `Origin: https://evil.com` + спуфленный `X-Forwarded-Host: evil.com` → `CSRF origin mismatch` (throw). Passed.
|
||||||
|
- **Дополнительно написаны и прогнаны живьём** PoC-тесты сверх встроенных:
|
||||||
|
- Только evil `Host: evil.com` (без Origin/Referer) в проде → `Missing Origin/Referer` (throw). Passed.
|
||||||
|
- Evil `Host` + evil `X-Forwarded-Host` + evil `X-Forwarded-Proto` + evil `Origin` одновременно → `CSRF origin mismatch` (throw). Passed.
|
||||||
|
|
||||||
|
## 6. OAuth start rate limiter
|
||||||
|
|
||||||
|
`oauthStartRateLimiter` — 10 запросов/60 сек на IP. Тест: 10 запросов проходят (307), 11-й → 429 с `rate limit exceeded`. Прогнан в составе полного suite — passed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Повторная проверка старых findings
|
||||||
|
|
||||||
|
| Finding | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| CRITICAL-1 Broken Access Control (включая listing IDOR из C-2) | **CLOSED** |
|
||||||
|
| CRITICAL-2 TokenVault public fallback | **CLOSED** |
|
||||||
|
| HIGH missing SQL migration | **CLOSED** |
|
||||||
|
| Grok OAuth state race | **CLOSED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `npm test` / `npm run lint` / `npm run build`
|
||||||
|
|
||||||
|
- **`npm test`**: **PASS**. 42 test files, 235 tests, все зелёные (включая новые `giveaway-listing-idor.test.ts`, `origin-and-csrf-gate.test.ts`, `oauth-concurrency.test.ts`, `token-vault.test.ts`).
|
||||||
|
⚠️ Прогнано после ручного стаба `@prisma/client` в песочнице — сеть песочницы блокирует `binaries.prisma.sh` (403, не в allow-list), из-за чего `prisma generate` не может скачать query engine. Это ограничение среды проверки, не дефект кода — реальный CI-пайплайн репозитория (`.github/workflows/ci.yml`) выполняет `prisma generate` → `prisma db push` → `npm test` → `npm run lint` → `npm run build` с полным доступом к сети.
|
||||||
|
- **`npm run lint`**: **PASS**. 0 ошибок, только косметические warning про `<img>` вместо `next/image` (были и раньше, не новые).
|
||||||
|
- **`npm run build`**: webpack/Next.js compile-шаг прошёл (`✓ Compiled successfully`), но TypeScript type-check упал на `prisma-repository.ts` с `implicitly has an 'any' type` — проверено: это из-за того, что сгенерированный `.d.ts` в песочнице **не содержит вообще никаких упоминаний** модели `Giveaway`/`organizerId` (сгенерирован до текущей схемы, т.к. `prisma generate` ни разу не завершился успешно в этой сети). Не удалось независимо подтвердить build end-to-end из-за сетевого ограничения песочницы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Финальный ответ
|
||||||
|
|
||||||
|
**Phase 2.2 security gate: PASS**
|
||||||
|
|
||||||
|
**Безопасно ли переходить к Phase 2.3: YES**
|
||||||
|
|
||||||
|
Реальных blockers из чек-листа C-2/G-4 не осталось. Единственная оговорка — `npm run build` не подтверждён end-to-end исключительно из-за сетевого ограничения проверочной песочницы (нет доступа к `binaries.prisma.sh`); тот же CI-пайплайн с полным сетевым доступом уже включает этот шаг и настроен идентично тому, что было запущено. Это не квалифицируется как security-blocker по существу проверки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Метаданные проверки
|
||||||
|
|
||||||
|
- **Reviewed commit:** `b5467f617e061c864333b362c6b84481469de890`
|
||||||
|
- **Предыдущий commit (C-2):** `02a04df2719094e28db97575b9fbecb940b6ead3`
|
||||||
|
- **Tests run:** 42 files / 235 tests passed (после локального стаба Prisma client из-за сетевого ограничения песочницы)
|
||||||
|
- **Live PoC написаны и прогнаны в рамках этой проверки:** evil Host header alone; evil Host + X-Forwarded-Host + X-Forwarded-Proto + Origin combo
|
||||||
|
- **Files inspected:** `src/app/api/giveaways/route.ts`, `src/lib/giveaway-store.ts`, `src/lib/repository/prisma-repository.ts`, `memory-repository.ts`, `prisma/migrations/20260818120000_ownership_invariant/migration.sql`, `src/lib/auth/oauth-state.ts`, `src/lib/auth/csrf-guard.ts`, `src/lib/auth/app-config.ts`, `src/app/api/auth/vk/callback/route.ts`, `src/app/api/auth/vk/start/route.ts`, тесты `giveaway-listing-idor`, `origin-and-csrf-gate`, `oauth-concurrency`, `token-vault`
|
||||||
39
docs/VK_AUTHENTICATED_ACCESS.md
Normal file
39
docs/VK_AUTHENTICATED_ACCESS.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# VK Authenticated Access & Method Capabilities Matrix
|
||||||
|
|
||||||
|
This document defines the token selection policy, capabilities, and fallback rules for all VK API methods used by Randomayzer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Principle of Least Privilege & Token Selection Policy
|
||||||
|
|
||||||
|
Randomayzer adheres to the strict principle of least privilege:
|
||||||
|
1. **Public Read Operations**: Always prefer `SERVICE` token (public service access) if the resource is public.
|
||||||
|
2. **Restricted / Private Operations**: Use the authenticated organizer's `USER` token only when required or when a service token receives a privacy/permission error.
|
||||||
|
3. **Community Operations**: Use `COMMUNITY` token when managing community-specific admin operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Method-by-Method Capabilities Matrix
|
||||||
|
|
||||||
|
| VK API Method | Service Token Support | User Token Support | Community Token Support | Privacy / Permissions | Controlled Fallback Rule |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **`wall.getById`** | **YES (Preferred for public)** | **YES (Required for private/restricted)** | **YES (for owned community wall)** | Works for public walls and communities. Returns error 15/30 if author profile or group is closed/private. | If `SERVICE` call returns `VkPrivateResourceError` (error 15/30), fallback to organizer `USER` token. |
|
||||||
|
| **`likes.getList`** | **YES (Preferred for public)** | **YES (Required for restricted)** | **YES** | Public posts allow open likes retrieval. Closed groups or friends-only posts require authenticated `USER` token. | If `SERVICE` call returns `VkPrivateResourceError` / `VkPermissionError`, fallback to organizer `USER` token. |
|
||||||
|
| **`wall.getComments`** | **YES (Preferred for public)** | **YES (Required for restricted)** | **YES** | Allows collecting comments and profile mapping. If comments are disabled on the post, returns error code 210/214. | If `SERVICE` call fails on private group post, fallback to organizer `USER` token. |
|
||||||
|
| **`groups.isMember`** | **YES (Preferred)** | **YES** | **YES** | Checks membership in open and closed groups. Batching supported up to 500 user IDs per call. | Defaults to `SERVICE` token; falls back to `USER` token if group is restricted. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fallback Policy Rules
|
||||||
|
|
||||||
|
### A. Permitted Fallback Conditions
|
||||||
|
A controlled fallback from `SERVICE` $\rightarrow$ `USER` token is allowed **strictly** when:
|
||||||
|
1. The initial call failed with `VkPrivateResourceError` (VK error codes 15, 30, 203) or `VkPermissionError` (VK error codes 7, 260);
|
||||||
|
2. AND the organizer is actively authenticated with a valid, non-expired `USER` credential.
|
||||||
|
|
||||||
|
### B. Forbidden Fallbacks
|
||||||
|
Fallback is strictly prohibited on:
|
||||||
|
- **Rate Limit (429 / error codes 6, 9, 29)**: Switching tokens to bypass rate limits violates VK terms of service and is never permitted.
|
||||||
|
- **Server Errors (500 / 502 / 503 / 504)**: Upstream VK errors must be retried via standard exponential backoff.
|
||||||
|
- **Client Validation Errors (400 / error codes 8, 100, 113)**: Malformed parameters indicate invalid client input.
|
||||||
|
- **Network / Timeout Errors**: Handled by network retry policy.
|
||||||
43
docs/VK_REAL_SMOKE_TEST.md
Normal file
43
docs/VK_REAL_SMOKE_TEST.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Real VK ID & API Live Smoke Test Runbook
|
||||||
|
|
||||||
|
This runbook outlines the live verification steps for testing VK ID OAuth 2.1 and authenticated VK operations without committing credentials into version control or CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Local Environment Preparation
|
||||||
|
|
||||||
|
Set in your `.env.local` file:
|
||||||
|
```bash
|
||||||
|
# VK ID Web Application Credentials
|
||||||
|
VK_APP_ID="<your_vk_app_id>"
|
||||||
|
VK_CLIENT_SECRET="<your_vk_client_secret>"
|
||||||
|
|
||||||
|
# Service Token for Public Operations
|
||||||
|
VK_SERVICE_TOKEN="<your_vk_service_token>"
|
||||||
|
|
||||||
|
# Canonical Local Configuration
|
||||||
|
APP_BASE_URL="http://localhost:3000"
|
||||||
|
VK_REDIRECT_URI="http://localhost:3000/api/auth/vk/callback"
|
||||||
|
|
||||||
|
# Cryptographic Keys (min 32 chars)
|
||||||
|
AUTH_SECRET="<random_hex_32_bytes>"
|
||||||
|
TOKEN_ENCRYPTION_KEY="<random_hex_32_bytes>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verification Checklist
|
||||||
|
|
||||||
|
- [ ] **A. OAuth Login Start**: Visit `/api/auth/vk/start` $\rightarrow$ Redirects to `https://id.vk.com/authorize` with PKCE `code_challenge` (S256).
|
||||||
|
- [ ] **B. OAuth Callback**: Authorize on VK screen $\rightarrow$ Redirected to `/api/auth/vk/callback`, sets HttpOnly cookie `randomayzer_session`.
|
||||||
|
- [ ] **C. Session Inspection**: Visit `/api/auth/me` $\rightarrow$ Returns authenticated user profile (name, avatar).
|
||||||
|
- [ ] **D. Public Post Preview**: Paste public VK post URL in `/giveaways/new` $\rightarrow$ Preview loads with `accessMode: "PUBLIC_SERVICE"`.
|
||||||
|
- [ ] **E. Private/Restricted Post Preview**: Paste post URL from closed group where organizer is member $\rightarrow$ Resolver falls back to `ORGANIZER_USER`.
|
||||||
|
- [ ] **F. Create Giveaway**: Submit giveaway form $\rightarrow$ Giveaway created with `organizerId: sessionUser.id`.
|
||||||
|
- [ ] **G. Import Participants**: Click Import Participants $\rightarrow$ Likes and comments fetched via `VkAuthContextResolver`.
|
||||||
|
- [ ] **H. Subscription Verification**: Run community subscription filter $\rightarrow$ Batch `groups.isMember` executed successfully.
|
||||||
|
- [ ] **I. Snapshot Locking**: Lock snapshot $\rightarrow$ Canonical hashes computed.
|
||||||
|
- [ ] **J. Deterministic Draw**: Execute draw $\rightarrow$ Winner selected via unbiased CSPRNG rejection sampling.
|
||||||
|
- [ ] **K. Public Audit**: Open `/api/giveaways/[id]/verify` in incognito window $\rightarrow$ Audit passes without authentication.
|
||||||
|
- [ ] **L. Token Expiry & Refresh**: Wait for access token expiry or simulate $\rightarrow$ Next API request automatically triggers server-side refresh without user interruption.
|
||||||
|
- [ ] **M. Logout**: Click logout $\rightarrow$ Session terminated, cookie destroyed.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { defaultOAuthTransactionStore } from '@/lib/auth/oauth-state';
|
import { defaultOAuthTransactionStore } from '@/lib/auth/oauth-state';
|
||||||
import { getOAuthClient } from '../start/route';
|
import { getOAuthClient } from '@/integrations/vk/vk-oauth-client';
|
||||||
import { defaultTokenVault } from '@/lib/auth/token-vault';
|
import { defaultTokenVault } from '@/lib/auth/token-vault';
|
||||||
import { defaultUserRepository } from '@/lib/repository/user-repository';
|
import { defaultUserRepository } from '@/lib/repository/user-repository';
|
||||||
import { defaultSessionStore, setSessionCookie } from '@/lib/auth/session';
|
import { defaultSessionStore, setSessionCookie } from '@/lib/auth/session';
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,14 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { defaultOAuthTransactionStore } from '@/lib/auth/oauth-state';
|
import { defaultOAuthTransactionStore } from '@/lib/auth/oauth-state';
|
||||||
import { defaultVkOAuthClient, IVkOAuthClient } from '@/integrations/vk/vk-oauth-client';
|
import { getOAuthClient } from '@/integrations/vk/vk-oauth-client';
|
||||||
import { MockVkOAuthClient } from '@/integrations/vk/mock-oauth-client';
|
|
||||||
import { handleApiError, ValidationError } from '@/core/errors/http-errors';
|
import { handleApiError, ValidationError } from '@/core/errors/http-errors';
|
||||||
import { validateSafeRedirectTarget } from '@/lib/auth/safe-redirect';
|
import { validateSafeRedirectTarget } from '@/lib/auth/safe-redirect';
|
||||||
import { getVkRedirectUri } from '@/lib/auth/app-config';
|
import { getVkRedirectUri } from '@/lib/auth/app-config';
|
||||||
import { SlidingWindowRateLimiter } from '@/lib/rate-limiter';
|
import { oauthStartRateLimiter } from '@/lib/rate-limiter';
|
||||||
import { resolveClientIp } from '@/lib/client-ip';
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
// Dedicated limiter for OAuth transaction creation (prevent flooding)
|
|
||||||
export const oauthStartRateLimiter = new SlidingWindowRateLimiter({
|
|
||||||
windowMs: 60 * 1000,
|
|
||||||
maxRequests: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const clientIp = resolveClientIp(req);
|
const clientIp = resolveClientIp(req);
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export async function POST(
|
||||||
expensiveApiRateLimiter.assertAllowed(`participants-import:${clientIp}:${id}`);
|
expensiveApiRateLimiter.assertAllowed(`participants-import:${clientIp}:${id}`);
|
||||||
|
|
||||||
// Enforce giveaway ownership authorization for importing participants
|
// Enforce giveaway ownership authorization for importing participants
|
||||||
const { giveaway } = await requireGiveawayOwner(req, id);
|
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
|
||||||
|
|
||||||
const rawBody = await req.json();
|
const rawBody = await req.json();
|
||||||
const validated = fetchParticipantsSchema.parse(rawBody);
|
const validated = fetchParticipantsSchema.parse(rawBody);
|
||||||
|
|
@ -77,6 +77,7 @@ export async function POST(
|
||||||
postId: giveaway.platformPostId,
|
postId: giveaway.platformPostId,
|
||||||
includeLikes: validated.filterRules.requireLike,
|
includeLikes: validated.filterRules.requireLike,
|
||||||
includeComments: validated.filterRules.requireComment,
|
includeComments: validated.filterRules.requireComment,
|
||||||
|
organizerId: sessionUser.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run participant fetch, enrichment, and filtering pipeline
|
// Run participant fetch, enrichment, and filtering pipeline
|
||||||
|
|
@ -86,6 +87,7 @@ export async function POST(
|
||||||
rules: validated.filterRules,
|
rules: validated.filterRules,
|
||||||
provider,
|
provider,
|
||||||
ownerId: giveaway.platformOwnerId,
|
ownerId: giveaway.platformOwnerId,
|
||||||
|
organizerId: sessionUser.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save atomic participant state in store
|
// Save atomic participant state in store
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { handleApiError } from '@/core/errors/http-errors';
|
||||||
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
import { resolveClientIp } from '@/lib/client-ip';
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
|
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
|
||||||
|
import { resolveEffectiveCapabilities } from '@/providers/vk/vk-capabilities';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -18,7 +19,14 @@ export async function GET(
|
||||||
// Enforce giveaway ownership authorization
|
// Enforce giveaway ownership authorization
|
||||||
const { giveaway } = await requireGiveawayOwner(req, id);
|
const { giveaway } = await requireGiveawayOwner(req, id);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, giveaway });
|
// Resolve runtime effective capabilities for the authenticated organizer
|
||||||
|
const effectiveCapabilities = resolveEffectiveCapabilities({ type: 'USER', token: 'active' });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
giveaway,
|
||||||
|
effectiveCapabilities,
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { postPreviewSchema } from '@/core/validation/giveaway-schemas';
|
||||||
import { handleApiError } from '@/core/errors/http-errors';
|
import { handleApiError } from '@/core/errors/http-errors';
|
||||||
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
import { resolveClientIp } from '@/lib/client-ip';
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
import { getSessionFromRequest } from '@/lib/auth/session';
|
||||||
|
import { resolveEffectiveCapabilities } from '@/providers/vk/vk-capabilities';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -13,12 +15,21 @@ export async function POST(req: NextRequest) {
|
||||||
const rawBody = await req.json();
|
const rawBody = await req.json();
|
||||||
const validated = postPreviewSchema.parse(rawBody);
|
const validated = postPreviewSchema.parse(rawBody);
|
||||||
|
|
||||||
|
const sessionUser = await getSessionFromRequest(req);
|
||||||
const provider = ProviderFactory.getVkProvider();
|
const provider = ProviderFactory.getVkProvider();
|
||||||
const post = await provider.fetchPost(validated.url);
|
|
||||||
|
// Fetch post with optional organizer session context for private/restricted access probe
|
||||||
|
const post = await provider.fetchPost(validated.url, { organizerId: sessionUser?.id });
|
||||||
|
|
||||||
|
// Derive effective capabilities based on authentication context
|
||||||
|
const effectiveCapabilities = resolveEffectiveCapabilities(
|
||||||
|
sessionUser ? { type: 'USER', token: 'active' } : { type: 'SERVICE', token: 'active' }
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
post,
|
post,
|
||||||
|
effectiveCapabilities,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
|
import { VkClientError } from '@/integrations/vk/vk-errors';
|
||||||
|
|
||||||
export abstract class AppError extends Error {
|
export abstract class AppError extends Error {
|
||||||
abstract readonly statusCode: number;
|
abstract readonly statusCode: number;
|
||||||
|
|
@ -24,11 +25,26 @@ export class UnauthorizedError extends AppError {
|
||||||
readonly code = 'UNAUTHORIZED';
|
readonly code = 'UNAUTHORIZED';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class VkReauthenticationRequiredError extends AppError {
|
||||||
|
readonly statusCode = 401;
|
||||||
|
readonly code = 'VK_REAUTHENTICATION_REQUIRED';
|
||||||
|
}
|
||||||
|
|
||||||
export class ForbiddenError extends AppError {
|
export class ForbiddenError extends AppError {
|
||||||
readonly statusCode = 403;
|
readonly statusCode = 403;
|
||||||
readonly code = 'FORBIDDEN';
|
readonly code = 'FORBIDDEN';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class VkPermissionRequiredError extends AppError {
|
||||||
|
readonly statusCode = 403;
|
||||||
|
readonly code = 'VK_PERMISSION_REQUIRED';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VkResourcePrivateError extends AppError {
|
||||||
|
readonly statusCode = 403;
|
||||||
|
readonly code = 'VK_RESOURCE_PRIVATE';
|
||||||
|
}
|
||||||
|
|
||||||
export class NotFoundError extends AppError {
|
export class NotFoundError extends AppError {
|
||||||
readonly statusCode = 404;
|
readonly statusCode = 404;
|
||||||
readonly code = 'NOT_FOUND';
|
readonly code = 'NOT_FOUND';
|
||||||
|
|
@ -117,6 +133,96 @@ export function handleApiError(error: unknown): NextResponse {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle typed VK client errors safely without exposing tokens or internal structures
|
||||||
|
if (error instanceof VkClientError) {
|
||||||
|
switch (error.category) {
|
||||||
|
case 'AUTH':
|
||||||
|
case 'REAUTHENTICATION_REQUIRED':
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VK_REAUTHENTICATION_REQUIRED',
|
||||||
|
message: 'VK authorization expired or required. Please reconnect your VK account.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'PERMISSION':
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VK_PERMISSION_REQUIRED',
|
||||||
|
message: 'Insufficient VK permissions to access this resource or perform this action.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'PRIVATE_RESOURCE':
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VK_RESOURCE_PRIVATE',
|
||||||
|
message: 'This VK resource or post is in a private/restricted community or profile.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VK_RESOURCE_NOT_FOUND',
|
||||||
|
message: 'VK post or resource was not found. Please check the post URL.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'RATE_LIMIT':
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VK_RATE_LIMIT_EXCEEDED',
|
||||||
|
message: 'VK API rate limit reached. Please retry in a few moments.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'TIMEOUT':
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VK_GATEWAY_TIMEOUT',
|
||||||
|
message: 'VK API did not respond in time. Please try again.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 504 }
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VK_UPSTREAM_ERROR',
|
||||||
|
message: 'A temporary error occurred while communicating with VK API.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle SyntaxError (Malformed JSON in request body)
|
// Handle SyntaxError (Malformed JSON in request body)
|
||||||
if (error instanceof SyntaxError && 'body' in error) {
|
if (error instanceof SyntaxError && 'body' in error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export interface EnrichmentPipelineParams {
|
||||||
rules: FilterRules;
|
rules: FilterRules;
|
||||||
provider: SocialMediaProvider;
|
provider: SocialMediaProvider;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
organizerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,7 +21,7 @@ export interface EnrichmentPipelineParams {
|
||||||
export async function executeParticipantPipeline(
|
export async function executeParticipantPipeline(
|
||||||
params: EnrichmentPipelineParams
|
params: EnrichmentPipelineParams
|
||||||
): Promise<FilterResult> {
|
): Promise<FilterResult> {
|
||||||
const { rawParticipants, rules, provider, ownerId } = params;
|
const { rawParticipants, rules, provider, ownerId, organizerId } = params;
|
||||||
|
|
||||||
let enrichedParticipants = rawParticipants.map(p => ({ ...p }));
|
let enrichedParticipants = rawParticipants.map(p => ({ ...p }));
|
||||||
|
|
||||||
|
|
@ -30,7 +31,10 @@ export async function executeParticipantPipeline(
|
||||||
const targetGroupId = rules.targetGroupId || (ownerId.startsWith('-') ? ownerId : undefined);
|
const targetGroupId = rules.targetGroupId || (ownerId.startsWith('-') ? ownerId : undefined);
|
||||||
|
|
||||||
if (targetGroupId && userIds.length > 0 && provider.capabilities.subscriptions) {
|
if (targetGroupId && userIds.length > 0 && provider.capabilities.subscriptions) {
|
||||||
const subMap = await provider.checkSubscription(userIds, targetGroupId);
|
const subMap = organizerId
|
||||||
|
? await provider.checkSubscription(userIds, targetGroupId, { organizerId })
|
||||||
|
: await provider.checkSubscription(userIds, targetGroupId);
|
||||||
|
|
||||||
for (const p of enrichedParticipants) {
|
for (const p of enrichedParticipants) {
|
||||||
p.subscribed = Boolean(subMap.get(p.platformUserId));
|
p.subscribed = Boolean(subMap.get(p.platformUserId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
95
src/integrations/vk/vk-auth-resolver.ts
Normal file
95
src/integrations/vk/vk-auth-resolver.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { VkAuthContext, VkTokenType } from './vk-types';
|
||||||
|
import { VkAuthError, VkReauthenticationRequiredError } from './vk-errors';
|
||||||
|
import { TokenRefresher, defaultTokenRefresher } from '@/lib/auth/token-refresher';
|
||||||
|
|
||||||
|
export interface ResolveAuthContextParams {
|
||||||
|
organizerId?: string;
|
||||||
|
method?: string;
|
||||||
|
resource?: {
|
||||||
|
ownerId: string;
|
||||||
|
postId?: string;
|
||||||
|
};
|
||||||
|
preferredMode?: VkTokenType;
|
||||||
|
allowFallback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VkAuthContextResolver {
|
||||||
|
constructor(private tokenRefresher: TokenRefresher = defaultTokenRefresher) {}
|
||||||
|
|
||||||
|
public setTokenRefresher(refresher: TokenRefresher): void {
|
||||||
|
this.tokenRefresher = refresher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the appropriate VK AuthContext based on the principle of least privilege.
|
||||||
|
*/
|
||||||
|
public async resolveAuthContext(params: ResolveAuthContextParams): Promise<VkAuthContext> {
|
||||||
|
const { organizerId, preferredMode } = params;
|
||||||
|
|
||||||
|
// 1. Explicit COMMUNITY token requested
|
||||||
|
if (preferredMode === 'COMMUNITY') {
|
||||||
|
const communityId = params.resource?.ownerId?.replace(/^-/, '');
|
||||||
|
const communityToken = process.env[`VK_COMMUNITY_TOKEN_${communityId}`];
|
||||||
|
if (communityToken) {
|
||||||
|
return { type: 'COMMUNITY', token: communityToken, communityId };
|
||||||
|
}
|
||||||
|
// If community token not configured, fallback to USER if organizer exists
|
||||||
|
if (organizerId) {
|
||||||
|
const userToken = await this.tokenRefresher.getOrRefreshUserToken(organizerId);
|
||||||
|
return { type: 'USER', token: userToken };
|
||||||
|
}
|
||||||
|
throw new VkAuthError('Community token not found and no organizer session provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Explicit USER token requested
|
||||||
|
if (preferredMode === 'USER') {
|
||||||
|
if (!organizerId) {
|
||||||
|
throw new VkReauthenticationRequiredError('Organizer authentication is required to use user credentials');
|
||||||
|
}
|
||||||
|
const userToken = await this.tokenRefresher.getOrRefreshUserToken(organizerId);
|
||||||
|
return { type: 'USER', token: userToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Explicit SERVICE token requested
|
||||||
|
if (preferredMode === 'SERVICE') {
|
||||||
|
const serviceToken = process.env.VK_SERVICE_TOKEN;
|
||||||
|
if (!serviceToken) {
|
||||||
|
if (organizerId) {
|
||||||
|
// Controlled fallback to USER if SERVICE token not configured
|
||||||
|
const userToken = await this.tokenRefresher.getOrRefreshUserToken(organizerId);
|
||||||
|
return { type: 'USER', token: userToken };
|
||||||
|
}
|
||||||
|
throw new VkAuthError('VK_SERVICE_TOKEN is not configured in server environment');
|
||||||
|
}
|
||||||
|
return { type: 'SERVICE', token: serviceToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Automatic Selection (Least Privilege Policy)
|
||||||
|
// Default to SERVICE token for public operations if available
|
||||||
|
const serviceToken = process.env.VK_SERVICE_TOKEN;
|
||||||
|
if (serviceToken) {
|
||||||
|
return { type: 'SERVICE', token: serviceToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no service token exists, but organizer is authenticated, resolve USER token
|
||||||
|
if (organizerId) {
|
||||||
|
const userToken = await this.tokenRefresher.getOrRefreshUserToken(organizerId);
|
||||||
|
return { type: 'USER', token: userToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new VkAuthError('No VK credentials (neither VK_SERVICE_TOKEN nor organizer session) available');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves USER token specifically for controlled fallback when a SERVICE call fails with a private resource error.
|
||||||
|
*/
|
||||||
|
public async resolveUserFallbackContext(organizerId: string): Promise<VkAuthContext> {
|
||||||
|
if (!organizerId) {
|
||||||
|
throw new VkReauthenticationRequiredError('Organizer authentication required for user credential fallback');
|
||||||
|
}
|
||||||
|
const userToken = await this.tokenRefresher.getOrRefreshUserToken(organizerId);
|
||||||
|
return { type: 'USER', token: userToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultVkAuthContextResolver = new VkAuthContextResolver();
|
||||||
|
|
@ -29,6 +29,14 @@ export class VkAuthError extends VkClientError {
|
||||||
readonly category = 'AUTH';
|
readonly category = 'AUTH';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VK Reauthentication Required Error: Stored organizer credentials expired, revoked, or refresh failed.
|
||||||
|
*/
|
||||||
|
export class VkReauthenticationRequiredError extends VkClientError {
|
||||||
|
readonly isRetryable = false;
|
||||||
|
readonly category = 'REAUTHENTICATION_REQUIRED';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260, HTTP 403)
|
* VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260, HTTP 403)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -264,4 +264,15 @@ export class VkOAuthClient implements IVkOAuthClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultVkOAuthClient: IVkOAuthClient = new VkOAuthClient();
|
export let defaultVkOAuthClient: IVkOAuthClient = new VkOAuthClient();
|
||||||
|
|
||||||
|
export function setOAuthClient(client: IVkOAuthClient): void {
|
||||||
|
defaultVkOAuthClient = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthClient(): IVkOAuthClient {
|
||||||
|
if (process.env.USE_VK_MOCK === 'true' || (process.env.NODE_ENV === 'test' && !process.env.VK_APP_ID)) {
|
||||||
|
return defaultVkOAuthClient;
|
||||||
|
}
|
||||||
|
return defaultVkOAuthClient;
|
||||||
|
}
|
||||||
|
|
|
||||||
113
src/lib/auth/token-refresher.ts
Normal file
113
src/lib/auth/token-refresher.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { IUserRepository, defaultUserRepository } from '@/lib/repository/user-repository';
|
||||||
|
import { ITokenVault, defaultTokenVault } from '@/lib/auth/token-vault';
|
||||||
|
import { IVkOAuthClient, defaultVkOAuthClient } from '@/integrations/vk/vk-oauth-client';
|
||||||
|
import { VkReauthenticationRequiredError } from '@/integrations/vk/vk-errors';
|
||||||
|
|
||||||
|
export class TokenRefresher {
|
||||||
|
private inFlightRefreshes = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private userRepo: IUserRepository = defaultUserRepository,
|
||||||
|
private tokenVault: ITokenVault = defaultTokenVault,
|
||||||
|
private oauthClient: IVkOAuthClient = defaultVkOAuthClient
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public setDependencies(deps: {
|
||||||
|
userRepo?: IUserRepository;
|
||||||
|
tokenVault?: ITokenVault;
|
||||||
|
oauthClient?: IVkOAuthClient;
|
||||||
|
}): void {
|
||||||
|
if (deps.userRepo) this.userRepo = deps.userRepo;
|
||||||
|
if (deps.tokenVault) this.tokenVault = deps.tokenVault;
|
||||||
|
if (deps.oauthClient) this.oauthClient = deps.oauthClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the user token using a single-flight concurrency mutex.
|
||||||
|
* If 20 concurrent requests attempt to refresh the token simultaneously for the same userId,
|
||||||
|
* only 1 network request to VK ID is executed, and all callers share the refreshed access token.
|
||||||
|
*/
|
||||||
|
public async getOrRefreshUserToken(userId: string): Promise<string> {
|
||||||
|
const cred = await this.userRepo.getUserCredentials(userId);
|
||||||
|
|
||||||
|
if (!cred || !cred.encryptedAccessToken) {
|
||||||
|
throw new VkReauthenticationRequiredError('VK organizer credentials not found. Please log in with VK ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const isExpiredOrExpiring = cred.expiresAt ? now >= cred.expiresAt.getTime() - 30 * 1000 : false;
|
||||||
|
|
||||||
|
if (!isExpiredOrExpiring) {
|
||||||
|
return await this.tokenVault.decrypt(cred.encryptedAccessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token is expired. Check if refresh token is available.
|
||||||
|
if (!cred.encryptedRefreshToken) {
|
||||||
|
throw new VkReauthenticationRequiredError(
|
||||||
|
'VK session expired and no refresh token is available. Please reconnect your VK account.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-Flight Mutex: join in-flight refresh or start a new one
|
||||||
|
let existingFlight = this.inFlightRefreshes.get(userId);
|
||||||
|
if (!existingFlight) {
|
||||||
|
existingFlight = this.executeRefresh(userId, cred.encryptedRefreshToken);
|
||||||
|
this.inFlightRefreshes.set(userId, existingFlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await existingFlight;
|
||||||
|
} finally {
|
||||||
|
this.inFlightRefreshes.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeRefresh(userId: string, encryptedRefreshToken: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const refreshToken = await this.tokenVault.decrypt(encryptedRefreshToken);
|
||||||
|
const clientId = process.env.VK_APP_ID || (process.env.NODE_ENV === 'test' ? 'test_vk_app_id' : '');
|
||||||
|
const clientSecret = process.env.VK_CLIENT_SECRET;
|
||||||
|
|
||||||
|
const refreshResponse = await this.oauthClient.refreshToken({
|
||||||
|
refreshToken,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refreshResponse.access_token) {
|
||||||
|
throw new VkReauthenticationRequiredError('VK token refresh response did not return a valid access token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedAccessToken = await this.tokenVault.encrypt(refreshResponse.access_token);
|
||||||
|
const newEncryptedRefreshToken = refreshResponse.refresh_token
|
||||||
|
? await this.tokenVault.encrypt(refreshResponse.refresh_token)
|
||||||
|
: encryptedRefreshToken;
|
||||||
|
|
||||||
|
const user = await this.userRepo.getUserById(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new VkReauthenticationRequiredError('User account not found during token refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepo.upsertUserWithTokens({
|
||||||
|
vkUserId: user.vkUserId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
username: user.username,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
encryptedAccessToken,
|
||||||
|
encryptedRefreshToken: newEncryptedRefreshToken,
|
||||||
|
expiresIn: refreshResponse.expires_in,
|
||||||
|
scope: refreshResponse.scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshResponse.access_token;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof VkReauthenticationRequiredError) throw err;
|
||||||
|
throw new VkReauthenticationRequiredError(
|
||||||
|
`Failed to refresh VK session: ${err instanceof Error ? err.message : 'Unknown error'}. Please reconnect your VK account.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultTokenRefresher = new TokenRefresher();
|
||||||
|
|
@ -117,3 +117,8 @@ export const generalApiRateLimiter = new SlidingWindowRateLimiter({
|
||||||
windowMs: 60_000, // 1 minute
|
windowMs: 60_000, // 1 minute
|
||||||
maxRequests: 120,
|
maxRequests: 120,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const oauthStartRateLimiter = new SlidingWindowRateLimiter({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
maxRequests: 10,
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface FetchParticipantsParams {
|
||||||
includeLikes?: boolean;
|
includeLikes?: boolean;
|
||||||
includeComments?: boolean;
|
includeComments?: boolean;
|
||||||
includeReposts?: boolean;
|
includeReposts?: boolean;
|
||||||
|
organizerId?: string;
|
||||||
onProgress?: (loaded: number, total: number, message: string) => void;
|
onProgress?: (loaded: number, total: number, message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ export interface SocialMediaProvider {
|
||||||
/**
|
/**
|
||||||
* Fetch post metadata, text, counters, and image
|
* Fetch post metadata, text, counters, and image
|
||||||
*/
|
*/
|
||||||
fetchPost(url: string): Promise<PostMetadata>;
|
fetchPost(url: string, options?: { organizerId?: string }): Promise<PostMetadata>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all raw participants performing actions on the post
|
* Fetch all raw participants performing actions on the post
|
||||||
|
|
@ -44,5 +45,5 @@ export interface SocialMediaProvider {
|
||||||
/**
|
/**
|
||||||
* Batch check membership in a community/channel
|
* Batch check membership in a community/channel
|
||||||
*/
|
*/
|
||||||
checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>>;
|
checkSubscription(userIds: string[], groupId: string, options?: { organizerId?: string }): Promise<Map<string, boolean>>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
src/providers/vk/vk-capabilities.ts
Normal file
40
src/providers/vk/vk-capabilities.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { ProviderCapabilities } from '../types';
|
||||||
|
import { VkAuthContext } from '@/integrations/vk/vk-types';
|
||||||
|
|
||||||
|
export type VkAccessMode = 'PUBLIC_SERVICE' | 'ORGANIZER_USER' | 'COMMUNITY_GROUP';
|
||||||
|
|
||||||
|
export interface EffectiveCapabilities extends ProviderCapabilities {
|
||||||
|
accessMode: VkAccessMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATIC_VK_CAPABILITIES: ProviderCapabilities = {
|
||||||
|
likes: true,
|
||||||
|
comments: true,
|
||||||
|
reposts: false,
|
||||||
|
repostsNote: 'Сбор репостов ограничен политикой приватности VK для закрытых профилей',
|
||||||
|
subscriptions: true,
|
||||||
|
adminDetection: false,
|
||||||
|
adminDetectionNote: 'Требует расширенных прав администратора сообщества',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives effective capabilities at runtime based on the resolved auth context and target resource.
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveCapabilities(authContext?: VkAuthContext): EffectiveCapabilities {
|
||||||
|
const accessMode: VkAccessMode = !authContext || authContext.type === 'SERVICE'
|
||||||
|
? 'PUBLIC_SERVICE'
|
||||||
|
: authContext.type === 'USER'
|
||||||
|
? 'ORGANIZER_USER'
|
||||||
|
: 'COMMUNITY_GROUP';
|
||||||
|
|
||||||
|
const isCommunityAdmin = authContext?.type === 'COMMUNITY';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...STATIC_VK_CAPABILITIES,
|
||||||
|
adminDetection: isCommunityAdmin,
|
||||||
|
adminDetectionNote: isCommunityAdmin
|
||||||
|
? undefined
|
||||||
|
: 'Требует прямого подключения токена сообщества с правами администратора',
|
||||||
|
accessMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -13,47 +13,50 @@ import {
|
||||||
VkWallGetCommentsResponse,
|
VkWallGetCommentsResponse,
|
||||||
VkIsMemberItem
|
VkIsMemberItem
|
||||||
} from '@/integrations/vk/vk-types';
|
} from '@/integrations/vk/vk-types';
|
||||||
import { VkNotFoundError, VkAuthError } from '@/integrations/vk/vk-errors';
|
import {
|
||||||
|
VkNotFoundError,
|
||||||
|
VkAuthError,
|
||||||
|
VkPrivateResourceError,
|
||||||
|
VkPermissionError
|
||||||
|
} from '@/integrations/vk/vk-errors';
|
||||||
|
import { STATIC_VK_CAPABILITIES } from './vk-capabilities';
|
||||||
|
import { VkAuthContextResolver, defaultVkAuthContextResolver } from '@/integrations/vk/vk-auth-resolver';
|
||||||
|
|
||||||
|
export interface ExtendedFetchParticipantsParams extends FetchParticipantsParams {
|
||||||
|
authContext?: VkAuthContext;
|
||||||
|
organizerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class VkProvider implements SocialMediaProvider {
|
export class VkProvider implements SocialMediaProvider {
|
||||||
readonly platform: PlatformType = 'VK';
|
readonly platform: PlatformType = 'VK';
|
||||||
readonly capabilities: ProviderCapabilities = {
|
readonly capabilities: ProviderCapabilities = STATIC_VK_CAPABILITIES;
|
||||||
likes: true,
|
|
||||||
comments: true,
|
|
||||||
reposts: false,
|
|
||||||
repostsNote: 'Сбор репостов ограничен политикой приватности VK для закрытых профилей',
|
|
||||||
subscriptions: true,
|
|
||||||
adminDetection: false,
|
|
||||||
adminDetectionNote: 'Требует расширенных прав администратора группы',
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly client: IVkClient;
|
private readonly client: IVkClient;
|
||||||
private readonly authContext: VkAuthContext;
|
private readonly defaultAuthContext: VkAuthContext;
|
||||||
|
private readonly authResolver: VkAuthContextResolver;
|
||||||
|
|
||||||
constructor(serviceToken?: string, client?: IVkClient) {
|
constructor(
|
||||||
const token = serviceToken || process.env.VK_SERVICE_TOKEN;
|
serviceToken?: string,
|
||||||
if (!token) {
|
client?: IVkClient,
|
||||||
// In tests or unconfigured environments, create a dummy context that will be validated on call
|
authResolver?: VkAuthContextResolver
|
||||||
this.authContext = createServiceAuth('');
|
) {
|
||||||
} else {
|
const token = serviceToken !== undefined ? serviceToken : (process.env.VK_SERVICE_TOKEN || '');
|
||||||
this.authContext = createServiceAuth(token);
|
this.defaultAuthContext = createServiceAuth(token);
|
||||||
}
|
|
||||||
this.client = client || defaultVkClient;
|
this.client = client || defaultVkClient;
|
||||||
|
this.authResolver = authResolver || defaultVkAuthContextResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public parsePostUrl(url: string): { ownerId: string; postId: string } | null {
|
public parsePostUrl(url: string): { ownerId: string; postId: string } | null {
|
||||||
return parseVkPostUrl(url);
|
return parseVkPostUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureConfigured(): void {
|
/**
|
||||||
if (!this.authContext.token) {
|
* Fetches VK post metadata with optional organizer context and controlled fallback.
|
||||||
throw new VkAuthError('VK_SERVICE_TOKEN is not configured in environment variables');
|
*/
|
||||||
}
|
async fetchPost(
|
||||||
}
|
url: string,
|
||||||
|
options?: { authContext?: VkAuthContext; organizerId?: string }
|
||||||
async fetchPost(url: string): Promise<PostMetadata> {
|
): Promise<PostMetadata> {
|
||||||
this.ensureConfigured();
|
|
||||||
|
|
||||||
const parsed = this.parsePostUrl(url);
|
const parsed = this.parsePostUrl(url);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
throw new VkNotFoundError('Invalid VK post URL format');
|
throw new VkNotFoundError('Invalid VK post URL format');
|
||||||
|
|
@ -61,6 +64,43 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
|
|
||||||
const { ownerId, postId } = parsed;
|
const { ownerId, postId } = parsed;
|
||||||
|
|
||||||
|
// 1. Initial attempt with resolved auth context (prefers explicit/service token by policy)
|
||||||
|
let activeAuth: VkAuthContext;
|
||||||
|
if (options?.authContext) {
|
||||||
|
activeAuth = options.authContext;
|
||||||
|
} else if (this.defaultAuthContext.token) {
|
||||||
|
activeAuth = this.defaultAuthContext;
|
||||||
|
} else {
|
||||||
|
activeAuth = await this.authResolver.resolveAuthContext({
|
||||||
|
organizerId: options?.organizerId,
|
||||||
|
method: 'wall.getById',
|
||||||
|
resource: { ownerId, postId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.executeFetchPost(ownerId, postId, url, activeAuth);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Controlled Fallback: If SERVICE token encountered private/restricted resource, and organizer is available
|
||||||
|
const isPrivateOrRestricted = err instanceof VkPrivateResourceError || err instanceof VkPermissionError;
|
||||||
|
if (isPrivateOrRestricted && activeAuth.type === 'SERVICE' && options?.organizerId) {
|
||||||
|
try {
|
||||||
|
const userAuth = await this.authResolver.resolveUserFallbackContext(options.organizerId);
|
||||||
|
return await this.executeFetchPost(ownerId, postId, url, userAuth);
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
throw fallbackErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeFetchPost(
|
||||||
|
ownerId: string,
|
||||||
|
postId: string,
|
||||||
|
url: string,
|
||||||
|
authContext: VkAuthContext
|
||||||
|
): Promise<PostMetadata> {
|
||||||
const response = await this.client.call<{
|
const response = await this.client.call<{
|
||||||
items: VkWallPost[];
|
items: VkWallPost[];
|
||||||
profiles?: VkUserProfile[];
|
profiles?: VkUserProfile[];
|
||||||
|
|
@ -68,7 +108,7 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
}>('wall.getById', {
|
}>('wall.getById', {
|
||||||
posts: `${ownerId}_${postId}`,
|
posts: `${ownerId}_${postId}`,
|
||||||
extended: 1,
|
extended: 1,
|
||||||
}, this.authContext);
|
}, authContext);
|
||||||
|
|
||||||
if (!response.items || response.items.length === 0) {
|
if (!response.items || response.items.length === 0) {
|
||||||
throw new VkNotFoundError(`Post "${ownerId}_${postId}" not found or access is restricted`);
|
throw new VkNotFoundError(`Post "${ownerId}_${postId}" not found or access is restricted`);
|
||||||
|
|
@ -123,9 +163,41 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
|
/**
|
||||||
this.ensureConfigured();
|
* Fetches participants for giveaway with optional explicit or resolved AuthContext.
|
||||||
|
*/
|
||||||
|
async fetchParticipants(params: ExtendedFetchParticipantsParams): Promise<RawParticipant[]> {
|
||||||
|
const { ownerId, postId, organizerId, authContext: explicitAuth } = params;
|
||||||
|
|
||||||
|
let activeAuth: VkAuthContext;
|
||||||
|
if (explicitAuth) {
|
||||||
|
activeAuth = explicitAuth;
|
||||||
|
} else if (this.defaultAuthContext.token) {
|
||||||
|
activeAuth = this.defaultAuthContext;
|
||||||
|
} else {
|
||||||
|
activeAuth = await this.authResolver.resolveAuthContext({
|
||||||
|
organizerId,
|
||||||
|
method: 'likes.getList',
|
||||||
|
resource: { ownerId, postId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.executeFetchParticipants(params, activeAuth);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const isPrivateOrRestricted = err instanceof VkPrivateResourceError || err instanceof VkPermissionError;
|
||||||
|
if (isPrivateOrRestricted && activeAuth.type === 'SERVICE' && organizerId) {
|
||||||
|
const userAuth = await this.authResolver.resolveUserFallbackContext(organizerId);
|
||||||
|
return await this.executeFetchParticipants(params, userAuth);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeFetchParticipants(
|
||||||
|
params: ExtendedFetchParticipantsParams,
|
||||||
|
authContext: VkAuthContext
|
||||||
|
): Promise<RawParticipant[]> {
|
||||||
const { ownerId, postId } = params;
|
const { ownerId, postId } = params;
|
||||||
const participantsMap = new Map<string, RawParticipant>();
|
const participantsMap = new Map<string, RawParticipant>();
|
||||||
|
|
||||||
|
|
@ -142,7 +214,7 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
extended: 1,
|
extended: 1,
|
||||||
count,
|
count,
|
||||||
offset,
|
offset,
|
||||||
}, this.authContext);
|
}, authContext);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: (res.items || []) as VkUserProfile[],
|
items: (res.items || []) as VkUserProfile[],
|
||||||
|
|
@ -186,7 +258,7 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
count,
|
count,
|
||||||
offset,
|
offset,
|
||||||
fields: 'photo_100,photo_200,screen_name',
|
fields: 'photo_100,photo_200,screen_name',
|
||||||
}, this.authContext);
|
}, authContext);
|
||||||
|
|
||||||
const profileMap = new Map<number, VkUserProfile>(
|
const profileMap = new Map<number, VkUserProfile>(
|
||||||
(res.profiles || []).map(p => [p.id, p])
|
(res.profiles || []).map(p => [p.id, p])
|
||||||
|
|
@ -235,12 +307,27 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
return Array.from(participantsMap.values());
|
return Array.from(participantsMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
|
async checkSubscription(
|
||||||
this.ensureConfigured();
|
userIds: string[],
|
||||||
|
groupId: string,
|
||||||
|
options?: { authContext?: VkAuthContext; organizerId?: string }
|
||||||
|
): Promise<Map<string, boolean>> {
|
||||||
const cleanGroupId = groupId.replace(/^-/, '');
|
const cleanGroupId = groupId.replace(/^-/, '');
|
||||||
const resultMap = new Map<string, boolean>();
|
const resultMap = new Map<string, boolean>();
|
||||||
|
|
||||||
|
let activeAuth: VkAuthContext;
|
||||||
|
if (options?.authContext) {
|
||||||
|
activeAuth = options.authContext;
|
||||||
|
} else if (this.defaultAuthContext.token) {
|
||||||
|
activeAuth = this.defaultAuthContext;
|
||||||
|
} else {
|
||||||
|
activeAuth = await this.authResolver.resolveAuthContext({
|
||||||
|
organizerId: options?.organizerId,
|
||||||
|
method: 'groups.isMember',
|
||||||
|
resource: { ownerId: `-${cleanGroupId}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// VK API groups.isMember allows up to 500 user_ids per batch call
|
// VK API groups.isMember allows up to 500 user_ids per batch call
|
||||||
const chunkSize = 500;
|
const chunkSize = 500;
|
||||||
for (let i = 0; i < userIds.length; i += chunkSize) {
|
for (let i = 0; i < userIds.length; i += chunkSize) {
|
||||||
|
|
@ -251,7 +338,7 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
group_id: cleanGroupId,
|
group_id: cleanGroupId,
|
||||||
user_ids: chunk.join(','),
|
user_ids: chunk.join(','),
|
||||||
},
|
},
|
||||||
this.authContext
|
activeAuth
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const item of res || []) {
|
for (const item of res || []) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { validateCsrfOrigin } from '../src/lib/auth/csrf-guard';
|
import { validateCsrfOrigin } from '../src/lib/auth/csrf-guard';
|
||||||
import { getAppBaseUrl, getVkRedirectUri } from '../src/lib/auth/app-config';
|
import { GET as vkStartGet } from '../src/app/api/auth/vk/start/route';
|
||||||
import { GET as vkStartGet, oauthStartRateLimiter } from '../src/app/api/auth/vk/start/route';
|
import { oauthStartRateLimiter } from '../src/lib/rate-limiter';
|
||||||
|
|
||||||
describe('Phase 2.2.3 Origin, CSRF Trusted Host & Rate Limiting Gate', () => {
|
describe('Phase 2.2.3 Origin, CSRF Trusted Host & Rate Limiting Gate', () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
|
|
||||||
105
tests/token-refresh-concurrency.test.ts
Normal file
105
tests/token-refresh-concurrency.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { TokenRefresher } from '../src/lib/auth/token-refresher';
|
||||||
|
import { MemoryUserRepository } from '../src/lib/repository/user-repository';
|
||||||
|
import { AesGcmTokenVault } from '../src/lib/auth/token-vault';
|
||||||
|
import { MockVkOAuthClient } from '../src/integrations/vk/mock-oauth-client';
|
||||||
|
import { VkReauthenticationRequiredError } from '../src/integrations/vk/vk-errors';
|
||||||
|
|
||||||
|
describe('Phase 2.3 Token Refresh & Single-Flight Concurrency Gate', () => {
|
||||||
|
let userRepo: MemoryUserRepository;
|
||||||
|
let tokenVault: AesGcmTokenVault;
|
||||||
|
let oauthClient: MockVkOAuthClient;
|
||||||
|
let tokenRefresher: TokenRefresher;
|
||||||
|
|
||||||
|
let organizerId: string;
|
||||||
|
const expiredAccessToken = 'vk1.a.expired_old_access_token';
|
||||||
|
const initialRefreshToken = 'vk1.a.initial_refresh_token_valid';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userRepo = new MemoryUserRepository();
|
||||||
|
tokenVault = new AesGcmTokenVault('test-master-token-encryption-key-32b!');
|
||||||
|
oauthClient = new MockVkOAuthClient();
|
||||||
|
tokenRefresher = new TokenRefresher(userRepo, tokenVault, oauthClient);
|
||||||
|
|
||||||
|
const encryptedAccessToken = await tokenVault.encrypt(expiredAccessToken);
|
||||||
|
const encryptedRefreshToken = await tokenVault.encrypt(initialRefreshToken);
|
||||||
|
|
||||||
|
// Save expired credential (expired 10 seconds ago)
|
||||||
|
const user = await userRepo.upsertUserWithTokens({
|
||||||
|
vkUserId: '98765432',
|
||||||
|
firstName: 'Bob',
|
||||||
|
lastName: 'Refresher',
|
||||||
|
encryptedAccessToken,
|
||||||
|
encryptedRefreshToken,
|
||||||
|
expiresIn: -10, // Expired in the past
|
||||||
|
});
|
||||||
|
organizerId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('20 concurrent requests for an expired token trigger exactly 1 refresh operation (single-flight mutex)', async () => {
|
||||||
|
let refreshCallsCount = 0;
|
||||||
|
const originalRefreshToken = oauthClient.refreshToken.bind(oauthClient);
|
||||||
|
|
||||||
|
oauthClient.refreshToken = async (params) => {
|
||||||
|
refreshCallsCount++;
|
||||||
|
// Artificial delay to allow all 20 concurrent requests to pile in
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
return originalRefreshToken(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Launch 20 concurrent requests
|
||||||
|
const promises = Array.from({ length: 20 }, () =>
|
||||||
|
tokenRefresher.getOrRefreshUserToken(organizerId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await Promise.all(promises);
|
||||||
|
|
||||||
|
// 1. Single-Flight guarantee: Exactly 1 network refresh call was made
|
||||||
|
expect(refreshCallsCount).toBe(1);
|
||||||
|
|
||||||
|
// 2. All 20 callers received the same valid refreshed access token
|
||||||
|
expect(tokens).toHaveLength(20);
|
||||||
|
const firstToken = tokens[0];
|
||||||
|
expect(firstToken).toMatch(/mock_refreshed_access_token_/);
|
||||||
|
expect(tokens.every(t => t === firstToken)).toBe(true);
|
||||||
|
|
||||||
|
// 3. Database credential record was updated with the new token
|
||||||
|
const updatedCred = await userRepo.getUserCredentials(organizerId);
|
||||||
|
expect(updatedCred?.expiresAt).toBeDefined();
|
||||||
|
expect(updatedCred?.expiresAt!.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates refresh_token when provided by VK ID response', async () => {
|
||||||
|
const refreshedToken = await tokenRefresher.getOrRefreshUserToken(organizerId);
|
||||||
|
expect(refreshedToken).toBeDefined();
|
||||||
|
|
||||||
|
const updatedCred = await userRepo.getUserCredentials(organizerId);
|
||||||
|
const decryptedNewRefresh = await tokenVault.decrypt(updatedCred!.encryptedRefreshToken!);
|
||||||
|
expect(decryptedNewRefresh).toMatch(/mock_new_refresh_token_/);
|
||||||
|
expect(decryptedNewRefresh).not.toBe(initialRefreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws VkReauthenticationRequiredError when refresh fails on VK side', async () => {
|
||||||
|
oauthClient.shouldFailRefresh = true;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tokenRefresher.getOrRefreshUserToken(organizerId)
|
||||||
|
).rejects.toThrow(VkReauthenticationRequiredError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws VkReauthenticationRequiredError when expired token has no refresh token', async () => {
|
||||||
|
// Create user without refresh token
|
||||||
|
const encryptedAccessToken = await tokenVault.encrypt(expiredAccessToken);
|
||||||
|
const userNoRefresh = await userRepo.upsertUserWithTokens({
|
||||||
|
vkUserId: '55555555',
|
||||||
|
firstName: 'No',
|
||||||
|
lastName: 'Refresh',
|
||||||
|
encryptedAccessToken,
|
||||||
|
expiresIn: -10, // Expired
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tokenRefresher.getOrRefreshUserToken(userNoRefresh.id)
|
||||||
|
).rejects.toThrow(VkReauthenticationRequiredError);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
tests/vk-auth-resolver.test.ts
Normal file
90
tests/vk-auth-resolver.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { VkAuthContextResolver } from '../src/integrations/vk/vk-auth-resolver';
|
||||||
|
import { TokenRefresher } from '../src/lib/auth/token-refresher';
|
||||||
|
import { MemoryUserRepository } from '../src/lib/repository/user-repository';
|
||||||
|
import { AesGcmTokenVault } from '../src/lib/auth/token-vault';
|
||||||
|
import { MockVkOAuthClient } from '../src/integrations/vk/mock-oauth-client';
|
||||||
|
import { VkReauthenticationRequiredError } from '../src/integrations/vk/vk-errors';
|
||||||
|
|
||||||
|
describe('Phase 2.3 VkAuthContextResolver & Token Selection Policy', () => {
|
||||||
|
let userRepo: MemoryUserRepository;
|
||||||
|
let tokenVault: AesGcmTokenVault;
|
||||||
|
let oauthClient: MockVkOAuthClient;
|
||||||
|
let tokenRefresher: TokenRefresher;
|
||||||
|
let resolver: VkAuthContextResolver;
|
||||||
|
|
||||||
|
let organizerId: string;
|
||||||
|
const rawAccessToken = 'vk1.a.alice_valid_user_access_token_12345';
|
||||||
|
const rawRefreshToken = 'vk1.a.alice_valid_refresh_token_67890';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userRepo = new MemoryUserRepository();
|
||||||
|
tokenVault = new AesGcmTokenVault('test-master-token-encryption-key-32b!');
|
||||||
|
oauthClient = new MockVkOAuthClient();
|
||||||
|
tokenRefresher = new TokenRefresher(userRepo, tokenVault, oauthClient);
|
||||||
|
resolver = new VkAuthContextResolver(tokenRefresher);
|
||||||
|
|
||||||
|
process.env.VK_SERVICE_TOKEN = 'vk_service_token_secret_12345';
|
||||||
|
|
||||||
|
// Store encrypted user credentials
|
||||||
|
const encryptedAccessToken = await tokenVault.encrypt(rawAccessToken);
|
||||||
|
const encryptedRefreshToken = await tokenVault.encrypt(rawRefreshToken);
|
||||||
|
|
||||||
|
const user = await userRepo.upsertUserWithTokens({
|
||||||
|
vkUserId: '12345678',
|
||||||
|
firstName: 'Alice',
|
||||||
|
lastName: 'Organizer',
|
||||||
|
encryptedAccessToken,
|
||||||
|
encryptedRefreshToken,
|
||||||
|
expiresIn: 3600, // Valid for 1 hour
|
||||||
|
});
|
||||||
|
organizerId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects SERVICE token by default for public operations (least privilege)', async () => {
|
||||||
|
const auth = await resolver.resolveAuthContext({
|
||||||
|
method: 'wall.getById',
|
||||||
|
resource: { ownerId: '-100', postId: '1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.type).toBe('SERVICE');
|
||||||
|
expect(auth.token).toBe('vk_service_token_secret_12345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects USER token when preferredMode is explicitly set to USER', async () => {
|
||||||
|
const auth = await resolver.resolveAuthContext({
|
||||||
|
organizerId,
|
||||||
|
preferredMode: 'USER',
|
||||||
|
method: 'wall.getById',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.type).toBe('USER');
|
||||||
|
expect(auth.token).toBe(rawAccessToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws VkReauthenticationRequiredError when USER token is requested but organizer is unauthenticated', async () => {
|
||||||
|
await expect(
|
||||||
|
resolver.resolveAuthContext({
|
||||||
|
preferredMode: 'USER',
|
||||||
|
method: 'wall.getById',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(VkReauthenticationRequiredError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws VkReauthenticationRequiredError when organizer credentials do not exist in database', async () => {
|
||||||
|
await expect(
|
||||||
|
resolver.resolveAuthContext({
|
||||||
|
organizerId: 'usr_non_existent_organizer',
|
||||||
|
preferredMode: 'USER',
|
||||||
|
method: 'wall.getById',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(VkReauthenticationRequiredError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves fallback USER token for controlled privacy/permission errors', async () => {
|
||||||
|
const auth = await resolver.resolveUserFallbackContext(organizerId);
|
||||||
|
|
||||||
|
expect(auth.type).toBe('USER');
|
||||||
|
expect(auth.token).toBe(rawAccessToken);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
tests/vk-provider-authenticated.test.ts
Normal file
170
tests/vk-provider-authenticated.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { VkProvider } from '../src/providers/vk/vk-provider';
|
||||||
|
import { VkAuthContextResolver } from '../src/integrations/vk/vk-auth-resolver';
|
||||||
|
import { TokenRefresher } from '../src/lib/auth/token-refresher';
|
||||||
|
import { MemoryUserRepository } from '../src/lib/repository/user-repository';
|
||||||
|
import { AesGcmTokenVault } from '../src/lib/auth/token-vault';
|
||||||
|
import { MockVkOAuthClient } from '../src/integrations/vk/mock-oauth-client';
|
||||||
|
import { IVkClient } from '../src/integrations/vk/vk-client';
|
||||||
|
import { VkAuthContext } from '../src/integrations/vk/vk-types';
|
||||||
|
import {
|
||||||
|
VkPrivateResourceError,
|
||||||
|
VkRateLimitError,
|
||||||
|
VkTemporaryError
|
||||||
|
} from '../src/integrations/vk/vk-errors';
|
||||||
|
import { resolveEffectiveCapabilities } from '../src/providers/vk/vk-capabilities';
|
||||||
|
|
||||||
|
describe('Phase 2.3 Authenticated VK Provider & Controlled Fallback Gate', () => {
|
||||||
|
let userRepo: MemoryUserRepository;
|
||||||
|
let tokenVault: AesGcmTokenVault;
|
||||||
|
let oauthClient: MockVkOAuthClient;
|
||||||
|
let tokenRefresher: TokenRefresher;
|
||||||
|
let authResolver: VkAuthContextResolver;
|
||||||
|
|
||||||
|
let organizerId: string;
|
||||||
|
const userTokenPlain = 'vk1.a.organizer_user_access_token_abc';
|
||||||
|
const serviceTokenPlain = 'vk_service_token_xyz';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userRepo = new MemoryUserRepository();
|
||||||
|
tokenVault = new AesGcmTokenVault('test-master-token-encryption-key-32b!');
|
||||||
|
oauthClient = new MockVkOAuthClient();
|
||||||
|
tokenRefresher = new TokenRefresher(userRepo, tokenVault, oauthClient);
|
||||||
|
authResolver = new VkAuthContextResolver(tokenRefresher);
|
||||||
|
|
||||||
|
process.env.VK_SERVICE_TOKEN = serviceTokenPlain;
|
||||||
|
|
||||||
|
const encryptedAccessToken = await tokenVault.encrypt(userTokenPlain);
|
||||||
|
const user = await userRepo.upsertUserWithTokens({
|
||||||
|
vkUserId: '77778888',
|
||||||
|
firstName: 'Dmitry',
|
||||||
|
lastName: 'Organizer',
|
||||||
|
encryptedAccessToken,
|
||||||
|
expiresIn: 7200,
|
||||||
|
});
|
||||||
|
organizerId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses SERVICE token for public posts by default (least privilege)', async () => {
|
||||||
|
let capturedAuth: VkAuthContext | undefined;
|
||||||
|
|
||||||
|
const mockClient: IVkClient = {
|
||||||
|
call: async (_method, _params, auth) => {
|
||||||
|
capturedAuth = auth;
|
||||||
|
return {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
owner_id: -100,
|
||||||
|
date: 1700000000,
|
||||||
|
text: 'Public post',
|
||||||
|
likes: { count: 5 },
|
||||||
|
comments: { count: 2 },
|
||||||
|
reposts: { count: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
|
||||||
|
const post = await provider.fetchPost('https://vk.com/wall-100_100', { organizerId });
|
||||||
|
|
||||||
|
expect(post.title).toBe('Public post...');
|
||||||
|
expect(capturedAuth?.type).toBe('SERVICE');
|
||||||
|
expect(capturedAuth?.token).toBe(serviceTokenPlain);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('performs controlled fallback to USER token when SERVICE token receives private resource error', async () => {
|
||||||
|
const authSequence: VkAuthContext[] = [];
|
||||||
|
|
||||||
|
const mockClient: IVkClient = {
|
||||||
|
call: async (_method, _params, auth) => {
|
||||||
|
authSequence.push(auth!);
|
||||||
|
if (auth?.type === 'SERVICE') {
|
||||||
|
// Simulate VK API error 15 / 30 (Access denied to private group/profile)
|
||||||
|
throw new VkPrivateResourceError('Access denied: post is in a private group', { errorCode: 15 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// USER token succeeds
|
||||||
|
return {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 200,
|
||||||
|
owner_id: -200,
|
||||||
|
date: 1700000000,
|
||||||
|
text: 'Private group post visible to organizer',
|
||||||
|
likes: { count: 10 },
|
||||||
|
comments: { count: 4 },
|
||||||
|
reposts: { count: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
|
||||||
|
const post = await provider.fetchPost('https://vk.com/wall-200_200', { organizerId });
|
||||||
|
|
||||||
|
expect(post.title).toBe('Private group post visible to organizer...');
|
||||||
|
expect(authSequence).toHaveLength(2);
|
||||||
|
expect(authSequence[0].type).toBe('SERVICE');
|
||||||
|
expect(authSequence[1].type).toBe('USER');
|
||||||
|
expect(authSequence[1].token).toBe(userTokenPlain);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strictly forbids fallback on rate limits (HTTP 429 / error 6/29)', async () => {
|
||||||
|
const authSequence: VkAuthContext[] = [];
|
||||||
|
|
||||||
|
const mockClient: IVkClient = {
|
||||||
|
call: async (_method, _params, auth) => {
|
||||||
|
authSequence.push(auth!);
|
||||||
|
throw new VkRateLimitError('VK rate limit reached', { errorCode: 6 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
provider.fetchPost('https://vk.com/wall-100_100', { organizerId })
|
||||||
|
).rejects.toThrow(VkRateLimitError);
|
||||||
|
|
||||||
|
// Fallback was NOT attempted on rate limit
|
||||||
|
expect(authSequence).toHaveLength(1);
|
||||||
|
expect(authSequence[0].type).toBe('SERVICE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strictly forbids fallback on VK server errors (HTTP 500 / error 10)', async () => {
|
||||||
|
const authSequence: VkAuthContext[] = [];
|
||||||
|
|
||||||
|
const mockClient: IVkClient = {
|
||||||
|
call: async (_method, _params, auth) => {
|
||||||
|
authSequence.push(auth!);
|
||||||
|
throw new VkTemporaryError('VK Internal Server Error', { errorCode: 10 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
provider.fetchPost('https://vk.com/wall-100_100', { organizerId })
|
||||||
|
).rejects.toThrow(VkTemporaryError);
|
||||||
|
|
||||||
|
expect(authSequence).toHaveLength(1);
|
||||||
|
expect(authSequence[0].type).toBe('SERVICE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives effective capabilities accurately depending on accessMode', () => {
|
||||||
|
const serviceCapabilities = resolveEffectiveCapabilities({ type: 'SERVICE', token: 's' });
|
||||||
|
expect(serviceCapabilities.accessMode).toBe('PUBLIC_SERVICE');
|
||||||
|
expect(serviceCapabilities.adminDetection).toBe(false);
|
||||||
|
|
||||||
|
const userCapabilities = resolveEffectiveCapabilities({ type: 'USER', token: 'u' });
|
||||||
|
expect(userCapabilities.accessMode).toBe('ORGANIZER_USER');
|
||||||
|
expect(userCapabilities.adminDetection).toBe(false);
|
||||||
|
|
||||||
|
const communityCapabilities = resolveEffectiveCapabilities({ type: 'COMMUNITY', token: 'c', communityId: '100' });
|
||||||
|
expect(communityCapabilities.accessMode).toBe('COMMUNITY_GROUP');
|
||||||
|
expect(communityCapabilities.adminDetection).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue