/** * 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"; import * as jose from "jose"; /** * Helper function to create test JWTs for testing */ async function createTestJWT( claims: OIDCTokenClaims, secret: string = "test-secret-key-for-jwt-signing" ): Promise { const secretKey = new TextEncoder().encode(secret); const jwt = await new jose.SignJWT(claims as Record) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt(claims.iat) .setExpirationTime(claims.exp) .setSubject(claims.sub) .setIssuer(claims.iss) .setAudience(claims.aud) .sign(secretKey); return jwt; } 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 - Real JWT Validation", () => { it("should reject malformed token (not a JWT)", async () => { const token = "not-a-jwt-token"; const instanceId = "remote-instance-123"; const result = await service.validateToken(token, instanceId); expect(result.valid).toBe(false); expect(result.error).toContain("Malformed token"); }); it("should reject token with invalid format (missing parts)", async () => { const token = "header.payload"; // Missing signature const instanceId = "remote-instance-123"; const result = await service.validateToken(token, instanceId); expect(result.valid).toBe(false); expect(result.error).toContain("Malformed token"); }); it("should reject expired token", async () => { // Create an expired JWT (exp in the past) const expiredToken = await createTestJWT({ sub: "user-123", iss: "https://auth.example.com", aud: "mosaic-client-id", exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago iat: Math.floor(Date.now() / 1000) - 7200, email: "user@example.com", }); const result = await service.validateToken(expiredToken, "remote-instance-123"); expect(result.valid).toBe(false); expect(result.error).toContain("expired"); }); it("should reject token with invalid signature", async () => { // Create a JWT with a different key than what the service will validate const invalidToken = await createTestJWT( { sub: "user-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", }, "wrong-secret-key" ); const result = await service.validateToken(invalidToken, "remote-instance-123"); expect(result.valid).toBe(false); expect(result.error).toContain("signature"); }); it("should reject token with wrong issuer", async () => { const token = await createTestJWT({ sub: "user-123", iss: "https://wrong-issuer.com", // Wrong issuer aud: "mosaic-client-id", exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000), email: "user@example.com", }); const result = await service.validateToken(token, "remote-instance-123"); expect(result.valid).toBe(false); expect(result.error).toContain("issuer"); }); it("should reject token with wrong audience", async () => { const token = await createTestJWT({ sub: "user-123", iss: "https://auth.example.com", aud: "wrong-audience", // Wrong audience exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000), email: "user@example.com", }); const result = await service.validateToken(token, "remote-instance-123"); expect(result.valid).toBe(false); expect(result.error).toContain("audience"); }); it("should validate a valid JWT token with correct signature and claims", async () => { const validToken = await createTestJWT({ sub: "user-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, name: "Test User", }); const result = await service.validateToken(validToken, "remote-instance-123"); expect(result.valid).toBe(true); expect(result.userId).toBe("user-123"); expect(result.subject).toBe("user-123"); expect(result.email).toBe("user@example.com"); expect(result.instanceId).toBe("remote-instance-123"); expect(result.error).toBeUndefined(); }); it("should extract all user info from valid token", async () => { const validToken = await createTestJWT({ sub: "user-456", iss: "https://auth.example.com", aud: "mosaic-client-id", exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000), email: "test@example.com", email_verified: true, name: "Test User", preferred_username: "testuser", }); const result = await service.validateToken(validToken, "remote-instance-123"); expect(result.valid).toBe(true); expect(result.userId).toBe("user-456"); expect(result.email).toBe("test@example.com"); expect(result.subject).toBe("user-456"); }); }); 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)); }); }); });