feat(#87): implement cross-instance identity linking for federation
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>
This commit is contained in:
319
apps/api/src/federation/identity-linking.controller.spec.ts
Normal file
319
apps/api/src/federation/identity-linking.controller.spec.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Identity Linking Controller Tests
|
||||
*
|
||||
* Integration tests for identity linking API endpoints.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { IdentityLinkingController } from "./identity-linking.controller";
|
||||
import { IdentityLinkingService } from "./identity-linking.service";
|
||||
import { IdentityResolutionService } from "./identity-resolution.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import type { FederatedIdentity } from "./types/oidc.types";
|
||||
import type {
|
||||
CreateIdentityMappingDto,
|
||||
UpdateIdentityMappingDto,
|
||||
VerifyIdentityDto,
|
||||
ResolveIdentityDto,
|
||||
BulkResolveIdentityDto,
|
||||
} from "./dto/identity-linking.dto";
|
||||
|
||||
describe("IdentityLinkingController", () => {
|
||||
let controller: IdentityLinkingController;
|
||||
let identityLinkingService: IdentityLinkingService;
|
||||
let identityResolutionService: IdentityResolutionService;
|
||||
|
||||
const mockIdentity: 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(),
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: "local-user-id",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockIdentityLinkingService = {
|
||||
verifyIdentity: vi.fn(),
|
||||
createIdentityMapping: vi.fn(),
|
||||
updateIdentityMapping: vi.fn(),
|
||||
validateIdentityMapping: vi.fn(),
|
||||
listUserIdentities: vi.fn(),
|
||||
revokeIdentityMapping: vi.fn(),
|
||||
};
|
||||
|
||||
const mockIdentityResolutionService = {
|
||||
resolveIdentity: vi.fn(),
|
||||
reverseResolveIdentity: vi.fn(),
|
||||
bulkResolveIdentities: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuthGuard = {
|
||||
canActivate: (context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
request.user = mockUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [IdentityLinkingController],
|
||||
providers: [
|
||||
{ provide: IdentityLinkingService, useValue: mockIdentityLinkingService },
|
||||
{ provide: IdentityResolutionService, useValue: mockIdentityResolutionService },
|
||||
{ provide: Reflector, useValue: { getAllAndOverride: vi.fn(() => []) } },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.compile();
|
||||
|
||||
controller = module.get<IdentityLinkingController>(IdentityLinkingController);
|
||||
identityLinkingService = module.get(IdentityLinkingService);
|
||||
identityResolutionService = module.get(IdentityResolutionService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /identity/verify", () => {
|
||||
it("should verify identity with valid request", async () => {
|
||||
const dto: VerifyIdentityDto = {
|
||||
localUserId: "local-user-id",
|
||||
remoteUserId: "remote-user-id",
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
oidcToken: "valid-token",
|
||||
timestamp: Date.now(),
|
||||
signature: "valid-signature",
|
||||
};
|
||||
|
||||
identityLinkingService.verifyIdentity.mockResolvedValue({
|
||||
verified: true,
|
||||
localUserId: "local-user-id",
|
||||
remoteUserId: "remote-user-id",
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
email: "user@example.com",
|
||||
});
|
||||
|
||||
const result = await controller.verifyIdentity(dto);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.localUserId).toBe("local-user-id");
|
||||
expect(identityLinkingService.verifyIdentity).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it("should return verification failure", async () => {
|
||||
const dto: VerifyIdentityDto = {
|
||||
localUserId: "local-user-id",
|
||||
remoteUserId: "remote-user-id",
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
oidcToken: "invalid-token",
|
||||
timestamp: Date.now(),
|
||||
signature: "invalid-signature",
|
||||
};
|
||||
|
||||
identityLinkingService.verifyIdentity.mockResolvedValue({
|
||||
verified: false,
|
||||
error: "Invalid signature",
|
||||
});
|
||||
|
||||
const result = await controller.verifyIdentity(dto);
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.error).toBe("Invalid signature");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /identity/resolve", () => {
|
||||
it("should resolve remote user to local user", async () => {
|
||||
const dto: ResolveIdentityDto = {
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
remoteUserId: "remote-user-id",
|
||||
};
|
||||
|
||||
identityResolutionService.resolveIdentity.mockResolvedValue({
|
||||
found: true,
|
||||
localUserId: "local-user-id",
|
||||
remoteUserId: "remote-user-id",
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
email: "user@example.com",
|
||||
});
|
||||
|
||||
const result = await controller.resolveIdentity(dto);
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.localUserId).toBe("local-user-id");
|
||||
expect(identityResolutionService.resolveIdentity).toHaveBeenCalledWith(
|
||||
"remote-instance-id",
|
||||
"remote-user-id"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return not found when mapping does not exist", async () => {
|
||||
const dto: ResolveIdentityDto = {
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
remoteUserId: "unknown-user-id",
|
||||
};
|
||||
|
||||
identityResolutionService.resolveIdentity.mockResolvedValue({
|
||||
found: false,
|
||||
remoteUserId: "unknown-user-id",
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
});
|
||||
|
||||
const result = await controller.resolveIdentity(dto);
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.localUserId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /identity/bulk-resolve", () => {
|
||||
it("should resolve multiple remote users", async () => {
|
||||
const dto: BulkResolveIdentityDto = {
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
remoteUserIds: ["remote-user-1", "remote-user-2", "unknown-user"],
|
||||
};
|
||||
|
||||
identityResolutionService.bulkResolveIdentities.mockResolvedValue({
|
||||
mappings: {
|
||||
"remote-user-1": "local-user-1",
|
||||
"remote-user-2": "local-user-2",
|
||||
},
|
||||
notFound: ["unknown-user"],
|
||||
});
|
||||
|
||||
const result = await controller.bulkResolveIdentity(dto);
|
||||
|
||||
expect(result.mappings).toEqual({
|
||||
"remote-user-1": "local-user-1",
|
||||
"remote-user-2": "local-user-2",
|
||||
});
|
||||
expect(result.notFound).toEqual(["unknown-user"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /identity/me", () => {
|
||||
it("should return current user's federated identities", async () => {
|
||||
identityLinkingService.listUserIdentities.mockResolvedValue([mockIdentity]);
|
||||
|
||||
const result = await controller.getCurrentUserIdentities(mockUser);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(mockIdentity);
|
||||
expect(identityLinkingService.listUserIdentities).toHaveBeenCalledWith("local-user-id");
|
||||
});
|
||||
|
||||
it("should return empty array if no identities", async () => {
|
||||
identityLinkingService.listUserIdentities.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getCurrentUserIdentities(mockUser);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /identity/link", () => {
|
||||
it("should create identity mapping", async () => {
|
||||
const dto: CreateIdentityMappingDto = {
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
remoteUserId: "remote-user-id",
|
||||
oidcSubject: "oidc-subject",
|
||||
email: "user@example.com",
|
||||
metadata: { source: "manual" },
|
||||
};
|
||||
|
||||
identityLinkingService.createIdentityMapping.mockResolvedValue(mockIdentity);
|
||||
|
||||
const result = await controller.createIdentityMapping(mockUser, dto);
|
||||
|
||||
expect(result).toEqual(mockIdentity);
|
||||
expect(identityLinkingService.createIdentityMapping).toHaveBeenCalledWith(
|
||||
"local-user-id",
|
||||
dto
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /identity/:remoteInstanceId", () => {
|
||||
it("should update identity mapping", async () => {
|
||||
const remoteInstanceId = "remote-instance-id";
|
||||
const dto: UpdateIdentityMappingDto = {
|
||||
metadata: { updated: true },
|
||||
};
|
||||
|
||||
const updatedIdentity = { ...mockIdentity, metadata: { updated: true } };
|
||||
identityLinkingService.updateIdentityMapping.mockResolvedValue(updatedIdentity);
|
||||
|
||||
const result = await controller.updateIdentityMapping(mockUser, remoteInstanceId, dto);
|
||||
|
||||
expect(result.metadata).toEqual({ updated: true });
|
||||
expect(identityLinkingService.updateIdentityMapping).toHaveBeenCalledWith(
|
||||
"local-user-id",
|
||||
remoteInstanceId,
|
||||
dto
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /identity/:remoteInstanceId", () => {
|
||||
it("should revoke identity mapping", async () => {
|
||||
const remoteInstanceId = "remote-instance-id";
|
||||
|
||||
identityLinkingService.revokeIdentityMapping.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.revokeIdentityMapping(mockUser, remoteInstanceId);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(identityLinkingService.revokeIdentityMapping).toHaveBeenCalledWith(
|
||||
"local-user-id",
|
||||
remoteInstanceId
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /identity/:remoteInstanceId/validate", () => {
|
||||
it("should validate existing identity mapping", async () => {
|
||||
const remoteInstanceId = "remote-instance-id";
|
||||
|
||||
identityLinkingService.validateIdentityMapping.mockResolvedValue({
|
||||
valid: true,
|
||||
localUserId: "local-user-id",
|
||||
remoteUserId: "remote-user-id",
|
||||
remoteInstanceId: "remote-instance-id",
|
||||
});
|
||||
|
||||
const result = await controller.validateIdentityMapping(mockUser, remoteInstanceId);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.localUserId).toBe("local-user-id");
|
||||
});
|
||||
|
||||
it("should return invalid if mapping not found", async () => {
|
||||
const remoteInstanceId = "unknown-instance-id";
|
||||
|
||||
identityLinkingService.validateIdentityMapping.mockResolvedValue({
|
||||
valid: false,
|
||||
error: "Identity mapping not found",
|
||||
});
|
||||
|
||||
const result = await controller.validateIdentityMapping(mockUser, remoteInstanceId);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user