Files
stack/apps/api/src/federation/identity-linking.service.spec.ts
Jason Woltje 70a6bc82e0 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>
2026-02-03 12:55:37 -06:00

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();
});
});
});