fix(auth): Phase 2.3.1.1 truthful giveaway detail capability based on credential status

This commit is contained in:
Ochenstarik 2026-08-18 15:03:15 +07:00
parent 0ce91d7b0d
commit 9927e74421
5 changed files with 328 additions and 21 deletions

View file

@ -0,0 +1,17 @@
# Task Done: Phase 2.3.1.1 — Giveaway Detail Capability Truthfulness
**Status:** DONE
**Assigned to:** Antigravity (@orchestrator)
**Date:** 2026-08-18
**Base Commit:** `4e4370d`
## Summary of Changes
- Implemented `getCredentialStatus(userId: string)` in `TokenRefresher` with exact states:
- `AVAILABLE`: valid, non-expired USER access token present.
- `REFRESHABLE`: expired or unknown expiry, but refresh token is present.
- `REAUTH_REQUIRED`: expired or unknown expiry, without refresh token.
- `MISSING`: no credentials stored for user.
- Updated `GET /api/giveaways/[id]` to query `defaultTokenRefresher.getCredentialStatus(sessionUser.id)` without performing network calls or leaking tokens into response.
- `resolveEffectiveCapabilities` assigns `accessMode: 'ORGANIZER_USER'` only when `status` is `AVAILABLE` or `REFRESHABLE`. For `MISSING` or `REAUTH_REQUIRED`, it strictly defaults to `PUBLIC_SERVICE`.
- Expanded `tests/effective-capabilities-truthfulness.test.ts` to 11 tests covering all credential states, expiration boundaries, refresh token presence, and token secrecy.
- All 273 tests passing across 47 test files (100% green).

View file

@ -4,7 +4,7 @@ import { generalApiRateLimiter } from '@/lib/rate-limiter';
import { resolveClientIp } from '@/lib/client-ip';
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
import { resolveEffectiveCapabilities } from '@/providers/vk/vk-capabilities';
import { defaultUserRepository } from '@/lib/repository/user-repository';
import { defaultTokenRefresher } from '@/lib/auth/token-refresher';
export const dynamic = 'force-dynamic';
@ -20,16 +20,17 @@ export async function GET(
// Enforce giveaway ownership authorization
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
// Resolve runtime effective capabilities truthfully based on stored organizer credentials
let authType: 'SERVICE' | 'USER' = 'SERVICE';
// Resolve runtime effective capabilities truthfully based on stored organizer credential status
let credentialStatus: 'AVAILABLE' | 'REFRESHABLE' | 'REAUTH_REQUIRED' | 'MISSING' = 'MISSING';
if (sessionUser?.id) {
const cred = await defaultUserRepository.getUserCredentials(sessionUser.id);
if (cred?.encryptedAccessToken) {
authType = 'USER';
}
credentialStatus = await defaultTokenRefresher.getCredentialStatus(sessionUser.id);
}
const effectiveCapabilities = resolveEffectiveCapabilities({ type: authType });
const isUserAuthUsable = credentialStatus === 'AVAILABLE' || credentialStatus === 'REFRESHABLE';
const effectiveCapabilities = resolveEffectiveCapabilities({
type: isUserAuthUsable ? 'USER' : 'SERVICE',
credentialStatus,
});
return NextResponse.json({
success: true,

View file

@ -31,13 +31,15 @@ import { VkReauthenticationRequiredError, VkAuthError } from '@/integrations/vk/
* - VK token refresh responses include user_id. We verify it matches the stored User.vkUserId.
* - Mismatch VkReauthenticationRequiredError. Tokens are NOT persisted.
*/
export type UserCredentialStatus = 'AVAILABLE' | 'REFRESHABLE' | 'REAUTH_REQUIRED' | 'MISSING';
export class TokenRefresher {
private inFlightRefreshes = new Map<string, Promise<string>>();
constructor(
private userRepo: IUserRepository = defaultUserRepository,
private tokenVault: ITokenVault = defaultTokenVault,
private oauthClient: IVkOAuthClient = defaultVkOAuthClient
private userRepo?: IUserRepository,
private tokenVault?: ITokenVault,
private oauthClient?: IVkOAuthClient
) {}
public setDependencies(deps: {
@ -50,12 +52,53 @@ export class TokenRefresher {
if (deps.oauthClient) this.oauthClient = deps.oauthClient;
}
private getUserRepo(): IUserRepository {
return this.userRepo || defaultUserRepository;
}
private getTokenVault(): ITokenVault {
return this.tokenVault || defaultTokenVault;
}
private getOAuthClient(): IVkOAuthClient {
return this.oauthClient || defaultVkOAuthClient;
}
/**
* Fast, server-side status check of user credentials without performing network calls
* or decrypting secrets.
*/
public async getCredentialStatus(userId: string): Promise<UserCredentialStatus> {
try {
const cred = await this.getUserRepo().getUserCredentials(userId);
if (!cred || !cred.encryptedAccessToken) {
return 'MISSING';
}
const now = Date.now();
const hasRefreshToken = Boolean(cred.encryptedRefreshToken);
if (cred.expiresAt === null || cred.expiresAt === undefined) {
return hasRefreshToken ? 'REFRESHABLE' : 'REAUTH_REQUIRED';
}
const isExpiredOrExpiring = now >= cred.expiresAt.getTime() - 30_000;
if (!isExpiredOrExpiring) {
return 'AVAILABLE';
}
return hasRefreshToken ? 'REFRESHABLE' : 'REAUTH_REQUIRED';
} catch {
return 'REAUTH_REQUIRED';
}
}
/**
* 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);
const cred = await this.getUserRepo().getUserCredentials(userId);
if (!cred || !cred.encryptedAccessToken) {
throw new VkReauthenticationRequiredError(
@ -85,7 +128,7 @@ export class TokenRefresher {
}
if (!isExpiredOrExpiring) {
return await this.tokenVault.decrypt(cred.encryptedAccessToken);
return await this.getTokenVault().decrypt(cred.encryptedAccessToken);
}
// Token is expired or expiry is unknown. Check if refresh token is available.
@ -125,11 +168,11 @@ export class TokenRefresher {
credentialUpdatedAt: Date
): Promise<string> {
try {
const refreshToken = await this.tokenVault.decrypt(encryptedRefreshToken);
const refreshToken = await this.getTokenVault().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({
const refreshResponse = await this.getOAuthClient().refreshToken({
refreshToken,
clientId,
clientSecret,
@ -143,7 +186,7 @@ export class TokenRefresher {
// --- IDENTITY BINDING ---
// Verify the refreshed token belongs to the same VK user.
const user = await this.userRepo.getUserById(userId);
const user = await this.getUserRepo().getUserById(userId);
if (!user) {
throw new VkReauthenticationRequiredError(
'User account not found during token refresh'
@ -162,11 +205,11 @@ export class TokenRefresher {
// 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);
const encryptedAccessToken = await this.getTokenVault().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);
newEncryptedRefreshToken = await this.getTokenVault().encrypt(refreshResponse.refresh_token);
} else {
// No refresh token in response — retain the previous encrypted token
newEncryptedRefreshToken = encryptedRefreshToken;
@ -181,7 +224,7 @@ export class TokenRefresher {
// 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(
const written = await this.getUserRepo().updateCredentialConditionally(
userId,
{
encryptedAccessToken,
@ -222,4 +265,8 @@ export class TokenRefresher {
}
}
export const defaultTokenRefresher = new TokenRefresher();
export let defaultTokenRefresher: TokenRefresher = new TokenRefresher();
export function setTokenRefresher(refresher: TokenRefresher): void {
defaultTokenRefresher = refresher;
}

View file

@ -1,10 +1,12 @@
import { ProviderCapabilities } from '../types';
import { VkAuthContext } from '@/integrations/vk/vk-types';
export type UserCredentialStatus = 'AVAILABLE' | 'REFRESHABLE' | 'REAUTH_REQUIRED' | 'MISSING';
export type VkAccessMode = 'PUBLIC_SERVICE' | 'ORGANIZER_USER' | 'COMMUNITY_GROUP';
export interface EffectiveCapabilities extends ProviderCapabilities {
accessMode: VkAccessMode;
credentialStatus?: UserCredentialStatus;
}
export const STATIC_VK_CAPABILITIES: ProviderCapabilities = {
@ -20,7 +22,9 @@ export const STATIC_VK_CAPABILITIES: ProviderCapabilities = {
/**
* Derives effective capabilities at runtime based on the resolved auth context and target resource.
*/
export function resolveEffectiveCapabilities(authContext?: { type: 'SERVICE' | 'USER' | 'COMMUNITY' } | VkAuthContext | null): EffectiveCapabilities {
export function resolveEffectiveCapabilities(
authContext?: { type: 'SERVICE' | 'USER' | 'COMMUNITY'; credentialStatus?: UserCredentialStatus } | VkAuthContext | null
): EffectiveCapabilities {
const accessMode: VkAccessMode = !authContext || authContext.type === 'SERVICE'
? 'PUBLIC_SERVICE'
: authContext.type === 'USER'
@ -28,6 +32,7 @@ export function resolveEffectiveCapabilities(authContext?: { type: 'SERVICE' | '
: 'COMMUNITY_GROUP';
const isCommunityAdmin = authContext?.type === 'COMMUNITY';
const credentialStatus = authContext && 'credentialStatus' in authContext ? authContext.credentialStatus : undefined;
return {
...STATIC_VK_CAPABILITIES,
@ -36,5 +41,6 @@ export function resolveEffectiveCapabilities(authContext?: { type: 'SERVICE' | '
? undefined
: 'Требует прямого подключения токена сообщества с правами администратора',
accessMode,
...(credentialStatus ? { credentialStatus } : {}),
};
}

View file

@ -327,6 +327,7 @@ describe('Phase 2.3.1 — Effective Capabilities Truthfulness Gate', () => {
expect(resAlice.status).toBe(200);
expect(jsonAlice.effectiveCapabilities.accessMode).toBe('ORGANIZER_USER');
expect(jsonAlice.effectiveCapabilities.credentialStatus).toBe('AVAILABLE');
// 6b. Create giveaway for Charlie (no credentials)
const gwCharlie = await GiveawayStore.create({
@ -359,5 +360,240 @@ describe('Phase 2.3.1 — Effective Capabilities Truthfulness Gate', () => {
expect(resCharlie.status).toBe(200);
expect(jsonCharlie.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE');
expect(jsonCharlie.effectiveCapabilities.credentialStatus).toBe('MISSING');
});
// ─── Test 7: Expired credential + refresh token available → ORGANIZER_USER (REFRESHABLE) ─
it('7. expired credential + refresh token reports ORGANIZER_USER with REFRESHABLE status', async () => {
const encryptedAccessToken = await tokenVault.encrypt('old_token');
const encryptedRefreshToken = await tokenVault.encrypt('valid_refresh_token');
const expiredUser = await userRepo.upsertUserWithTokens({
vkUserId: '55556666',
firstName: 'David',
lastName: 'Refreshable',
encryptedAccessToken,
encryptedRefreshToken,
expiresIn: -60, // expired 1 minute ago
});
const sessionId = await defaultSessionStore.createSession(expiredUser);
const cookie = `${SESSION_COOKIE_NAME}=${sessionId}`;
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-555_555',
platform: 'VK',
platformOwnerId: '-555',
platformPostId: '555',
title: 'David Giveaway',
organizerId: expiredUser.id,
post: {
platform: 'VK',
ownerId: '-555',
postId: '555',
sourceUrl: 'https://vk.com/wall-555_555',
title: 'David Giveaway',
text: 'Text',
likesCount: 5,
commentsCount: 1,
repostsCount: 0,
},
});
const req = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}`, {
method: 'GET',
headers: { 'Cookie': cookie },
});
const res = await giveawayDetailGet(req, { params: { id: gw.id } });
const json = await res.json();
expect(res.status).toBe(200);
expect(json.effectiveCapabilities.accessMode).toBe('ORGANIZER_USER');
expect(json.effectiveCapabilities.credentialStatus).toBe('REFRESHABLE');
});
// ─── Test 8: Expired credential WITHOUT refresh token → PUBLIC_SERVICE (REAUTH_REQUIRED) ─
it('8. expired credential without refresh token reports PUBLIC_SERVICE with REAUTH_REQUIRED status', async () => {
const encryptedAccessToken = await tokenVault.encrypt('old_token_no_refresh');
const expiredNoRefreshUser = await userRepo.upsertUserWithTokens({
vkUserId: '77779999',
firstName: 'Eve',
lastName: 'NoRefresh',
encryptedAccessToken,
expiresIn: -60, // expired 1 minute ago
});
const sessionId = await defaultSessionStore.createSession(expiredNoRefreshUser);
const cookie = `${SESSION_COOKIE_NAME}=${sessionId}`;
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-777_777',
platform: 'VK',
platformOwnerId: '-777',
platformPostId: '777',
title: 'Eve Giveaway',
organizerId: expiredNoRefreshUser.id,
post: {
platform: 'VK',
ownerId: '-777',
postId: '777',
sourceUrl: 'https://vk.com/wall-777_777',
title: 'Eve Giveaway',
text: 'Text',
likesCount: 5,
commentsCount: 1,
repostsCount: 0,
},
});
const req = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}`, {
method: 'GET',
headers: { 'Cookie': cookie },
});
const res = await giveawayDetailGet(req, { params: { id: gw.id } });
const json = await res.json();
expect(res.status).toBe(200);
// Must NOT claim ORGANIZER_USER
expect(json.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE');
expect(json.effectiveCapabilities.credentialStatus).toBe('REAUTH_REQUIRED');
});
// ─── Test 9: Null expiresAt WITHOUT refresh token → PUBLIC_SERVICE (REAUTH_REQUIRED) ─
it('9. null expiresAt without refresh token reports PUBLIC_SERVICE with REAUTH_REQUIRED status', async () => {
const encryptedAccessToken = await tokenVault.encrypt('legacy_token');
const legacyUser = await userRepo.upsertUserWithTokens({
vkUserId: '88880000',
firstName: 'Frank',
lastName: 'LegacyNoExpiry',
encryptedAccessToken,
// expiresIn: undefined -> expiresAt: null
});
const sessionId = await defaultSessionStore.createSession(legacyUser);
const cookie = `${SESSION_COOKIE_NAME}=${sessionId}`;
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-888_888',
platform: 'VK',
platformOwnerId: '-888',
platformPostId: '888',
title: 'Frank Giveaway',
organizerId: legacyUser.id,
post: {
platform: 'VK',
ownerId: '-888',
postId: '888',
sourceUrl: 'https://vk.com/wall-888_888',
title: 'Frank Giveaway',
text: 'Text',
likesCount: 5,
commentsCount: 1,
repostsCount: 0,
},
});
const req = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}`, {
method: 'GET',
headers: { 'Cookie': cookie },
});
const res = await giveawayDetailGet(req, { params: { id: gw.id } });
const json = await res.json();
expect(res.status).toBe(200);
expect(json.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE');
expect(json.effectiveCapabilities.credentialStatus).toBe('REAUTH_REQUIRED');
});
// ─── Test 10: Null expiresAt WITH refresh token → ORGANIZER_USER (REFRESHABLE) ─
it('10. null expiresAt with refresh token reports ORGANIZER_USER with REFRESHABLE status', async () => {
const encryptedAccessToken = await tokenVault.encrypt('legacy_token');
const encryptedRefreshToken = await tokenVault.encrypt('valid_refresh_token');
const legacyRefreshUser = await userRepo.upsertUserWithTokens({
vkUserId: '99991111',
firstName: 'Grace',
lastName: 'LegacyRefreshable',
encryptedAccessToken,
encryptedRefreshToken,
// expiresIn: undefined -> expiresAt: null
});
const sessionId = await defaultSessionStore.createSession(legacyRefreshUser);
const cookie = `${SESSION_COOKIE_NAME}=${sessionId}`;
const gw = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-999_999',
platform: 'VK',
platformOwnerId: '-999',
platformPostId: '999',
title: 'Grace Giveaway',
organizerId: legacyRefreshUser.id,
post: {
platform: 'VK',
ownerId: '-999',
postId: '999',
sourceUrl: 'https://vk.com/wall-999_999',
title: 'Grace Giveaway',
text: 'Text',
likesCount: 5,
commentsCount: 1,
repostsCount: 0,
},
});
const req = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}`, {
method: 'GET',
headers: { 'Cookie': cookie },
});
const res = await giveawayDetailGet(req, { params: { id: gw.id } });
const json = await res.json();
expect(res.status).toBe(200);
expect(json.effectiveCapabilities.accessMode).toBe('ORGANIZER_USER');
expect(json.effectiveCapabilities.credentialStatus).toBe('REFRESHABLE');
});
// ─── Test 11: Giveaway detail does not leak tokens or secret fields ──────────
it('11. giveaway detail response never leaks token or secret fields', async () => {
const gwAlice = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-101_101',
platform: 'VK',
platformOwnerId: '-101',
platformPostId: '101',
title: 'Alice Giveaway',
organizerId: loggedInUser.id,
post: {
platform: 'VK',
ownerId: '-101',
postId: '101',
sourceUrl: 'https://vk.com/wall-101_101',
title: 'Alice Giveaway',
text: 'Text',
likesCount: 10,
commentsCount: 2,
repostsCount: 0,
},
});
const reqAlice = new NextRequest(`http://localhost:3000/api/giveaways/${gwAlice.id}`, {
method: 'GET',
headers: { 'Cookie': sessionCookie },
});
const resAlice = await giveawayDetailGet(reqAlice, { params: { id: gwAlice.id } });
const rawText = await resAlice.text();
expect(rawText).not.toContain(userTokenPlain);
expect(rawText).not.toContain(serviceTokenPlain);
expect(rawText).not.toContain('encryptedAccessToken');
expect(rawText).not.toContain('encryptedRefreshToken');
expect(rawText).not.toContain('tokenVault');
});
});