Compare commits
15 Commits
2792f78ada
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8412eb6b3e | |||
| 9e1909a7e8 | |||
| 3058bccfb6 | |||
| 604f6556fe | |||
| ca7c6b408e | |||
| 1c1f280553 | |||
| f1ca33f2a2 | |||
| 9f7216565b | |||
| 2a394969d2 | |||
| 6afb935302 | |||
| aa9d59a952 | |||
| 44aa34d1ff | |||
| dea946653b | |||
| 3da51a60bc | |||
| bc0d1ba8bf |
65
README.md
65
README.md
@@ -1,14 +1,59 @@
|
||||
# Fabric.Backend.Center
|
||||
|
||||
Identity Hub service for Fabric.
|
||||
The **identity hub** for Fabric (NestJS, ES modules, MySQL/TypeORM). Default
|
||||
port `7001`, global prefix `/api`.
|
||||
|
||||
## Scope (MVP)
|
||||
- User register/login
|
||||
- Session/token management
|
||||
- Guild Node registration + shared-secret handshake
|
||||
- Center-level audit logs
|
||||
Center is the single identity authority. Guild nodes register with it and
|
||||
introspect the tokens it issues; the frontend uses it to log in and to
|
||||
discover which guilds a user belongs to.
|
||||
|
||||
## Next
|
||||
- API skeleton (NestJS)
|
||||
- Auth module
|
||||
- Guild node registry module
|
||||
## Responsibilities
|
||||
|
||||
- **Users & sessions** — register/login, JWT access + refresh tokens, `GET`/
|
||||
`PATCH /auth/me`. User display name defaults to the email until changed.
|
||||
- **Agent auth** — per-agent **API keys** (`fak_…`); `POST /auth/agent/login`
|
||||
exchanges a key for a normal user session (used by `Fabric.OpenclawPlugin`).
|
||||
- **Guild-node registry** — nodes register (`/api/nodes/register`, localhost
|
||||
or node API key) and are handed out to users as endpoints + short-lived
|
||||
guild access tokens.
|
||||
- **Name resolution** — `POST /auth/resolve-names` maps `name`/email →
|
||||
userId, scoped to a guild's members (used for `<@user.name:NAME>` mentions).
|
||||
- **Membership** — `POST /auth/me/guilds/join`, `GET /auth/me/guilds`
|
||||
(returns guilds + fresh guild access tokens), `GET /auth/guilds/:nodeId/members`.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
node dist/cli.js user create --email <e> --password <p>
|
||||
node dist/cli.js user apikey --email <e> [--label <l>] # prints fak_… once
|
||||
node dist/cli.js node register --node-id <id> --name <n> --endpoint <url>
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build && npm start # or: npm run start:dev
|
||||
```
|
||||
|
||||
Typically run via the root `docker-compose.local.yml` (service
|
||||
`backend-center`). MySQL schema is auto-managed (`DB_SYNC`).
|
||||
|
||||
## Notable env
|
||||
|
||||
- `FABRIC_BACKEND_CENTER_PORT` (default 7001)
|
||||
- `FABRIC_BACKEND_CENTER_DB_*` (host/port/user/password/name)
|
||||
- JWT signing secret(s) — see `src/` config
|
||||
|
||||
## Auth model
|
||||
|
||||
A global `CenterApiKeyGuard` protects most routes; auth/session endpoints
|
||||
(`login`, `agent/login`, `refresh`, `logout`, `me`, `me/guilds*`,
|
||||
`resolve-names`, guild members, node register) are exempted so users, agents,
|
||||
and nodes can bootstrap.
|
||||
|
||||
## Notes
|
||||
|
||||
- ES modules (`NodeNext`); CJS deps are default-imported
|
||||
(`import jwt from 'jsonwebtoken'`, `import bcrypt from 'bcryptjs'`).
|
||||
- `@IsEmail()` rejects single-character TLDs — use e.g. `@t.tt`.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "fabric-backend-center",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Fabric Identity Hub (Center service)",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { buildTypeOrmConfig } from './database.config';
|
||||
import { HealthController } from './common/health.controller';
|
||||
import { MetricsController } from './common/metrics.controller';
|
||||
import { MetricsService } from './common/metrics.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { NodesModule } from './nodes/nodes.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { CenterApiKeyGuard } from './common/center-api-key.guard';
|
||||
import { GuildNode } from './entities/guild-node.entity';
|
||||
import { buildTypeOrmConfig } from './database.config.js';
|
||||
import { HealthController } from './common/health.controller.js';
|
||||
import { MetricsController } from './common/metrics.controller.js';
|
||||
import { MetricsService } from './common/metrics.service.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { NodesModule } from './nodes/nodes.module.js';
|
||||
import { AuditModule } from './audit/audit.module.js';
|
||||
import { CenterApiKeyGuard } from './common/center-api-key.guard.js';
|
||||
import { GuildNode } from './entities/guild-node.entity.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditLog } from '../entities/audit-log.entity';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditLog } from '../entities/audit-log.entity.js';
|
||||
import { AuditService } from './audit.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditLog } from '../entities/audit-log.entity';
|
||||
import { AuditLog } from '../entities/audit-log.entity.js';
|
||||
|
||||
export type AuditWriteInput = {
|
||||
action: string;
|
||||
|
||||
@@ -1,51 +1,122 @@
|
||||
import { Body, Controller, Get, Headers, Param, Post, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto.login.dto';
|
||||
import { RefreshDto } from './dto.refresh.dto';
|
||||
import { LogoutDto } from './dto.logout.dto';
|
||||
import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto } from './dto.login.dto.js';
|
||||
import { RefreshDto } from './dto.refresh.dto.js';
|
||||
import { LogoutDto } from './dto.logout.dto.js';
|
||||
import { UpdateMeDto } from './dto.update-me.dto.js';
|
||||
import { Public } from '../common/public.decorator.js';
|
||||
import type { AuthedGuildNode } from '../common/center-api-key.guard.js';
|
||||
|
||||
function bearer(authorization?: string): string {
|
||||
return authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
login(@Body() body: LoginDto) {
|
||||
return this.authService.login(body);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
refresh(@Body() body: RefreshDto) {
|
||||
return this.authService.refresh(body.refreshToken);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
logout(@Body() body: LogoutDto) {
|
||||
return this.authService.logout(body.refreshToken);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('me')
|
||||
me(@Headers('authorization') authorization?: string) {
|
||||
const token = bearer(authorization);
|
||||
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||
return this.authService.getMe(token);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Patch('me')
|
||||
updateMe(@Headers('authorization') authorization: string | undefined, @Body() body: UpdateMeDto) {
|
||||
const token = bearer(authorization);
|
||||
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||
return this.authService.updateMe(token, body.name);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('me/guilds')
|
||||
meGuilds(@Headers('authorization') authorization?: string) {
|
||||
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
|
||||
const token = bearer(authorization);
|
||||
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||
return this.authService.listMyGuilds(token);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('me/guilds/join')
|
||||
joinGuild(@Headers('authorization') authorization: string | undefined, @Body() body: { guildNodeId?: string }) {
|
||||
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
|
||||
const token = bearer(authorization);
|
||||
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||
return this.authService.joinGuild(token, String(body?.guildNodeId ?? ''));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('guilds/:guildNodeId/members')
|
||||
guildMembers(@Headers('authorization') authorization: string | undefined, @Param('guildNodeId') guildNodeId: string) {
|
||||
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
|
||||
const token = bearer(authorization);
|
||||
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||
return this.authService.listGuildMembers(token, guildNodeId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('agent/login')
|
||||
agentLogin(@Body() body: { apiKey?: string }) {
|
||||
return this.authService.agentLogin(String(body?.apiKey ?? ''));
|
||||
}
|
||||
|
||||
// Not @Public(): requires a guild node api key. Returns the single
|
||||
// Center-scoped admin user `{email, userId}` so the calling guild can
|
||||
// mark them as triage observers, or `null` if no admin is set.
|
||||
@Get('admin-email')
|
||||
getAdminEmail(@Req() req: { guildNode?: AuthedGuildNode }) {
|
||||
if (!req.guildNode) throw new UnauthorizedException('guild node api key required');
|
||||
return this.authService.getAdminEmail();
|
||||
}
|
||||
|
||||
// Not @Public(): requires a guild node api key. The guard attaches the
|
||||
// authenticated node; a node may only introspect tokens for its own
|
||||
// guild (Center C2 — prevents cross-tenant identity probing).
|
||||
@Post('introspect')
|
||||
introspect(@Body() body: { token?: string; guildNodeId?: string }) {
|
||||
return this.authService.introspectGuildToken(body?.token ?? '', body?.guildNodeId ?? '');
|
||||
introspect(
|
||||
@Req() req: { guildNode?: AuthedGuildNode },
|
||||
@Body() body: { token?: string; guildNodeId?: string },
|
||||
) {
|
||||
const requested = String(body?.guildNodeId ?? '');
|
||||
if (!req.guildNode || req.guildNode.nodeId !== requested) {
|
||||
throw new ForbiddenException('api key not authorized for this guild node');
|
||||
}
|
||||
return this.authService.introspectGuildToken(body?.token ?? '', requested);
|
||||
}
|
||||
|
||||
// Not @Public(): same node-ownership rule as introspect (Center C2 —
|
||||
// prevents one node enumerating another guild's member identities).
|
||||
@Post('resolve-names')
|
||||
resolveNames(
|
||||
@Req() req: { guildNode?: AuthedGuildNode },
|
||||
@Body() body: { guildNodeId?: string; names?: string[] },
|
||||
) {
|
||||
const requested = String(body?.guildNodeId ?? '');
|
||||
if (!req.guildNode || req.guildNode.nodeId !== requested) {
|
||||
throw new ForbiddenException('api key not authorized for this guild node');
|
||||
}
|
||||
return this.authService.resolveNames(
|
||||
requested,
|
||||
Array.isArray(body?.names) ? body.names : [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { User } from '../entities/user.entity';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import { GuildUser } from '../entities/guild-user.entity';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { OidcController } from './oidc.controller.js';
|
||||
import { OidcService } from './oidc.service.js';
|
||||
import { TesseraService } from './tessera.service.js';
|
||||
import { User } from '../entities/user.entity.js';
|
||||
import { GuildNode } from '../entities/guild-node.entity.js';
|
||||
import { GuildUser } from '../entities/guild-user.entity.js';
|
||||
import { UserApiKey } from '../entities/user-api-key.entity.js';
|
||||
import { OidcConfig } from '../entities/oidc-config.entity.js';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser])],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey, OidcConfig])],
|
||||
controllers: [AuthController, OidcController],
|
||||
providers: [AuthService, OidcService, TesseraService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -5,15 +5,18 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { User } from '../entities/user.entity';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import { GuildUser } from '../entities/guild-user.entity';
|
||||
import { RegisterDto } from './dto.register.dto';
|
||||
import { LoginDto } from './dto.login.dto';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { parseDurationToSeconds } from './token.util';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { User } from '../entities/user.entity.js';
|
||||
import { GuildNode } from '../entities/guild-node.entity.js';
|
||||
import { GuildUser } from '../entities/guild-user.entity.js';
|
||||
import { UserApiKey } from '../entities/user-api-key.entity.js';
|
||||
import { RegisterDto } from './dto.register.dto.js';
|
||||
import { LoginDto } from './dto.login.dto.js';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
import { TesseraService, type TesseraClaims } from './tessera.service.js';
|
||||
import { parseDurationToSeconds } from './token.util.js';
|
||||
|
||||
function signAccessToken(userId: string, email: string): string {
|
||||
const secret = process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string;
|
||||
@@ -46,9 +49,183 @@ export class AuthService {
|
||||
private readonly guildNodeRepo: Repository<GuildNode>,
|
||||
@InjectRepository(GuildUser)
|
||||
private readonly guildUserRepo: Repository<GuildUser>,
|
||||
@InjectRepository(UserApiKey)
|
||||
private readonly apiKeyRepo: Repository<UserApiKey>,
|
||||
private readonly audit: AuditService,
|
||||
private readonly tessera: TesseraService,
|
||||
) {}
|
||||
|
||||
// Mint a long-lived API key for a user (agent machine credential).
|
||||
async createUserApiKey(email: string, label?: string) {
|
||||
const user = await this.userRepo.findOne({ where: { email } });
|
||||
if (!user) throw new UnauthorizedException('user not found');
|
||||
const raw = `fak_${randomBytes(24).toString('hex')}`;
|
||||
await this.apiKeyRepo.save(
|
||||
this.apiKeyRepo.create({
|
||||
userId: user.id,
|
||||
apiKeyHash: await bcrypt.hash(raw, 10),
|
||||
label: label ?? null,
|
||||
}),
|
||||
);
|
||||
await this.audit.write({
|
||||
action: 'auth.apikey.create',
|
||||
actorId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
detail: JSON.stringify({ label: label ?? null }),
|
||||
});
|
||||
return { userId: user.id, email: user.email, apiKey: raw };
|
||||
}
|
||||
|
||||
// Exchange an agent API key for a normal user session (same shape as login).
|
||||
async agentLogin(apiKey: string) {
|
||||
if (!apiKey) throw new UnauthorizedException('missing api key');
|
||||
const keys = await this.apiKeyRepo.find();
|
||||
let userId: string | null = null;
|
||||
for (const k of keys) {
|
||||
if (await bcrypt.compare(apiKey, k.apiKeyHash)) {
|
||||
userId = k.userId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!userId) throw new UnauthorizedException('invalid api key');
|
||||
|
||||
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('invalid api key');
|
||||
|
||||
const accessToken = signAccessToken(user.id, user.email);
|
||||
const refreshToken = signRefreshToken(user.id, user.email);
|
||||
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
||||
await this.userRepo.save(user);
|
||||
|
||||
await this.audit.write({
|
||||
action: 'auth.agent_login',
|
||||
actorId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
detail: null,
|
||||
});
|
||||
|
||||
const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: getAccessTokenExpiresInSeconds(),
|
||||
user: { id: user.id, email: user.email, name: user.name ?? user.email },
|
||||
guilds,
|
||||
guildAccessTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// OIDC: find a user by email or auto-provision one (no usable password —
|
||||
// OIDC users never password-login). Returns the userId.
|
||||
async provisionOidcUser(email: string, name?: string): Promise<string> {
|
||||
const e = String(email ?? '').trim();
|
||||
if (!e) throw new UnauthorizedException('oidc: missing email claim');
|
||||
let user = await this.userRepo.findOne({ where: { email: e } });
|
||||
if (!user) {
|
||||
const passwordHash = await bcrypt.hash(`oidc!${randomBytes(24).toString('hex')}`, 10);
|
||||
user = await this.userRepo.save(
|
||||
this.userRepo.create({
|
||||
email: e,
|
||||
name: (name ?? '').trim() || e,
|
||||
passwordHash,
|
||||
refreshTokenHash: null,
|
||||
}),
|
||||
);
|
||||
await this.audit.write({
|
||||
action: 'auth.oidc.provision',
|
||||
actorId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
detail: JSON.stringify({ email: e }),
|
||||
});
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
// Tessera (external OIDC) access token: find a user by email or
|
||||
// auto-provision one (mirrors provisionOidcUser), then sync the admin
|
||||
// flag from the token's roles. Returns the Fabric userId + email.
|
||||
async provisionTesseraUser(claims: TesseraClaims): Promise<{ userId: string; email: string }> {
|
||||
const email = String(claims.email ?? claims.preferred_username ?? '').trim();
|
||||
if (!email) throw new UnauthorizedException('tessera: missing email/preferred_username claim');
|
||||
const name = String(claims.name ?? claims.preferred_username ?? '').trim() || email;
|
||||
|
||||
let user = await this.userRepo.findOne({ where: { email } });
|
||||
if (!user) {
|
||||
const passwordHash = await bcrypt.hash(`oidc!${randomBytes(24).toString('hex')}`, 10);
|
||||
user = await this.userRepo.save(
|
||||
this.userRepo.create({
|
||||
email,
|
||||
name,
|
||||
passwordHash,
|
||||
refreshTokenHash: null,
|
||||
}),
|
||||
);
|
||||
await this.audit.write({
|
||||
action: 'auth.tessera.provision',
|
||||
actorId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
detail: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
|
||||
const shouldBeAdmin = this.tessera.isAdmin(claims);
|
||||
if (shouldBeAdmin && !user.isAdmin) {
|
||||
user.isAdmin = true;
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
|
||||
return { userId: user.id, email: user.email };
|
||||
}
|
||||
|
||||
// Resolve an inbound bearer access token to a Fabric userId. Tries
|
||||
// Fabric's own HS256 access token first; on failure, falls back to an
|
||||
// external Tessera RS256 token (auto-provisioning the user). This is the
|
||||
// shared path behind getMe/updateMe/listMyGuilds/join/members so all of
|
||||
// them accept either token type.
|
||||
private async resolveAccessToken(accessToken: string): Promise<{ userId: string; email: string }> {
|
||||
try {
|
||||
const payload = this.verifyCenterAccessToken(accessToken);
|
||||
return { userId: String(payload.sub ?? ''), email: String(payload.email ?? '') };
|
||||
} catch {
|
||||
// fall through to Tessera
|
||||
}
|
||||
const claims = await this.tessera.verify(accessToken);
|
||||
return this.provisionTesseraUser(claims);
|
||||
}
|
||||
|
||||
// Issue a full session for an already-authenticated userId (same shape as
|
||||
// login()). Used by the OIDC ticket exchange.
|
||||
async issueSessionForUserId(userId: string) {
|
||||
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('user not found');
|
||||
const accessToken = signAccessToken(user.id, user.email);
|
||||
const refreshToken = signRefreshToken(user.id, user.email);
|
||||
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
||||
await this.userRepo.save(user);
|
||||
await this.audit.write({
|
||||
action: 'auth.oidc.login',
|
||||
actorId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
detail: null,
|
||||
});
|
||||
const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: getAccessTokenExpiresInSeconds(),
|
||||
user: { id: user.id, email: user.email, name: user.name ?? user.email },
|
||||
guilds,
|
||||
guildAccessTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private async getUserGuildsAndTokens(userId: string, email: string) {
|
||||
const memberships = await this.guildUserRepo.find({
|
||||
where: { userId, status: 'active' },
|
||||
@@ -70,6 +247,7 @@ export class AuthService {
|
||||
name: n.name,
|
||||
endpoint: n.endpoint,
|
||||
status: n.status,
|
||||
purpose: n.purpose ?? null,
|
||||
}));
|
||||
|
||||
const guildAccessTokens = guilds.map((g) => ({
|
||||
@@ -82,6 +260,58 @@ export class AuthService {
|
||||
return { guilds, guildAccessTokens };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Center-scoped admin (at most one per deployment).
|
||||
//
|
||||
// setAdmin / clearAdmin are reachable only via cli, not HTTP — admin
|
||||
// promotion bypasses every HTTP gate (the admin observes ALL triage
|
||||
// traffic) so we don't want it tunneled through any token / api-key.
|
||||
//
|
||||
// getAdminEmail is exposed to guild backends (api-key auth) so each
|
||||
// guild can mark the admin as a triage observer.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
async getAdminEmail(): Promise<{ email: string; userId: string } | null> {
|
||||
const admin = await this.userRepo.findOne({ where: { isAdmin: true } });
|
||||
if (!admin) return null;
|
||||
return { email: admin.email, userId: admin.id };
|
||||
}
|
||||
|
||||
async setAdmin(email: string): Promise<{ email: string; userId: string }> {
|
||||
const target = await this.userRepo.findOne({ where: { email } });
|
||||
if (!target) {
|
||||
throw new UnauthorizedException(`user ${email} not found`);
|
||||
}
|
||||
// Enforce at-most-one-admin: clear every existing admin row first
|
||||
// (TypeORM rejects an empty-where update, so target by isAdmin=true
|
||||
// — the no-op cost when no rows match is one cheap query), then set
|
||||
// the target. Two UPDATEs in one txn so a peer never sees two admins.
|
||||
await this.userRepo.manager.transaction(async (txn) => {
|
||||
await txn.update(User, { isAdmin: true }, { isAdmin: false });
|
||||
await txn.update(User, { id: target.id }, { isAdmin: true });
|
||||
});
|
||||
await this.audit.write({
|
||||
action: 'admin.set',
|
||||
actorId: target.id,
|
||||
targetType: 'user',
|
||||
targetId: target.id,
|
||||
detail: JSON.stringify({ email: target.email }),
|
||||
});
|
||||
return { email: target.email, userId: target.id };
|
||||
}
|
||||
|
||||
async clearAdmin(): Promise<{ cleared: number }> {
|
||||
const res = await this.userRepo.update({ isAdmin: true }, { isAdmin: false });
|
||||
await this.audit.write({
|
||||
action: 'admin.clear',
|
||||
actorId: 'cli',
|
||||
targetType: 'user',
|
||||
targetId: 'all',
|
||||
detail: JSON.stringify({ cleared: res.affected ?? 0 }),
|
||||
});
|
||||
return { cleared: res.affected ?? 0 };
|
||||
}
|
||||
|
||||
async register(input: RegisterDto) {
|
||||
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
||||
if (exists) {
|
||||
@@ -91,6 +321,7 @@ export class AuthService {
|
||||
const passwordHash = await bcrypt.hash(input.password, 10);
|
||||
const user = this.userRepo.create({
|
||||
email: input.email,
|
||||
name: input.email,
|
||||
passwordHash,
|
||||
refreshTokenHash: null,
|
||||
});
|
||||
@@ -107,6 +338,7 @@ export class AuthService {
|
||||
return {
|
||||
id: saved.id,
|
||||
email: saved.email,
|
||||
name: saved.name ?? saved.email,
|
||||
createdAt: saved.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -141,22 +373,49 @@ export class AuthService {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? user.email,
|
||||
},
|
||||
guilds,
|
||||
guildAccessTokens,
|
||||
};
|
||||
}
|
||||
|
||||
async getMe(accessToken: string) {
|
||||
const { userId } = await this.resolveAccessToken(accessToken);
|
||||
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('user not found');
|
||||
return { id: user.id, email: user.email, name: user.name ?? user.email };
|
||||
}
|
||||
|
||||
async updateMe(accessToken: string, name: string) {
|
||||
const { userId } = await this.resolveAccessToken(accessToken);
|
||||
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('user not found');
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new ConflictException('name must not be empty');
|
||||
|
||||
user.name = trimmed;
|
||||
await this.userRepo.save(user);
|
||||
|
||||
await this.audit.write({
|
||||
action: 'auth.update_profile',
|
||||
actorId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
detail: JSON.stringify({ name: trimmed }),
|
||||
});
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
}
|
||||
|
||||
async listMyGuilds(accessToken: string) {
|
||||
const payload = this.verifyCenterAccessToken(accessToken);
|
||||
const userId = String(payload.sub ?? '');
|
||||
const email = String(payload.email ?? '');
|
||||
const { userId, email } = await this.resolveAccessToken(accessToken);
|
||||
return this.getUserGuildsAndTokens(userId, email);
|
||||
}
|
||||
|
||||
async joinGuild(accessToken: string, guildNodeId: string) {
|
||||
const payload = this.verifyCenterAccessToken(accessToken);
|
||||
const userId = String(payload.sub ?? '');
|
||||
const { userId } = await this.resolveAccessToken(accessToken);
|
||||
if (!userId) throw new UnauthorizedException('invalid access token');
|
||||
|
||||
const node = await this.guildNodeRepo.findOne({ where: { nodeId: guildNodeId, status: 'active' } });
|
||||
@@ -176,8 +435,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async listGuildMembers(accessToken: string, guildNodeId: string) {
|
||||
const payload = this.verifyCenterAccessToken(accessToken);
|
||||
const userId = String(payload.sub ?? '');
|
||||
const { userId } = await this.resolveAccessToken(accessToken);
|
||||
if (!userId) throw new UnauthorizedException('invalid access token');
|
||||
|
||||
const selfMembership = await this.guildUserRepo.findOne({ where: { userId, guildNodeId, status: 'active' } });
|
||||
@@ -194,10 +452,45 @@ export class AuthService {
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
return members
|
||||
.map((m) => ({ userId: m.userId, email: userMap.get(m.userId)?.email ?? '', status: m.status }))
|
||||
.map((m) => {
|
||||
const u = userMap.get(m.userId);
|
||||
return {
|
||||
userId: m.userId,
|
||||
email: u?.email ?? '',
|
||||
name: u?.name ?? u?.email ?? '',
|
||||
status: m.status,
|
||||
};
|
||||
})
|
||||
.filter((x) => !!x.email);
|
||||
}
|
||||
|
||||
// Resolve display names (or emails) to userIds, scoped to a guild node's
|
||||
// active members. Called by guild nodes (api-key auth). Unresolved names
|
||||
// are omitted from the result.
|
||||
async resolveNames(
|
||||
guildNodeId: string,
|
||||
names: string[],
|
||||
): Promise<{ resolved: Record<string, string> }> {
|
||||
const wanted = [...new Set(names.map((n) => String(n ?? '').trim()).filter(Boolean))];
|
||||
if (!guildNodeId || !wanted.length) return { resolved: {} };
|
||||
|
||||
const members = await this.guildUserRepo.find({ where: { guildNodeId, status: 'active' } });
|
||||
const userIds = [...new Set(members.map((m) => m.userId))];
|
||||
if (!userIds.length) return { resolved: {} };
|
||||
|
||||
const users = await this.userRepo
|
||||
.createQueryBuilder('u')
|
||||
.where('u.id IN (:...userIds)', { userIds })
|
||||
.getMany();
|
||||
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const name of wanted) {
|
||||
const hit = users.find((u) => u.name === name) ?? users.find((u) => u.email === name);
|
||||
if (hit) resolved[name] = hit.id;
|
||||
}
|
||||
return { resolved };
|
||||
}
|
||||
|
||||
verifyCenterAccessToken(accessToken: string): jwt.JwtPayload {
|
||||
try {
|
||||
const payload = jwt.verify(accessToken, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload;
|
||||
@@ -209,17 +502,39 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async introspectGuildToken(token: string, guildNodeId: string) {
|
||||
let payload: jwt.JwtPayload;
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload;
|
||||
} catch {
|
||||
return { active: false };
|
||||
}
|
||||
// Resolve the bearer to a Fabric user. Two accepted token shapes:
|
||||
// 1. Fabric HS256 guild_access token (bound to a specific gid).
|
||||
// 2. External Tessera RS256 access token (no gid — authorized purely
|
||||
// by the resolved user's active membership in this guild node).
|
||||
let userId = '';
|
||||
let email = '';
|
||||
|
||||
const userId = String(payload.sub ?? '');
|
||||
const tokenGuildNodeId = String(payload.gid ?? '');
|
||||
if (!userId || tokenGuildNodeId !== guildNodeId || payload.typ !== 'guild_access') {
|
||||
return { active: false };
|
||||
try {
|
||||
const payload = jwt.verify(
|
||||
token,
|
||||
process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string,
|
||||
) as jwt.JwtPayload;
|
||||
const tokenGuildNodeId = String(payload.gid ?? '');
|
||||
if (!payload.sub || tokenGuildNodeId !== guildNodeId || payload.typ !== 'guild_access') {
|
||||
return { active: false };
|
||||
}
|
||||
userId = String(payload.sub);
|
||||
email = String(payload.email ?? '');
|
||||
} catch {
|
||||
// Not a Fabric token — try Tessera.
|
||||
let claims: TesseraClaims;
|
||||
try {
|
||||
claims = await this.tessera.verify(token);
|
||||
} catch {
|
||||
return { active: false };
|
||||
}
|
||||
try {
|
||||
const provisioned = await this.provisionTesseraUser(claims);
|
||||
userId = provisioned.userId;
|
||||
email = provisioned.email;
|
||||
} catch {
|
||||
return { active: false };
|
||||
}
|
||||
}
|
||||
|
||||
const membership = await this.guildUserRepo.findOne({
|
||||
@@ -229,10 +544,7 @@ export class AuthService {
|
||||
|
||||
return {
|
||||
active: true,
|
||||
user: {
|
||||
id: userId,
|
||||
email: String(payload.email ?? ''),
|
||||
},
|
||||
user: { id: userId, email },
|
||||
guildNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
8
src/auth/dto.update-me.dto.ts
Normal file
8
src/auth/dto.update-me.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateMeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
}
|
||||
51
src/auth/oidc.controller.ts
Normal file
51
src/auth/oidc.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { Public } from '../common/public.decorator.js';
|
||||
import { OidcService } from './oidc.service.js';
|
||||
|
||||
@Public()
|
||||
@Controller('auth/oidc')
|
||||
export class OidcController {
|
||||
constructor(private readonly oidc: OidcService) {}
|
||||
|
||||
// Frontend asks whether to show the SSO button.
|
||||
@Get('status')
|
||||
async status() {
|
||||
return { enabled: await this.oidc.isEnabled() };
|
||||
}
|
||||
|
||||
// Browser hits this; we 302 to the IdP authorize endpoint.
|
||||
@Get('start')
|
||||
async start(@Res() res: Response) {
|
||||
const url = await this.oidc.buildAuthorizeUrl();
|
||||
res.redirect(url);
|
||||
}
|
||||
|
||||
// IdP redirects back here with ?code&state. We mint a one-time ticket
|
||||
// and bounce the browser to the SPA with it in the URL fragment (so the
|
||||
// ticket never lands in server/proxy access logs).
|
||||
@Get('callback')
|
||||
async callback(
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
const { ticket, redirect } = await this.oidc.handleCallback(code, state);
|
||||
const sep = redirect.includes('#') ? '&' : '#';
|
||||
res.redirect(`${redirect}${sep}oidc_ticket=${encodeURIComponent(ticket)}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'oidc error';
|
||||
const c = await this.oidc.getConfig();
|
||||
const base = c?.postLoginRedirect || '/';
|
||||
const sep = base.includes('#') ? '&' : '#';
|
||||
res.redirect(`${base}${sep}oidc_error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// SPA exchanges the one-time ticket for a full Fabric session.
|
||||
@Post('exchange')
|
||||
async exchange(@Body() body: { ticket?: string }) {
|
||||
return this.oidc.exchangeTicket(String(body?.ticket ?? ''));
|
||||
}
|
||||
}
|
||||
200
src/auth/oidc.service.ts
Normal file
200
src/auth/oidc.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { OidcConfig } from '../entities/oidc-config.entity.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
|
||||
const ROW_ID = 'default';
|
||||
|
||||
type Discovery = {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
};
|
||||
|
||||
function b64url(buf: Buffer): string {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private discoveryCache: { issuer: string; doc: Discovery; at: number } | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(OidcConfig)
|
||||
private readonly repo: Repository<OidcConfig>,
|
||||
private readonly auth: AuthService,
|
||||
) {}
|
||||
|
||||
private stateSecret(): string {
|
||||
return process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<OidcConfig | null> {
|
||||
return this.repo.findOne({ where: { id: ROW_ID } });
|
||||
}
|
||||
|
||||
async setConfig(patch: Partial<Omit<OidcConfig, 'id' | 'updatedAt'>>): Promise<OidcConfig> {
|
||||
let row = await this.repo.findOne({ where: { id: ROW_ID } });
|
||||
if (!row) row = this.repo.create({ id: ROW_ID });
|
||||
Object.assign(row, patch);
|
||||
return this.repo.save(row);
|
||||
}
|
||||
|
||||
async isEnabled(): Promise<boolean> {
|
||||
const c = await this.getConfig();
|
||||
return !!(c && c.enabled && c.issuer && c.clientId && c.clientSecret && c.redirectUri);
|
||||
}
|
||||
|
||||
private async requireEnabledConfig(): Promise<OidcConfig> {
|
||||
const c = await this.getConfig();
|
||||
if (!c || !c.enabled || !c.issuer || !c.clientId || !c.clientSecret || !c.redirectUri) {
|
||||
throw new BadRequestException('oidc is not configured/enabled');
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private async discover(issuer: string): Promise<Discovery> {
|
||||
const now = Date.now();
|
||||
if (this.discoveryCache && this.discoveryCache.issuer === issuer && now - this.discoveryCache.at < 3600_000) {
|
||||
return this.discoveryCache.doc;
|
||||
}
|
||||
const url = issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch {
|
||||
throw new BadRequestException('oidc: issuer unreachable (discovery failed)');
|
||||
}
|
||||
if (!res.ok) throw new BadRequestException(`oidc discovery failed: ${res.status}`);
|
||||
const doc = (await res.json()) as Discovery;
|
||||
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
||||
throw new BadRequestException('oidc discovery missing endpoints');
|
||||
}
|
||||
this.discoveryCache = { issuer, doc, at: now };
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Build the IdP authorize URL. PKCE verifier + nonce are sealed into a
|
||||
// short-lived signed `state` JWT (no server-side session needed).
|
||||
async buildAuthorizeUrl(): Promise<string> {
|
||||
const c = await this.requireEnabledConfig();
|
||||
const doc = await this.discover(c.issuer);
|
||||
const codeVerifier = b64url(randomBytes(32));
|
||||
const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest());
|
||||
const nonce = b64url(randomBytes(16));
|
||||
const state = jwt.sign({ cv: codeVerifier, n: nonce, typ: 'oidc_state' }, this.stateSecret(), {
|
||||
expiresIn: 600,
|
||||
});
|
||||
const p = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: c.clientId,
|
||||
redirect_uri: c.redirectUri,
|
||||
scope: c.scopes || 'openid email profile',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${doc.authorization_endpoint}?${p.toString()}`;
|
||||
}
|
||||
|
||||
// Handle the IdP callback: validate state, exchange the code, resolve the
|
||||
// user's email/name, provision/find the user, and return a one-time
|
||||
// ticket (JWT, 60s) plus the post-login redirect URL.
|
||||
async handleCallback(code: string, state: string): Promise<{ ticket: string; redirect: string }> {
|
||||
if (!code || !state) throw new BadRequestException('oidc: missing code/state');
|
||||
const c = await this.requireEnabledConfig();
|
||||
|
||||
let st: { cv?: string; typ?: string };
|
||||
try {
|
||||
st = jwt.verify(state, this.stateSecret()) as { cv?: string; typ?: string };
|
||||
} catch {
|
||||
throw new UnauthorizedException('oidc: invalid state');
|
||||
}
|
||||
if (st.typ !== 'oidc_state' || !st.cv) throw new UnauthorizedException('oidc: invalid state');
|
||||
|
||||
const doc = await this.discover(c.issuer);
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: c.redirectUri,
|
||||
client_id: c.clientId,
|
||||
client_secret: c.clientSecret,
|
||||
code_verifier: st.cv,
|
||||
});
|
||||
let tokRes: Response;
|
||||
try {
|
||||
tokRes = await fetch(doc.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body: body.toString(),
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('oidc: token endpoint unreachable');
|
||||
}
|
||||
if (!tokRes.ok) {
|
||||
throw new UnauthorizedException(`oidc: token exchange failed (${tokRes.status})`);
|
||||
}
|
||||
const tok = (await tokRes.json()) as { access_token?: string; id_token?: string };
|
||||
|
||||
const claims = await this.resolveClaims(doc, tok);
|
||||
const email = claims.email;
|
||||
if (!email) throw new UnauthorizedException('oidc: no email claim');
|
||||
|
||||
const userId = await this.auth.provisionOidcUser(email, claims.name);
|
||||
const ticket = jwt.sign({ sub: userId, typ: 'oidc_ticket' }, this.stateSecret(), { expiresIn: 60 });
|
||||
return { ticket, redirect: c.postLoginRedirect };
|
||||
}
|
||||
|
||||
// Prefer userinfo (verified over TLS with the fresh access_token); fall
|
||||
// back to decoding id_token payload for email/name/sub.
|
||||
private async resolveClaims(
|
||||
doc: Discovery,
|
||||
tok: { access_token?: string; id_token?: string },
|
||||
): Promise<{ email?: string; name?: string }> {
|
||||
if (doc.userinfo_endpoint && tok.access_token) {
|
||||
try {
|
||||
const r = await fetch(doc.userinfo_endpoint, {
|
||||
headers: { authorization: `Bearer ${tok.access_token}` },
|
||||
});
|
||||
if (r.ok) {
|
||||
const u = (await r.json()) as { email?: string; name?: string; preferred_username?: string };
|
||||
if (u.email) return { email: u.email, name: u.name ?? u.preferred_username };
|
||||
}
|
||||
} catch {
|
||||
// fall through to id_token
|
||||
}
|
||||
}
|
||||
if (tok.id_token) {
|
||||
const part = tok.id_token.split('.')[1];
|
||||
if (part) {
|
||||
try {
|
||||
const p = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as {
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
};
|
||||
return { email: p.email, name: p.name ?? p.preferred_username };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Redeem the one-time ticket for a full Fabric session (login shape).
|
||||
async exchangeTicket(ticket: string) {
|
||||
let t: { sub?: string; typ?: string };
|
||||
try {
|
||||
t = jwt.verify(ticket, this.stateSecret()) as { sub?: string; typ?: string };
|
||||
} catch {
|
||||
throw new UnauthorizedException('oidc: invalid or expired ticket');
|
||||
}
|
||||
if (t.typ !== 'oidc_ticket' || !t.sub) throw new UnauthorizedException('oidc: invalid ticket');
|
||||
return this.auth.issueSessionForUserId(t.sub);
|
||||
}
|
||||
}
|
||||
113
src/auth/tessera.service.ts
Normal file
113
src/auth/tessera.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// Verifies access tokens issued by the external "Tessera" OIDC provider
|
||||
// (Keycloak-compatible). These are RS256 JWS with a `kid` in the header;
|
||||
// the signing keys are published as a JWKS at {issuer}/protocol/
|
||||
// openid-connect/certs (each key carries an x5c cert chain).
|
||||
//
|
||||
// This is ADDITIVE to Fabric's own HS256 access tokens — see how
|
||||
// AuthService falls back to it.
|
||||
|
||||
type Jwk = { kid: string; kty: string; use?: string; alg?: string; x5c?: string[]; n?: string; e?: string };
|
||||
|
||||
export type TesseraClaims = jwt.JwtPayload & {
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
realm_access?: { roles?: string[] };
|
||||
resource_access?: Record<string, { roles?: string[] }>;
|
||||
};
|
||||
|
||||
function tesseraIssuer(): string {
|
||||
return (process.env.TESSERA_ISSUER ?? 'https://login.hangman-lab.top/realms/Hangman-Lab').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function tesseraAudience(): string {
|
||||
return process.env.TESSERA_AUDIENCE ?? 'fabric';
|
||||
}
|
||||
|
||||
function pemFromX5c(x5c: string): string {
|
||||
const body = x5c.replace(/(.{64})/g, '$1\n');
|
||||
return `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TesseraService {
|
||||
// Cache verification keys by kid. Refetched when an unknown kid is seen
|
||||
// (e.g. after key rotation) or the cache is older than the TTL.
|
||||
private keyCache = new Map<string, string>();
|
||||
private fetchedAt = 0;
|
||||
private readonly cacheTtlMs = 3600_000;
|
||||
|
||||
private async loadKeys(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && this.keyCache.size && now - this.fetchedAt < this.cacheTtlMs) return;
|
||||
|
||||
const url = `${tesseraIssuer()}/protocol/openid-connect/certs`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch {
|
||||
throw new UnauthorizedException('tessera: jwks unreachable');
|
||||
}
|
||||
if (!res.ok) throw new UnauthorizedException(`tessera: jwks fetch failed (${res.status})`);
|
||||
const doc = (await res.json()) as { keys?: Jwk[] };
|
||||
const next = new Map<string, string>();
|
||||
for (const k of doc.keys ?? []) {
|
||||
if (k.kty !== 'RSA' || !k.kid) continue;
|
||||
if (k.x5c && k.x5c[0]) next.set(k.kid, pemFromX5c(k.x5c[0]));
|
||||
}
|
||||
if (next.size) {
|
||||
this.keyCache = next;
|
||||
this.fetchedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
private async keyForKid(kid: string): Promise<string | undefined> {
|
||||
await this.loadKeys();
|
||||
if (this.keyCache.has(kid)) return this.keyCache.get(kid);
|
||||
// Unknown kid: keys may have rotated — force a refresh once.
|
||||
await this.loadKeys(true);
|
||||
return this.keyCache.get(kid);
|
||||
}
|
||||
|
||||
// Verify a Tessera access token. Returns decoded claims on success;
|
||||
// throws UnauthorizedException otherwise. Checks RS256 signature against
|
||||
// the matching JWKS key, issuer, audience and expiry.
|
||||
async verify(token: string): Promise<TesseraClaims> {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded || typeof decoded === 'string') {
|
||||
throw new UnauthorizedException('tessera: malformed token');
|
||||
}
|
||||
if (decoded.header.alg !== 'RS256') {
|
||||
throw new UnauthorizedException('tessera: unsupported alg');
|
||||
}
|
||||
const kid = decoded.header.kid;
|
||||
if (!kid) throw new UnauthorizedException('tessera: missing kid');
|
||||
|
||||
const pem = await this.keyForKid(kid);
|
||||
if (!pem) throw new UnauthorizedException('tessera: unknown signing key');
|
||||
|
||||
try {
|
||||
// `aud` may be a string or an array; jsonwebtoken's `audience`
|
||||
// option matches against either form.
|
||||
const payload = jwt.verify(token, pem, {
|
||||
algorithms: ['RS256'],
|
||||
issuer: tesseraIssuer(),
|
||||
audience: tesseraAudience(),
|
||||
}) as TesseraClaims;
|
||||
return payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('tessera: invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
// Does this token claim an admin role for Fabric? Checks realm roles and
|
||||
// the audience client's resource roles for "fabric-admin"/"admin".
|
||||
isAdmin(claims: TesseraClaims): boolean {
|
||||
const adminRoles = new Set(['fabric-admin', 'admin']);
|
||||
const realmRoles = claims.realm_access?.roles ?? [];
|
||||
const clientRoles = claims.resource_access?.[tesseraAudience()]?.roles ?? [];
|
||||
return [...realmRoles, ...clientRoles].some((r) => adminRoles.has(r));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseDurationToSeconds } from './token.util';
|
||||
import { parseDurationToSeconds } from './token.util.js';
|
||||
|
||||
describe('parseDurationToSeconds', () => {
|
||||
it('parses time units', () => {
|
||||
|
||||
99
src/cli.ts
99
src/cli.ts
@@ -1,8 +1,9 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { NodeAdminService } from './nodes/node-admin.service';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { AuthService } from './auth/auth.service.js';
|
||||
import { OidcService } from './auth/oidc.service.js';
|
||||
import { NodeAdminService } from './nodes/node-admin.service.js';
|
||||
|
||||
function getArg(flag: string): string | null {
|
||||
const idx = process.argv.indexOf(flag);
|
||||
@@ -13,7 +14,17 @@ function getArg(flag: string): string | null {
|
||||
function printUsageAndExit(): never {
|
||||
console.error('Usage:');
|
||||
console.error(' node dist/cli.js user create --email <email> --password <password>');
|
||||
console.error(' node dist/cli.js user apikey --email <email> [--label <label>]');
|
||||
console.error(' node dist/cli.js user set-admin --email <email>');
|
||||
console.error(' node dist/cli.js user clear-admin');
|
||||
console.error(' node dist/cli.js user show-admin');
|
||||
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
|
||||
console.error(' node dist/cli.js node set-purpose --node-id <id> --purpose <text>');
|
||||
console.error(
|
||||
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
|
||||
' [--callback-url <url>] [--post-login-redirect <url>] [--scopes "openid email profile"]\n' +
|
||||
' [--enabled true|false] (sets provided fields; prints config with secret masked)',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -34,6 +45,41 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'user' && action === 'apikey') {
|
||||
const email = getArg('--email');
|
||||
const label = getArg('--label');
|
||||
if (!email) printUsageAndExit();
|
||||
|
||||
const auth = app.get(AuthService);
|
||||
const res = await auth.createUserApiKey(email, label ?? undefined);
|
||||
process.stdout.write(JSON.stringify({ ok: true, ...res }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'user' && action === 'set-admin') {
|
||||
const email = getArg('--email');
|
||||
if (!email) printUsageAndExit();
|
||||
|
||||
const auth = app.get(AuthService);
|
||||
const res = await auth.setAdmin(email);
|
||||
process.stdout.write(JSON.stringify({ ok: true, admin: res }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'user' && action === 'clear-admin') {
|
||||
const auth = app.get(AuthService);
|
||||
const res = await auth.clearAdmin();
|
||||
process.stdout.write(JSON.stringify({ ok: true, ...res }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'user' && action === 'show-admin') {
|
||||
const auth = app.get(AuthService);
|
||||
const res = await auth.getAdminEmail();
|
||||
process.stdout.write(JSON.stringify({ ok: true, admin: res }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'node' && action === 'register') {
|
||||
const nodeId = getArg('--node-id');
|
||||
const name = getArg('--name');
|
||||
@@ -46,6 +92,53 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'node' && action === 'set-purpose') {
|
||||
const nodeId = getArg('--node-id');
|
||||
const purpose = getArg('--purpose');
|
||||
if (!nodeId || purpose === null) printUsageAndExit();
|
||||
|
||||
const nodes = app.get(NodeAdminService);
|
||||
const result = await nodes.setPurpose(nodeId, purpose);
|
||||
process.stdout.write(JSON.stringify({ ok: true, node: result }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === 'config' && action === 'oidc') {
|
||||
const oidc = app.get(OidcService);
|
||||
const patch: Record<string, unknown> = {};
|
||||
const issuer = getArg('--issuer');
|
||||
if (issuer !== null) patch.issuer = issuer;
|
||||
const clientId = getArg('--client-id');
|
||||
if (clientId !== null) patch.clientId = clientId;
|
||||
const clientSecret = getArg('--client-secret');
|
||||
if (clientSecret !== null) patch.clientSecret = clientSecret;
|
||||
const callbackUrl = getArg('--callback-url');
|
||||
if (callbackUrl !== null) patch.redirectUri = callbackUrl;
|
||||
const postLogin = getArg('--post-login-redirect');
|
||||
if (postLogin !== null) patch.postLoginRedirect = postLogin;
|
||||
const scopes = getArg('--scopes');
|
||||
if (scopes !== null) patch.scopes = scopes;
|
||||
const enabled = getArg('--enabled');
|
||||
if (enabled !== null) patch.enabled = enabled === 'true';
|
||||
|
||||
const saved = await oidc.setConfig(patch);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
config: {
|
||||
issuer: saved.issuer,
|
||||
clientId: saved.clientId,
|
||||
clientSecret: saved.clientSecret ? '***set***' : '',
|
||||
redirectUri: saved.redirectUri,
|
||||
postLoginRedirect: saved.postLoginRedirect,
|
||||
scopes: saved.scopes,
|
||||
enabled: saved.enabled,
|
||||
},
|
||||
}) + '\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
printUsageAndExit();
|
||||
} finally {
|
||||
await app.close();
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { GuildNode } from '../entities/guild-node.entity.js';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator.js';
|
||||
|
||||
// The authenticated guild node, attached to the request by this guard so
|
||||
// downstream handlers can enforce node-ownership (a node may only act on
|
||||
// its own nodeId / introspect tokens for its own guild).
|
||||
export type AuthedGuildNode = { id: string; nodeId: string };
|
||||
|
||||
@Injectable()
|
||||
export class CenterApiKeyGuard implements CanActivate {
|
||||
constructor(
|
||||
@InjectRepository(GuildNode)
|
||||
private readonly nodeRepo: Repository<GuildNode>,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
path?: string;
|
||||
method?: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
guildNode?: AuthedGuildNode;
|
||||
}>();
|
||||
|
||||
const path = req.path ?? '';
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
|
||||
const noApiKeyRequired =
|
||||
path === '/healthz' ||
|
||||
path.endsWith('/healthz') ||
|
||||
(method === 'POST' && (path === '/auth/login' || path.endsWith('/auth/login'))) ||
|
||||
(method === 'POST' && (path === '/auth/refresh' || path.endsWith('/auth/refresh'))) ||
|
||||
(method === 'POST' && (path === '/auth/logout' || path.endsWith('/auth/logout'))) ||
|
||||
(method === 'GET' && (path === '/auth/me/guilds' || path.endsWith('/auth/me/guilds'))) ||
|
||||
(method === 'POST' && (path === '/auth/me/guilds/join' || path.endsWith('/auth/me/guilds/join'))) ||
|
||||
(method === 'GET' && (path.includes('/auth/guilds/') && path.endsWith('/members')));
|
||||
|
||||
if (noApiKeyRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const received = req.headers['x-api-key'];
|
||||
const apiKey = Array.isArray(received) ? received[0] : received;
|
||||
if (!apiKey) throw new UnauthorizedException('missing api key');
|
||||
@@ -43,7 +39,12 @@ export class CenterApiKeyGuard implements CanActivate {
|
||||
for (const node of nodes) {
|
||||
if (!node.apiKeyHash) continue;
|
||||
const ok = await bcrypt.compare(apiKey, node.apiKeyHash);
|
||||
if (ok) return true;
|
||||
if (ok) {
|
||||
// Identify which node this key belongs to so handlers can enforce
|
||||
// that a node only acts within its own scope (Center C2/C3).
|
||||
req.guildNode = { id: node.id, nodeId: node.nodeId };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('invalid api key');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Public } from './public.decorator.js';
|
||||
|
||||
@Public()
|
||||
@Controller('healthz')
|
||||
export class HealthController {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { MetricsService } from './metrics.service';
|
||||
import { MetricsService } from './metrics.service.js';
|
||||
|
||||
@Controller('metrics')
|
||||
export class MetricsController {
|
||||
|
||||
8
src/common/public.decorator.ts
Normal file
8
src/common/public.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
// Routes annotated with @Public() skip the global CenterApiKeyGuard
|
||||
// (api-key + node-identity) check. This is metadata-driven on purpose:
|
||||
// the previous string-matching whitelist (path.endsWith(...)) was a
|
||||
// bypass surface. Only the route's own decorator opens it.
|
||||
export const IS_PUBLIC_KEY = 'fabric:isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { MetricsService } from './metrics.service';
|
||||
import { MetricsService } from './metrics.service.js';
|
||||
|
||||
type ReqWithId = Request & { requestId?: string };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { buildTypeOrmConfig } from './database.config';
|
||||
import { buildTypeOrmConfig } from './database.config.js';
|
||||
|
||||
const cfg = buildTypeOrmConfig();
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { GuildNode } from './entities/guild-node.entity';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { GuildUser } from './entities/guild-user.entity';
|
||||
import { User } from './entities/user.entity.js';
|
||||
import { GuildNode } from './entities/guild-node.entity.js';
|
||||
import { AuditLog } from './entities/audit-log.entity.js';
|
||||
import { GuildUser } from './entities/guild-user.entity.js';
|
||||
import { UserApiKey } from './entities/user-api-key.entity.js';
|
||||
import { OidcConfig } from './entities/oidc-config.entity.js';
|
||||
|
||||
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||
type: 'mysql',
|
||||
@@ -11,7 +13,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||
username: process.env.FABRIC_BACKEND_CENTER_DB_USER ?? 'fabric',
|
||||
password: process.env.FABRIC_BACKEND_CENTER_DB_PASSWORD ?? 'fabric',
|
||||
database: process.env.FABRIC_BACKEND_CENTER_DB_NAME ?? 'fabric_center',
|
||||
entities: [User, GuildNode, GuildUser, AuditLog],
|
||||
entities: [User, GuildNode, GuildUser, AuditLog, UserApiKey, OidcConfig],
|
||||
synchronize: (process.env.FABRIC_BACKEND_CENTER_DB_SYNC ?? 'true') === 'true',
|
||||
logging: (process.env.FABRIC_BACKEND_CENTER_DB_LOGGING ?? 'false') === 'true',
|
||||
});
|
||||
|
||||
@@ -20,6 +20,15 @@ export class GuildNode {
|
||||
@Column()
|
||||
endpoint!: string;
|
||||
|
||||
// Free-form description of this guild's purpose — what it's used for,
|
||||
// who its agents/users serve. Surfaced to agents via /auth/me/guilds so
|
||||
// the cross-guild/cross-channel announce-target picker can find the
|
||||
// right guild by intent ("which guild is about debate broadcasts?")
|
||||
// without anything hard-coded into agent workflows. admin-only write
|
||||
// (cli: `node set-purpose --node-id X --purpose Y`).
|
||||
@Column({ type: 'text', nullable: true })
|
||||
purpose!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
apiKeyHash!: string | null;
|
||||
|
||||
|
||||
37
src/entities/oidc-config.entity.ts
Normal file
37
src/entities/oidc-config.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
// Single-row OIDC provider configuration, managed via the CLI
|
||||
// `config-oidc`. id is always 'default'.
|
||||
@Entity('oidc_config')
|
||||
export class OidcConfig {
|
||||
@PrimaryColumn({ type: 'varchar', length: 16 })
|
||||
id!: string; // always 'default'
|
||||
|
||||
@Column({ type: 'varchar', length: 512, default: '' })
|
||||
issuer!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 256, default: '' })
|
||||
clientId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 512, default: '' })
|
||||
clientSecret!: string;
|
||||
|
||||
// Center's own callback URL registered at the IdP, e.g.
|
||||
// https://fabric-api.example/api/auth/oidc/callback
|
||||
@Column({ type: 'varchar', length: 512, default: '' })
|
||||
redirectUri!: string;
|
||||
|
||||
// Where to send the browser back after a successful login (the SPA),
|
||||
// e.g. https://fabric.example/oidc
|
||||
@Column({ type: 'varchar', length: 512, default: '' })
|
||||
postLoginRedirect!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 256, default: 'openid email profile' })
|
||||
scopes!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enabled!: boolean;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
22
src/entities/user-api-key.entity.ts
Normal file
22
src/entities/user-api-key.entity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
// Machine credential for an agent: a long-lived API key that maps to a user.
|
||||
// The plugin exchanges it for a normal user session (POST /auth/agent/login).
|
||||
@Entity('user_api_keys')
|
||||
export class UserApiKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'user_id', type: 'varchar', length: 64 })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: 'api_key_hash', type: 'varchar', length: 255 })
|
||||
apiKeyHash!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
label!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -8,12 +8,28 @@ export class User {
|
||||
@Column({ unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@Column()
|
||||
passwordHash!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
refreshTokenHash!: string | null;
|
||||
|
||||
/**
|
||||
* Center-scoped admin flag. At most one user should have this set at a
|
||||
* time; the cli `user set-admin` enforces single-admin by clearing all
|
||||
* other admins before promoting the target user. Guild backends learn
|
||||
* the current admin via `GET /admin-email`.
|
||||
*
|
||||
* Use: triage channels deliver every message to the admin as a silent
|
||||
* observer (wake=false) regardless of on-duty / mention status, so a
|
||||
* single human can audit all triage traffic.
|
||||
*/
|
||||
@Column({ type: 'tinyint', default: 0, name: 'isAdmin' })
|
||||
isAdmin!: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('center integration (mysql + api)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { AppModule } = await import('./app.module');
|
||||
const { AppModule } = await import('./app.module.js');
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'reflect-metadata';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { createRequestContextMiddleware } from './common/request-context.middleware';
|
||||
import { MetricsService } from './common/metrics.service';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { createRequestContextMiddleware } from './common/request-context.middleware.js';
|
||||
import { MetricsService } from './common/metrics.service.js';
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { GuildNode } from '../entities/guild-node.entity.js';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class NodeAdminService {
|
||||
@@ -55,5 +55,26 @@ export class NodeAdminService {
|
||||
apiKey: rawApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
// Admin-only via cli (never HTTP): set the free-form purpose string on
|
||||
// a guild node. Pass an empty string to clear it (null).
|
||||
async setPurpose(nodeId: string, purpose: string) {
|
||||
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||
if (!node) throw new NotFoundException(`node ${nodeId} not found`);
|
||||
const trimmed = String(purpose ?? '').trim();
|
||||
node.purpose = trimmed === '' ? null : trimmed;
|
||||
const saved = await this.nodeRepo.save(node);
|
||||
await this.audit.write({
|
||||
action: 'node.set_purpose',
|
||||
targetType: 'node',
|
||||
targetId: saved.nodeId,
|
||||
detail: JSON.stringify({ purpose: saved.purpose, via: 'cli' }),
|
||||
});
|
||||
return {
|
||||
nodeId: saved.nodeId,
|
||||
name: saved.name,
|
||||
purpose: saved.purpose,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { UpdateNodeStatusDto } from './dto.update-node-status.dto';
|
||||
import { GuildNode } from '../entities/guild-node.entity.js';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
import { UpdateNodeStatusDto } from './dto.update-node-status.dto.js';
|
||||
import type { AuthedGuildNode } from '../common/center-api-key.guard.js';
|
||||
|
||||
@Controller('nodes')
|
||||
export class NodesController {
|
||||
@@ -24,8 +27,21 @@ export class NodesController {
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
// A node may only act on itself. The guard authenticates the caller via
|
||||
// its api key and attaches the matching node; reject if it tries to
|
||||
// touch any other nodeId (Center C3 — prevents node hijack/DoS).
|
||||
private assertSelf(req: { guildNode?: AuthedGuildNode }, nodeId: string) {
|
||||
if (!req.guildNode || req.guildNode.nodeId !== nodeId) {
|
||||
throw new ForbiddenException('api key not authorized for this node');
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':nodeId/heartbeat')
|
||||
async heartbeat(@Param('nodeId') nodeId: string) {
|
||||
async heartbeat(
|
||||
@Req() req: { guildNode?: AuthedGuildNode },
|
||||
@Param('nodeId') nodeId: string,
|
||||
) {
|
||||
this.assertSelf(req, nodeId);
|
||||
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||
if (!node) {
|
||||
throw new NotFoundException('node not found');
|
||||
@@ -53,9 +69,11 @@ export class NodesController {
|
||||
|
||||
@Patch(':nodeId/status')
|
||||
async updateStatus(
|
||||
@Req() req: { guildNode?: AuthedGuildNode },
|
||||
@Param('nodeId') nodeId: string,
|
||||
@Body() body: UpdateNodeStatusDto,
|
||||
) {
|
||||
this.assertSelf(req, nodeId);
|
||||
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||
if (!node) {
|
||||
throw new NotFoundException('node not found');
|
||||
@@ -93,7 +111,19 @@ export class NodesController {
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
// Never expose apiKeyHash (Center C3 — credential hash leak / offline
|
||||
// brute-force). Project to an explicit safe shape.
|
||||
items: items.map((n: GuildNode) => ({
|
||||
id: n.id,
|
||||
nodeId: n.nodeId,
|
||||
name: n.name,
|
||||
endpoint: n.endpoint,
|
||||
status: n.status,
|
||||
purpose: n.purpose ?? null,
|
||||
lastHeartbeatAt: n.lastHeartbeatAt,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
})),
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
total,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { NodesController } from './nodes.controller';
|
||||
import { GuildNode } from '../entities/guild-node.entity';
|
||||
import { NodeAdminService } from './node-admin.service';
|
||||
import { NodesController } from './nodes.controller.js';
|
||||
import { GuildNode } from '../entities/guild-node.entity.js';
|
||||
import { NodeAdminService } from './node-admin.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GuildNode])],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "es2022",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
|
||||
Reference in New Issue
Block a user