1 Commits

Author SHA1 Message Date
root
b27cb0c2e1 feat(guild): validate bearer tokens via center introspection 2026-05-13 07:59:57 +00:00
6 changed files with 61 additions and 24 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -18,6 +18,13 @@ export class ChannelsService {
});
}
listAll() {
return this.channelRepo.find({
order: { createdAt: 'ASC' },
take: 200,
});
}
create(input: Partial<Channel>) {
const channel = this.channelRepo.create({
guildId: String(input.guildId ?? ''),

View File

@@ -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<boolean> {
const req = context.switchToHttp().getRequest<{ path?: string; headers: Record<string, string | string[] | undefined> }>();
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;
}

28
src/common/center-auth.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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<void> {
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', {