Compare commits

5 Commits

Author SHA1 Message Date
8412eb6b3e Accept Tessera (Keycloak-compatible) OIDC tokens as API bearer
Adds an additive bearer-verification path: verify RS256 access tokens against
Tessera's JWKS (iss/aud/exp), map sub/preferred_username/email + roles
(realm_access.roles, resource_access.<audience>.roles) to the app's identity.
Existing auth (API keys / app JWTs / sessions) is unchanged. Issuer + audience
are env-configurable. Validated end-to-end against the local sim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:11:30 +01:00
9e1909a7e8 refactor(guild-discovery): drop serviceEndpoint (no longer needed)
Pairs with Dialectic.Backend@5cf4302 which removes the backend-driven
broadcast that was the only consumer of serviceEndpoint. With agent-
driven recruitment broadcasts via fabric-send-message, the
service-to-service URL distinction goes away (agents use the regular
guild endpoint).

Removed:
  - GuildNode.serviceEndpoint column (TypeORM will drop on next sync)
  - GET /api/auth/me/guilds + /api/nodes response fields
  - NodeAdminService.setServiceEndpoint()
  - cli 'node set-service-endpoint' subcommand

Kept:
  - GuildNode.purpose (used by fabric-guild-list for intent-based
    channel discovery — still wanted)
2026-05-23 23:48:19 +01:00
3058bccfb6 feat(guild-discovery): add serviceEndpoint for backend-to-backend reachability
A guild's existing 'endpoint' field is its client-facing URL (browser,
remote openclaw plugin) — but in-deployment services on the same docker
network can't always reach it. Concretely, dialectic-backend on
compose_default network sees 'http://server.t3:7002' (from agent-supplied
URL) resolve to dind-network IP which it can't route to. Broadcasts
silently fail with 'connection refused'.

Adds a second URL per guild: serviceEndpoint. Used by other backends
in the same deployment (compose service name + internal port). Plumbed
through:

  - GuildNode.serviceEndpoint (varchar 255 nullable; TypeORM auto-migrates)
  - GET /api/auth/me/guilds returns serviceEndpoint per row
  - GET /api/nodes (admin) returns serviceEndpoint
  - new cli: 'node set-service-endpoint --node-id X --endpoint URL'
    (admin-only via cli — same pattern as set-purpose)

Pairs with Fabric.OpenclawPlugin's fabric-guild-list returning the new
field + workflows teaching agents to use serviceEndpoint for
announce_guild_base_url (NOT the client-facing endpoint).

E2e verified: sim recruiter agent discovered sim-guild-1's
serviceEndpoint=http://fabric-backend-guild:7002, plumbed it into
dialectic_propose_topic, all 4 lifecycle broadcasts (signup_open,
signup_closed, debating, completed) landed in the announce channel.
2026-05-23 22:21:03 +01:00
604f6556fe feat(guild-discovery): add purpose column to guild_nodes + cli set-purpose
Adds a free-form 'purpose' text field on GuildNode so admins can describe
what each guild is for (debate broadcasts / agent triage / dev sandbox /
etc.). Surfaced through:

  - GET /api/auth/me/guilds   (existing user-facing list)
  - GET /api/nodes             (existing admin-facing list)
  - new cli: node set-purpose --node-id X --purpose 'text'

Admin-only writes via CLI (HangmanLab pattern) — never HTTP. Empty string
clears. TypeORM synchronize auto-adds the column on first startup.

Motivates: agents discover the right guild by intent (purpose substring
match) before picking a channel, so dialectic/announce workflows don't
need hardcoded guild ids.
2026-05-23 19:21:49 +01:00
ca7c6b408e Merge pull request 'feat(auth): center-scoped single admin + GET /admin-email + cli' (#1) from feat/admin-user into main 2026-05-22 21:59:14 +00:00
7 changed files with 253 additions and 27 deletions

View File

@@ -4,6 +4,7 @@ import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js'; import { AuthService } from './auth.service.js';
import { OidcController } from './oidc.controller.js'; import { OidcController } from './oidc.controller.js';
import { OidcService } from './oidc.service.js'; import { OidcService } from './oidc.service.js';
import { TesseraService } from './tessera.service.js';
import { User } from '../entities/user.entity.js'; import { User } from '../entities/user.entity.js';
import { GuildNode } from '../entities/guild-node.entity.js'; import { GuildNode } from '../entities/guild-node.entity.js';
import { GuildUser } from '../entities/guild-user.entity.js'; import { GuildUser } from '../entities/guild-user.entity.js';
@@ -13,6 +14,6 @@ import { OidcConfig } from '../entities/oidc-config.entity.js';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey, OidcConfig])], imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey, OidcConfig])],
controllers: [AuthController, OidcController], controllers: [AuthController, OidcController],
providers: [AuthService, OidcService], providers: [AuthService, OidcService, TesseraService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -15,6 +15,7 @@ import { UserApiKey } from '../entities/user-api-key.entity.js';
import { RegisterDto } from './dto.register.dto.js'; import { RegisterDto } from './dto.register.dto.js';
import { LoginDto } from './dto.login.dto.js'; import { LoginDto } from './dto.login.dto.js';
import { AuditService } from '../audit/audit.service.js'; import { AuditService } from '../audit/audit.service.js';
import { TesseraService, type TesseraClaims } from './tessera.service.js';
import { parseDurationToSeconds } from './token.util.js'; import { parseDurationToSeconds } from './token.util.js';
function signAccessToken(userId: string, email: string): string { function signAccessToken(userId: string, email: string): string {
@@ -51,6 +52,7 @@ export class AuthService {
@InjectRepository(UserApiKey) @InjectRepository(UserApiKey)
private readonly apiKeyRepo: Repository<UserApiKey>, private readonly apiKeyRepo: Repository<UserApiKey>,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly tessera: TesseraService,
) {} ) {}
// Mint a long-lived API key for a user (agent machine credential). // Mint a long-lived API key for a user (agent machine credential).
@@ -143,6 +145,59 @@ export class AuthService {
return user.id; 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 // Issue a full session for an already-authenticated userId (same shape as
// login()). Used by the OIDC ticket exchange. // login()). Used by the OIDC ticket exchange.
async issueSessionForUserId(userId: string) { async issueSessionForUserId(userId: string) {
@@ -192,6 +247,7 @@ export class AuthService {
name: n.name, name: n.name,
endpoint: n.endpoint, endpoint: n.endpoint,
status: n.status, status: n.status,
purpose: n.purpose ?? null,
})); }));
const guildAccessTokens = guilds.map((g) => ({ const guildAccessTokens = guilds.map((g) => ({
@@ -325,16 +381,14 @@ export class AuthService {
} }
async getMe(accessToken: string) { async getMe(accessToken: string) {
const payload = this.verifyCenterAccessToken(accessToken); const { userId } = await this.resolveAccessToken(accessToken);
const userId = String(payload.sub ?? '');
const user = await this.userRepo.findOne({ where: { id: userId } }); const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new UnauthorizedException('user not found'); if (!user) throw new UnauthorizedException('user not found');
return { id: user.id, email: user.email, name: user.name ?? user.email }; return { id: user.id, email: user.email, name: user.name ?? user.email };
} }
async updateMe(accessToken: string, name: string) { async updateMe(accessToken: string, name: string) {
const payload = this.verifyCenterAccessToken(accessToken); const { userId } = await this.resolveAccessToken(accessToken);
const userId = String(payload.sub ?? '');
const user = await this.userRepo.findOne({ where: { id: userId } }); const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new UnauthorizedException('user not found'); if (!user) throw new UnauthorizedException('user not found');
@@ -356,15 +410,12 @@ export class AuthService {
} }
async listMyGuilds(accessToken: string) { async listMyGuilds(accessToken: string) {
const payload = this.verifyCenterAccessToken(accessToken); const { userId, email } = await this.resolveAccessToken(accessToken);
const userId = String(payload.sub ?? '');
const email = String(payload.email ?? '');
return this.getUserGuildsAndTokens(userId, email); return this.getUserGuildsAndTokens(userId, email);
} }
async joinGuild(accessToken: string, guildNodeId: string) { async joinGuild(accessToken: string, guildNodeId: string) {
const payload = this.verifyCenterAccessToken(accessToken); const { userId } = await this.resolveAccessToken(accessToken);
const userId = String(payload.sub ?? '');
if (!userId) throw new UnauthorizedException('invalid access token'); if (!userId) throw new UnauthorizedException('invalid access token');
const node = await this.guildNodeRepo.findOne({ where: { nodeId: guildNodeId, status: 'active' } }); const node = await this.guildNodeRepo.findOne({ where: { nodeId: guildNodeId, status: 'active' } });
@@ -384,8 +435,7 @@ export class AuthService {
} }
async listGuildMembers(accessToken: string, guildNodeId: string) { async listGuildMembers(accessToken: string, guildNodeId: string) {
const payload = this.verifyCenterAccessToken(accessToken); const { userId } = await this.resolveAccessToken(accessToken);
const userId = String(payload.sub ?? '');
if (!userId) throw new UnauthorizedException('invalid access token'); if (!userId) throw new UnauthorizedException('invalid access token');
const selfMembership = await this.guildUserRepo.findOne({ where: { userId, guildNodeId, status: 'active' } }); const selfMembership = await this.guildUserRepo.findOne({ where: { userId, guildNodeId, status: 'active' } });
@@ -452,18 +502,40 @@ export class AuthService {
} }
async introspectGuildToken(token: string, guildNodeId: string) { async introspectGuildToken(token: string, guildNodeId: string) {
let payload: jwt.JwtPayload; // 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 = '';
try { try {
payload = jwt.verify(token, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload; 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 { } catch {
return { active: false }; return { active: false };
} }
try {
const userId = String(payload.sub ?? ''); const provisioned = await this.provisionTesseraUser(claims);
const tokenGuildNodeId = String(payload.gid ?? ''); userId = provisioned.userId;
if (!userId || tokenGuildNodeId !== guildNodeId || payload.typ !== 'guild_access') { email = provisioned.email;
} catch {
return { active: false }; return { active: false };
} }
}
const membership = await this.guildUserRepo.findOne({ const membership = await this.guildUserRepo.findOne({
where: { userId, guildNodeId, status: 'active' }, where: { userId, guildNodeId, status: 'active' },
@@ -472,10 +544,7 @@ export class AuthService {
return { return {
active: true, active: true,
user: { user: { id: userId, email },
id: userId,
email: String(payload.email ?? ''),
},
guildNodeId, guildNodeId,
}; };
} }

113
src/auth/tessera.service.ts Normal file
View 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));
}
}

View File

@@ -19,6 +19,7 @@ function printUsageAndExit(): never {
console.error(' node dist/cli.js user clear-admin'); console.error(' node dist/cli.js user clear-admin');
console.error(' node dist/cli.js user show-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 register --node-id <id> --name <name> --endpoint <url>');
console.error(' node dist/cli.js node set-purpose --node-id <id> --purpose <text>');
console.error( console.error(
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' + ' 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' + ' [--callback-url <url>] [--post-login-redirect <url>] [--scopes "openid email profile"]\n' +
@@ -91,6 +92,17 @@ async function main() {
return; 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') { if (subject === 'config' && action === 'oidc') {
const oidc = app.get(OidcService); const oidc = app.get(OidcService);
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};

View File

@@ -20,6 +20,15 @@ export class GuildNode {
@Column() @Column()
endpoint!: string; 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 }) @Column({ type: 'varchar', length: 255, nullable: true })
apiKeyHash!: string | null; apiKeyHash!: string | null;

View File

@@ -1,4 +1,4 @@
import { ConflictException, Injectable } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
@@ -55,5 +55,26 @@ export class NodeAdminService {
apiKey: rawApiKey, 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,
};
}
} }

View File

@@ -119,6 +119,7 @@ export class NodesController {
name: n.name, name: n.name,
endpoint: n.endpoint, endpoint: n.endpoint,
status: n.status, status: n.status,
purpose: n.purpose ?? null,
lastHeartbeatAt: n.lastHeartbeatAt, lastHeartbeatAt: n.lastHeartbeatAt,
createdAt: n.createdAt, createdAt: n.createdAt,
updatedAt: n.updatedAt, updatedAt: n.updatedAt,