From b27cb0c2e1bd81162c461a3bcb8b6c2b4370d723 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 13 May 2026 07:59:57 +0000 Subject: [PATCH] feat(guild): validate bearer tokens via center introspection --- .env.example | 3 --- src/channels/channels.controller.ts | 2 +- src/channels/channels.service.ts | 7 +++++++ src/common/api-key.guard.ts | 20 +++++++++----------- src/common/center-auth.ts | 28 ++++++++++++++++++++++++++++ src/realtime/realtime.gateway.ts | 25 ++++++++++++++++--------- 6 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 src/common/center-auth.ts diff --git a/.env.example b/.env.example index fdde1f3..5cfdc17 100644 --- a/.env.example +++ b/.env.example @@ -10,9 +10,6 @@ DB_NAME=fabric_guild DB_SYNC=true DB_LOGGING=false -# Unified inbound API auth -FABRIC_API_KEY=change-me-api-key - # Guild identity GUILD_NODE_ID=guild-node-1 GUILD_NODE_NAME=Guild Node 1 diff --git a/src/channels/channels.controller.ts b/src/channels/channels.controller.ts index 35f47b0..2508cf8 100644 --- a/src/channels/channels.controller.ts +++ b/src/channels/channels.controller.ts @@ -7,7 +7,7 @@ export class ChannelsController { @Get() list(@Query('guildId') guildId?: string) { - if (!guildId) return []; + if (!guildId) return this.channelsService.listAll(); return this.channelsService.listByGuild(guildId); } diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts index 04f228d..169225e 100644 --- a/src/channels/channels.service.ts +++ b/src/channels/channels.service.ts @@ -18,6 +18,13 @@ export class ChannelsService { }); } + listAll() { + return this.channelRepo.find({ + order: { createdAt: 'ASC' }, + take: 200, + }); + } + create(input: Partial) { const channel = this.channelRepo.create({ guildId: String(input.guildId ?? ''), diff --git a/src/common/api-key.guard.ts b/src/common/api-key.guard.ts index ac74af3..195ec48 100644 --- a/src/common/api-key.guard.ts +++ b/src/common/api-key.guard.ts @@ -2,13 +2,13 @@ import { CanActivate, ExecutionContext, Injectable, - ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; +import { introspectGuildToken } from './center-auth'; @Injectable() export class ApiKeyGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest<{ path?: string; headers: Record }>(); const path = req.path ?? ''; @@ -17,17 +17,15 @@ export class ApiKeyGuard implements CanActivate { return true; } - const expected = process.env.FABRIC_API_KEY; - if (!expected || expected.trim() === '') { - throw new ServiceUnavailableException('FABRIC_API_KEY is not configured'); - } + const auth = req.headers['authorization']; + const authValue = Array.isArray(auth) ? auth[0] : auth; + const token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : ''; + if (!token) throw new UnauthorizedException('missing bearer token'); - const received = req.headers['x-api-key']; - const receivedValue = Array.isArray(received) ? received[0] : received; + const result = await introspectGuildToken(token); + if (!result.active || !result.user?.id) throw new UnauthorizedException('invalid guild token'); - if (!receivedValue || receivedValue !== expected) { - throw new UnauthorizedException('invalid api key'); - } + (req as { userId?: string }).userId = result.user.id; return true; } diff --git a/src/common/center-auth.ts b/src/common/center-auth.ts new file mode 100644 index 0000000..fdb3a9d --- /dev/null +++ b/src/common/center-auth.ts @@ -0,0 +1,28 @@ +export async function introspectGuildToken(token: string): Promise<{ active: boolean; user?: { id: string; email?: string } }> { + const centerBaseUrl = process.env.CENTER_BASE_URL; + const sharedSecret = process.env.CENTER_SHARED_SECRET; + const guildNodeId = process.env.GUILD_NODE_ID; + + if (!centerBaseUrl || !sharedSecret || !guildNodeId) { + return { active: false }; + } + + const res = await fetch(`${centerBaseUrl}/api/auth/introspect`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-center-shared-secret': sharedSecret, + }, + body: JSON.stringify({ token, guildNodeId }), + }); + + if (!res.ok) return { active: false }; + const data = (await res.json()) as { active?: boolean; user?: { id: string; email?: string } }; + if (!data.active) return { active: false }; + + return { + active: true, + user: data.user, + }; +} + diff --git a/src/realtime/realtime.gateway.ts b/src/realtime/realtime.gateway.ts index b231636..825cd6c 100644 --- a/src/realtime/realtime.gateway.ts +++ b/src/realtime/realtime.gateway.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/websockets'; import { Logger } from '@nestjs/common'; import { Server, Socket } from 'socket.io'; +import { introspectGuildToken } from '../common/center-auth'; @WebSocketGateway({ namespace: '/realtime', @@ -30,18 +31,24 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect return userId && typeof userId === 'string' && userId.trim() !== '' ? userId : `anon:${client.id}`; } - handleConnection(client: Socket): void { - const expected = process.env.FABRIC_API_KEY; - if (!expected) { + async handleConnection(client: Socket): Promise { + const authToken = client.handshake.auth?.token; + const headerAuth = client.handshake.headers['authorization']; + const bearer = + typeof authToken === 'string' + ? authToken + : typeof headerAuth === 'string' && headerAuth.startsWith('Bearer ') + ? headerAuth.slice(7) + : ''; + + if (!bearer) { + this.logger.warn(`socket rejected (missing token): ${client.id}`); client.disconnect(true); return; } - const authKey = client.handshake.auth?.apiKey; - const headerKey = client.handshake.headers['x-api-key']; - const apiKey = typeof authKey === 'string' ? authKey : Array.isArray(headerKey) ? headerKey[0] : headerKey; - - if (apiKey !== expected) { + const result = await introspectGuildToken(bearer); + if (!result.active || !result.user?.id) { this.logger.warn(`socket rejected: ${client.id}`); client.disconnect(true); return; @@ -49,7 +56,7 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect this.logger.log(`socket connected: ${client.id}`); - const userId = this.userIdFromClient(client); + const userId = result.user.id || this.userIdFromClient(client); client.data.userId = userId; this.onlineUsers.add(userId); this.server.emit('presence.online', {