Phase 2.3.1: token refresh correctness gate

This commit is contained in:
Ochenstarik 2026-08-18 14:37:09 +07:00
parent d6f087c21e
commit bc2b658570
11 changed files with 1312 additions and 53 deletions

View file

@ -0,0 +1,311 @@
# Randomayzer — Claude Phase C-4
## Phase 2.3 Auth Resolver & Refresh Security Review
**Repository:** https://github.com/ochenstarik-ui/randomayzer
**Review commit:** `d6f087c21efb593ee7db58f816be98a2d087b3e3`
**Source of truth:** local snapshot archive `randomayzer-d6f087c.zip`, uploaded and extracted directly. GitHub/web was **not** used as a code source.
**Scope:** New Phase 2.3 security-sensitive code only (Auth Resolver, Refresh, Credential Repository, Participants authenticated flow, SERVICE→USER fallback). No general re-audit was performed.
---
## 0. Files Reviewed
| Area | File |
|---|---|
| Auth resolver | `src/integrations/vk/vk-auth-resolver.ts` |
| Token refresher | `src/lib/auth/token-refresher.ts` |
| Token vault | `src/lib/auth/token-vault.ts` |
| VK OAuth client | `src/integrations/vk/vk-oauth-client.ts`, `src/integrations/vk/mock-oauth-client.ts` |
| Credential repository | `src/lib/repository/user-repository.ts` (+ `prisma/schema.prisma`) |
| VK provider / authenticated flow | `src/providers/vk/vk-provider.ts`, `src/integrations/vk/vk-client.ts`, `src/integrations/vk/vk-errors.ts` |
| Capabilities | `src/providers/vk/vk-capabilities.ts` |
| Session / CSRF / OAuth state | `src/lib/auth/session.ts`, `src/lib/auth/csrf-guard.ts`, `src/lib/auth/oauth-state.ts`, `src/lib/auth/auth-guard.ts` |
| API routes | `src/app/api/giveaways/[id]/participants/route.ts`, `src/app/api/posts/preview/route.ts`, `src/app/api/auth/vk/callback/route.ts`, `src/app/api/giveaways/route.ts` |
| Pipeline | `src/core/pipeline/participant-enricher.ts` |
| Error mapping | `src/core/errors/http-errors.ts` |
| Tests | `tests/token-refresh-concurrency.test.ts`, `tests/vk-auth-resolver.test.ts`, `tests/vk-provider-authenticated.test.ts`, `tests/oauth-concurrency.test.ts` |
---
## 1. Credential Data Flow Trace
```
HTTP request (cookie: randomayzer_session)
→ getSessionFromRequest() [session.ts: opaque 32-byte token, server-side Map lookup]
→ requireGiveawayOwner(req, giveawayId) [auth-guard.ts: CSRF-origin check + ownership check]
→ giveaway.organizerId === sessionUser.id ? (else 403, and null-organizer is force-denied)
→ sessionUser.id passed as `organizerId` into provider.fetchParticipants()/fetchPost()/checkSubscription()
→ VkAuthContextResolver.resolveAuthContext({ organizerId, ... })
→ TokenRefresher.getOrRefreshUserToken(organizerId)
→ IUserRepository.getUserCredentials(userId) [Prisma: WHERE userId = <internal id>]
→ TokenVault.decrypt(encryptedAccessToken) [AES-256-GCM]
→ VkAuthContext{ type: 'USER', token: <plaintext> }
→ VkProvider → VkClient.call() → token placed in outbound form body only
```
### Trust boundaries identified
1. **Cookie → session store** — session id is a random, unguessable, server-generated 32-byte token (`randomBytes(32)`), stored server-side (`MemorySessionStore`). The client never supplies `userId`/`organizerId` directly.
2. **Session → giveaway ownership**`requireGiveawayOwner` compares `giveaway.organizerId` (DB, server-set at creation) to `sessionUser.id` (server-derived from session). Explicitly denies when `organizerId` is null (anti-orphan invariant).
3. **organizerId → resolver**`organizerId` is **only ever populated from `sessionUser.id`** at every call site (`participants/route.ts:80,90`, `posts/preview/route.ts:22`, `giveaways/route.ts:65`). Verified with a full-repo grep — **no client-supplied field named `organizerId` exists in any Zod schema** (`giveaway-schemas.ts`), so it cannot be injected via request body/query.
4. **Resolver → TokenRefresher → UserRepository** — lookup is by internal `userId` (cuid), not by attacker-controlled VK id.
5. **TokenVault** — AES-256-GCM, key derived via SHA-256 from `TOKEN_ENCRYPTION_KEY` (hard-fails in production if unset or <32 chars). Decrypted plaintext lives only in function-local variables, never persisted or logged.
6. **VkClient → VK API** — token is placed only in the outbound `URLSearchParams` body; never logged, never included in thrown errors (see §10).
### Can organizer/user id be influenced by client data?
**No.** Every code path that reaches `resolveAuthContext` / `resolveUserFallbackContext` / `getOrRefreshUserToken` receives `organizerId` that was assigned server-side from `sessionUser.id`, itself derived from an unguessable opaque session token validated against an in-memory session store the client cannot write to.
---
## 2. Horizontal Access Control
**Claim: User A cannot cause the resolver to decrypt/use User B's token.**
All resolver call sites were enumerated (`grep -rn "resolveAuthContext\|resolveUserFallbackContext\|getOrRefreshUserToken"`):
| Call site | organizerId origin |
|---|---|
| `vk-provider.ts:74` (`fetchPost`) | `options?.organizerId` — caller-supplied param |
| `vk-provider.ts:88` (fallback) | same |
| `vk-provider.ts:178` (`fetchParticipants`) | `params.organizerId` — caller-supplied param |
| `vk-provider.ts:190` (fallback) | same |
| `vk-provider.ts:324` (`checkSubscription`) | `options?.organizerId` — caller-supplied param |
All of these `VkProvider` methods are only invoked from two places in `src/app`:
- `participants/route.ts``organizerId: sessionUser.id` (post-ownership-check)
- `posts/preview/route.ts``organizerId: sessionUser?.id` (session-only, no ownership check needed since this is a public preview endpoint and worst case is resolving to the *current caller's own* USER token)
Because `VkProvider` itself has no HTTP-layer awareness, its "trust boundary" is the constructor/method contract: **any caller of `VkProvider.fetchParticipants/fetchPost/checkSubscription` that passes an arbitrary `organizerId` would be able to force resolution of that organizer's token.** Today, in this snapshot, no such caller exists outside the two verified sites. This is a **structural risk, not an active vulnerability**, and should be called out explicitly:
> ⚠️ `VkAuthContextResolver`/`TokenRefresher`/`VkProvider` do not themselves enforce that the `organizerId` passed in belongs to the authenticated caller — that invariant is enforced entirely by *callers* (currently correctly, in both cases). Any new API route or background job added later that passes a client-controlled or cross-user `organizerId` into these methods **would** constitute a full horizontal privilege escalation (User A obtains User B's decrypted VK token). This should be treated as an architectural trust assumption that needs to be documented and defended in code review for every future call site, not just today's two.
**Verdict for this snapshot: NO** (not currently exploitable) — see Final Verdict §18 for the caveat above.
---
## 3. Refresh Single-Flight Correctness
`TokenRefresher.getOrRefreshUserToken()` (`token-refresher.ts:30-63`):
- **Lock key**: `userId` (internal cuid) — correct, scoped per-user, no cross-user collision possible since the map key is the same value used for the DB lookup.
- **Exactly one refresh**: `inFlightRefreshes.get(userId)` is checked before creating a new promise; the promise is stored **synchronously** before any `await`, so concurrent callers within the same event-loop tick correctly join the same in-flight promise (verified in `tests/token-refresh-concurrency.test.ts`: 20 concurrent calls → `refreshCallsCount === 1`, all 20 receive the identical token).
- **Finally cleanup**: `try { return await existingFlight } finally { this.inFlightRefreshes.delete(userId) }` — the map entry is deleted regardless of success or failure, so no permanently stuck promise.
- **Exception cleanup**: `executeRefresh` itself catches all errors and rethrows as `VkReauthenticationRequiredError`; the outer `finally` still deletes the map entry. Confirmed via `tests/token-refresh-concurrency.test.ts` ("throws VkReauthenticationRequiredError when refresh fails on VK side") that a failed refresh correctly propagates the typed error. Not directly tested: that a **second** call after a failed first call is allowed to retry (i.e., the map entry was truly cleared) — implied correct by the `finally`, but there is no explicit regression test for it.
- **No cross-user lock collision**: keys are per-`userId`; no shared/global key used.
**Existing concurrency test critique**: `token-refresh-concurrency.test.ts` is a real exercise of `TokenRefresher` + `MemoryUserRepository` + `AesGcmTokenVault` + `MockVkOAuthClient` — not a shallow mock-everything test. It genuinely exercises the single-flight map, the encrypt/decrypt round trip, and the repository upsert. It does **not** test:
- Two *different* users refreshing concurrently (to prove no accidental shared state) — low risk given the per-userId map key, but worth adding.
- Recovery/retry after a failed refresh (map cleanup verification).
**Verdict: Single-flight is correctly implemented for a single Node process.** See §4 for the multi-instance caveat.
---
## 4. Refresh Persistence Race (Stale Overwrite)
- `UserCredential` (Prisma schema) has `updatedAt` (auto) but **no optimistic-concurrency `version` column and no CAS-conditioned update** (`WHERE version = ...`). The `upsertUserWithTokens` write is a plain `prisma.user.upsert(...)` with a nested `credentials.upsert`, i.e., last-write-wins by design.
- **Within a single Node process**, this is not exploitable: the in-memory single-flight mutex guarantees only one `executeRefresh` runs per user at a time, so there is no concurrent writer to race against.
- **Across multiple instances** (horizontal scaling), `inFlightRefreshes` is a per-process `Map` — it provides **no cross-instance mutual exclusion**. Two instances could both observe the same expired credential, both call VK's refresh endpoint with the same (still-valid, not-yet-rotated) `refresh_token`, and both attempt to persist. Because there is no CAS/version guard, the second write silently overwrites the first, and (depending on real-world VK refresh-token rotation semantics — see §5) the token that "loses" the race may still be a **valid and equally fresh token** rather than a stale one, since both refreshes were derived from the same VK refresh call input. The practical impact is bounded:
- Worst case if VK **does** invalidate the used refresh_token after first use: the *losing* instance's exchange fails outright with `invalid_grant`, surfacing as `VkReauthenticationRequiredError` — a forced-reauth availability bug, not a credential leak or corruption of another user's data.
- It cannot cause a different user's credential to be corrupted (write is scoped by `vkUserId`/`userId` uniqueness constraints).
- **Contrast**: `MemorySessionStore` and `MemoryOAuthTransactionStore` both explicitly `throw` a fatal configuration error when `MULTI_INSTANCE=true`. `TokenRefresher`/`AesGcmTokenVault`/`MemoryUserRepository` (memory-driver mode) have **no equivalent guard**, so a misconfigured horizontal deployment would fail loudly for sessions/OAuth-state but silently degrade (occasional forced reauth, no data corruption) for token refresh.
**Classification: MEDIUM** — availability/correctness gap under horizontal scaling, not a confidentiality or cross-user integrity issue. No CAS/version field exists; recommend adding one and/or a startup guard consistent with the session/OAuth-state stores.
---
## 5. Rotating Refresh Token
- `executeRefresh` (`token-refresher.ts:81-84`): if `refreshResponse.refresh_token` is present, it is encrypted and replaces the stored value; if absent, the **previous** `encryptedRefreshToken` is retained unchanged. This matches the project's own documented contract in `docs/VK_ID_LIVE_CONTRACT.md` §2 ("Refresh Token Expiry... if present, stored encrypted; if absent, flow continues safely") — internally consistent.
- `VkOAuthClient.refreshToken()` (`vk-oauth-client.ts:206`) defaults `refresh_token: data.refresh_token || params.refreshToken` at the HTTP-client layer, which is redundant with but not contradictory to the `token-refresher.ts` retention logic (double-safe).
- **External verification**: I do not have network access in this environment to hit VK's live `id.vk.com/oauth2/auth` endpoint, and general web search did not surface an authoritative, current public VK ID contract page confirming whether refresh tokens are single-use/rotating (the repo's own `docs/VK_ID_LIVE_CONTRACT.md` explicitly marks this **"UNVERIFIED on test app"**). The implementation's behavior (retain-if-absent, replace-if-present) is the correct defensive default regardless of which VK behavior turns out to be true, so this is **not a blocker**, but the live-VK smoke test called for in `docs/VK_REAL_SMOKE_TEST.md` / `VK_MANUAL_SMOKE_TEST.md` should still be run to close this out formally.
**Verdict: Correct as implemented; contract still formally unverified against live VK (pre-existing, documented limitation).**
---
## 6. Refresh Failure Handling
`executeRefresh`'s `catch` block (`token-refresher.ts:104-109`) wraps **any** non-`VkReauthenticationRequiredError` exception (invalid refresh token, VK auth error, network failure, malformed response) into a `VkReauthenticationRequiredError` and rethrows. Critically, **no partial state is persisted**: `userRepo.upsertUserWithTokens(...)` is only called after `refreshResponse.access_token` has been validated truthy (line 77-79) — if the response is malformed (missing `access_token`), the function throws *before* any encryption or persistence occurs. A malformed VK response (e.g., valid HTTP 200 with missing fields) therefore cannot corrupt stored credentials.
**Verdict: Correct — no partial/undefined credential persistence possible on any failure path.**
---
## 7. Expired Token Ordering
`getOrRefreshUserToken` (`token-refresher.ts:37-42`) computes `isExpiredOrExpiring` using a 30-second safety margin (`now >= expiresAt - 30_000`) **before** returning a decrypted token, and only returns the currently-stored token when it is *not* expiring. Refresh is attempted first, and only the resulting fresh token is ever handed to the VK API caller (`VkAuthContextResolver` → `VkProvider``VkClient`). There is no path where a known-expired token reaches `VkClient.call()` ahead of a refresh attempt.
**Verdict: Correct ordering.**
---
## 8 & 9. SERVICE→USER Fallback: Catch Conditions & Method Contract
Fallback is implemented identically in `fetchPost` and `fetchParticipants` (`vk-provider.ts:83-95`, `186-194`):
```ts
const isPrivateOrRestricted = err instanceof VkPrivateResourceError || err instanceof VkPermissionError;
if (isPrivateOrRestricted && activeAuth.type === 'SERVICE' && organizerId) { ... }
```
This is an **explicit instanceof whitelist**, not a generic/catch-all. Cross-checked against `vk-errors.ts`'s `mapVkApiError`/`mapHttpStatusError`:
| Condition | Mapped error class | Triggers fallback? |
|---|---|---|
| VK code 15/30/203 (private) | `VkPrivateResourceError` | ✅ yes (intended) |
| VK code 7/260 (permission) / HTTP 403 | `VkPermissionError` | ✅ yes (intended) |
| VK code 6/9/29 / HTTP 429 (rate limit) | `VkRateLimitError` | ❌ no — confirmed by `vk-provider-authenticated.test.ts` ("strictly forbids fallback on rate limits") |
| VK code 1/10 / HTTP 5xx (temporary) | `VkTemporaryError` | ❌ no — confirmed by test ("strictly forbids fallback on VK server errors") |
| Network failure | `VkNetworkError` | ❌ no (not in whitelist) |
| Timeout | `VkTimeoutError` | ❌ no (not in whitelist) |
| Validation (code 8/100/113/150) | `VkValidationError` | ❌ no (not in whitelist) |
| Auth (code 4/5/28 / HTTP 401) | `VkAuthError` | ❌ no (not in whitelist) |
No generic "service failed → try user" wrapper exists; the fallback also requires `activeAuth.type === 'SERVICE'` (never triggers when already on USER/COMMUNITY) **and** a non-empty `organizerId`. `checkSubscription` (the third resolver caller) has **no fallback branch at all** — a private/permission error there simply propagates. This is a minor **inconsistency** (not a vulnerability): `checkSubscription` is architecturally capable of the same fallback but doesn't implement it, which just means subscription checks against a private/restricted group fail outright for organizers where post/participant fetch would have succeeded via fallback. Low-impact, functional-completeness note only.
Fallback method contract (§9): the whitelist is enforced at the `catch` level of each method individually (`fetchPost`, `fetchParticipants`), not as a shared generic wrapper — each method explicitly re-implements the same narrow check. This avoids a blanket "service failed, try user" wrapper for arbitrary VK methods, satisfying the requirement, at the cost of minor duplication.
**Verdict: Whitelist is correct and narrow. NO catch-all fallback exists.**
---
## 10. Token Leak Review
Full-repo `grep` for `console.log|console.error|console.warn|console.debug|console.info`, `JSON.stringify`, and object-spread patterns involving `cred`/`user`/`token`/`auth` across every Phase 2.3 file (`vk-auth-resolver.ts`, `token-refresher.ts`, `token-vault.ts`, `vk-oauth-client.ts`, `user-repository.ts`, `vk-provider.ts`, `vk-capabilities.ts`, `participants/route.ts`, `auth/vk/callback/route.ts`, `auth/vk/start/route.ts`, `mock-oauth-client.ts`): **zero matches**.
Additional checks:
- `vk-errors.ts` includes a dedicated `sanitizeRequestParams()` helper that redacts any parameter whose key contains `token`/`access_token` before attaching it to `VkClientError.details` — defense in depth even for the internal (non-HTTP-facing) error object.
- `http-errors.ts`'s `handleApiError()` **never** serializes `VkClientError.details` (or any raw VK error payload) to the HTTP response — every `VkClientError` category is mapped to a hand-written, generic, token-free message (§10 cross-reference with §"Error Mapping" review above). Plaintext tokens, encrypted blobs, and raw VK API responses are structurally unreachable from any API response body.
- `token-vault.ts` decrypted plaintext only ever exists as a local variable / return value passed directly into `VkAuthContext.token`, which itself is only consumed by `VkClient.executeSingleCall` to build the outbound `URLSearchParams` body — never logged, never echoed back.
**Verdict: NO token leak found — plaintext or encrypted — in Phase 2.3 code or its HTTP-facing error paths.**
---
## 11. User Credential Repository Update
`upsertUserWithTokens` (`user-repository.ts:29-81`, Prisma impl) keys the upsert on `vkUserId` (`where: { vkUserId: params.vkUserId }`), which has a **DB-level `@unique` constraint** (`prisma/schema.prisma:35`). `UserCredential.userId` also has `@unique` (schema line 49) with `onDelete: Cascade` from `User`. The caller (`TokenRefresher.executeRefresh`) always derives `params.vkUserId` from `await this.userRepo.getUserById(userId)` — i.e., it re-reads the **existing** user record by internal id and re-uses its own `vkUserId`; it is not possible for a caller to pass an arbitrary/different `vkUserId` into the update path, because the value is sourced from the DB record matching the original `userId`, not from any external input.
The in-memory driver (`MemoryUserRepository`) mirrors the same "find-by-vkUserId-or-create" semantics and preserves the same uniqueness invariant in application code (no DB constraint to fall back on, but logically equivalent for tests/dev).
**Verdict: Caller cannot choose an arbitrary userId/vkUserId credential to update. Uniqueness constraints present at both DB (`@unique`) and application (find-or-create) layers.**
---
## 12. Participants Route
`src/app/api/giveaways/[id]/participants/route.ts`:
- **Ownership before resolver**: `requireGiveawayOwner(req, id)` (line 53) runs and throws before `provider.fetchParticipants(...)` (line 75) is ever reached. Confirmed by direct code order inspection — no possible reordering since `giveaway`/`sessionUser` returned from the guard are the same values passed downstream.
- **Idempotency**: `Idempotency-Key` header, when present, is checked (`IdempotencyStore.get`) before any provider call and set (`IdempotencyStore.set`) only after a full successful pipeline run, scoped by `operation:giveawayId:key` per `docs/PRODUCTION_GUARDS.md` §1 — consistent with the documented Phase-1 contract. Nothing in Phase 2.3 changed this ordering.
- **Fallback vs. Phase 1 concurrency rules**: the SERVICE→USER fallback happens entirely *inside* `provider.fetchParticipants()`, before `GiveawayStore.updateParticipants(id, allParticipants)` is called — i.e., fallback is fully resolved before the atomic participant-state write, so it cannot interact with or break the Phase 1 concurrency/idempotency guarantees around `GiveawayStore`.
**Verdict: Correct ordering; idempotency and Phase 1 concurrency invariants preserved.**
---
## 13. Effective Capabilities Overpromise Check
`resolveEffectiveCapabilities()` (`vk-capabilities.ts`): `reposts` is **statically `false`** regardless of `accessMode` (SERVICE/USER/COMMUNITY) — it never overpromises reposts capability even under a USER token. `adminDetection` is only ever `true` when `authContext.type === 'COMMUNITY'` — correctly gated (organizer USER tokens never claim admin-level capability).
**However, one real overpromise bug was found**, not in `vk-capabilities.ts` itself but in its caller:
`src/app/api/posts/preview/route.ts:25-27`:
```ts
const effectiveCapabilities = resolveEffectiveCapabilities(
sessionUser ? { type: 'USER', token: 'active' } : { type: 'SERVICE', token: 'active' }
);
```
This calls `resolveEffectiveCapabilities` with a **synthetic stub auth context** based purely on "does a session cookie exist," not on the actual `VkAuthContext` that `provider.fetchPost()` resolved and used a few lines above. Concretely:
- If the organizer is logged in but their **VK credential is missing/expired and refresh fails** (`VkReauthenticationRequiredError`), `fetchPost()` would have already succeeded using the **SERVICE** token for a public post (no fallback was even needed) — yet the response still reports `accessMode: 'ORGANIZER_USER'` and USER-tier capabilities to the frontend, which is inaccurate.
- Conversely, if `fetchPost` genuinely fell back to a USER token to reach a private resource, the reported capabilities happen to be correct only coincidentally.
This is a **UI-truthfulness / trust-boundary correctness issue**, not a credential-exposure issue — no token or PII is exposed — but it means the frontend cannot reliably use `effectiveCapabilities` from this endpoint to reason about what the *next* authenticated action will actually be able to do (e.g., it might imply reauth is not needed when it is).
**Classification: MEDIUM** (correctness / capability overpromise, `posts/preview` route only — `vk-capabilities.ts` core logic itself is sound).
---
## 14. Identity Consistency (Token ↔ User)
- **At initial OAuth login** (`auth/vk/callback/route.ts:80-81`): `vkUserId: String(tokenResponse.user_id)` is taken directly from VK's own token-exchange response (`tokenResponse.user_id`), not from client input — the session is correctly bound to the VK-asserted identity at creation time.
- **At refresh time** (`token-refresher.ts:65-110`): `executeRefresh` calls `oauthClient.refreshToken(...)`, which (per `vk-oauth-client.ts` and the mock) **does** return a `user_id` field in `VkOAuthTokenResponse` — but `token-refresher.ts` **never reads or validates `refreshResponse.user_id` against the existing `user.vkUserId`**. The refreshed `access_token` is persisted purely based on which internal `userId` initiated the refresh, with no re-assertion that VK still considers the refreshed token to belong to the same VK user.
**Risk assessment**: Not currently exploitable as a cross-user vector, because:
1. The `refresh_token` used as input was itself encrypted and stored under this specific `userId`'s row, sourced only from that same user's original OAuth login.
2. There is no code path allowing one user's stored `refresh_token` to be fed into another user's refresh call.
It is, however, a **missing defense-in-depth check**: if VK's refresh endpoint ever returned a mismatched `user_id` (server-side bug, token-family confusion, or a future VK API change), the application would silently accept and store it under the *original* internal user without ever detecting the mismatch.
**Classification: LOW** — add an assertion `refreshResponse.user_id == user.vkUserId` (when VK provides `user_id` on refresh) that throws `VkReauthenticationRequiredError` on mismatch, as defense-in-depth. Real VK response data needed to confirm whether `user_id` is actually populated on the refresh grant (see §17 limitations).
---
## 15. Database Schema
Migration present for Phase 2.3's era: `prisma/migrations/20260818120000_ownership_invariant/` (ownership invariant — relates to `Giveaway.organizerId` non-null enforcement, consistent with `auth-guard.ts`'s explicit null-organizer denial). No new columns were required specifically for token refresh in this snapshot; `UserCredential` (`encryptedAccessToken`, `encryptedRefreshToken`, `expiresAt`, `scope`, `updatedAt`) has sufficient fields for the current refresh lifecycle logic (§4, §6, §7 all validated against these fields). **Missing**: an optimistic-concurrency `version` (or equivalent) column, called out in §4 as a MEDIUM finding for multi-instance deployments — this would require a new migration if implemented.
**Verdict: No schema changes were required by Phase 2.3 as implemented; current fields are sufficient for single-instance-safe lifecycle management. A version/CAS column is recommended as a future migration for horizontal-scale safety (§4).**
---
## 16. Test Quality
| Test file | Exercises real production logic? | Notes |
|---|---|---|
| `tests/token-refresh-concurrency.test.ts` | **Yes** — real `TokenRefresher`, `MemoryUserRepository`, `AesGcmTokenVault`, only `MockVkOAuthClient` is a test double (appropriate, since it's the network boundary). Genuinely exercises the single-flight `Map`, encrypt/decrypt round-trip, and repository upsert. | Missing: cross-user concurrent refresh test; explicit "map cleared after failure, retry succeeds" test. |
| `tests/vk-auth-resolver.test.ts` | **Yes** — real `VkAuthContextResolver` + real `TokenRefresher` chain, only the OAuth HTTP boundary is mocked. | Missing: an explicit horizontal-access test (e.g., "resolver given organizerId=B while only A's credentials are seeded correctly returns A's data / never B's" — current tests only prove single-organizer correctness, not cross-organizer isolation at the resolver's own API surface). Given §2's finding, this test would be valuable to add. |
| `tests/vk-provider-authenticated.test.ts` | **Yes** — real `VkProvider` + real `VkAuthContextResolver` + real `TokenRefresher`, with a hand-written `IVkClient` mock standing in for the actual VK HTTP call (correct boundary to mock). Explicitly tests the fallback whitelist against `VkRateLimitError` and `VkTemporaryError` to prove they do **not** trigger fallback — this is exactly the "prove the whitelist is narrow" test the review scope calls for. | Good coverage; no significant gaps found for the scenarios it targets. |
| `tests/oauth-concurrency.test.ts` | **Yes** — real `MemoryOAuthTransactionStore`, 100-way concurrent single-use consumption race, genuinely exercises the atomic delete-then-check logic. | Solid; not itself part of Phase 2.3's Auth Resolver scope but adjacent and reviewed for context. |
No false-positive ("mocks all the way down, proves nothing about production code") tests were found among the four in scope. The tests consistently mock only the true external boundary (the HTTP call to VK), which is the correct approach.
---
## 17. Build/Test Execution — ENVIRONMENT LIMITATION
```
$ npm install --offline
npm error code ENOTCACHED
npm error request to https://registry.npmjs.org/zod/-/zod-4.4.3.tgz failed:
cache mode is 'only-if-cached' but no cached response is available.
```
**This sandbox has no outbound network access** (confirmed: bash tool network is disabled). `node_modules` is not present in the snapshot archive, and no local npm cache/mirror is available. As a result, **`npm test`, `npm run lint`, and `npm run build` could not be executed** in this environment. `node` (v22.22.2) and `npm` (10.9.7) are present, but dependency installation itself is blocked at the network layer, not by Prisma specifically.
This is a hard tooling limitation of the review environment, not a finding about the codebase. All conclusions above are based on **full static reading of the actual source files** (not summaries, not GitHub web rendering, not assumptions) plus **manual tracing of test file logic** (read in full, not executed). If a maintainer can run `npm install && npm test && npm run lint && npm run build` in an environment with network access, that should be done to mechanically confirm what this review verified by inspection.
---
## 18. Final Verdict
| Severity | Finding | Section |
|---|---|---|
| **MEDIUM** | No optimistic-concurrency/version guard on `UserCredential` persistence; `TokenRefresher`'s single-flight mutex is per-process only, with no `MULTI_INSTANCE` guard (unlike `MemorySessionStore`/`MemoryOAuthTransactionStore`, which fail loudly). Under horizontal scaling this can cause spurious forced-reauth, not credential corruption or cross-user leakage. | §4 |
| **MEDIUM** | `posts/preview` route reports `effectiveCapabilities` derived from "is there a session" rather than the actual resolved `VkAuthContext`, which can overstate USER-tier capability when the organizer's stored VK credential is actually missing/expired. UI-truthfulness issue, no data exposure. | §13 |
| **LOW** | Refreshed token's `user_id` (if returned by VK) is never cross-checked against the stored `user.vkUserId` — missing defense-in-depth identity assertion. Not currently exploitable. | §14 |
| **LOW** | `checkSubscription` has no SERVICE→USER fallback branch, unlike `fetchPost`/`fetchParticipants` — functional inconsistency, not a security gap. | §8/§9 |
| **LOW** | Resolver/refresher/provider layer has no self-contained enforcement that `organizerId` belongs to the calling session — this invariant is currently upheld entirely (and correctly) by the two HTTP-route callers, but is not defended at the library boundary itself. Structural risk for future call sites. | §2 |
| **INFO** | `getOAuthClient()` in `vk-oauth-client.ts` has dead/redundant branching (both branches return the same value) — code-quality note only. | §14 (context) |
| **INFO/BLOCKER** | `npm test`/`lint`/`build` could not be run — no network access in review sandbox. | §17 |
### Direct answers
**A. Can one organizer use another organizer's VK credential?**
**NO** — for the current call sites. `organizerId` is exclusively server-derived from the authenticated session at every point it reaches the resolver, verified by full-repo trace and schema check. (Caveat: this invariant is enforced by callers, not by the resolver/refresher/provider library itself — see §2 and the MEDIUM findings.)
**B. Can concurrent refresh corrupt token state?**
**POSSIBLE** (not YES, not clean NO) — impossible within a single process (single-flight mutex verified correct and tested); theoretically possible only under multi-instance horizontal deployment due to the absent CAS/version guard, and even then the realistic worst case is a forced reauth rather than silent data corruption or cross-user leakage (§4).
**C. Can fallback bypass rate-limit/network policy?**
**NO** — the fallback whitelist is a narrow `instanceof` check against exactly `VkPrivateResourceError`/`VkPermissionError`; rate-limit (`VkRateLimitError`) and network/timeout/temporary/validation/auth errors are explicitly excluded and this exclusion is covered by passing tests (§8/§9).
**D. Can plaintext token leak to frontend/API?**
**NO** — verified via full grep for logging/stringify/spread patterns (zero matches) and via inspection of `handleApiError`, which maps every `VkClientError` to a hand-written, token-free generic message and never serializes `.details` or raw VK payloads to the HTTP response (§10).
**E. Is Phase 2.3 safe for REAL VK SMOKE TEST?**
**YES**, with the following non-blocking caveats to keep in mind while running the smoke test:
- Confirm empirically whether VK's refresh grant response includes `user_id`, and if so, consider adding the identity cross-check from §14 before/after the smoke test as a follow-up (not a blocker for running the test itself).
- The refresh-token rotation behavior itself (§5) is exactly what the smoke test is meant to verify against `docs/VK_ID_LIVE_CONTRACT.md`'s "UNVERIFIED" markers — this review found no code-level blocker to running it.
- No credential-exposure, no horizontal-access, and no fallback-abuse blockers were found that would make it unsafe to point this code at real VK infrastructure with a real (non-privileged, test) organizer account.
No CRITICAL or HIGH findings were identified in the reviewed Phase 2.3 code.

View file

@ -0,0 +1,298 @@
# Randomayzer — Phase G-5 Authenticated VK Access / Token Lifecycle Review
**Reviewer:** Grok (xAI)
**Date:** 2026-08-18
**Commit:** `d6f087c21efb593ee7db58f816be98a2d087b3e3`
**Scope:** Phase 2.3 — VkAuthContextResolver, token refresh, SERVICE→USER fallback, credential ownership, capabilities, import auth.
**Constraint:** Review / tests / docs only. No production code changes.
---
## 1. Executive Verdicts
| Area | Verdict |
|------|---------|
| **Credential isolation** | **PASS** |
| **Resolver** | **PASS WITH WARNINGS** |
| **Refresh** | **PASS WITH WARNINGS** |
| **Refresh concurrency** | **PASS WITH WARNINGS** |
| **Fallback** | **PASS WITH WARNINGS** |
| **Capabilities** | **PASS WITH WARNINGS** |
| **Token confidentiality** | **PASS** |
| **Participant import** | **PASS WITH WARNINGS** |
| **VK contract** | **PASS WITH WARNINGS** |
| **Overall Phase 2.3** | **PASS WITH FIXES** |
### Безопасно ли переходить к реальному VK smoke test?
**YES.**
**Real blockers:** none for a controlled smoke test with:
- configured `VK_SERVICE_TOKEN` / organizer USER login,
- `TOKEN_ENCRYPTION_KEY`,
- single-instance process (in-memory single-flight map).
**Must watch during smoke:** refresh single-flight under load, null `expiresAt` behaviour, fallback only on private/permission errors, no token in API responses.
---
## 2. Credential Ownership / IDOR
Participants POST/GET and other mutations call `requireGiveawayOwner` **before** any provider/resolver call.
```ts
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
// ...
organizerId: sessionUser.id // from session, not body
```
- Organizer identity for resolver comes from **trusted session** after ownership check.
- Client cannot pass another users `userId` / `vkUserId` / `organizerId` to decrypt or use their credential.
- As giveaway never loads Bs `UserCredential`.
- Null `organizerId` still Forbidden (prior phase invariant).
**Horizontal privilege escalation: not found.**
---
## 3. VkAuthContextResolver
Least-privilege default: SERVICE if configured; else USER if `organizerId` present.
| Mode | Behaviour |
|------|-----------|
| preferred SERVICE | SERVICE env token; if missing + organizer → USER |
| preferred USER | requires organizerId → getOrRefreshUserToken |
| preferred COMMUNITY | env `VK_COMMUNITY_TOKEN_{id}` or USER fallback |
| automatic | SERVICE preferred; else USER; else AuthError |
`resolveUserFallbackContext(organizerId)` is explicit and only used by provider on private/permission failure.
No silent arbitrary token switching outside documented paths.
**WARN:** COMMUNITY → USER fallback when community token missing is broad; acceptable if documented.
---
## 4. SERVICE → USER Fallback
Provider (e.g. `fetchPost`) only falls back when:
```ts
err instanceof VkPrivateResourceError || err instanceof VkPermissionError
&& activeAuth.type === 'SERVICE'
&& options?.organizerId
```
| Error class | Fallback? |
|-------------|-----------|
| VkPrivateResourceError | YES (documented) |
| VkPermissionError | YES (documented; broader than pure private) |
| VkRateLimitError | NO |
| VkTemporaryError | NO |
| VkNetworkError / Timeout | NO |
| VkValidationError | NO |
| VkAuthError on SERVICE | NO (rethrows) |
Rate-limit bypass via token switch: **blocked**.
**WARN:** Treating all `VkPermissionError` as fallback-eligible may include non-privacy permission failures; policy is explicit in `VK_AUTHENTICATED_ACCESS.md`.
**Fallback loop:** USER path does not re-enter SERVICE fallback → no SERVICE↔USER loop.
---
## 5. User Token Expiry
```ts
const isExpiredOrExpiring = cred.expiresAt
? now >= cred.expiresAt.getTime() - 30_000
: false;
```
| Case | Behaviour |
|------|-----------|
| future expiresAt | decrypt & use |
| within 30s of expiry | refresh |
| past expiry | refresh |
| **null expiresAt** | **treated as non-expired** → send without refresh |
| missing access token | ReauthenticationRequired |
**WARN:** null/legacy `expiresAt` never triggers refresh. Prefer “unknown expiry → refresh or re-auth” for safety.
Expired token is not knowingly sent when `expiresAt` is present and past.
---
## 610. Refresh Security & Concurrency
**Security**
- Refresh token decrypted only server-side in `executeRefresh`.
- New access (and rotated refresh if present) encrypted before `upsertUserWithTokens`.
- Failures → `VkReauthenticationRequiredError`; message may include generic error text, not raw tokens.
- Refresh response `user_id` is **not** checked against stored `vkUserId`**WARN** (account binding): should reject identity mismatch.
**Single-flight**
- Map keyed by `userId`.
- Existing test: 20 concurrent → 1 refresh call, same token to all (PASS in test).
- **WARN:** every waiter runs `finally { inFlightRefreshes.delete(userId) }`. First completer clears the key; a new concurrent request can start a **second** refresh while other waiters still use the first promise. Prefer delete only if `map.get(id) === thisFlight`.
- Locks are per-user → A does not block B (OK).
- On error, waiters all reject; key cleared → subsequent call can retry (no permanent stuck lock).
**Stale write**
- No version/CAS on credential update. Late refresh can overwrite a credential updated by a concurrent login/refresh.
- Severity: MEDIUMHIGH under concurrent refresh+relogin; lower if single-flight holds for most cases.
**Refresh failure matrix (expected)**
| VK mock outcome | Result |
|-----------------|--------|
| invalid/expired refresh | ReauthenticationRequired |
| network/timeout/429/500 | wrapped ReauthenticationRequired |
| missing access_token | ReauthenticationRequired |
| malformed | ReauthenticationRequired |
| No plaintext token in thrown message by design | OK |
---
## 11. Account Binding
Upsert on refresh uses **DB user.vkUserId**, not token response identity.
Silent rebind to another VK account: **not implemented**.
**GAP:** no explicit reject if refresh response `user_id` ≠ stored vkUserId.
---
## 1213. Token Confidentiality
Markers must not appear in API JSON, participant responses, giveaway detail, capabilities, audit proof.
Design:
- Credentials only via vault decrypt on server.
- POST participants returns summary counts only.
- Session cookie is opaque ID.
- UserCredential not spread into public DTOs.
**Encrypted ciphertext** also should not be returned to frontend — repository responses used by API must omit credential fields (verify list/detail serializers).
**Token leak result (static review):** no intentional plaintext path found. Smoke test should grep responses/logs for markers.
---
## 1416. Participant Import Auth Flow
Order:
1. Rate limit
2. **requireGiveawayOwner** (session + ownership + CSRF)
3. Validate body
4. Idempotency lookup
5. Provider `fetchParticipants` with `organizerId: sessionUser.id`
6. Pipeline / persist
7. Summary response + idempotency store
No provider call before ownership. Client organizer id cannot control resolver.
**Partial import + fallback:** if SERVICE fails mid-pagination with private error, fallback restarts USER fetch. Provider should not merge partial SERVICE pages with USER result as one complete set without clear restart. **WARN:** confirm import path fully restarts on fallback (likes/comments) rather than appending mixed auth pages.
**Idempotency:** key includes operation + giveawayId + payload; successful USER completion after SERVICE deny should cache final result; replay returns cache (design intent).
---
## 1718. Runtime Capabilities
Docs define method matrix + fallback rules. Static `provider.capabilities` still flag reposts/adminDetection false.
**TOCTOU:** UI capability snapshot can go stale if token expires before import; import path re-resolves via refresher → revalidation on execution (OK). Do not trust UI-only flags for authorization.
**WARN:** Ensure API “effectiveCapabilities” for a giveaway reflects actual SERVICE availability + organizer credential presence, not only static provider flags.
---
## 1920. Subscription / Preview
`groups.isMember` batching remains 500; auth via resolver/organizerId. Prefer consistent token for all batches of one import (no mixed SERVICE/USER batches unless intentional full restart).
`/api/posts/preview`: must not accept foreign organizer tokens; use session or SERVICE only; no token fields in response. (Confirm route does not take client-supplied user tokens.)
---
## 2122. Rate Limit & Refresh Storm
Global VK limiter can starve short calls during large import (ops issue, not security).
100 concurrent + refresh 429/500: single-flight should yield one attempt then shared failure; after key clear, retries possible — avoid unbounded client retry amplification (application/API rate limits).
---
## 23. Credential Invalidation
Confirmed auth failure → `VkReauthenticationRequiredError` → client reconnect.
No infinite retry of bad refresh token in-process without new user action (OK).
Optional: clear stored refresh on definitive invalid_grant (product choice).
---
## 2425. Versioning / Audit Isolation
Ciphertext has no explicit key-version field → **TECH DEBT**, non-blocking.
Auth mode / token metadata must not enter Randomizer/AuditProof inputs — unchanged core; **PASS**.
---
## 2627. Mock vs Real / VK Contract
| Claim | Implementation | Official | Verdict |
|-------|----------------|----------|---------|
| Refresh endpoint oauth2/auth | Yes | VK ID docs | **VERIFIED** (path) |
| grant_type refresh_token | via oauth client | Required | **VERIFIED** if client sends it |
| device_id on refresh | optional / often absent | Sometimes required | **UNVERIFIED** |
| access + optional refresh rotation | Yes | Common | **VERIFIED** pattern |
| expires_in handling | Yes (+30s skew) | Yes | **PARTIAL** (null expiry) |
| SERVICE→USER only on private/permission | Yes | Product policy | **VERIFIED** policy |
| Scope separator | comma default | space in some VK ID | **UNVERIFIED** live |
**No definite WRONG** refresh contract found that blocks smoke. Confirm `device_id` and scope format on the registered app during smoke.
---
## 28. Performance
Resolver + decrypt + expiry check are O(1) vs network/pagination. Overhead negligible vs 100k import.
---
## 29. CRITICAL / HIGH
**CRITICAL:** none for controlled smoke with proper env.
**HIGH:**
1. Single-flight `finally` deletes key for every waiter → possible double refresh under overlap.
2. null `expiresAt` never refreshes.
3. No CAS/version on credential write (stale refresh overwrite).
4. No refresh response `user_id` vs stored `vkUserId` check.
**MEDIUM:**
- Fallback includes all PermissionError.
- Partial SERVICE pages + USER restart semantics.
- Global limiter starvation (ops).
---
## 30. Refresh stress scale
Existing test: **20 concurrent → 1 refresh**.
Design targets 50100; recommend extending test with the “delete only if same promise” fix verification.
---
## 31. Phase 2.3 readiness for real VK smoke
**YES.**
Proceed with manual/real smoke (`docs/VK_REAL_SMOKE_TEST.md` / `VK_MANUAL_SMOKE_TEST.md`) while monitoring:
- single refresh under parallel import,
- private wall fallback SERVICE→USER,
- zero token markers in HTTP bodies/logs,
- reconnect path when refresh fails.
Fix HIGH items before multi-instance production traffic, not necessarily before first smoke.

View file

@ -0,0 +1,79 @@
# Phase 2.3 Failure Matrix — Grok G-5
**Commit:** `d6f087c21efb593ee7db58f816be98a2d087b3e3`
**Date:** 2026-08-18
## Credential / IDOR
| Attack | Result | Grade |
|--------|--------|-------|
| B uses As giveaway id | 403 owner check | OK |
| Client supplies foreign organizerId | ignored; session owner used | OK |
| Decrypt B credential as A | no path | OK |
| Null organizerId authorize | Forbidden | OK |
## Resolver / Fallback
| Case | Behaviour | Grade |
|------|-----------|-------|
| Public + SERVICE configured | SERVICE | OK |
| Private + SERVICE fail PrivateResource | USER fallback | OK |
| RateLimit on SERVICE | no fallback | OK |
| Network/Timeout/Validation | no fallback | OK |
| SERVICE→USER→SERVICE loop | no | OK |
| PermissionError fallback | allowed by policy | WARN |
## Refresh
| Case | Behaviour | Grade |
|------|-----------|-------|
| 20 concurrent expired | 1 HTTP refresh (test) | OK |
| 50100 concurrent | intended single-flight; finally-delete race | WARN |
| A and B concurrent | separate keys | OK |
| null expiresAt | no refresh | WARN |
| identity mismatch on refresh | not checked | WARN |
| stale write overwrite | no CAS | WARN |
| invalid refresh / 429 / 500 | ReauthenticationRequired | OK |
| token in error/API | not by design | OK |
## Import / Capabilities
| Case | Behaviour | Grade |
|------|-----------|-------|
| Provider before ownership | no | OK |
| Idempotent completed import | cache hit | OK |
| Partial SERVICE + USER restart | must full restart | WARN |
| Stale UI capabilities | re-resolve on import | OK |
| Token in participants response | summary only | OK |
## VK contract
| Item | Verdict |
|------|---------|
| Refresh endpoint | VERIFIED / PARTIAL |
| device_id | UNVERIFIED |
| Scope separator | UNVERIFIED |
| Fallback policy | VERIFIED (product) |
| Definitely WRONG | None |
---
## Summary grades
| Area | Grade |
|------|-------|
| Credential isolation | **PASS** |
| Resolver | **PASS WITH WARNINGS** |
| Refresh | **PASS WITH WARNINGS** |
| Refresh concurrency | **PASS WITH WARNINGS** |
| Fallback | **PASS WITH WARNINGS** |
| Capabilities | **PASS WITH WARNINGS** |
| Token confidentiality | **PASS** |
| Participant import | **PASS WITH WARNINGS** |
| VK contract | **PASS WITH WARNINGS** |
| **Overall** | **PASS WITH FIXES** |
## Smoke-test readiness
**YES** — no CRITICAL blockers for controlled real VK smoke.
Watch: single-flight under load, null expiry, fallback only private/permission, zero token leakage in responses/logs.

View file

@ -1,9 +1,15 @@
import { IVkOAuthClient, VkOAuthTokenResponse } from './vk-oauth-client';
import { VkAuthError, VkValidationError } from './vk-errors';
import { VkAuthError, VkNetworkError, VkValidationError } from './vk-errors';
export class MockVkOAuthClient implements IVkOAuthClient {
public shouldFailExchange = false;
public shouldFailRefresh = false;
/** Simulates a transient network failure on refresh (not an auth error). */
public shouldFailRefreshWithNetwork = false;
/** Controls the user_id returned in token responses. Useful for identity mismatch tests. */
public mockUserId: number = 12345678;
/** When true, refresh response omits refresh_token (tests token retention). */
public shouldReturnNoRefreshToken = false;
public buildAuthorizationUrl(params: {
clientId: string;
@ -45,7 +51,7 @@ export class MockVkOAuthClient implements IVkOAuthClient {
access_token: `mock_vk_access_token_${params.code}`,
token_type: 'Bearer',
expires_in: 86400,
user_id: 12345678,
user_id: this.mockUserId,
refresh_token: `mock_vk_refresh_token_${params.code}`,
scope: 'wall,groups,offline',
};
@ -59,16 +65,20 @@ export class MockVkOAuthClient implements IVkOAuthClient {
if (!params.refreshToken) {
throw new VkValidationError('Refresh token is required');
}
if (this.shouldFailRefreshWithNetwork) {
throw new VkNetworkError('Simulated network error during token refresh');
}
if (this.shouldFailRefresh || params.refreshToken === 'invalid_refresh') {
throw new VkAuthError('VK OAuth token refresh failed: invalid_grant');
}
const ts = Date.now();
return {
access_token: `mock_refreshed_access_token_${Date.now()}`,
access_token: `mock_refreshed_access_token_${ts}`,
token_type: 'Bearer',
expires_in: 86400,
user_id: 12345678,
refresh_token: `mock_new_refresh_token_${Date.now()}`,
user_id: this.mockUserId,
refresh_token: this.shouldReturnNoRefreshToken ? undefined : `mock_new_refresh_token_${ts}`,
scope: 'wall,groups,offline',
};
}

View file

@ -1,8 +1,36 @@
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';
import { VkReauthenticationRequiredError, VkAuthError } from '@/integrations/vk/vk-errors';
/**
* TokenRefresher: Single-flight concurrency mutex for VK token refresh.
*
* Concurrency strategy:
* - A single Promise per userId is stored in inFlightRefreshes.
* - New callers that find an existing flight JOIN it (no cleanup responsibility).
* - Only the caller that CREATED the flight may delete it (reference-equality guard).
* - This prevents two races:
* (a) Multiple waiters all deleting the entry in finally next real refresh starts multiple HTTP calls.
* (b) Late caller after cleanup getting an orphaned stale entry.
*
* CAS / stale-write strategy:
* - Before calling executeRefresh, we capture cred.updatedAt.
* - After refresh completes, we use updateCredentialConditionally(userId, update, expectedUpdatedAt).
* - If the credential was replaced (re-login or another refresh won) between our read and our write,
* the CAS returns false and we skip the write. The freshly-computed access token is still returned
* (it is valid for the remainder of its TTL) but the DB retains the newer credential.
*
* Null expiresAt policy:
* - VK ID OAuth 2.1 always returns expires_in. A null expiresAt in the DB indicates a legacy
* credential stored without expiry metadata.
* - Policy: if refresh token exists treat as expired, force refresh.
* - If no refresh token ReauthenticationRequired (we cannot safely assume the token is valid).
*
* Identity binding:
* - VK token refresh responses include user_id. We verify it matches the stored User.vkUserId.
* - Mismatch VkReauthenticationRequiredError. Tokens are NOT persisted.
*/
export class TokenRefresher {
private inFlightRefreshes = new Map<string, Promise<string>>();
@ -23,46 +51,79 @@ export class TokenRefresher {
}
/**
* 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.
* Returns a valid plaintext VK access token for the given userId.
* Refreshes if expired or if expiry is unknown. Uses single-flight mutex.
*/
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.');
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;
// --- NULL expiresAt POLICY ---
// A null/undefined expiresAt means expiry is unknown (legacy credential).
// We do NOT assume the token is valid forever.
let isExpiredOrExpiring: boolean;
if (cred.expiresAt === null || cred.expiresAt === undefined) {
if (cred.encryptedRefreshToken) {
// Conservative: unknown expiry + refresh token available → force refresh
isExpiredOrExpiring = true;
} else {
// No expiry info, no way to refresh → require re-login
throw new VkReauthenticationRequiredError(
'VK session has unknown expiry and no refresh token is available. Please reconnect your VK account.'
);
}
} else {
// Normal path: check if within 30-second pre-expiry window
isExpiredOrExpiring = now >= cred.expiresAt.getTime() - 30_000;
}
if (!isExpiredOrExpiring) {
return await this.tokenVault.decrypt(cred.encryptedAccessToken);
}
// Token is expired. Check if refresh token is available.
// Token is expired or expiry is unknown. 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);
// --- SINGLE-FLIGHT MUTEX ---
// If a refresh is already in flight for this userId, JOIN it.
// Joiners do NOT have cleanup responsibility — only the owner does.
const existingFlight = this.inFlightRefreshes.get(userId);
if (existingFlight) {
return await existingFlight;
}
// We are the owner: create and register the flight.
const flight = this.executeRefresh(userId, cred.encryptedRefreshToken, cred.updatedAt);
this.inFlightRefreshes.set(userId, flight);
try {
return await existingFlight;
return await flight;
} finally {
this.inFlightRefreshes.delete(userId);
// Reference-equality guard: only delete if THIS flight is still registered.
// This prevents a late waiter (who joined after we completed) from deleting
// a NEW flight that started for the same userId after ours resolved.
if (this.inFlightRefreshes.get(userId) === flight) {
this.inFlightRefreshes.delete(userId);
}
}
}
private async executeRefresh(userId: string, encryptedRefreshToken: string): Promise<string> {
private async executeRefresh(
userId: string,
encryptedRefreshToken: string,
credentialUpdatedAt: Date
): 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' : '');
@ -75,37 +136,88 @@ export class TokenRefresher {
});
if (!refreshResponse.access_token) {
throw new VkReauthenticationRequiredError('VK token refresh response did not return a valid 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;
// --- IDENTITY BINDING ---
// Verify the refreshed token belongs to the same VK user.
const user = await this.userRepo.getUserById(userId);
if (!user) {
throw new VkReauthenticationRequiredError('User account not found during token refresh');
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,
});
if (refreshResponse.user_id && String(refreshResponse.user_id) !== String(user.vkUserId)) {
// SECURITY: The token response is for a different VK account.
// Do NOT persist these tokens. Require re-authentication.
throw new VkReauthenticationRequiredError(
'SECURITY: VK token refresh user_id mismatch — tokens not persisted. Please reconnect your account.'
);
}
// --- REFRESH TOKEN ROTATION ---
// VK ID may issue a new refresh_token. If it does, rotate it.
// If the response omits refresh_token, retain the previous encrypted token.
// IMPORTANT: only store defined non-null values.
const encryptedAccessToken = await this.tokenVault.encrypt(refreshResponse.access_token);
let newEncryptedRefreshToken: string;
if (refreshResponse.refresh_token) {
// New (or same) refresh token returned — encrypt and store
newEncryptedRefreshToken = await this.tokenVault.encrypt(refreshResponse.refresh_token);
} else {
// No refresh token in response — retain the previous encrypted token
newEncryptedRefreshToken = encryptedRefreshToken;
}
const newExpiresAt = refreshResponse.expires_in
? new Date(Date.now() + refreshResponse.expires_in * 1_000)
: null;
// --- CAS STALE WRITE PROTECTION ---
// Only write the refreshed credential if the DB record has not been updated
// since we read it (i.e., no re-login or concurrent refresh has won the race).
// On CAS miss, we still return the freshly-computed access token — it's valid —
// but we do NOT overwrite the newer credential in the DB.
const written = await this.userRepo.updateCredentialConditionally(
userId,
{
encryptedAccessToken,
encryptedRefreshToken: newEncryptedRefreshToken,
expiresAt: newExpiresAt,
scope: refreshResponse.scope,
},
credentialUpdatedAt
);
if (!written) {
// CAS miss: a newer credential exists (re-login or parallel refresh won).
// Return the freshly-computed token without corrupting the DB.
return refreshResponse.access_token;
}
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.`
);
// --- REFRESH FAILURE CLASSIFICATION ---
// Auth failures (invalid_grant, revoked token, bad credentials):
// → VkReauthenticationRequiredError (user must log in again)
// → Credentials in DB are NOT modified (no partial write)
if (err instanceof VkAuthError) {
throw new VkReauthenticationRequiredError(
`VK session is no longer valid: ${
err instanceof Error ? err.message : 'Auth error'
}. Please reconnect your VK account.`
);
}
// Network / rate-limit / temporary VK failures:
// → Propagate the original error as-is (caller can decide to retry)
// → Credentials in DB are NOT modified
throw err;
}
}
}

View file

@ -22,12 +22,23 @@ export interface IUserRepository {
encryptedRefreshToken?: string | null;
expiresAt?: Date | null;
scope?: string | null;
updatedAt: Date;
} | null>;
updateCredentialConditionally(
userId: string,
update: {
encryptedAccessToken: string;
encryptedRefreshToken: string;
expiresAt: Date | null;
scope?: string | null;
},
expectedUpdatedAt: Date
): Promise<boolean>;
}
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 expiresAt = params.expiresIn !== undefined ? new Date(Date.now() + params.expiresIn * 1000) : null;
const user = await prisma.user.upsert({
where: { vkUserId: params.vkUserId },
@ -114,8 +125,31 @@ export class PrismaUserRepository implements IUserRepository {
encryptedRefreshToken: cred.encryptedRefreshToken,
expiresAt: cred.expiresAt,
scope: cred.scope,
updatedAt: cred.updatedAt,
};
}
public async updateCredentialConditionally(
userId: string,
update: {
encryptedAccessToken: string;
encryptedRefreshToken: string;
expiresAt: Date | null;
scope?: string | null;
},
expectedUpdatedAt: Date
): Promise<boolean> {
const result = await prisma.userCredential.updateMany({
where: { userId, updatedAt: expectedUpdatedAt },
data: {
encryptedAccessToken: update.encryptedAccessToken,
encryptedRefreshToken: update.encryptedRefreshToken,
expiresAt: update.expiresAt,
scope: update.scope ?? undefined,
},
});
return result.count > 0;
}
}
export class MemoryUserRepository implements IUserRepository {
@ -143,12 +177,13 @@ export class MemoryUserRepository implements IUserRepository {
this.users.set(id, user);
const expiresAt = params.expiresIn ? new Date(Date.now() + params.expiresIn * 1000) : null;
const expiresAt = params.expiresIn !== undefined ? new Date(Date.now() + params.expiresIn * 1000) : null;
this.credentials.set(id, {
encryptedAccessToken: params.encryptedAccessToken,
encryptedRefreshToken: params.encryptedRefreshToken,
expiresAt,
scope: params.scope,
updatedAt: new Date(),
});
return user;
@ -166,7 +201,40 @@ export class MemoryUserRepository implements IUserRepository {
}
public async getUserCredentials(userId: string) {
return this.credentials.get(userId) || null;
const cred = this.credentials.get(userId);
if (!cred) return null;
return {
encryptedAccessToken: cred.encryptedAccessToken,
encryptedRefreshToken: cred.encryptedRefreshToken ?? null,
expiresAt: cred.expiresAt ?? null,
scope: cred.scope ?? null,
updatedAt: cred.updatedAt ?? new Date(0),
};
}
public async updateCredentialConditionally(
userId: string,
update: {
encryptedAccessToken: string;
encryptedRefreshToken: string;
expiresAt: Date | null;
scope?: string | null;
},
expectedUpdatedAt: Date
): Promise<boolean> {
const existing = this.credentials.get(userId);
if (!existing) return false;
const existingUpdatedAt: Date = existing.updatedAt ?? new Date(0);
if (existingUpdatedAt.getTime() !== expectedUpdatedAt.getTime()) return false;
this.credentials.set(userId, {
...existing,
encryptedAccessToken: update.encryptedAccessToken,
encryptedRefreshToken: update.encryptedRefreshToken,
expiresAt: update.expiresAt,
scope: update.scope ?? existing.scope,
updatedAt: new Date(),
});
return true;
}
public clear(): void {

View file

@ -81,15 +81,32 @@ export class VkProvider implements SocialMediaProvider {
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
/**
* Controlled SERVICE USER fallback policy:
*
* ALLOWED:
* - VkPrivateResourceError (codes 15, 30, 203): private/restricted post or group.
* Organizer's personal token may have explicit access.
* - VkPermissionError (codes 7, 260) on RESOURCE-ACCESS methods only
* (likes.getList, wall.getComments, wall.getById): the service token
* may lack implicit access to restricted content. User token carries
* the organizer's explicit VK grants.
*
* FORBIDDEN (never fall back):
* - VkRateLimitError: rate limit is per-token; switching token does not help.
* - VkTemporaryError: VK server-side issue; fallback would waste quota.
* - VkNetworkError / VkTimeoutError: infrastructure issue; retry, don't switch.
* - VkValidationError: malformed request; switching token won't fix params.
*
* PAGINATION SAFETY:
* executeFetchParticipants always starts with a fresh empty participantsMap.
* If SERVICE fails mid-pagination, the USER retry is a COMPLETE RESTART
* no partial SERVICE results are carried over.
*/
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;
}
const userAuth = await this.authResolver.resolveUserFallbackContext(options.organizerId);
return await this.executeFetchPost(ownerId, postId, url, userAuth);
}
throw err;
}
@ -185,6 +202,28 @@ export class VkProvider implements SocialMediaProvider {
try {
return await this.executeFetchParticipants(params, activeAuth);
} catch (err: unknown) {
/**
* Controlled SERVICE USER fallback policy:
*
* ALLOWED:
* - VkPrivateResourceError (codes 15, 30, 203): private/restricted post or group.
* Organizer's personal token may have explicit access.
* - VkPermissionError (codes 7, 260) on RESOURCE-ACCESS methods only
* (likes.getList, wall.getComments, wall.getById): the service token
* may lack implicit access to restricted content. User token carries
* the organizer's explicit VK grants.
*
* FORBIDDEN (never fall back):
* - VkRateLimitError: rate limit is per-token; switching token does not help.
* - VkTemporaryError: VK server-side issue; fallback would waste quota.
* - VkNetworkError / VkTimeoutError: infrastructure issue; retry, don't switch.
* - VkValidationError: malformed request; switching token won't fix params.
*
* PAGINATION SAFETY:
* executeFetchParticipants always starts with a fresh empty participantsMap.
* If SERVICE fails mid-pagination, the USER retry is a COMPLETE RESTART
* no partial SERVICE results are carried over.
*/
const isPrivateOrRestricted = err instanceof VkPrivateResourceError || err instanceof VkPermissionError;
if (isPrivateOrRestricted && activeAuth.type === 'SERVICE' && organizerId) {
const userAuth = await this.authResolver.resolveUserFallbackContext(organizerId);

View file

@ -3,12 +3,13 @@ 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 { MemoryUserRepository, setUserRepository } 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';
import { setOAuthClient } from '../src/integrations/vk/vk-oauth-client';
describe('Phase 2.2 VK ID OAuth 2.1 Security & Token Safety Tests', () => {
beforeEach(() => {
@ -85,6 +86,8 @@ describe('Phase 2.2 VK ID OAuth 2.1 Security & Token Safety Tests', () => {
const userRepo = new MemoryUserRepository();
const vault = new AesGcmTokenVault('test-secret-key-12345');
const sessionStore = new MemorySessionStore();
setOAuthClient(mockOAuth);
setUserRepository(userRepo);
// 1. Create start transaction
const { state, codeVerifier } = await defaultOAuthTransactionStore.createTransaction({

View file

@ -3,6 +3,7 @@ import { NextRequest } from 'next/server';
import { validateCsrfOrigin } from '../src/lib/auth/csrf-guard';
import { GET as vkStartGet } from '../src/app/api/auth/vk/start/route';
import { oauthStartRateLimiter } from '../src/lib/rate-limiter';
import { getAppBaseUrl, getVkRedirectUri } from '../src/lib/auth/app-config';
describe('Phase 2.2.3 Origin, CSRF Trusted Host & Rate Limiting Gate', () => {
const originalEnv = process.env;

View file

@ -26,7 +26,7 @@ describe('Phase 2.3 Token Refresh & Single-Flight Concurrency Gate', () => {
// Save expired credential (expired 10 seconds ago)
const user = await userRepo.upsertUserWithTokens({
vkUserId: '98765432',
vkUserId: '12345678',
firstName: 'Bob',
lastName: 'Refresher',
encryptedAccessToken,

View file

@ -0,0 +1,338 @@
import { describe, it, expect, beforeEach, vi } 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, VkNetworkError } from '../src/integrations/vk/vk-errors';
const TEST_KEY = 'test-master-token-encryption-key-32b!';
async function makeExpiredUser(
userRepo: MemoryUserRepository,
tokenVault: AesGcmTokenVault,
opts: { vkUserId?: string; hasRefreshToken?: boolean; noExpiry?: boolean } = {}
): Promise<string> {
const { vkUserId = '12345678', hasRefreshToken = true, noExpiry = false } = opts;
const encryptedAccessToken = await tokenVault.encrypt('old_access_token');
const encryptedRefreshToken = hasRefreshToken
? await tokenVault.encrypt('old_refresh_token')
: undefined;
const user = await userRepo.upsertUserWithTokens({
vkUserId,
firstName: 'Test',
lastName: 'User',
encryptedAccessToken,
encryptedRefreshToken,
expiresIn: noExpiry ? undefined : -10, // expired 10s ago (or no expiry)
});
return user.id;
}
describe('Phase 2.3.1 — Token Refresh Correctness Gate', () => {
let userRepo: MemoryUserRepository;
let tokenVault: AesGcmTokenVault;
let oauthClient: MockVkOAuthClient;
let refresher: TokenRefresher;
beforeEach(() => {
userRepo = new MemoryUserRepository();
tokenVault = new AesGcmTokenVault(TEST_KEY);
oauthClient = new MockVkOAuthClient();
refresher = new TokenRefresher(userRepo, tokenVault, oauthClient);
});
// ─── Test 1: 100 concurrent expired requests → exactly 1 refresh ─────────────
it('100 concurrent expired token requests trigger exactly 1 refresh HTTP call', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault);
let callCount = 0;
const orig = oauthClient.refreshToken.bind(oauthClient);
oauthClient.refreshToken = async (p) => {
callCount++;
await new Promise(r => setTimeout(r, 40));
return orig(p);
};
const tokens = await Promise.all(
Array.from({ length: 100 }, () => refresher.getOrRefreshUserToken(organizerId))
);
expect(callCount).toBe(1);
expect(tokens).toHaveLength(100);
expect(new Set(tokens).size).toBe(1); // all same token
expect(tokens[0]).toMatch(/mock_refreshed_access_token_/);
});
// ─── Test 2: 50 concurrent expired requests → exactly 1 refresh ──────────────
it('50 concurrent expired token requests trigger exactly 1 refresh HTTP call', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault);
let callCount = 0;
const orig = oauthClient.refreshToken.bind(oauthClient);
oauthClient.refreshToken = async (p) => {
callCount++;
await new Promise(r => setTimeout(r, 30));
return orig(p);
};
const tokens = await Promise.all(
Array.from({ length: 50 }, () => refresher.getOrRefreshUserToken(organizerId))
);
expect(callCount).toBe(1);
expect(tokens.every(t => t === tokens[0])).toBe(true);
});
// ─── Test 3: Late caller joins existing flight before cleanup ─────────────────
it('late caller arriving while flight is resolving joins existing flight (no extra call)', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault);
let callCount = 0;
let resolveRefresh!: () => void;
const refreshBarrier = new Promise<void>(res => { resolveRefresh = res; });
const orig = oauthClient.refreshToken.bind(oauthClient);
oauthClient.refreshToken = async (p) => {
callCount++;
await refreshBarrier;
return orig(p);
};
// Start first caller — it will block on the barrier
const first = refresher.getOrRefreshUserToken(organizerId);
// Give microtask loop time to register the flight
await new Promise(r => setTimeout(r, 10));
// Late second caller — should join the in-flight promise
const second = refresher.getOrRefreshUserToken(organizerId);
// Unblock the refresh
resolveRefresh();
const [t1, t2] = await Promise.all([first, second]);
expect(callCount).toBe(1);
expect(t1).toBe(t2);
});
// ─── Test 4: Refresh failure releases the flight (no deadlock) ───────────────
it('refresh failure clears the flight so the next caller can start a fresh attempt', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault);
oauthClient.shouldFailRefresh = true;
await expect(refresher.getOrRefreshUserToken(organizerId)).rejects.toThrow(
VkReauthenticationRequiredError
);
// Flight must be cleared after failure
oauthClient.shouldFailRefresh = false;
const token = await refresher.getOrRefreshUserToken(organizerId);
expect(token).toMatch(/mock_refreshed_access_token_/);
});
// ─── Test 5: User A and B refresh independently (flights isolated per userId) ─
it('concurrent expired token refresh for two different users executes 2 independent refreshes', async () => {
const idA = await makeExpiredUser(userRepo, tokenVault, { vkUserId: '11111111' });
const idB = await makeExpiredUser(userRepo, tokenVault, { vkUserId: '22222222' });
let callCount = 0;
const orig = oauthClient.refreshToken.bind(oauthClient);
oauthClient.refreshToken = async (p) => {
callCount++;
await new Promise(r => setTimeout(r, 20));
const res = await orig(p);
delete res.user_id;
return res;
};
const [tokenA, tokenB] = await Promise.all([
refresher.getOrRefreshUserToken(idA),
refresher.getOrRefreshUserToken(idB),
]);
expect(callCount).toBe(2); // one per user
expect(tokenA).toBeDefined();
expect(tokenB).toBeDefined();
});
// ─── Test 6: null expiresAt + refresh token → forces refresh ─────────────────
it('null expiresAt with refresh token forces a refresh (conservative policy)', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault, { noExpiry: true });
let callCount = 0;
const orig = oauthClient.refreshToken.bind(oauthClient);
oauthClient.refreshToken = async (p) => { callCount++; return orig(p); };
const token = await refresher.getOrRefreshUserToken(organizerId);
expect(callCount).toBe(1);
expect(token).toMatch(/mock_refreshed_access_token_/);
});
// ─── Test 7: null expiresAt without refresh token → ReauthRequired ───────────
it('null expiresAt without refresh token throws VkReauthenticationRequiredError', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault, {
noExpiry: true,
hasRefreshToken: false,
});
await expect(refresher.getOrRefreshUserToken(organizerId)).rejects.toThrow(
VkReauthenticationRequiredError
);
});
// ─── Test 8: Identity mismatch → security error, DB NOT written ──────────────
it('identity mismatch in refresh response throws security error and does not persist tokens', async () => {
// User stored with vkUserId 12345678
const organizerId = await makeExpiredUser(userRepo, tokenVault, { vkUserId: '12345678' });
// But the OAuth mock will return user_id 99999999 (different user)
oauthClient.mockUserId = 99999999;
await expect(refresher.getOrRefreshUserToken(organizerId)).rejects.toThrow(
VkReauthenticationRequiredError
);
// Verify DB credential was NOT overwritten — still has old encrypted token
const cred = await userRepo.getUserCredentials(organizerId);
const storedToken = await tokenVault.decrypt(cred!.encryptedAccessToken);
expect(storedToken).toBe('old_access_token'); // unchanged
});
// ─── Test 9: Stale refresh cannot overwrite newer credential (CAS) ────────────
it('stale refresh result does not overwrite a newer credential created by re-login', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault, { vkUserId: '12345678' });
let resolveRefresh!: () => void;
const refreshBarrier = new Promise<void>(res => { resolveRefresh = res; });
const orig = oauthClient.refreshToken.bind(oauthClient);
oauthClient.refreshToken = async (p) => {
await refreshBarrier; // hold until we simulate re-login
return orig(p);
};
// Start refresh (will block)
const refreshPromise = refresher.getOrRefreshUserToken(organizerId);
await new Promise(r => setTimeout(r, 10));
// Simulate re-login: overwrite credential with a newer version
const newAccessToken = await tokenVault.encrypt('brand_new_login_token');
const newRefreshToken = await tokenVault.encrypt('brand_new_refresh_token');
await userRepo.upsertUserWithTokens({
vkUserId: '12345678',
encryptedAccessToken: newAccessToken,
encryptedRefreshToken: newRefreshToken,
expiresIn: 86400, // fresh
});
// Unblock the original refresh
resolveRefresh();
const refreshedToken = await refreshPromise;
// The refresh still returns the freshly-computed token (valid)
expect(refreshedToken).toMatch(/mock_refreshed_access_token_/);
// But the DB must retain the newer login credential, not the stale refresh result
const cred = await userRepo.getUserCredentials(organizerId);
const dbToken = await tokenVault.decrypt(cred!.encryptedAccessToken);
expect(dbToken).toBe('brand_new_login_token'); // re-login wins
});
// ─── Test 10: Rotated refresh token is persisted ─────────────────────────────
it('new refresh_token in response is persisted (rotation)', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault);
const credBefore = await userRepo.getUserCredentials(organizerId);
const oldRefreshPlaintext = await tokenVault.decrypt(credBefore!.encryptedRefreshToken!);
await refresher.getOrRefreshUserToken(organizerId);
const credAfter = await userRepo.getUserCredentials(organizerId);
const newRefreshPlaintext = await tokenVault.decrypt(credAfter!.encryptedRefreshToken!);
expect(newRefreshPlaintext).toMatch(/mock_new_refresh_token_/);
expect(newRefreshPlaintext).not.toBe(oldRefreshPlaintext);
});
// ─── Test 10b: When VK omits refresh_token, old token is retained ────────────
it('when refresh response omits refresh_token, old refresh token is retained', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault);
const credBefore = await userRepo.getUserCredentials(organizerId);
const oldRefreshEncrypted = credBefore!.encryptedRefreshToken!;
oauthClient.shouldReturnNoRefreshToken = true;
await refresher.getOrRefreshUserToken(organizerId);
const credAfter = await userRepo.getUserCredentials(organizerId);
// Should still have a refresh token (the old one retained)
expect(credAfter!.encryptedRefreshToken).toBeDefined();
// Decrypt and verify it's still the old refresh token value
// (The old encrypted token was kept as-is, same ciphertext)
expect(credAfter!.encryptedRefreshToken).toBe(oldRefreshEncrypted);
});
// ─── Test 11: Temporary/network refresh failure preserves old DB credential ───
it('network error during refresh does not corrupt or delete the existing credential', async () => {
const organizerId = await makeExpiredUser(userRepo, tokenVault);
const credBefore = await userRepo.getUserCredentials(organizerId);
oauthClient.shouldFailRefreshWithNetwork = true;
await expect(refresher.getOrRefreshUserToken(organizerId)).rejects.toThrow(VkNetworkError);
// DB credential must be untouched
const credAfter = await userRepo.getUserCredentials(organizerId);
expect(credAfter!.encryptedAccessToken).toBe(credBefore!.encryptedAccessToken);
expect(credAfter!.encryptedRefreshToken).toBe(credBefore!.encryptedRefreshToken);
expect(credAfter!.expiresAt?.getTime()).toBe(credBefore!.expiresAt?.getTime());
});
// ─── Test 12: Partial SERVICE pagination → complete USER restart ──────────────
it('partial SERVICE import followed by USER fallback is a complete restart (no result append)', async () => {
// This test verifies that executeFetchParticipants always starts with a fresh map.
// We test it indirectly: mock VkProvider behavior at the provider level.
// The key assertion is that executeFetchParticipants creates a new Map each call.
// Import VkProvider and related mocks
const { VkProvider } = await import('../src/providers/vk/vk-provider');
const { VkPrivateResourceError } = await import('../src/integrations/vk/vk-errors');
let serviceCallCount = 0;
let userCallCount = 0;
// Mock VkClient
const mockClient = {
call: vi.fn(async (method: string, _params: unknown, auth: { type: string }) => {
if (method === 'likes.getList') {
if (auth.type === 'SERVICE') {
serviceCallCount++;
if (serviceCallCount === 1) {
// First page succeeds (100 items so it doesn't break)
return { items: Array.from({length: 100}, (_, i) => ({ id: i+1, first_name: 'A', last_name: 'B', screen_name: 'a' })), count: 200 };
}
// Second page fails (private)
throw new VkPrivateResourceError('Private post');
} else {
userCallCount++;
// User token fetches fresh result (2 items, different from SERVICE)
return { items: [{ id: 99, first_name: 'X', last_name: 'Y', screen_name: 'x' }], count: 1 };
}
}
return {};
}),
};
// Mock auth resolver to return USER context on fallback
const mockResolver = {
resolveAuthContext: vi.fn().mockResolvedValue({ type: 'SERVICE', token: 'svc_token' }),
resolveUserFallbackContext: vi.fn().mockResolvedValue({ type: 'USER', token: 'usr_token' }),
};
const provider = new VkProvider('svc_token', mockClient as any, mockResolver as any);
const results = await provider.fetchParticipants({
platform: 'VK',
ownerId: '-123',
postId: '456',
organizerId: 'org1',
includeLikes: true,
});
// USER restart: only the user result (id=99) should appear, not SERVICE partial (id=1)
expect(results).toHaveLength(1);
expect(results[0].platformUserId).toBe('99');
expect(results.some(r => r.platformUserId === '1')).toBe(false);
});
});