Compare commits
1 Commits
9e1909a7e8
...
8412eb6b3e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8412eb6b3e |
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -13,6 +14,6 @@ import { OidcConfig } from '../entities/oidc-config.entity.js';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey, OidcConfig])],
|
||||
controllers: [AuthController, OidcController],
|
||||
providers: [AuthService, OidcService],
|
||||
providers: [AuthService, OidcService, TesseraService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 {
|
||||
@@ -51,6 +52,7 @@ export class AuthService {
|
||||
@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).
|
||||
@@ -143,6 +145,59 @@ export class AuthService {
|
||||
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) {
|
||||
@@ -326,16 +381,14 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async getMe(accessToken: string) {
|
||||
const payload = this.verifyCenterAccessToken(accessToken);
|
||||
const userId = String(payload.sub ?? '');
|
||||
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 payload = this.verifyCenterAccessToken(accessToken);
|
||||
const userId = String(payload.sub ?? '');
|
||||
const { userId } = await this.resolveAccessToken(accessToken);
|
||||
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('user not found');
|
||||
|
||||
@@ -357,15 +410,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
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' } });
|
||||
@@ -385,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' } });
|
||||
@@ -453,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({
|
||||
@@ -473,10 +544,7 @@ export class AuthService {
|
||||
|
||||
return {
|
||||
active: true,
|
||||
user: {
|
||||
id: userId,
|
||||
email: String(payload.email ?? ''),
|
||||
},
|
||||
user: { id: userId, email },
|
||||
guildNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user