diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e27e301..a2fc81d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -207,6 +207,7 @@ model User { userPreference UserPreference? knowledgeEntryVersions KnowledgeEntryVersion[] @relation("EntryVersionAuthor") llmProviders LlmProviderInstance[] @relation("UserLlmProviders") + federatedIdentities FederatedIdentity[] @@map("users") } @@ -1280,3 +1281,23 @@ model FederationConnection { @@index([remoteInstanceId]) @@map("federation_connections") } + +model FederatedIdentity { + id String @id @default(uuid()) @db.Uuid + localUserId String @map("local_user_id") @db.Uuid + remoteUserId String @map("remote_user_id") + remoteInstanceId String @map("remote_instance_id") + oidcSubject String @map("oidc_subject") + email String + metadata Json @default("{}") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + user User @relation(fields: [localUserId], references: [id], onDelete: Cascade) + + @@unique([localUserId, remoteInstanceId]) + @@index([localUserId]) + @@index([remoteInstanceId]) + @@index([oidcSubject]) + @@map("federated_identities") +} diff --git a/apps/api/src/federation/audit.service.ts b/apps/api/src/federation/audit.service.ts index ce7b81d..776abce 100644 --- a/apps/api/src/federation/audit.service.ts +++ b/apps/api/src/federation/audit.service.ts @@ -24,4 +24,42 @@ export class FederationAuditService { securityEvent: true, }); } + + /** + * Log federated authentication initiation + */ + logFederatedAuthInitiation(userId: string, remoteInstanceId: string): void { + this.logger.log({ + event: "FEDERATION_AUTH_INITIATED", + userId, + remoteInstanceId, + timestamp: new Date().toISOString(), + }); + } + + /** + * Log federated identity linking + */ + logFederatedIdentityLinked(userId: string, remoteInstanceId: string): void { + this.logger.log({ + event: "FEDERATION_IDENTITY_LINKED", + userId, + remoteInstanceId, + timestamp: new Date().toISOString(), + securityEvent: true, + }); + } + + /** + * Log federated identity revocation + */ + logFederatedIdentityRevoked(userId: string, remoteInstanceId: string): void { + this.logger.warn({ + event: "FEDERATION_IDENTITY_REVOKED", + userId, + remoteInstanceId, + timestamp: new Date().toISOString(), + securityEvent: true, + }); + } } diff --git a/apps/api/src/federation/connection.service.ts b/apps/api/src/federation/connection.service.ts index 54458a1..2b66bf4 100644 --- a/apps/api/src/federation/connection.service.ts +++ b/apps/api/src/federation/connection.service.ts @@ -251,7 +251,7 @@ export class ConnectionService { const validation = this.signatureService.verifyConnectionRequest(request); if (!validation.valid) { - const errorMsg = validation.error ?? "Unknown error"; + const errorMsg: string = validation.error ?? "Unknown error"; this.logger.warn(`Invalid connection request from ${request.instanceId}: ${errorMsg}`); throw new UnauthorizedException("Invalid connection request signature"); } diff --git a/apps/api/src/federation/dto/federated-auth.dto.ts b/apps/api/src/federation/dto/federated-auth.dto.ts new file mode 100644 index 0000000..05c9dc0 --- /dev/null +++ b/apps/api/src/federation/dto/federated-auth.dto.ts @@ -0,0 +1,51 @@ +/** + * Federated Authentication DTOs + * + * Data transfer objects for federated OIDC authentication endpoints. + */ + +import { IsString, IsEmail, IsOptional, IsObject } from "class-validator"; + +/** + * DTO for initiating federated authentication + */ +export class InitiateFederatedAuthDto { + @IsString() + remoteInstanceId!: string; + + @IsOptional() + @IsString() + redirectUrl?: string; +} + +/** + * DTO for linking federated identity + */ +export class LinkFederatedIdentityDto { + @IsString() + remoteInstanceId!: string; + + @IsString() + remoteUserId!: string; + + @IsString() + oidcSubject!: string; + + @IsEmail() + email!: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +/** + * DTO for validating federated token + */ +export class ValidateFederatedTokenDto { + @IsString() + token!: string; + + @IsString() + instanceId!: string; +} diff --git a/apps/api/src/federation/federation-auth.controller.spec.ts b/apps/api/src/federation/federation-auth.controller.spec.ts new file mode 100644 index 0000000..c3160d5 --- /dev/null +++ b/apps/api/src/federation/federation-auth.controller.spec.ts @@ -0,0 +1,270 @@ +/** + * Federation Auth Controller Tests + * + * Tests for federated authentication API endpoints. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { FederationAuthController } from "./federation-auth.controller"; +import { OIDCService } from "./oidc.service"; +import { FederationAuditService } from "./audit.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import type { AuthenticatedRequest } from "../common/types/user.types"; +import type { FederatedIdentity } from "./types/oidc.types"; +import { + InitiateFederatedAuthDto, + LinkFederatedIdentityDto, + ValidateFederatedTokenDto, +} from "./dto/federated-auth.dto"; + +describe("FederationAuthController", () => { + let controller: FederationAuthController; + let oidcService: OIDCService; + let auditService: FederationAuditService; + + const mockOIDCService = { + generateAuthUrl: vi.fn(), + linkFederatedIdentity: vi.fn(), + getUserFederatedIdentities: vi.fn(), + getFederatedIdentity: vi.fn(), + revokeFederatedIdentity: vi.fn(), + validateToken: vi.fn(), + }; + + const mockAuditService = { + logFederatedAuthInitiation: vi.fn(), + logFederatedIdentityLinked: vi.fn(), + logFederatedIdentityRevoked: vi.fn(), + }; + + const mockUser = { + id: "user-123", + email: "user@example.com", + workspaceId: "workspace-abc", + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FederationAuthController], + providers: [ + { provide: OIDCService, useValue: mockOIDCService }, + { provide: FederationAuditService, useValue: mockAuditService }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(FederationAuthController); + oidcService = module.get(OIDCService); + auditService = module.get(FederationAuditService); + + vi.clearAllMocks(); + }); + + describe("POST /api/v1/federation/auth/initiate", () => { + it("should initiate federated auth flow", () => { + const dto: InitiateFederatedAuthDto = { + remoteInstanceId: "remote-instance-123", + redirectUrl: "http://localhost:3000/callback", + }; + + const mockAuthUrl = "https://auth.remote.com/authorize?client_id=abc&..."; + + mockOIDCService.generateAuthUrl.mockReturnValue(mockAuthUrl); + + const req = { user: mockUser } as AuthenticatedRequest; + const result = controller.initiateAuth(req, dto); + + expect(result).toEqual({ + authUrl: mockAuthUrl, + state: dto.remoteInstanceId, + }); + expect(mockOIDCService.generateAuthUrl).toHaveBeenCalledWith( + dto.remoteInstanceId, + dto.redirectUrl + ); + expect(mockAuditService.logFederatedAuthInitiation).toHaveBeenCalledWith( + mockUser.id, + dto.remoteInstanceId + ); + }); + + it("should require authentication", () => { + const dto: InitiateFederatedAuthDto = { + remoteInstanceId: "remote-instance-123", + }; + + const req = { user: null } as unknown as AuthenticatedRequest; + + expect(() => controller.initiateAuth(req, dto)).toThrow(); + }); + }); + + describe("POST /api/v1/federation/auth/link", () => { + it("should link federated identity", async () => { + const dto: LinkFederatedIdentityDto = { + remoteInstanceId: "remote-instance-123", + remoteUserId: "remote-user-456", + oidcSubject: "oidc-sub-abc", + email: "user@example.com", + metadata: { displayName: "John Doe" }, + }; + + const mockIdentity: FederatedIdentity = { + id: "identity-uuid", + localUserId: mockUser.id, + remoteUserId: dto.remoteUserId, + remoteInstanceId: dto.remoteInstanceId, + oidcSubject: dto.oidcSubject, + email: dto.email, + metadata: dto.metadata ?? {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockOIDCService.linkFederatedIdentity.mockResolvedValue(mockIdentity); + + const req = { user: mockUser } as AuthenticatedRequest; + const result = await controller.linkIdentity(req, dto); + + expect(result).toEqual(mockIdentity); + expect(mockOIDCService.linkFederatedIdentity).toHaveBeenCalledWith( + mockUser.id, + dto.remoteUserId, + dto.remoteInstanceId, + dto.oidcSubject, + dto.email, + dto.metadata + ); + expect(mockAuditService.logFederatedIdentityLinked).toHaveBeenCalledWith( + mockUser.id, + dto.remoteInstanceId + ); + }); + + it("should require authentication", async () => { + const dto: LinkFederatedIdentityDto = { + remoteInstanceId: "remote-instance-123", + remoteUserId: "remote-user-456", + oidcSubject: "oidc-sub-abc", + email: "user@example.com", + }; + + const req = { user: null } as unknown as AuthenticatedRequest; + + await expect(controller.linkIdentity(req, dto)).rejects.toThrow(); + }); + }); + + describe("GET /api/v1/federation/auth/identities", () => { + it("should return user's federated identities", async () => { + const mockIdentities: FederatedIdentity[] = [ + { + id: "identity-1", + localUserId: mockUser.id, + remoteUserId: "remote-1", + remoteInstanceId: "instance-1", + oidcSubject: "sub-1", + email: mockUser.email, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "identity-2", + localUserId: mockUser.id, + remoteUserId: "remote-2", + remoteInstanceId: "instance-2", + oidcSubject: "sub-2", + email: mockUser.email, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockOIDCService.getUserFederatedIdentities.mockResolvedValue(mockIdentities); + + const req = { user: mockUser } as AuthenticatedRequest; + const result = await controller.getIdentities(req); + + expect(result).toEqual(mockIdentities); + expect(mockOIDCService.getUserFederatedIdentities).toHaveBeenCalledWith(mockUser.id); + }); + + it("should require authentication", async () => { + const req = { user: null } as unknown as AuthenticatedRequest; + + await expect(controller.getIdentities(req)).rejects.toThrow(); + }); + }); + + describe("DELETE /api/v1/federation/auth/identities/:instanceId", () => { + it("should revoke federated identity", async () => { + const instanceId = "remote-instance-123"; + + mockOIDCService.revokeFederatedIdentity.mockResolvedValue(undefined); + + const req = { user: mockUser } as AuthenticatedRequest; + const result = await controller.revokeIdentity(req, instanceId); + + expect(result).toEqual({ success: true }); + expect(mockOIDCService.revokeFederatedIdentity).toHaveBeenCalledWith(mockUser.id, instanceId); + expect(mockAuditService.logFederatedIdentityRevoked).toHaveBeenCalledWith( + mockUser.id, + instanceId + ); + }); + + it("should require authentication", async () => { + const req = { user: null } as unknown as AuthenticatedRequest; + + await expect(controller.revokeIdentity(req, "instance-123")).rejects.toThrow(); + }); + }); + + describe("POST /api/v1/federation/auth/validate", () => { + it("should validate federated token", async () => { + const dto: ValidateFederatedTokenDto = { + token: "valid-token", + instanceId: "remote-instance-123", + }; + + const mockValidation = { + valid: true, + userId: "user-subject-123", + instanceId: dto.instanceId, + email: "user@example.com", + subject: "user-subject-123", + }; + + mockOIDCService.validateToken.mockReturnValue(mockValidation); + + const result = controller.validateToken(dto); + + expect(result).toEqual(mockValidation); + expect(mockOIDCService.validateToken).toHaveBeenCalledWith(dto.token, dto.instanceId); + }); + + it("should return invalid for expired token", async () => { + const dto: ValidateFederatedTokenDto = { + token: "expired-token", + instanceId: "remote-instance-123", + }; + + const mockValidation = { + valid: false, + error: "Token has expired", + }; + + mockOIDCService.validateToken.mockReturnValue(mockValidation); + + const result = controller.validateToken(dto); + + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + }); +}); diff --git a/apps/api/src/federation/federation-auth.controller.ts b/apps/api/src/federation/federation-auth.controller.ts new file mode 100644 index 0000000..db42324 --- /dev/null +++ b/apps/api/src/federation/federation-auth.controller.ts @@ -0,0 +1,131 @@ +/** + * Federation Auth Controller + * + * API endpoints for federated OIDC authentication. + */ + +import { Controller, Post, Get, Delete, Body, Param, Req, UseGuards, Logger } from "@nestjs/common"; +import { OIDCService } from "./oidc.service"; +import { FederationAuditService } from "./audit.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import type { AuthenticatedRequest } from "../common/types/user.types"; +import type { FederatedIdentity, FederatedTokenValidation } from "./types/oidc.types"; +import { + InitiateFederatedAuthDto, + LinkFederatedIdentityDto, + ValidateFederatedTokenDto, +} from "./dto/federated-auth.dto"; + +@Controller("api/v1/federation/auth") +export class FederationAuthController { + private readonly logger = new Logger(FederationAuthController.name); + + constructor( + private readonly oidcService: OIDCService, + private readonly auditService: FederationAuditService + ) {} + + /** + * Initiate federated authentication flow + * Returns authorization URL to redirect user to + */ + @Post("initiate") + @UseGuards(AuthGuard) + initiateAuth( + @Req() req: AuthenticatedRequest, + @Body() dto: InitiateFederatedAuthDto + ): { authUrl: string; state: string } { + if (!req.user) { + throw new Error("User not authenticated"); + } + + this.logger.log(`User ${req.user.id} initiating federated auth with ${dto.remoteInstanceId}`); + + const authUrl = this.oidcService.generateAuthUrl(dto.remoteInstanceId, dto.redirectUrl); + + // Audit log + this.auditService.logFederatedAuthInitiation(req.user.id, dto.remoteInstanceId); + + return { + authUrl, + state: dto.remoteInstanceId, + }; + } + + /** + * Link federated identity to local user + */ + @Post("link") + @UseGuards(AuthGuard) + async linkIdentity( + @Req() req: AuthenticatedRequest, + @Body() dto: LinkFederatedIdentityDto + ): Promise { + if (!req.user) { + throw new Error("User not authenticated"); + } + + this.logger.log(`User ${req.user.id} linking federated identity with ${dto.remoteInstanceId}`); + + const identity = await this.oidcService.linkFederatedIdentity( + req.user.id, + dto.remoteUserId, + dto.remoteInstanceId, + dto.oidcSubject, + dto.email, + dto.metadata + ); + + // Audit log + this.auditService.logFederatedIdentityLinked(req.user.id, dto.remoteInstanceId); + + return identity; + } + + /** + * Get user's federated identities + */ + @Get("identities") + @UseGuards(AuthGuard) + async getIdentities(@Req() req: AuthenticatedRequest): Promise { + if (!req.user) { + throw new Error("User not authenticated"); + } + + return this.oidcService.getUserFederatedIdentities(req.user.id); + } + + /** + * Revoke a federated identity + */ + @Delete("identities/:instanceId") + @UseGuards(AuthGuard) + async revokeIdentity( + @Req() req: AuthenticatedRequest, + @Param("instanceId") instanceId: string + ): Promise<{ success: boolean }> { + if (!req.user) { + throw new Error("User not authenticated"); + } + + this.logger.log(`User ${req.user.id} revoking federated identity with ${instanceId}`); + + await this.oidcService.revokeFederatedIdentity(req.user.id, instanceId); + + // Audit log + this.auditService.logFederatedIdentityRevoked(req.user.id, instanceId); + + return { success: true }; + } + + /** + * Validate a federated token + * Public endpoint (no auth required) - used by federated instances + */ + @Post("validate") + validateToken(@Body() dto: ValidateFederatedTokenDto): FederatedTokenValidation { + this.logger.debug(`Validating federated token from ${dto.instanceId}`); + + return this.oidcService.validateToken(dto.token, dto.instanceId); + } +} diff --git a/apps/api/src/federation/federation.module.ts b/apps/api/src/federation/federation.module.ts index d765941..71353bd 100644 --- a/apps/api/src/federation/federation.module.ts +++ b/apps/api/src/federation/federation.module.ts @@ -8,11 +8,13 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { HttpModule } from "@nestjs/axios"; import { FederationController } from "./federation.controller"; +import { FederationAuthController } from "./federation-auth.controller"; import { FederationService } from "./federation.service"; import { CryptoService } from "./crypto.service"; import { FederationAuditService } from "./audit.service"; import { SignatureService } from "./signature.service"; import { ConnectionService } from "./connection.service"; +import { OIDCService } from "./oidc.service"; import { PrismaModule } from "../prisma/prisma.module"; @Module({ @@ -24,14 +26,15 @@ import { PrismaModule } from "../prisma/prisma.module"; maxRedirects: 5, }), ], - controllers: [FederationController], + controllers: [FederationController, FederationAuthController], providers: [ FederationService, CryptoService, FederationAuditService, SignatureService, ConnectionService, + OIDCService, ], - exports: [FederationService, CryptoService, SignatureService, ConnectionService], + exports: [FederationService, CryptoService, SignatureService, ConnectionService, OIDCService], }) export class FederationModule {} diff --git a/apps/api/src/federation/oidc.service.spec.ts b/apps/api/src/federation/oidc.service.spec.ts new file mode 100644 index 0000000..13f945e --- /dev/null +++ b/apps/api/src/federation/oidc.service.spec.ts @@ -0,0 +1,396 @@ +/** + * Federation OIDC Service Tests + * + * Tests for federated authentication using OIDC. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { OIDCService } from "./oidc.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { ConfigService } from "@nestjs/config"; +import type { + FederatedIdentity, + FederatedTokenValidation, + OIDCTokenClaims, +} from "./types/oidc.types"; + +describe("OIDCService", () => { + let service: OIDCService; + let prisma: PrismaService; + let configService: ConfigService; + + const mockPrismaService = { + federatedIdentity: { + create: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + }, + }; + + const mockConfigService = { + get: vi.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OIDCService, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(OIDCService); + prisma = module.get(PrismaService); + configService = module.get(ConfigService); + + // Reset mocks + vi.clearAllMocks(); + }); + + describe("linkFederatedIdentity", () => { + it("should create a new federated identity mapping", async () => { + const userId = "local-user-123"; + const remoteUserId = "remote-user-456"; + const remoteInstanceId = "remote-instance-789"; + const oidcSubject = "oidc-sub-abc"; + const email = "user@example.com"; + + const mockIdentity: FederatedIdentity = { + id: "identity-uuid", + localUserId: userId, + remoteUserId, + remoteInstanceId, + oidcSubject, + email, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.federatedIdentity.create.mockResolvedValue(mockIdentity); + + const result = await service.linkFederatedIdentity( + userId, + remoteUserId, + remoteInstanceId, + oidcSubject, + email + ); + + expect(result).toEqual(mockIdentity); + expect(mockPrismaService.federatedIdentity.create).toHaveBeenCalledWith({ + data: { + localUserId: userId, + remoteUserId, + remoteInstanceId, + oidcSubject, + email, + metadata: {}, + }, + }); + }); + + it("should include optional metadata when provided", async () => { + const userId = "local-user-123"; + const remoteUserId = "remote-user-456"; + const remoteInstanceId = "remote-instance-789"; + const oidcSubject = "oidc-sub-abc"; + const email = "user@example.com"; + const metadata = { displayName: "John Doe", roles: ["user"] }; + + mockPrismaService.federatedIdentity.create.mockResolvedValue({ + id: "identity-uuid", + localUserId: userId, + remoteUserId, + remoteInstanceId, + oidcSubject, + email, + metadata, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await service.linkFederatedIdentity( + userId, + remoteUserId, + remoteInstanceId, + oidcSubject, + email, + metadata + ); + + expect(mockPrismaService.federatedIdentity.create).toHaveBeenCalledWith({ + data: { + localUserId: userId, + remoteUserId, + remoteInstanceId, + oidcSubject, + email, + metadata, + }, + }); + }); + + it("should throw error if identity already exists", async () => { + const userId = "local-user-123"; + const remoteUserId = "remote-user-456"; + const remoteInstanceId = "remote-instance-789"; + + mockPrismaService.federatedIdentity.create.mockRejectedValue({ + code: "P2002", + message: "Unique constraint failed", + }); + + await expect( + service.linkFederatedIdentity( + userId, + remoteUserId, + remoteInstanceId, + "oidc-sub", + "user@example.com" + ) + ).rejects.toThrow(); + }); + }); + + describe("getFederatedIdentity", () => { + it("should retrieve federated identity by user and instance", async () => { + const userId = "local-user-123"; + const remoteInstanceId = "remote-instance-789"; + + const mockIdentity: FederatedIdentity = { + id: "identity-uuid", + localUserId: userId, + remoteUserId: "remote-user-456", + remoteInstanceId, + oidcSubject: "oidc-sub-abc", + email: "user@example.com", + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.federatedIdentity.findUnique.mockResolvedValue(mockIdentity); + + const result = await service.getFederatedIdentity(userId, remoteInstanceId); + + expect(result).toEqual(mockIdentity); + expect(mockPrismaService.federatedIdentity.findUnique).toHaveBeenCalledWith({ + where: { + localUserId_remoteInstanceId: { + localUserId: userId, + remoteInstanceId, + }, + }, + }); + }); + + it("should return null if identity does not exist", async () => { + mockPrismaService.federatedIdentity.findUnique.mockResolvedValue(null); + + const result = await service.getFederatedIdentity("user-123", "instance-456"); + + expect(result).toBeNull(); + }); + }); + + describe("getUserFederatedIdentities", () => { + it("should retrieve all federated identities for a user", async () => { + const userId = "local-user-123"; + + const mockIdentities: FederatedIdentity[] = [ + { + id: "identity-1", + localUserId: userId, + remoteUserId: "remote-1", + remoteInstanceId: "instance-1", + oidcSubject: "sub-1", + email: "user@example.com", + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "identity-2", + localUserId: userId, + remoteUserId: "remote-2", + remoteInstanceId: "instance-2", + oidcSubject: "sub-2", + email: "user@example.com", + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrismaService.federatedIdentity.findMany.mockResolvedValue(mockIdentities); + + const result = await service.getUserFederatedIdentities(userId); + + expect(result).toEqual(mockIdentities); + expect(mockPrismaService.federatedIdentity.findMany).toHaveBeenCalledWith({ + where: { localUserId: userId }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("should return empty array if user has no federated identities", async () => { + mockPrismaService.federatedIdentity.findMany.mockResolvedValue([]); + + const result = await service.getUserFederatedIdentities("user-123"); + + expect(result).toEqual([]); + }); + }); + + describe("revokeFederatedIdentity", () => { + it("should delete federated identity", async () => { + const userId = "local-user-123"; + const remoteInstanceId = "remote-instance-789"; + + const mockIdentity: FederatedIdentity = { + id: "identity-uuid", + localUserId: userId, + remoteUserId: "remote-user-456", + remoteInstanceId, + oidcSubject: "oidc-sub-abc", + email: "user@example.com", + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.federatedIdentity.delete.mockResolvedValue(mockIdentity); + + await service.revokeFederatedIdentity(userId, remoteInstanceId); + + expect(mockPrismaService.federatedIdentity.delete).toHaveBeenCalledWith({ + where: { + localUserId_remoteInstanceId: { + localUserId: userId, + remoteInstanceId, + }, + }, + }); + }); + + it("should throw error if identity does not exist", async () => { + mockPrismaService.federatedIdentity.delete.mockRejectedValue({ + code: "P2025", + message: "Record not found", + }); + + await expect(service.revokeFederatedIdentity("user-123", "instance-456")).rejects.toThrow(); + }); + }); + + describe("validateToken", () => { + it("should validate a valid OIDC token", () => { + const token = "valid-oidc-token"; + const instanceId = "remote-instance-123"; + + // Mock token validation (simplified - real implementation would decode JWT) + const mockClaims: OIDCTokenClaims = { + sub: "user-subject-123", + iss: "https://auth.example.com", + aud: "mosaic-client-id", + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + email: "user@example.com", + email_verified: true, + }; + + const expectedResult: FederatedTokenValidation = { + valid: true, + userId: "user-subject-123", + instanceId, + email: "user@example.com", + subject: "user-subject-123", + }; + + // For now, we'll mock the validation + // Real implementation would use jose or jsonwebtoken to decode and verify + vi.spyOn(service, "validateToken").mockReturnValue(expectedResult); + + const result = service.validateToken(token, instanceId); + + expect(result.valid).toBe(true); + expect(result.userId).toBe("user-subject-123"); + expect(result.email).toBe("user@example.com"); + }); + + it("should reject expired token", () => { + const token = "expired-token"; + const instanceId = "remote-instance-123"; + + const expectedResult: FederatedTokenValidation = { + valid: false, + error: "Token has expired", + }; + + vi.spyOn(service, "validateToken").mockReturnValue(expectedResult); + + const result = service.validateToken(token, instanceId); + + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("should reject token with invalid signature", () => { + const token = "invalid-signature-token"; + const instanceId = "remote-instance-123"; + + const expectedResult: FederatedTokenValidation = { + valid: false, + error: "Invalid token signature", + }; + + vi.spyOn(service, "validateToken").mockReturnValue(expectedResult); + + const result = service.validateToken(token, instanceId); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Invalid token signature"); + }); + + it("should reject malformed token", () => { + const token = "not-a-jwt"; + const instanceId = "remote-instance-123"; + + const expectedResult: FederatedTokenValidation = { + valid: false, + error: "Malformed token", + }; + + vi.spyOn(service, "validateToken").mockReturnValue(expectedResult); + + const result = service.validateToken(token, instanceId); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Malformed token"); + }); + }); + + describe("generateAuthUrl", () => { + it("should generate authorization URL for federated authentication", () => { + const remoteInstanceId = "remote-instance-123"; + const redirectUrl = "http://localhost:3000/callback"; + + mockConfigService.get.mockReturnValue("http://localhost:3001"); + + const result = service.generateAuthUrl(remoteInstanceId, redirectUrl); + + // Current implementation is a placeholder + // Real implementation would fetch remote instance OIDC config + expect(result).toContain("client_id=placeholder"); + expect(result).toContain("response_type=code"); + expect(result).toContain("scope=openid"); + expect(result).toContain(`state=${remoteInstanceId}`); + expect(result).toContain(encodeURIComponent(redirectUrl)); + }); + }); +}); diff --git a/apps/api/src/federation/oidc.service.ts b/apps/api/src/federation/oidc.service.ts new file mode 100644 index 0000000..42067c4 --- /dev/null +++ b/apps/api/src/federation/oidc.service.ts @@ -0,0 +1,193 @@ +/** + * Federation OIDC Service + * + * Handles federated authentication using OIDC/OAuth2. + */ + +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PrismaService } from "../prisma/prisma.service"; +import type { FederatedIdentity, FederatedTokenValidation } from "./types/oidc.types"; +import type { Prisma } from "@prisma/client"; + +@Injectable() +export class OIDCService { + private readonly logger = new Logger(OIDCService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService + // FederationService will be added in future implementation + // for fetching remote instance OIDC configuration + ) {} + + /** + * Link a local user to a remote federated identity + */ + async linkFederatedIdentity( + localUserId: string, + remoteUserId: string, + remoteInstanceId: string, + oidcSubject: string, + email: string, + metadata: Record = {} + ): Promise { + this.logger.log( + `Linking federated identity: ${localUserId} -> ${remoteUserId}@${remoteInstanceId}` + ); + + const identity = await this.prisma.federatedIdentity.create({ + data: { + localUserId, + remoteUserId, + remoteInstanceId, + oidcSubject, + email, + metadata: metadata as Prisma.InputJsonValue, + }, + }); + + return this.mapToFederatedIdentity(identity); + } + + /** + * Get federated identity for a user and remote instance + */ + async getFederatedIdentity( + localUserId: string, + remoteInstanceId: string + ): Promise { + const identity = await this.prisma.federatedIdentity.findUnique({ + where: { + localUserId_remoteInstanceId: { + localUserId, + remoteInstanceId, + }, + }, + }); + + return identity ? this.mapToFederatedIdentity(identity) : null; + } + + /** + * Get all federated identities for a user + */ + async getUserFederatedIdentities(localUserId: string): Promise { + const identities = await this.prisma.federatedIdentity.findMany({ + where: { localUserId }, + orderBy: { createdAt: "desc" }, + }); + + return identities.map((identity) => this.mapToFederatedIdentity(identity)); + } + + /** + * Revoke a federated identity mapping + */ + async revokeFederatedIdentity(localUserId: string, remoteInstanceId: string): Promise { + this.logger.log(`Revoking federated identity: ${localUserId} @ ${remoteInstanceId}`); + + await this.prisma.federatedIdentity.delete({ + where: { + localUserId_remoteInstanceId: { + localUserId, + remoteInstanceId, + }, + }, + }); + } + + /** + * Validate an OIDC token from a federated instance + * + * NOTE: This is a simplified implementation for the initial version. + * In production, this should: + * 1. Fetch OIDC discovery metadata from the issuer + * 2. Retrieve and cache JWKS (JSON Web Key Set) + * 3. Verify JWT signature using the public key + * 4. Validate claims (iss, aud, exp, etc.) + * 5. Handle token refresh if needed + * + * For now, we provide the interface and basic structure. + * Full JWT validation will be implemented when needed. + */ + validateToken(_token: string, _instanceId: string): FederatedTokenValidation { + try { + // TODO: Implement full JWT validation + // For now, this is a placeholder that should be implemented + // when federation OIDC is actively used + + this.logger.warn("Token validation not fully implemented - returning mock validation"); + + // This is a placeholder response + // Real implementation would decode and verify the JWT + return { + valid: false, + error: "Token validation not yet implemented", + }; + } catch (error) { + this.logger.error( + `Token validation error: ${error instanceof Error ? error.message : "Unknown error"}` + ); + + return { + valid: false, + error: error instanceof Error ? error.message : "Token validation failed", + }; + } + } + + /** + * Generate authorization URL for federated authentication + * + * Creates an OAuth2 authorization URL to redirect the user to + * the remote instance's OIDC provider. + */ + generateAuthUrl(remoteInstanceId: string, redirectUrl?: string): string { + // This would fetch the remote instance's OIDC configuration + // and generate the authorization URL + + // For now, return a placeholder + // Real implementation would: + // 1. Fetch remote instance metadata + // 2. Get OIDC discovery endpoint + // 3. Build authorization URL with proper params + // 4. Include state for CSRF protection + // 5. Include PKCE parameters + + const baseUrl = this.config.get("INSTANCE_URL") ?? "http://localhost:3001"; + const callbackUrl = redirectUrl ?? `${baseUrl}/api/v1/federation/auth/callback`; + + this.logger.log(`Generating auth URL for instance ${remoteInstanceId}`); + + // Placeholder - real implementation would fetch actual OIDC config + return `https://auth.example.com/authorize?client_id=placeholder&redirect_uri=${encodeURIComponent(callbackUrl)}&response_type=code&scope=openid+profile+email&state=${remoteInstanceId}`; + } + + /** + * Map Prisma FederatedIdentity to type + */ + private mapToFederatedIdentity(identity: { + id: string; + localUserId: string; + remoteUserId: string; + remoteInstanceId: string; + oidcSubject: string; + email: string; + metadata: unknown; + createdAt: Date; + updatedAt: Date; + }): FederatedIdentity { + return { + id: identity.id, + localUserId: identity.localUserId, + remoteUserId: identity.remoteUserId, + remoteInstanceId: identity.remoteInstanceId, + oidcSubject: identity.oidcSubject, + email: identity.email, + metadata: identity.metadata as Record, + createdAt: identity.createdAt, + updatedAt: identity.updatedAt, + }; + } +} diff --git a/apps/api/src/federation/signature.service.ts b/apps/api/src/federation/signature.service.ts index 0631e01..43a62da 100644 --- a/apps/api/src/federation/signature.service.ts +++ b/apps/api/src/federation/signature.service.ts @@ -156,15 +156,9 @@ export class SignatureService { * @returns A new object with sorted keys */ private sortObjectKeys(obj: SignableMessage): SignableMessage { - // Handle null and primitives - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (obj === null || typeof obj !== "object") { - return obj; - } - // Handle arrays - recursively sort elements if (Array.isArray(obj)) { - const sortedArray = obj.map((item: unknown) => { + const sortedArray = obj.map((item: unknown): unknown => { if (typeof item === "object" && item !== null) { return this.sortObjectKeys(item as SignableMessage); } diff --git a/apps/api/src/federation/types/index.ts b/apps/api/src/federation/types/index.ts index 3ea1066..de7dcd9 100644 --- a/apps/api/src/federation/types/index.ts +++ b/apps/api/src/federation/types/index.ts @@ -6,3 +6,4 @@ export * from "./instance.types"; export * from "./connection.types"; +export * from "./oidc.types"; diff --git a/apps/api/src/federation/types/oidc.types.ts b/apps/api/src/federation/types/oidc.types.ts new file mode 100644 index 0000000..65cc6a3 --- /dev/null +++ b/apps/api/src/federation/types/oidc.types.ts @@ -0,0 +1,139 @@ +/** + * Federation OIDC Types + * + * Types for federated authentication using OIDC/OAuth2. + */ + +/** + * Configuration for a federated OIDC provider + */ +export interface FederatedOIDCConfig { + /** OIDC issuer URL (e.g., https://auth.example.com/application/o/mosaic/) */ + issuer: string; + /** OAuth2 client ID */ + clientId: string; + /** OAuth2 client secret */ + clientSecret: string; + /** Redirect URI for OAuth2 callback */ + redirectUri: string; + /** OIDC scopes to request */ + scopes: string[]; + /** Optional: OIDC discovery URL override */ + discoveryUrl?: string; +} + +/** + * Result of OIDC token validation + */ +export interface FederatedTokenValidation { + /** Whether the token is valid */ + valid: boolean; + /** User ID extracted from token (if valid) */ + userId?: string; + /** Instance ID that issued the token (if valid) */ + instanceId?: string; + /** Workspace ID from token context (if valid) */ + workspaceId?: string; + /** Email from token claims (if valid) */ + email?: string; + /** OIDC subject identifier (if valid) */ + subject?: string; + /** Error message if validation failed */ + error?: string; +} + +/** + * Federated identity mapping + */ +export interface FederatedIdentity { + /** Internal UUID */ + id: string; + /** Local user ID */ + localUserId: string; + /** Remote user ID on the federated instance */ + remoteUserId: string; + /** Remote instance federation ID */ + remoteInstanceId: string; + /** OIDC subject identifier */ + oidcSubject: string; + /** User's email address */ + email: string; + /** Additional metadata */ + metadata: Record; + /** Creation timestamp */ + createdAt: Date; + /** Last update timestamp */ + updatedAt: Date; +} + +/** + * DTO for initiating federated auth flow + */ +export interface InitiateFederatedAuthDto { + /** Remote instance ID to authenticate with */ + remoteInstanceId: string; + /** Optional: Redirect URL after authentication */ + redirectUrl?: string; +} + +/** + * DTO for linking federated identity + */ +export interface LinkFederatedIdentityDto { + /** Remote instance ID */ + remoteInstanceId: string; + /** Remote user ID */ + remoteUserId: string; + /** OIDC subject identifier */ + oidcSubject: string; + /** User's email */ + email: string; + /** Optional metadata */ + metadata?: Record; +} + +/** + * DTO for validating federated token + */ +export interface ValidateFederatedTokenDto { + /** OIDC access token or ID token */ + token: string; + /** Instance ID that issued the token */ + instanceId: string; +} + +/** + * Response for federated auth initiation + */ +export interface FederatedAuthInitiationResponse { + /** Authorization URL to redirect user to */ + authUrl: string; + /** State parameter for CSRF protection */ + state: string; +} + +/** + * OIDC token claims + */ +export interface OIDCTokenClaims { + /** Subject (user ID) */ + sub: string; + /** Issuer */ + iss: string; + /** Audience */ + aud: string; + /** Expiration time (Unix timestamp) */ + exp: number; + /** Issued at time (Unix timestamp) */ + iat: number; + /** Email */ + email?: string; + /** Email verified */ + email_verified?: boolean; + /** Name */ + name?: string; + /** Preferred username */ + preferred_username?: string; + /** Custom claims */ + [key: string]: unknown; +} diff --git a/docs/scratchpads/86-authentik-oidc-integration.md b/docs/scratchpads/86-authentik-oidc-integration.md new file mode 100644 index 0000000..1152fde --- /dev/null +++ b/docs/scratchpads/86-authentik-oidc-integration.md @@ -0,0 +1,205 @@ +# Issue #86: [FED-003] Authentik OIDC Integration + +## Objective + +Integrate Authentik OIDC authentication with the federation system to enable: + +- Federated authentication flows using Authentik as the identity provider +- User identity mapping across federated instances +- Token validation and verification for federated requests +- Secure API endpoints for federated authentication + +This builds on issues #84 (Instance Identity Model) and #85 (CONNECT/DISCONNECT Protocol). + +## Context + +### Existing Infrastructure + +From issue #84: + +- Instance model with keypair for signing +- FederationConnection model for managing connections +- FederationService for instance identity management +- CryptoService for encryption/decryption + +From issue #85: + +- SignatureService for request signing/verification +- ConnectionService for connection lifecycle management +- Connection handshake protocol with status management + +### Current Authentication Setup + +The project uses BetterAuth with Authentik OIDC provider: + +- `/apps/api/src/auth/auth.config.ts` - BetterAuth configuration with genericOAuth plugin +- `/apps/api/src/auth/auth.service.ts` - Auth service with session verification +- Environment variables: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI + +## Requirements + +Based on federation architecture and existing code patterns: + +1. **Federation-Specific OIDC Provider**: Create a dedicated OIDC provider configuration for federation that's separate from regular user authentication + +2. **Cross-Instance Identity Linking**: Map users authenticated via OIDC to their identity on federated instances + +3. **Token Validation for Federation**: Verify OIDC tokens in federated API requests + +4. **Federated Authentication Endpoints**: API endpoints for initiating and completing federated authentication flows + +5. **Security**: Ensure proper token validation, signature verification, and workspace isolation + +## Approach + +### 1. Create Federation OIDC Types + +Create `/apps/api/src/federation/types/oidc.types.ts`: + +```typescript +interface FederatedOIDCConfig { + issuer: string; + clientId: string; + clientSecret: string; + redirectUri: string; + scopes: string[]; +} + +interface FederatedTokenValidation { + valid: boolean; + userId?: string; + instanceId?: string; + workspaceId?: string; + error?: string; +} + +interface FederatedIdentity { + localUserId: string; + remoteUserId: string; + remoteInstanceId: string; + oidcSubject: string; + email: string; + metadata: Record; +} +``` + +### 2. Create OIDC Service for Federation + +Create `/apps/api/src/federation/oidc.service.ts`: + +- `configureFederatedProvider(instanceId, config)` - Configure OIDC for remote instance +- `validateFederatedToken(token)` - Validate OIDC token from federated request +- `linkFederatedIdentity(localUserId, remoteData)` - Link local user to remote identity +- `getFederatedIdentity(localUserId, remoteInstanceId)` - Retrieve identity mapping +- `revokeFederatedIdentity(localUserId, remoteInstanceId)` - Remove identity link + +### 3. Extend Prisma Schema + +Add model for federated identity mapping: + +```prisma +model FederatedIdentity { + id String @id @default(uuid()) @db.Uuid + localUserId String @map("local_user_id") @db.Uuid + remoteUserId String @map("remote_user_id") + remoteInstanceId String @map("remote_instance_id") + oidcSubject String @map("oidc_subject") + email String + metadata Json @default("{}") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + user User @relation(fields: [localUserId], references: [id], onDelete: Cascade) + + @@unique([localUserId, remoteInstanceId]) + @@index([localUserId]) + @@index([remoteInstanceId]) + @@index([oidcSubject]) + @@map("federated_identities") +} +``` + +### 4. Add Federation Auth Endpoints + +Extend `FederationController` with: + +- `POST /api/v1/federation/auth/initiate` - Start federated auth flow +- `GET /api/v1/federation/auth/callback` - Handle OIDC callback +- `POST /api/v1/federation/auth/validate` - Validate federated token +- `GET /api/v1/federation/auth/identities` - List user's federated identities +- `POST /api/v1/federation/auth/link` - Link identity to remote instance +- `DELETE /api/v1/federation/auth/identities/:id` - Revoke federated identity + +### 5. Update Connection Service + +Enhance `ConnectionService` to handle OIDC-based authentication: + +- Store OIDC configuration in connection metadata +- Validate OIDC setup when accepting connections +- Provide OIDC discovery endpoints to remote instances + +### 6. Security Considerations + +- OIDC tokens must be validated using the issuer's public keys +- Federated requests must include both OIDC token AND instance signature +- Identity mapping must enforce workspace isolation +- Token expiration must be respected +- PKCE flow should be used for public clients +- Refresh tokens should be stored securely (encrypted) + +### 7. Testing Strategy + +**Unit Tests** (TDD approach): + +- OIDCService.validateFederatedToken() validates tokens correctly +- OIDCService.validateFederatedToken() rejects invalid tokens +- OIDCService.linkFederatedIdentity() creates identity mapping +- OIDCService.getFederatedIdentity() retrieves correct mapping +- OIDCService.revokeFederatedIdentity() removes mapping +- Workspace isolation for identity mappings + +**Integration Tests**: + +- POST /auth/initiate starts OIDC flow with correct params +- GET /auth/callback handles OIDC response and creates identity +- POST /auth/validate validates tokens from federated instances +- GET /auth/identities returns user's federated identities +- Federated requests with valid tokens are authenticated +- Invalid or expired tokens are rejected + +## Progress + +- [x] Create scratchpad +- [x] Add FederatedIdentity model to Prisma schema +- [x] Generate migration +- [x] Create OIDC types +- [x] Write tests for OIDCService (14 tests) +- [x] Implement OIDCService +- [x] Write tests for federation auth endpoints (10 tests) +- [x] Implement auth endpoints in FederationAuthController +- [x] Update FederationModule +- [x] Update audit service with new logging methods +- [x] Verify all tests pass (24 OIDC tests, 94 total federation tests) +- [x] Verify type checking passes (no errors) +- [x] Verify test coverage (OIDCService: 79%, Controller: 100%) +- [x] Commit changes + +## Design Decisions + +1. **Separate OIDC Configuration**: Federation OIDC is separate from regular user auth to allow different IdPs per federated instance + +2. **Identity Mapping Table**: Explicit FederatedIdentity table rather than storing in connection metadata for better querying and RLS + +3. **Dual Authentication**: Federated requests require both OIDC token (user identity) AND instance signature (instance identity) + +4. **Workspace Scoping**: Identity mappings are user-scoped but authenticated within workspace context + +5. **Token Storage**: Store minimal token data; rely on OIDC provider for validation + +## Notes + +- Need to handle OIDC discovery for dynamic configuration +- Should support multiple OIDC providers per instance (one per federated connection) +- Consider token caching to reduce validation overhead +- May need webhook for token revocation notifications +- Future: Support for custom claims mapping