온라인 인증 시도와 처리 비용 및 Kakao 응답 경계를 제한한다

This commit is contained in:
2026-10-09 03:43:59 +09:00
parent 9dceaf8e4e
commit 3d9f7392df
12 changed files with 874 additions and 25 deletions
+187
View File
@@ -0,0 +1,187 @@
import { createHmac } from 'node:crypto';
export interface AuthCounter {
key: string;
limit: number;
windowMs: number;
}
export interface AuthCounterStore {
readonly available?: boolean;
/** Atomically check every counter, then charge them only when accepted. */
consume(counters: AuthCounter[]): Promise<number>;
}
export class AuthBudgetError extends Error {
constructor(
readonly reason: 'limited' | 'unavailable',
readonly retryAfterSeconds = 1
) {
super(
reason === 'limited'
? 'Authentication attempts are temporarily limited.'
: 'Authentication protection is unavailable.'
);
}
}
const CONSUME_SCRIPT = `
local retry = 0
for i, key in ipairs(KEYS) do
local count = tonumber(redis.call('GET', key) or '0')
if count >= tonumber(ARGV[i * 2 - 1]) then
local ttl = redis.call('PTTL', key)
if ttl < 0 then
return -1
end
retry = math.max(retry, math.max(ttl, 1))
end
end
if retry > 0 then return retry end
for i, key in ipairs(KEYS) do
local count = redis.call('INCR', key)
if count == 1 then redis.call('PEXPIRE', key, ARGV[i * 2]) end
end
return 0
`;
export class RedisAuthCounterStore implements AuthCounterStore {
constructor(
private readonly client: {
readonly isReady?: boolean;
withAbortSignal?: (signal: AbortSignal) => {
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
};
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
}
) {}
get available(): boolean {
return this.client.isReady !== false;
}
async consume(counters: AuthCounter[]): Promise<number> {
if (this.client.isReady === false) throw new AuthBudgetError('unavailable');
const client = this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
const result = await client.eval(CONSUME_SCRIPT, {
keys: counters.map((counter) => counter.key),
arguments: counters.flatMap((counter) => [String(counter.limit), String(counter.windowMs)]),
});
if (typeof result !== 'number' || !Number.isFinite(result) || result < 0) {
throw new AuthBudgetError('unavailable');
}
return result;
}
}
/** Direct callers/tests have a bounded local store; the HTTP server always injects Redis. */
export class InMemoryAuthCounterStore implements AuthCounterStore {
private readonly entries = new Map<string, { count: number; expiresAt: number }>();
async consume(counters: AuthCounter[]): Promise<number> {
const now = Date.now();
for (const [key, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(key);
if (this.entries.size + counters.filter((counter) => !this.entries.has(counter.key)).length > 8192) {
throw new AuthBudgetError('unavailable');
}
let retryMs = 0;
for (const counter of counters) {
const entry = this.entries.get(counter.key);
if (entry && entry.count >= counter.limit) retryMs = Math.max(retryMs, entry.expiresAt - now);
}
if (retryMs > 0) return retryMs;
for (const counter of counters) {
const entry = this.entries.get(counter.key) ?? { count: 0, expiresAt: now + counter.windowMs };
entry.count += 1;
this.entries.set(counter.key, entry);
}
return 0;
}
}
const EXPENSIVE_ACTIONS = new Set([
'auth.bootstrapLocal',
'auth.kakaoExchange',
'auth.kakaoResolveAccount',
'auth.kakaoSetPassword',
'auth.register',
'auth.registerLocal',
'auth.login',
'auth.kakaoOtp',
'account.changePassword',
'account.scheduleDeletion',
]);
const PUBLIC_ACTIONS = new Set(['auth.passwordKey', 'auth.checkRegistrationField', 'auth.kakaoStart']);
export const isBudgetedAuthAction = (action: string): boolean =>
EXPENSIVE_ACTIONS.has(action) || PUBLIC_ACTIONS.has(action);
export class AuthAttemptBudget {
private active = 0;
private pendingChecks = 0;
constructor(
private readonly store: AuthCounterStore,
private readonly prefix: string,
private readonly secret: string
) {}
private key(kind: string, value: string): string {
const digest = createHmac('sha256', this.secret).update(`auth-budget-v1:${kind}:${value}`).digest('hex');
return `${this.prefix}:auth-budget:v1:${kind}:${digest}`;
}
async run<T>(
action: string,
client: string,
subject: string | undefined | (() => Promise<string | undefined>),
work: () => Promise<T>
): Promise<T> {
if (this.store.available === false || this.pendingChecks >= 32) throw new AuthBudgetError('unavailable');
const expensive = EXPENSIVE_ACTIONS.has(action);
if (expensive && this.active >= 4) throw new AuthBudgetError('limited');
if (expensive) this.active += 1;
try {
const consume = async (): Promise<number> => {
const resolvedSubject = typeof subject === 'function' ? await subject() : subject;
const counters: AuthCounter[] = [
{ key: this.key('global', 'all'), limit: 1200, windowMs: 60_000 },
{ key: this.key('client', client), limit: 600, windowMs: 60_000 },
];
if (resolvedSubject)
counters.push({ key: this.key('subject', resolvedSubject), limit: 10, windowMs: 60_000 });
return this.store.consume(counters);
};
this.pendingChecks += 1;
const checking = consume();
// A caller timeout does not prove the underlying Redis/actor read settled.
void checking.then(
() => {
this.pendingChecks -= 1;
},
() => {
this.pendingChecks -= 1;
}
);
let timeout: ReturnType<typeof setTimeout> | undefined;
let retryMs: number;
try {
retryMs = await Promise.race([
checking,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new AuthBudgetError('unavailable')), 2000);
timeout.unref();
}),
]);
} catch {
throw new AuthBudgetError('unavailable');
} finally {
clearTimeout(timeout);
}
if (retryMs > 0) throw new AuthBudgetError('limited', Math.max(1, Math.ceil(retryMs / 1000)));
return await work();
} finally {
if (expensive) this.active -= 1;
}
}
}
+56 -15
View File
@@ -4,6 +4,7 @@ export interface KakaoOAuthConfig {
redirectUri: string;
oauthHost?: string;
apiHost?: string;
requestTimeoutMs?: number;
}
export interface KakaoOAuthToken {
@@ -52,12 +53,57 @@ export class KakaoOAuthClient {
private readonly redirectUri: string;
private readonly oauthHost: string;
private readonly apiHost: string;
private readonly requestTimeoutMs: number;
constructor(config: KakaoOAuthConfig) {
this.restKey = config.restKey;
this.redirectUri = config.redirectUri;
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
this.apiHost = config.apiHost ?? 'https://kapi.kakao.com';
this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000;
if (!Number.isInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 50 || this.requestTimeoutMs > 10_000) {
throw new Error('Kakao request timeout must be between 50 and 10000 milliseconds.');
}
}
private async requestJson(
url: URL,
init: RequestInit
): Promise<{ response: Response; payload: Record<string, unknown> }> {
try {
const response = await fetch(url, {
...init,
redirect: 'error',
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
if (!response.body) throw new Error('Missing response body.');
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024) {
void reader.cancel().catch(() => undefined);
throw new Error('Response is too large.');
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
const payload: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
);
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
throw new Error('Invalid response shape.');
return { response, payload: payload as Record<string, unknown> };
} catch {
// Provider/network payloads can include access tokens or account details.
throw new Error('Kakao HTTP request failed.');
}
}
buildAuthUrl(state: string, scopes: string[]): string {
@@ -73,7 +119,7 @@ export class KakaoOAuthClient {
}
async exchangeCode(code: string): Promise<KakaoOAuthToken> {
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -85,15 +131,14 @@ export class KakaoOAuthClient {
code,
}),
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`);
throw new Error('Kakao OAuth token error.');
}
return parseToken(payload);
}
async refreshToken(refreshToken: string): Promise<KakaoOAuthToken> {
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -104,27 +149,25 @@ export class KakaoOAuthClient {
refresh_token: refreshToken,
}),
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`);
throw new Error('Kakao OAuth refresh error.');
}
return parseToken(payload);
}
async signup(accessToken: string): Promise<KakaoSignupResult> {
const response = await fetch(new URL('/v1/user/signup', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v1/user/signup', this.apiHost), {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok && payload.code === -102 && payload.msg === 'already registered') {
return {
alreadyRegistered: true,
};
}
if (!response.ok) {
throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`);
throw new Error('Kakao signup error.');
}
return {
id: payload.id ? String(payload.id) : undefined,
@@ -133,15 +176,14 @@ export class KakaoOAuthClient {
}
async getMe(accessToken: string): Promise<KakaoUserInfo> {
const response = await fetch(new URL('/v2/user/me', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v2/user/me', this.apiHost), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao me error: ${JSON.stringify(payload)}`);
throw new Error('Kakao me error.');
}
const kakaoAccount = (payload.kakao_account ?? {}) as Record<string, unknown>;
return {
@@ -156,7 +198,7 @@ export class KakaoOAuthClient {
}
async sendTalkMessage(accessToken: string, message: string, link: string): Promise<void> {
const response = await fetch(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -174,10 +216,9 @@ export class KakaoOAuthClient {
}),
}),
});
const payload = (await response.json()) as Record<string, unknown>;
const code = Number(payload.code ?? 0);
if (!response.ok || code < 0) {
throw new Error(`Kakao talk message error: ${JSON.stringify(payload)}`);
throw new Error('Kakao talk message error.');
}
}
}
+9
View File
@@ -1,3 +1,4 @@
import { AuthAttemptBudget, InMemoryAuthCounterStore } from './auth/attemptBudget.js';
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
import type { GatewaySessionService } from './auth/sessionService.js';
import type { UserRepository } from './auth/userRepository.js';
@@ -40,6 +41,8 @@ export interface GatewayApiContext {
releases: GatewayReleaseRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
authBudget: AuthAttemptBudget;
requestIp: string;
requestHeaders: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit: AdminAuditStore;
@@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: {
releases?: GatewayReleaseRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
authBudget?: AuthAttemptBudget;
requestIp?: string;
requestHeaders?: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit?: AdminAuditStore;
@@ -95,6 +100,10 @@ export const createGatewayApiContext = (options: {
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
orchestrator: options.orchestrator,
profileStatus: options.profileStatus,
authBudget:
options.authBudget ??
new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'direct-caller', options.gameTokenSecret),
requestIp: options.requestIp ?? 'unknown',
requestHeaders: options.requestHeaders ?? {},
prisma: options.prisma,
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
+10
View File
@@ -25,6 +25,7 @@ import { createPasswordEnvelopeService } from './auth/passwordEnvelope.js';
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRepository.js';
import { AuthAttemptBudget, RedisAuthCounterStore } from './auth/attemptBudget.js';
import { appRouter } from './router.js';
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
@@ -66,6 +67,11 @@ export const createGatewayApiServer = async () => {
sessionTtlSeconds: config.sessionTtlSeconds,
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
});
const authBudget = new AuthAttemptBudget(
new RedisAuthCounterStore(redis.client),
config.redisKeyPrefix,
config.gameTokenSecret
);
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
const kakaoClient = new KakaoOAuthClient({
restKey: config.kakaoRestKey,
@@ -91,6 +97,8 @@ export const createGatewayApiServer = async () => {
);
const app = fastify({
logger: safeHttpLoggerOptions,
// Count the actual peer; forwarded headers are untrusted without an explicit proxy policy.
trustProxy: false,
routerOptions: gatewayFastifyRouterOptions,
});
app.setErrorHandler(safeHttpErrorHandler);
@@ -154,6 +162,8 @@ export const createGatewayApiServer = async () => {
releases,
orchestrator,
profileStatus,
authBudget,
requestIp: req.ip,
requestHeaders: req.headers,
prisma: postgres.prisma as GatewayPrismaClient,
navigationConfig,
+29 -2
View File
@@ -1,9 +1,36 @@
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
import { initTRPC } from '@trpc/server';
import { AuthBudgetError, isBudgetedAuthAction } from './auth/attemptBudget.js';
import { initTRPC, TRPCError } from '@trpc/server';
import type { GatewayApiContext } from './context.js';
const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
export const router = t.router;
export const procedure = t.procedure;
export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }) => {
if (!isBudgetedAuthAction(path)) return next();
const raw: unknown = await getRawInput();
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const resolveSubject = async (): Promise<string | undefined> => {
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
}
if (path.startsWith('account.') && typeof input.sessionToken === 'string' && input.sessionToken.length <= 256) {
const session = await ctx.sessions.getSession(input.sessionToken);
if (session) return `account:${session.userId}`;
}
return undefined;
};
try {
return await ctx.authBudget.run(path, ctx.requestIp, resolveSubject, () => next());
} catch (error) {
if (!(error instanceof AuthBudgetError)) throw error;
throw new TRPCError({
code: error.reason === 'limited' ? 'TOO_MANY_REQUESTS' : 'SERVICE_UNAVAILABLE',
message:
error.reason === 'limited'
? `인증 요청이 많습니다. ${error.retryAfterSeconds}초 후 다시 시도해 주세요.`
: '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.',
});
}
});
@@ -0,0 +1,53 @@
import { randomUUID } from 'node:crypto';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('Redis authentication budget atomicity', () => {
const client = createClient({ url: redisUrl });
const prefix = `auth-budget-test:${randomUUID()}`;
const store = new RedisAuthCounterStore(client);
const keys = ['subject', 'global', 'expires', 'invalid'].map((kind) => `${prefix}:${kind}`);
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
await client.del(keys);
await client.quit();
});
it('admits exactly ten concurrent attempts and does not charge other buckets on rejection', async () => {
const attempts = await Promise.all(
Array.from({ length: 100 }, () =>
store.consume([
{ key: keys[0]!, limit: 10, windowMs: 60_000 },
{ key: keys[1]!, limit: 1000, windowMs: 60_000 },
])
)
);
expect(attempts.filter((retry) => retry === 0)).toHaveLength(10);
expect(attempts.filter((retry) => retry > 0)).toHaveLength(90);
expect(await client.get(keys[0]!)).toBe('10');
expect(await client.get(keys[1]!)).toBe('10');
expect(await client.pTTL(keys[0]!)).toBeGreaterThan(0);
});
it('keeps the original expiry under repeated rejected attempts and permits recovery', async () => {
const counters = [{ key: keys[2]!, limit: 1, windowMs: 120 }];
expect(await store.consume(counters)).toBe(0);
const before = await client.pTTL(keys[2]!);
expect(await store.consume(counters)).toBeGreaterThan(0);
expect(await client.pTTL(keys[2]!)).toBeLessThanOrEqual(before);
await expect.poll(() => client.exists(keys[2]!), { interval: 20, timeout: 1000 }).toBe(0);
expect(await store.consume(counters)).toBe(0);
});
it('rejects a malformed counter without an expiry instead of permanently locking the account', async () => {
await client.set(keys[3]!, '10');
await expect(store.consume([{ key: keys[3]!, limit: 10, windowMs: 60_000 }])).rejects.toMatchObject({
reason: 'unavailable',
});
});
});
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthAttemptBudget, AuthBudgetError, InMemoryAuthCounterStore } from '../src/auth/attemptBudget.js';
describe('authentication attempt and resource budgets', () => {
afterEach(() => {
vi.useRealTimers();
});
it('shares a login subject across clients without exposing account or address in keys', async () => {
const store = new InMemoryAuthCounterStore();
const consume = vi.spyOn(store, 'consume');
const budget = new AuthAttemptBudget(store, 'fixture', 'fixture-secret');
for (let i = 0; i < 10; i++)
await budget.run('auth.login', `client-${i}`, 'login:fixture-user', async () => 'ok');
const work = vi.fn(async () => 'must not run');
await expect(budget.run('auth.login', 'another-client', 'login:fixture-user', work)).rejects.toMatchObject({
reason: 'limited',
retryAfterSeconds: 60,
});
expect(work).not.toHaveBeenCalled();
expect(JSON.stringify(consume.mock.calls)).not.toContain('fixture-user');
expect(JSON.stringify(consume.mock.calls)).not.toContain('client-');
await expect(budget.run('auth.login', 'another-client', 'login:other-user', async () => 'ok')).resolves.toBe(
'ok'
);
});
it('does not extend a blocked account window and admits it after expiry', async () => {
vi.useFakeTimers();
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
for (let i = 0; i < 10; i++) await budget.run('auth.login', 'client', 'login:user', async () => null);
await vi.advanceTimersByTimeAsync(59_000);
await expect(budget.run('auth.login', 'client', 'login:user', async () => null)).rejects.toMatchObject({
retryAfterSeconds: 1,
});
await vi.advanceTimersByTimeAsync(1000);
await expect(budget.run('auth.login', 'client', 'login:user', async () => 'recovered')).resolves.toBe(
'recovered'
);
});
it('limits public auth traffic per peer and across peers without charging rejected requests', async () => {
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-a', undefined, async () => null);
await expect(budget.run('auth.kakaoStart', 'peer-a', undefined, async () => null)).rejects.toMatchObject({
reason: 'limited',
});
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-b', undefined, async () => null);
await expect(budget.run('auth.kakaoStart', 'peer-c', undefined, async () => null)).rejects.toMatchObject({
reason: 'limited',
});
});
it('bounds expensive work and releases capacity after resolver failures', async () => {
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const work = vi.fn(async () => {
await pending;
throw new Error('fixture resolver failure');
});
const running = Array.from({ length: 4 }, () =>
budget.run('auth.login', 'client', undefined, work).catch(() => null)
);
await vi.waitFor(() => expect(work).toHaveBeenCalledTimes(4));
const rejected = vi.fn(async () => null);
await expect(budget.run('auth.registerLocal', 'client', undefined, rejected)).rejects.toBeInstanceOf(
AuthBudgetError
);
expect(rejected).not.toHaveBeenCalled();
release();
await Promise.all(running);
await expect(budget.run('auth.login', 'client', undefined, async () => 'ok')).resolves.toBe('ok');
});
it('bounds unfinished checks after caller timeouts and recovers only after they settle', async () => {
vi.useFakeTimers();
let release!: (retry: number) => void;
const waiting = new Promise<number>((resolve) => {
release = resolve;
});
const store = { consume: vi.fn(() => waiting) };
const budget = new AuthAttemptBudget(store, 'fixture', 'secret');
const work = vi.fn(async () => 'ok');
const requests = Array.from({ length: 32 }, () =>
budget.run('auth.passwordKey', 'client', undefined, work).catch((error: unknown) => error)
);
await vi.advanceTimersByTimeAsync(2001);
expect(
(await Promise.all(requests)).every(
(error) => error instanceof AuthBudgetError && error.reason === 'unavailable'
)
).toBe(true);
const actorLookup = vi.fn(async () => undefined);
await expect(budget.run('auth.login', 'client', actorLookup, work)).rejects.toMatchObject({
reason: 'unavailable',
});
expect(actorLookup).not.toHaveBeenCalled();
expect(work).not.toHaveBeenCalled();
expect(store.consume).toHaveBeenCalledTimes(32);
release(0);
await Promise.resolve();
await Promise.resolve();
await expect(budget.run('auth.login', 'client', undefined, work)).resolves.toBe('ok');
expect(work).toHaveBeenCalledTimes(1);
});
it('fails closed before credential work when the counter backend errors or stalls', async () => {
const work = vi.fn(async () => null);
const failing = new AuthAttemptBudget(
{
consume: async () => {
throw new Error('private-backend-error');
},
},
'fixture',
'secret'
);
await expect(failing.run('auth.login', 'client', undefined, work)).rejects.toMatchObject({
reason: 'unavailable',
});
vi.useFakeTimers();
const stalled = new AuthAttemptBudget({ consume: () => new Promise(() => {}) }, 'fixture', 'secret');
const result = stalled.run('auth.login', 'client', undefined, work).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(2001);
expect(await result).toMatchObject({ reason: 'unavailable' });
expect(work).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,238 @@
import { constants, publicEncrypt, randomUUID } from 'node:crypto';
import fastify, { type FastifyRequest } from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { createGatewayApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('authentication budgets over real HTTP and Redis', () => {
const client = createClient({ url: redisUrl });
const prefix = `auth-budget-http-test:${randomUUID()}`;
const envelope = createPasswordEnvelopeService();
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
const keys: string[] = [];
for await (const page of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) keys.push(...page);
if (keys.length) await client.del(keys);
await client.quit();
});
const credential = (password: string) => {
const key = envelope.getPublicKey();
return {
keyId: key.keyId,
ciphertext: publicEncrypt(
{ key: key.publicKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
Buffer.from(password)
).toString('base64'),
};
};
const fixture = async (available = true) => {
const users = createInMemoryUserRepository();
await users.createUser({ username: 'target-user', password: 'correct-password' });
await users.createUser({ username: 'other-user', password: 'correct-password' });
const requestIps = new Set<string>();
const peer = (request: FastifyRequest): string => {
requestIps.add(request.ip);
return request.ip;
};
const verify = vi.spyOn(users, 'verifyPassword');
const open = vi.fn(envelope.open);
const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 600 });
const authBudget = new AuthAttemptBudget(
new RedisAuthCounterStore(available ? client : client.duplicate()),
`${prefix}:${randomUUID()}`,
'synthetic-budget-key'
);
const app = fastify({ trustProxy: false });
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) =>
createGatewayApiContext({
users,
sessions,
passwordEnvelope: { ...envelope, open },
authBudget,
requestIp: peer(req),
requestHeaders: req.headers,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'synthetic-game-key',
gameSessionTtlSeconds: 600,
kakaoClient: {} as never,
oauthSessions: {} as never,
publicBaseUrl: 'http://localhost',
adminLocalAccountEnabled: true,
localRegistrationEnabled: true,
localAccountGraceDays: 7,
profiles: {} as never,
orchestrator: {} as never,
profileStatus: {} as never,
prisma: {} as never,
}),
},
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const post = (operation: string, input: unknown, headers: Record<string, string> = {}) =>
fetch(`${address}/trpc/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(input),
});
return { app, address, post, users, sessions, verify, open, requestIps };
};
it('blocks an eleventh normalized account attempt before RSA/Argon2 despite spoofed forwarding headers', async () => {
const f = await fixture();
try {
for (let i = 0; i < 10; i++) {
const response = await f.post(
'auth.login',
{ username: i % 2 ? ' TARGET-USER ' : 'target-user', credential: credential('wrong-password') },
{ 'x-forwarded-for': `198.51.100.${i + 1}` }
);
expect(response.status).toBe(401);
}
expect(f.requestIps.size).toBe(1);
expect(f.requestIps.has('127.0.0.1')).toBe(true);
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
const denied = await f.post(
'auth.login',
{ username: 'target-user', credential: credential('correct-password') },
{ 'x-forwarded-for': '203.0.113.99' }
);
expect(denied.status).toBe(429);
expect(JSON.stringify(await denied.json())).not.toContain('stack');
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
expect(
(await f.post('auth.login', { username: 'other-user', credential: credential('correct-password') }))
.status
).toBe(200);
} finally {
await f.app.close();
}
});
it('charges each operation in a tRPC batch instead of granting one budget per HTTP request', async () => {
const f = await fixture();
try {
const statuses: number[] = [];
for (let round = 0; round < 4; round++) {
const input = Object.fromEntries(
Array.from({ length: 3 }, (_value, i) => [
String(i),
{ username: 'target-user', credential: credential('wrong-password') },
])
);
const response = await f.post('auth.login,auth.login,auth.login?batch=1', input);
const body = (await response.json()) as Array<{ error: { data: { httpStatus: number } } }>;
statuses.push(...body.map((entry) => entry.error.data.httpStatus));
}
expect(statuses.filter((status) => status === 401)).toHaveLength(10);
expect(statuses.filter((status) => status === 429)).toHaveLength(2);
expect(f.verify).toHaveBeenCalledTimes(10);
} finally {
await f.app.close();
}
});
it('shares the password/deletion subject across sessions of the same authenticated actor', async () => {
const f = await fixture();
const user = await f.users.findByUsername('target-user');
try {
for (let i = 0; i < 10; i++) {
const session = await f.sessions.createSession(user!);
const response = await f.post(i % 2 ? 'account.changePassword' : 'account.scheduleDeletion', {
sessionToken: session.sessionToken,
currentCredential: credential('wrong-password'),
newCredential: credential('new-password'),
});
expect(response.status).toBe(401);
}
const session = await f.sessions.createSession(user!);
expect(
(
await f.post('account.scheduleDeletion', {
sessionToken: session.sessionToken,
currentCredential: credential('correct-password'),
})
).status
).toBe(429);
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
expect((await f.users.findById(user!.id))?.deleteAfter).toBeUndefined();
} finally {
await f.app.close();
}
});
it('fails closed on unavailable protection while keeping logout independent of its counters', async () => {
const f = await fixture(false);
try {
expect(
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
.status
).toBe(503);
expect(f.open).not.toHaveBeenCalled();
expect(f.verify).not.toHaveBeenCalled();
const user = await f.users.findByUsername('target-user');
const session = await f.sessions.createSession(user!);
expect((await f.post('auth.logout', { sessionToken: session.sessionToken })).status).toBe(200);
expect(await f.sessions.getSession(session.sessionToken)).toBeNull();
expect(
(await f.post('auth.login', { username: 123, credential: credential('correct-password') })).status
).toBe(503);
const malformed = await fetch(`${f.address}/trpc/auth.login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{',
});
expect(malformed.status).toBe(400);
} finally {
await f.app.close();
}
});
it('bounds parallel credential work and admits a subsequent request after all failures settle', async () => {
const f = await fixture();
let release!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
f.verify.mockImplementation(async () => {
await waiting;
return false;
});
const input = { username: 'target-user', credential: credential('wrong-password') };
const pending = Array.from({ length: 4 }, () => f.post('auth.login', input));
try {
await vi.waitFor(() => expect(f.verify).toHaveBeenCalledTimes(4));
expect((await f.post('auth.login', input)).status).toBe(429);
expect(f.open).toHaveBeenCalledTimes(4);
release();
expect((await Promise.all(pending)).map((r) => r.status)).toEqual([401, 401, 401, 401]);
f.verify.mockRestore();
expect(
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
.status
).toBe(200);
} finally {
release();
await Promise.allSettled(pending);
await f.app.close();
}
});
});
+11 -8
View File
@@ -25,11 +25,16 @@ describe('Kakao OAuth HTTP transport', () => {
await expect(createClient().signup('access-token')).resolves.toEqual({
alreadyRegistered: true,
});
expect(fetchMock).toHaveBeenCalledWith(new URL('https://kapi.example.test/v1/user/signup'), {
headers: {
Authorization: 'Bearer access-token',
},
});
expect(fetchMock).toHaveBeenCalledWith(
new URL('https://kapi.example.test/v1/user/signup'),
expect.objectContaining({
headers: {
Authorization: 'Bearer access-token',
},
redirect: 'error',
signal: expect.any(AbortSignal),
})
);
});
it('continues to reject unrelated Kakao signup errors', async () => {
@@ -40,8 +45,6 @@ describe('Kakao OAuth HTTP transport', () => {
})
);
await expect(createClient().signup('access-token')).rejects.toThrow(
'Kakao signup error: {"msg":"invalid request","code":-201}'
);
await expect(createClient().signup('access-token')).rejects.toThrow('Kakao signup error.');
});
});
@@ -0,0 +1,95 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { gzipSync } from 'node:zlib';
import { afterEach, describe, expect, it } from 'vitest';
import { KakaoOAuthClient } from '../src/auth/kakaoClient.js';
describe('bounded Kakao provider transport', () => {
const servers: Server[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
const provider = async (respond: (response: ServerResponse) => void, requestTimeoutMs = 1000) => {
const server = createServer((_request, response) => respond(response));
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Fixture provider listener failed.');
const origin = `http://127.0.0.1:${address.port}`;
return new KakaoOAuthClient({
restKey: 'synthetic-key',
redirectUri: `${origin}/callback`,
apiHost: origin,
oauthHost: origin,
requestTimeoutMs,
});
};
it('retains successful token/account responses and documented recovery responses', async () => {
const client = await provider((res) =>
res.end(
JSON.stringify({
id: 42,
access_token: 'synthetic-token',
expires_in: 3600,
kakao_account: {
has_email: true,
email: 'fixture@example.test',
is_email_valid: true,
is_email_verified: true,
},
})
)
);
expect(await client.exchangeCode('synthetic-code')).toMatchObject({
accessToken: 'synthetic-token',
accessTokenExpiresIn: 3600,
});
expect(await client.getMe('synthetic-token')).toMatchObject({
id: '42',
kakaoAccount: { email: 'fixture@example.test', isEmailVerified: true },
});
});
it('aborts both header and response-body stalls within the configured deadline', async () => {
const silent = await provider(() => {}, 100);
await expect(silent.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
const partial = await provider((res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.write('{');
}, 100);
await expect(partial.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
});
it('limits decompressed response bytes and rejects malformed JSON without exposing payloads', async () => {
const oversized = await provider((res) => {
res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' });
res.end(gzipSync(JSON.stringify({ payload: 'x'.repeat(2 * 1024 * 1024) })));
});
await expect(oversized.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
const malformed = await provider((res) => res.end('synthetic-sensitive-provider-data'));
await expect(malformed.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
});
it('does not follow redirects or expose provider error details', async () => {
let destinationRequests = 0;
const destination = await provider((res) => {
destinationRequests += 1;
res.end('{}');
});
const destinationUrl = new URL(destination.buildAuthUrl('state', []));
const redirect = await provider((res) => {
res.writeHead(302, { location: destinationUrl.origin });
res.end();
});
await expect(redirect.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
expect(destinationRequests).toBe(0);
const failure = await provider((res) => {
res.writeHead(400);
res.end(JSON.stringify({ msg: 'synthetic-provider-secret', code: -201 }));
});
await expect(failure.signup('synthetic-token')).rejects.toThrow('Kakao signup error.');
});
});