fix(api)!: enforce declared rate limits and correct route auth (#2500)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:28:32 +02:00
1 parent 908e1b8bd4
commit f8a04b8985
13 files changed
+317 -77

No files matched your search

+4 -6
View File
@@ -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',
+14 -6
View File
@@ -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(),
);
});
});