Implements FED-004: Cross-Instance Identity Linking, building on the foundation from FED-001, FED-002, and FED-003. New Services: - IdentityLinkingService: Handles identity verification and mapping with signature validation and OIDC token verification - IdentityResolutionService: Resolves identities between local and remote instances with support for bulk operations New API Endpoints (IdentityLinkingController): - POST /api/v1/federation/identity/verify - Verify remote identity - POST /api/v1/federation/identity/resolve - Resolve remote to local user - POST /api/v1/federation/identity/bulk-resolve - Bulk resolution - GET /api/v1/federation/identity/me - Get current user's identities - POST /api/v1/federation/identity/link - Create identity mapping - PATCH /api/v1/federation/identity/:id - Update mapping - DELETE /api/v1/federation/identity/:id - Revoke mapping - GET /api/v1/federation/identity/:id/validate - Validate mapping Security Features: - Signature verification using remote instance public keys - OIDC token validation before creating mappings - Timestamp validation to prevent replay attacks - Workspace isolation via authentication guards - Comprehensive audit logging for all identity operations Enhancements: - Added SignatureService.verifyMessage() for remote signature verification - Added FederationService.getConnectionByRemoteInstanceId() - Extended FederationAuditService with identity logging methods - Created comprehensive DTOs with class-validator decorators Testing: - 38 new tests (19 service + 7 resolution + 12 controller) - All 132 federation tests passing - TypeScript compilation passing with no errors - High test coverage achieved (>85% requirement exceeded) Technical Details: - Leverages existing FederatedIdentity model from FED-003 - Uses RSA SHA-256 signatures for cryptographic verification - Supports one identity mapping per remote instance per user - Resolution service optimized for read-heavy operations - Built following TDD principles (Red-Green-Refactor) Closes #87 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
/**
|
|
* Identity Linking Service Tests
|
|
*
|
|
* Tests for cross-instance identity verification and mapping.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { IdentityLinkingService } from "./identity-linking.service";
|
|
import { OIDCService } from "./oidc.service";
|
|
import { SignatureService } from "./signature.service";
|
|
import { FederationAuditService } from "./audit.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import type {
|
|
IdentityVerificationRequest,
|
|
CreateIdentityMappingDto,
|
|
UpdateIdentityMappingDto,
|
|
} from "./types/identity-linking.types";
|
|
import type { FederatedIdentity } from "./types/oidc.types";
|
|
|
|
describe("IdentityLinkingService", () => {
|
|
let service: IdentityLinkingService;
|
|
let oidcService: OIDCService;
|
|
let signatureService: SignatureService;
|
|
let auditService: FederationAuditService;
|
|
let prismaService: PrismaService;
|
|
|
|
const mockFederatedIdentity: FederatedIdentity = {
|
|
id: "identity-id",
|
|
localUserId: "local-user-id",
|
|
remoteUserId: "remote-user-id",
|
|
remoteInstanceId: "remote-instance-id",
|
|
oidcSubject: "oidc-subject",
|
|
email: "user@example.com",
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const mockOIDCService = {
|
|
linkFederatedIdentity: vi.fn(),
|
|
getFederatedIdentity: vi.fn(),
|
|
getUserFederatedIdentities: vi.fn(),
|
|
revokeFederatedIdentity: vi.fn(),
|
|
validateToken: vi.fn(),
|
|
};
|
|
|
|
const mockSignatureService = {
|
|
verifyMessage: vi.fn(),
|
|
validateTimestamp: vi.fn(),
|
|
};
|
|
|
|
const mockAuditService = {
|
|
logIdentityVerification: vi.fn(),
|
|
logIdentityLinking: vi.fn(),
|
|
logIdentityRevocation: vi.fn(),
|
|
};
|
|
|
|
const mockPrismaService = {
|
|
federatedIdentity: {
|
|
findUnique: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
IdentityLinkingService,
|
|
{ provide: OIDCService, useValue: mockOIDCService },
|
|
{ provide: SignatureService, useValue: mockSignatureService },
|
|
{ provide: FederationAuditService, useValue: mockAuditService },
|
|
{ provide: PrismaService, useValue: mockPrismaService },
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<IdentityLinkingService>(IdentityLinkingService);
|
|
oidcService = module.get(OIDCService);
|
|
signatureService = module.get(SignatureService);
|
|
auditService = module.get(FederationAuditService);
|
|
prismaService = module.get(PrismaService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("verifyIdentity", () => {
|
|
it("should verify identity with valid signature and token", async () => {
|
|
const request: IdentityVerificationRequest = {
|
|
localUserId: "local-user-id",
|
|
remoteUserId: "remote-user-id",
|
|
remoteInstanceId: "remote-instance-id",
|
|
oidcToken: "valid-token",
|
|
timestamp: Date.now(),
|
|
signature: "valid-signature",
|
|
};
|
|
|
|
signatureService.validateTimestamp.mockReturnValue(true);
|
|
signatureService.verifyMessage.mockResolvedValue({ valid: true });
|
|
oidcService.validateToken.mockReturnValue({
|
|
valid: true,
|
|
userId: "remote-user-id",
|
|
instanceId: "remote-instance-id",
|
|
email: "user@example.com",
|
|
});
|
|
oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
|
|
|
|
const result = await service.verifyIdentity(request);
|
|
|
|
expect(result.verified).toBe(true);
|
|
expect(result.localUserId).toBe("local-user-id");
|
|
expect(result.remoteUserId).toBe("remote-user-id");
|
|
expect(result.remoteInstanceId).toBe("remote-instance-id");
|
|
expect(signatureService.validateTimestamp).toHaveBeenCalledWith(request.timestamp);
|
|
expect(signatureService.verifyMessage).toHaveBeenCalled();
|
|
expect(oidcService.validateToken).toHaveBeenCalledWith("valid-token", "remote-instance-id");
|
|
expect(auditService.logIdentityVerification).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should reject identity with invalid signature", async () => {
|
|
const request: IdentityVerificationRequest = {
|
|
localUserId: "local-user-id",
|
|
remoteUserId: "remote-user-id",
|
|
remoteInstanceId: "remote-instance-id",
|
|
oidcToken: "valid-token",
|
|
timestamp: Date.now(),
|
|
signature: "invalid-signature",
|
|
};
|
|
|
|
signatureService.validateTimestamp.mockReturnValue(true);
|
|
signatureService.verifyMessage.mockResolvedValue({
|
|
valid: false,
|
|
error: "Invalid signature",
|
|
});
|
|
|
|
const result = await service.verifyIdentity(request);
|
|
|
|
expect(result.verified).toBe(false);
|
|
expect(result.error).toContain("Invalid signature");
|
|
expect(oidcService.validateToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should reject identity with expired timestamp", async () => {
|
|
const request: IdentityVerificationRequest = {
|
|
localUserId: "local-user-id",
|
|
remoteUserId: "remote-user-id",
|
|
remoteInstanceId: "remote-instance-id",
|
|
oidcToken: "valid-token",
|
|
timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago
|
|
signature: "valid-signature",
|
|
};
|
|
|
|
signatureService.validateTimestamp.mockReturnValue(false);
|
|
|
|
const result = await service.verifyIdentity(request);
|
|
|
|
expect(result.verified).toBe(false);
|
|
expect(result.error).toContain("expired");
|
|
expect(signatureService.verifyMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should reject identity with invalid OIDC token", async () => {
|
|
const request: IdentityVerificationRequest = {
|
|
localUserId: "local-user-id",
|
|
remoteUserId: "remote-user-id",
|
|
remoteInstanceId: "remote-instance-id",
|
|
oidcToken: "invalid-token",
|
|
timestamp: Date.now(),
|
|
signature: "valid-signature",
|
|
};
|
|
|
|
signatureService.validateTimestamp.mockReturnValue(true);
|
|
signatureService.verifyMessage.mockResolvedValue({ valid: true });
|
|
oidcService.validateToken.mockReturnValue({
|
|
valid: false,
|
|
error: "Invalid token",
|
|
});
|
|
|
|
const result = await service.verifyIdentity(request);
|
|
|
|
expect(result.verified).toBe(false);
|
|
expect(result.error).toContain("Invalid token");
|
|
});
|
|
|
|
it("should reject identity if mapping does not exist", async () => {
|
|
const request: IdentityVerificationRequest = {
|
|
localUserId: "local-user-id",
|
|
remoteUserId: "remote-user-id",
|
|
remoteInstanceId: "remote-instance-id",
|
|
oidcToken: "valid-token",
|
|
timestamp: Date.now(),
|
|
signature: "valid-signature",
|
|
};
|
|
|
|
signatureService.validateTimestamp.mockReturnValue(true);
|
|
signatureService.verifyMessage.mockResolvedValue({ valid: true });
|
|
oidcService.validateToken.mockReturnValue({
|
|
valid: true,
|
|
userId: "remote-user-id",
|
|
instanceId: "remote-instance-id",
|
|
});
|
|
oidcService.getFederatedIdentity.mockResolvedValue(null);
|
|
|
|
const result = await service.verifyIdentity(request);
|
|
|
|
expect(result.verified).toBe(false);
|
|
expect(result.error).toContain("not found");
|
|
});
|
|
});
|
|
|
|
describe("resolveLocalIdentity", () => {
|
|
it("should resolve remote user to local user", async () => {
|
|
prismaService.federatedIdentity.findFirst.mockResolvedValue(mockFederatedIdentity as never);
|
|
|
|
const result = await service.resolveLocalIdentity("remote-instance-id", "remote-user-id");
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.localUserId).toBe("local-user-id");
|
|
expect(result?.remoteUserId).toBe("remote-user-id");
|
|
expect(result?.email).toBe("user@example.com");
|
|
});
|
|
|
|
it("should return null when mapping not found", async () => {
|
|
prismaService.federatedIdentity.findFirst.mockResolvedValue(null);
|
|
|
|
const result = await service.resolveLocalIdentity("remote-instance-id", "unknown-user-id");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolveRemoteIdentity", () => {
|
|
it("should resolve local user to remote user", async () => {
|
|
oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
|
|
|
|
const result = await service.resolveRemoteIdentity("local-user-id", "remote-instance-id");
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.remoteUserId).toBe("remote-user-id");
|
|
expect(result?.localUserId).toBe("local-user-id");
|
|
});
|
|
|
|
it("should return null when mapping not found", async () => {
|
|
oidcService.getFederatedIdentity.mockResolvedValue(null);
|
|
|
|
const result = await service.resolveRemoteIdentity("unknown-user-id", "remote-instance-id");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("createIdentityMapping", () => {
|
|
it("should create identity mapping with valid data", async () => {
|
|
const dto: CreateIdentityMappingDto = {
|
|
remoteInstanceId: "remote-instance-id",
|
|
remoteUserId: "remote-user-id",
|
|
oidcSubject: "oidc-subject",
|
|
email: "user@example.com",
|
|
metadata: { source: "manual" },
|
|
};
|
|
|
|
oidcService.linkFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
|
|
|
|
const result = await service.createIdentityMapping("local-user-id", dto);
|
|
|
|
expect(result).toEqual(mockFederatedIdentity);
|
|
expect(oidcService.linkFederatedIdentity).toHaveBeenCalledWith(
|
|
"local-user-id",
|
|
"remote-user-id",
|
|
"remote-instance-id",
|
|
"oidc-subject",
|
|
"user@example.com",
|
|
{ source: "manual" }
|
|
);
|
|
expect(auditService.logIdentityLinking).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should validate OIDC token if provided", async () => {
|
|
const dto: CreateIdentityMappingDto = {
|
|
remoteInstanceId: "remote-instance-id",
|
|
remoteUserId: "remote-user-id",
|
|
oidcSubject: "oidc-subject",
|
|
email: "user@example.com",
|
|
oidcToken: "valid-token",
|
|
};
|
|
|
|
oidcService.validateToken.mockReturnValue({ valid: true });
|
|
oidcService.linkFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
|
|
|
|
await service.createIdentityMapping("local-user-id", dto);
|
|
|
|
expect(oidcService.validateToken).toHaveBeenCalledWith("valid-token", "remote-instance-id");
|
|
});
|
|
|
|
it("should throw error if OIDC token is invalid", async () => {
|
|
const dto: CreateIdentityMappingDto = {
|
|
remoteInstanceId: "remote-instance-id",
|
|
remoteUserId: "remote-user-id",
|
|
oidcSubject: "oidc-subject",
|
|
email: "user@example.com",
|
|
oidcToken: "invalid-token",
|
|
};
|
|
|
|
oidcService.validateToken.mockReturnValue({
|
|
valid: false,
|
|
error: "Invalid token",
|
|
});
|
|
|
|
await expect(service.createIdentityMapping("local-user-id", dto)).rejects.toThrow(
|
|
"Invalid OIDC token"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("updateIdentityMapping", () => {
|
|
it("should update identity mapping metadata", async () => {
|
|
const dto: UpdateIdentityMappingDto = {
|
|
metadata: { updated: true },
|
|
};
|
|
|
|
const updatedIdentity = { ...mockFederatedIdentity, metadata: { updated: true } };
|
|
prismaService.federatedIdentity.findUnique.mockResolvedValue(mockFederatedIdentity as never);
|
|
prismaService.federatedIdentity.update.mockResolvedValue(updatedIdentity as never);
|
|
|
|
const result = await service.updateIdentityMapping(
|
|
"local-user-id",
|
|
"remote-instance-id",
|
|
dto
|
|
);
|
|
|
|
expect(result.metadata).toEqual({ updated: true });
|
|
expect(prismaService.federatedIdentity.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should throw error if mapping not found", async () => {
|
|
const dto: UpdateIdentityMappingDto = {
|
|
metadata: { updated: true },
|
|
};
|
|
|
|
prismaService.federatedIdentity.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.updateIdentityMapping("unknown-user-id", "remote-instance-id", dto)
|
|
).rejects.toThrow("not found");
|
|
});
|
|
});
|
|
|
|
describe("validateIdentityMapping", () => {
|
|
it("should validate existing identity mapping", async () => {
|
|
oidcService.getFederatedIdentity.mockResolvedValue(mockFederatedIdentity);
|
|
|
|
const result = await service.validateIdentityMapping("local-user-id", "remote-instance-id");
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.localUserId).toBe("local-user-id");
|
|
expect(result.remoteUserId).toBe("remote-user-id");
|
|
});
|
|
|
|
it("should return invalid if mapping not found", async () => {
|
|
oidcService.getFederatedIdentity.mockResolvedValue(null);
|
|
|
|
const result = await service.validateIdentityMapping("unknown-user-id", "remote-instance-id");
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("not found");
|
|
});
|
|
});
|
|
|
|
describe("listUserIdentities", () => {
|
|
it("should list all federated identities for a user", async () => {
|
|
const identities = [mockFederatedIdentity];
|
|
oidcService.getUserFederatedIdentities.mockResolvedValue(identities);
|
|
|
|
const result = await service.listUserIdentities("local-user-id");
|
|
|
|
expect(result).toEqual(identities);
|
|
expect(oidcService.getUserFederatedIdentities).toHaveBeenCalledWith("local-user-id");
|
|
});
|
|
|
|
it("should return empty array if user has no federated identities", async () => {
|
|
oidcService.getUserFederatedIdentities.mockResolvedValue([]);
|
|
|
|
const result = await service.listUserIdentities("local-user-id");
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("revokeIdentityMapping", () => {
|
|
it("should revoke identity mapping", async () => {
|
|
oidcService.revokeFederatedIdentity.mockResolvedValue(undefined);
|
|
|
|
await service.revokeIdentityMapping("local-user-id", "remote-instance-id");
|
|
|
|
expect(oidcService.revokeFederatedIdentity).toHaveBeenCalledWith(
|
|
"local-user-id",
|
|
"remote-instance-id"
|
|
);
|
|
expect(auditService.logIdentityRevocation).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|