diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 7bdc62a..51704b7 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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 {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 483177e..6ac17e7 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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, 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, }; } diff --git a/src/auth/tessera.service.ts b/src/auth/tessera.service.ts new file mode 100644 index 0000000..c7144db --- /dev/null +++ b/src/auth/tessera.service.ts @@ -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; +}; + +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(); + private fetchedAt = 0; + private readonly cacheTtlMs = 3600_000; + + private async loadKeys(force = false): Promise { + 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(); + 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 { + 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 { + 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)); + } +}