mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(api)!: enforce declared rate limits and correct route auth (#2500)
This commit is contained in:
1 parent
908e1b8bd4
commit
f8a04b8985
13 files changed
+317
-77
No files matched your search
@@ -181,21 +181,19 @@ export function AuthController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/logout',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGOUT),
|
||||
LoginRequiredAllowSuspicious,
|
||||
OpenAPI({
|
||||
operationId: 'logout_user',
|
||||
summary: 'Logout account',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid.',
|
||||
'Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid. A bot token has no session to end, so the call answers 204 and the token stays valid.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').logout({
|
||||
authorizationHeader: ctx.req.header('Authorization') ?? undefined,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
});
|
||||
await ctx.get('authRequestService').logout({authToken: ctx.get('authToken')});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,7 +84,6 @@ interface AuthLoginMfaRequest {
|
||||
}
|
||||
|
||||
interface AuthLogoutRequest {
|
||||
authorizationHeader?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
@@ -187,10 +186,9 @@ export class AuthRequestService {
|
||||
return await this.toAuthTokenResponse(result);
|
||||
}
|
||||
|
||||
async logout({authorizationHeader, authToken}: AuthLogoutRequest): Promise<void> {
|
||||
const token = authToken ?? authorizationHeader;
|
||||
if (token) {
|
||||
await AuthSession.revokeToken(this.apiContext, token);
|
||||
async logout({authToken}: AuthLogoutRequest): Promise<void> {
|
||||
if (authToken) {
|
||||
await AuthSession.revokeToken(this.apiContext, authToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,18 @@ describe('Auth login and sessions', () => {
|
||||
await createBuilder(harness, account.token).post('/auth/logout').expect(204).execute();
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
|
||||
});
|
||||
it('logout requires a live session', async () => {
|
||||
await createBuilderWithoutAuth(harness).post('/auth/logout').expect(401).execute();
|
||||
await createBuilder(harness, createFakeAuthToken()).post('/auth/logout').expect(401).execute();
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token).post('/auth/logout').expect(204).execute();
|
||||
await createBuilder(harness, account.token).post('/auth/logout').expect(401).execute();
|
||||
});
|
||||
it('logout with a Bearer-prefixed session token revokes it', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, `Bearer ${account.token}`).post('/auth/logout').expect(204).execute();
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
|
||||
});
|
||||
it('treats /auth/sessions/logout as idempotent and removes targeted sessions', async () => {
|
||||
let account: TestAccount = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
|
||||
@@ -35,20 +35,28 @@ function isBlueskyOAuthEnabled(service: unknown): boolean {
|
||||
}
|
||||
|
||||
export function BlueskyOAuthController(app: HonoApp) {
|
||||
app.get('/connections/bluesky/client-metadata.json', async (ctx) => {
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!isBlueskyOAuthEnabled(service)) {
|
||||
return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404);
|
||||
}
|
||||
return ctx.json(service.clientMetadata);
|
||||
});
|
||||
app.get('/connections/bluesky/jwks.json', async (ctx) => {
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!isBlueskyOAuthEnabled(service)) {
|
||||
return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404);
|
||||
}
|
||||
return ctx.json(service.jwks);
|
||||
});
|
||||
app.get(
|
||||
'/connections/bluesky/client-metadata.json',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.BLUESKY_CLIENT_DOCUMENT),
|
||||
async (ctx) => {
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!isBlueskyOAuthEnabled(service)) {
|
||||
return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404);
|
||||
}
|
||||
return ctx.json(service.clientMetadata);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/connections/bluesky/jwks.json',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.BLUESKY_CLIENT_DOCUMENT),
|
||||
async (ctx) => {
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!isBlueskyOAuthEnabled(service)) {
|
||||
return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404);
|
||||
}
|
||||
return ctx.json(service.jwks);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/connections/bluesky/authorize',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_CREATE),
|
||||
@@ -98,47 +106,51 @@ export function BlueskyOAuthController(app: HonoApp) {
|
||||
return ctx.json({authorize_url: result.authorizeUrl});
|
||||
},
|
||||
);
|
||||
app.get('/connections/bluesky/callback', async (ctx) => {
|
||||
const appUrl = Config.endpoints.webApp;
|
||||
const callbackUrl = `${appUrl}/connection-callback`;
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!isBlueskyOAuthEnabled(service)) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=not_enabled`);
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams(ctx.req.url.split('?')[1] ?? '');
|
||||
let result: BlueskyCallbackResult;
|
||||
try {
|
||||
result = await service.callback(params);
|
||||
} catch (callbackError) {
|
||||
if (callbackError instanceof BlueskyOAuthNotEnabledError) {
|
||||
throw callbackError;
|
||||
}
|
||||
Logger.error({error: callbackError}, 'Bluesky OAuth callback error from upstream');
|
||||
if (
|
||||
callbackError instanceof Error &&
|
||||
(callbackError.message.toLowerCase().includes('state') ||
|
||||
callbackError.message.toLowerCase().includes('expired'))
|
||||
) {
|
||||
throw new BlueskyOAuthStateInvalidError();
|
||||
}
|
||||
throw new BlueskyOAuthCallbackFailedError();
|
||||
}
|
||||
const connectionService = ctx.get('connectionService');
|
||||
await connectionService.createOrUpdateBlueskyConnection(result.userId, result.did, result.handle);
|
||||
return ctx.redirect(`${callbackUrl}?status=connected`);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Bluesky OAuth callback failed');
|
||||
if (error instanceof BlueskyOAuthStateInvalidError) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=state_invalid`);
|
||||
}
|
||||
if (error instanceof BlueskyOAuthCallbackFailedError) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=callback_failed`);
|
||||
}
|
||||
if (error instanceof BlueskyOAuthNotEnabledError) {
|
||||
app.get(
|
||||
'/connections/bluesky/callback',
|
||||
RateLimitMiddleware(ConnectionRateLimitConfigs.BLUESKY_CALLBACK),
|
||||
async (ctx) => {
|
||||
const appUrl = Config.endpoints.webApp;
|
||||
const callbackUrl = `${appUrl}/connection-callback`;
|
||||
const service = ctx.get('blueskyOAuthService');
|
||||
if (!isBlueskyOAuthEnabled(service)) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=not_enabled`);
|
||||
}
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=unknown`);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const params = new URLSearchParams(ctx.req.url.split('?')[1] ?? '');
|
||||
let result: BlueskyCallbackResult;
|
||||
try {
|
||||
result = await service.callback(params);
|
||||
} catch (callbackError) {
|
||||
if (callbackError instanceof BlueskyOAuthNotEnabledError) {
|
||||
throw callbackError;
|
||||
}
|
||||
Logger.error({error: callbackError}, 'Bluesky OAuth callback error from upstream');
|
||||
if (
|
||||
callbackError instanceof Error &&
|
||||
(callbackError.message.toLowerCase().includes('state') ||
|
||||
callbackError.message.toLowerCase().includes('expired'))
|
||||
) {
|
||||
throw new BlueskyOAuthStateInvalidError();
|
||||
}
|
||||
throw new BlueskyOAuthCallbackFailedError();
|
||||
}
|
||||
const connectionService = ctx.get('connectionService');
|
||||
await connectionService.createOrUpdateBlueskyConnection(result.userId, result.did, result.handle);
|
||||
return ctx.redirect(`${callbackUrl}?status=connected`);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Bluesky OAuth callback failed');
|
||||
if (error instanceof BlueskyOAuthStateInvalidError) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=state_invalid`);
|
||||
}
|
||||
if (error instanceof BlueskyOAuthCallbackFailedError) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=callback_failed`);
|
||||
}
|
||||
if (error instanceof BlueskyOAuthNotEnabledError) {
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=not_enabled`);
|
||||
}
|
||||
return ctx.redirect(`${callbackUrl}?status=error&reason=unknown`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function GuildBaseController(app: HonoApp) {
|
||||
description: 'Only claimed, email-verified non-bot users can create guilds.',
|
||||
responseSchema: GuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
|
||||
@@ -60,7 +60,7 @@ export function GuildDiscoveryController(app: HonoApp) {
|
||||
description: 'Search for guilds listed in the discovery directory.',
|
||||
responseSchema: DiscoveryGuildListResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken'],
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
@@ -89,7 +89,7 @@ export function GuildDiscoveryController(app: HonoApp) {
|
||||
description: 'Returns the list of available discovery categories.',
|
||||
responseSchema: DiscoveryCategoryListResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken'],
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {
|
||||
BucketConfig,
|
||||
IRateLimitService,
|
||||
RateLimitConfig,
|
||||
RateLimitResult,
|
||||
} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {BlueskyOAuthController} from '../../bluesky/BlueskyOAuthController';
|
||||
import type {IBlueskyOAuthService} from '../../bluesky/IBlueskyOAuthService';
|
||||
import {ConnectionRateLimitConfigs} from '../../rate_limit_configs/ConnectionRateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '../../types/HonoEnv';
|
||||
import {RateLimitMiddleware} from '../RateLimitMiddleware';
|
||||
|
||||
const CLIENT_IP = '203.0.113.10';
|
||||
const HTTP_TOO_MANY_REQUESTS = 429;
|
||||
const RATE_LIMIT_MIDDLEWARE_SOURCE = String(RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_LIST));
|
||||
|
||||
interface BucketCheck {
|
||||
bucket: string;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
function allowedResult(limit: number): RateLimitResult {
|
||||
return {
|
||||
allowed: true,
|
||||
limit,
|
||||
remaining: limit - 1,
|
||||
resetTime: new Date(Date.now() + 60000),
|
||||
resetAfterDecimal: 60,
|
||||
};
|
||||
}
|
||||
|
||||
class RecordingRateLimitService implements IRateLimitService {
|
||||
readonly bucketChecks: Array<BucketCheck> = [];
|
||||
readonly globalIdentifiers: Array<string> = [];
|
||||
denyBuckets = false;
|
||||
|
||||
async checkLimit(config: RateLimitConfig): Promise<RateLimitResult> {
|
||||
return allowedResult(config.maxAttempts);
|
||||
}
|
||||
|
||||
async peekLimit(config: RateLimitConfig): Promise<RateLimitResult> {
|
||||
return allowedResult(config.maxAttempts);
|
||||
}
|
||||
|
||||
async checkBucketLimit(bucket: string, config: BucketConfig): Promise<RateLimitResult> {
|
||||
this.bucketChecks.push({bucket, limit: config.limit, windowMs: config.windowMs});
|
||||
return {...allowedResult(config.limit), allowed: !this.denyBuckets, remaining: this.denyBuckets ? 0 : 1};
|
||||
}
|
||||
|
||||
async checkGlobalLimit(identifier: string, limit: number): Promise<RateLimitResult> {
|
||||
this.globalIdentifiers.push(identifier);
|
||||
return allowedResult(limit);
|
||||
}
|
||||
|
||||
async resetLimit(_identifier: string): Promise<void> {}
|
||||
|
||||
async clearLimitsByIdentifierPrefix(_identifierPrefix: string): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const blueskyOAuthService = {
|
||||
clientMetadata: {client_id: 'https://fluxer.test/v1/connections/bluesky/client-metadata.json'},
|
||||
jwks: {keys: []},
|
||||
} as unknown as IBlueskyOAuthService;
|
||||
|
||||
interface Harness {
|
||||
app: HonoApp;
|
||||
service: RecordingRateLimitService;
|
||||
}
|
||||
|
||||
function buildHarness(register: (app: HonoApp) => void): Harness {
|
||||
const service = new RecordingRateLimitService();
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use('*', async (ctx, next) => {
|
||||
ctx.set('rateLimitService', service);
|
||||
ctx.set('blueskyOAuthService', blueskyOAuthService);
|
||||
await next();
|
||||
});
|
||||
register(app);
|
||||
return {app, service};
|
||||
}
|
||||
|
||||
async function callRoute(harness: Harness, path: string): Promise<Response> {
|
||||
return await harness.app.request(`http://localhost${path}`, {
|
||||
headers: {
|
||||
'x-forwarded-for': CLIENT_IP,
|
||||
'x-fluxer-test-enable-rate-limits': 'true',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function routesWithoutRateLimit(register: (app: HonoApp) => void): Array<string> {
|
||||
const app = new Hono<HonoEnv>();
|
||||
register(app);
|
||||
const registered = new Set<string>();
|
||||
const throttled = new Set<string>();
|
||||
for (const route of app.routes) {
|
||||
const shape = `${route.method} ${route.path}`;
|
||||
registered.add(shape);
|
||||
if (String(route.handler) === RATE_LIMIT_MIDDLEWARE_SOURCE) {
|
||||
throttled.add(shape);
|
||||
}
|
||||
}
|
||||
return [...registered].filter((shape) => !throttled.has(shape)).sort();
|
||||
}
|
||||
|
||||
function bucketHash(bucket: string): string {
|
||||
return createHash('sha256').update(bucket).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
describe('public route rate limits', () => {
|
||||
test('every Bluesky OAuth route declares a rate limit bucket', () => {
|
||||
expect(routesWithoutRateLimit(BlueskyOAuthController)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the Bluesky client documents share one bucket keyed by client IP', async () => {
|
||||
const harness = buildHarness(BlueskyOAuthController);
|
||||
|
||||
const metadata = await callRoute(harness, '/connections/bluesky/client-metadata.json');
|
||||
const jwks = await callRoute(harness, '/connections/bluesky/jwks.json');
|
||||
|
||||
expect(metadata.status).toBe(200);
|
||||
expect(jwks.status).toBe(200);
|
||||
expect(harness.service.bucketChecks).toEqual([
|
||||
{bucket: `ip:${CLIENT_IP}:connection:bluesky:client_document`, limit: 60, windowMs: 60000},
|
||||
{bucket: `ip:${CLIENT_IP}:connection:bluesky:client_document`, limit: 60, windowMs: 60000},
|
||||
]);
|
||||
expect(harness.service.globalIdentifiers).toEqual([`ip:${CLIENT_IP}`, `ip:${CLIENT_IP}`]);
|
||||
});
|
||||
|
||||
test('a denied bucket answers a Bluesky client document with 429 and the bucket hash', async () => {
|
||||
const harness = buildHarness(BlueskyOAuthController);
|
||||
harness.service.denyBuckets = true;
|
||||
|
||||
const response = await callRoute(harness, '/connections/bluesky/client-metadata.json');
|
||||
const body = (await response.json()) as {code?: string};
|
||||
|
||||
expect(response.status).toBe(HTTP_TOO_MANY_REQUESTS);
|
||||
expect(body.code).toBe(APIErrorCodes.RATE_LIMITED);
|
||||
expect(response.headers.get('X-RateLimit-Bucket')).toBe(
|
||||
bucketHash(ConnectionRateLimitConfigs.BLUESKY_CLIENT_DOCUMENT.bucket),
|
||||
);
|
||||
expect(response.headers.get('X-RateLimit-Scope')).toBe('user');
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,7 @@ export function OAuth2Controller(app: HonoApp) {
|
||||
);
|
||||
app.get(
|
||||
'/oauth2/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
|
||||
requireOAuth2BearerToken(),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
@@ -259,6 +260,7 @@ export function OAuth2Controller(app: HonoApp) {
|
||||
);
|
||||
app.get(
|
||||
'/applications/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
|
||||
OpenAPI({
|
||||
operationId: 'get_current_user_applications',
|
||||
summary: 'List current user applications',
|
||||
|
||||
@@ -1064,6 +1064,14 @@
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
@@ -1105,8 +1113,8 @@
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Logout account"}},
|
||||
"description": "Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid.",
|
||||
"security": [{"sessionToken": []}]
|
||||
"description": "Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid. A bot token has no session to end, so the call answers 204 and the token stays valid.",
|
||||
"security": [{"botToken": []}, {"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/auth/register": {
|
||||
@@ -5674,7 +5682,7 @@
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "List discovery categories"}},
|
||||
"description": "Returns the list of available discovery categories.",
|
||||
"security": [{"sessionToken": []}]
|
||||
"security": [{"botToken": []}, {"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/discovery/guilds": {
|
||||
@@ -5741,7 +5749,7 @@
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Search discoverable guilds"}},
|
||||
"description": "Search for guilds listed in the discovery directory.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
@@ -7110,7 +7118,7 @@
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Create guild"}},
|
||||
"description": "Only claimed, email-verified non-bot users can create guilds.",
|
||||
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildCreateRequest"}}}
|
||||
@@ -14748,7 +14756,7 @@
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Search messages"}},
|
||||
"description": "Searches for messages across guilds and channels accessible to the authenticated user. Bots must supply a context guild or channel and may only use the current scope.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GlobalSearchMessagesRequest"}}}
|
||||
|
||||
@@ -28,4 +28,12 @@ export const ConnectionRateLimitConfigs = {
|
||||
bucket: 'connection:verify_and_create',
|
||||
config: {limit: 5, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
BLUESKY_CLIENT_DOCUMENT: {
|
||||
bucket: 'connection:bluesky:client_document',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
BLUESKY_CALLBACK: {
|
||||
bucket: 'connection:bluesky:callback',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
} as const;
|
||||
@@ -23,7 +23,7 @@ export function SearchController(app: HonoApp) {
|
||||
'Searches for messages across guilds and channels accessible to the authenticated user. Bots must supply a context guild or channel and may only use the current scope.',
|
||||
responseSchema: MessageSearchResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Search',
|
||||
}),
|
||||
Validator('json', GlobalSearchMessagesRequest),
|
||||
|
||||
@@ -237,9 +237,9 @@ export function StripeController(app: HonoApp) {
|
||||
app.post(
|
||||
'/gifts/:code/redeem',
|
||||
RateLimitMiddleware(RateLimitConfigs.GIFT_CODE_REDEEM),
|
||||
CaptchaMiddleware,
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
CaptchaMiddleware,
|
||||
OpenAPI({
|
||||
operationId: 'redeem_gift_code',
|
||||
summary: 'Redeem gift code',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterEach, beforeEach, describe, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
|
||||
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousEnabled = Config.captcha.enabled;
|
||||
const previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.captcha.enabled = previousEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Gift Code Redeem Captcha', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('rejects an unauthenticated redeem before reading the captcha', async () => {
|
||||
await withCaptchaEnabled(async () =>
|
||||
createBuilderWithoutAuth(harness)
|
||||
.post('/gifts/test-gift-code/redeem')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.UNAUTHORIZED)
|
||||
.execute(),
|
||||
);
|
||||
});
|
||||
test('requires captcha when redeeming with a credential', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await withCaptchaEnabled(async () =>
|
||||
createBuilder(harness, account.token)
|
||||
.post('/gifts/test-gift-code/redeem')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user