Compare commits
8 Commits
2a394969d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8412eb6b3e | |||
| 9e1909a7e8 | |||
| 3058bccfb6 | |||
| 604f6556fe | |||
| ca7c6b408e | |||
| 1c1f280553 | |||
| f1ca33f2a2 | |||
| 9f7216565b |
@@ -79,6 +79,15 @@ export class AuthController {
|
|||||||
return this.authService.agentLogin(String(body?.apiKey ?? ''));
|
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
|
// Not @Public(): requires a guild node api key. The guard attaches the
|
||||||
// authenticated node; a node may only introspect tokens for its own
|
// authenticated node; a node may only introspect tokens for its own
|
||||||
// guild (Center C2 — prevents cross-tenant identity probing).
|
// guild (Center C2 — prevents cross-tenant identity probing).
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
@@ -204,6 +260,58 @@ export class AuthService {
|
|||||||
return { guilds, guildAccessTokens };
|
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) {
|
async register(input: RegisterDto) {
|
||||||
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -273,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');
|
||||||
|
|
||||||
@@ -304,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' } });
|
||||||
@@ -332,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' } });
|
||||||
@@ -400,17 +502,39 @@ 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:
|
||||||
try {
|
// 1. Fabric HS256 guild_access token (bound to a specific gid).
|
||||||
payload = jwt.verify(token, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload;
|
// 2. External Tessera RS256 access token (no gid — authorized purely
|
||||||
} catch {
|
// by the resolved user's active membership in this guild node).
|
||||||
return { active: false };
|
let userId = '';
|
||||||
}
|
let email = '';
|
||||||
|
|
||||||
const userId = String(payload.sub ?? '');
|
try {
|
||||||
const tokenGuildNodeId = String(payload.gid ?? '');
|
const payload = jwt.verify(
|
||||||
if (!userId || tokenGuildNodeId !== guildNodeId || payload.typ !== 'guild_access') {
|
token,
|
||||||
return { active: false };
|
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({
|
const membership = await this.guildUserRepo.findOne({
|
||||||
@@ -420,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ export class OidcService {
|
|||||||
return this.discoveryCache.doc;
|
return this.discoveryCache.doc;
|
||||||
}
|
}
|
||||||
const url = issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
|
const url = issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
|
||||||
const res = await fetch(url);
|
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}`);
|
if (!res.ok) throw new BadRequestException(`oidc discovery failed: ${res.status}`);
|
||||||
const doc = (await res.json()) as Discovery;
|
const doc = (await res.json()) as Discovery;
|
||||||
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
||||||
@@ -120,11 +125,16 @@ export class OidcService {
|
|||||||
client_secret: c.clientSecret,
|
client_secret: c.clientSecret,
|
||||||
code_verifier: st.cv,
|
code_verifier: st.cv,
|
||||||
});
|
});
|
||||||
const tokRes = await fetch(doc.token_endpoint, {
|
let tokRes: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
tokRes = await fetch(doc.token_endpoint, {
|
||||||
body: body.toString(),
|
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) {
|
if (!tokRes.ok) {
|
||||||
throw new UnauthorizedException(`oidc: token exchange failed (${tokRes.status})`);
|
throw new UnauthorizedException(`oidc: token exchange failed (${tokRes.status})`);
|
||||||
}
|
}
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/cli.ts
39
src/cli.ts
@@ -15,7 +15,11 @@ function printUsageAndExit(): never {
|
|||||||
console.error('Usage:');
|
console.error('Usage:');
|
||||||
console.error(' node dist/cli.js user create --email <email> --password <password>');
|
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 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 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' +
|
||||||
@@ -52,6 +56,30 @@ async function main() {
|
|||||||
return;
|
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') {
|
if (subject === 'node' && action === 'register') {
|
||||||
const nodeId = getArg('--node-id');
|
const nodeId = getArg('--node-id');
|
||||||
const name = getArg('--name');
|
const name = getArg('--name');
|
||||||
@@ -64,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> = {};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ export class User {
|
|||||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
refreshTokenHash!: string | null;
|
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()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user