/** * Identity Linking Service * * Handles cross-instance user identity verification and mapping. */ import { Injectable, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { OIDCService } from "./oidc.service"; import { SignatureService } from "./signature.service"; import { FederationAuditService } from "./audit.service"; import type { IdentityVerificationRequest, IdentityVerificationResponse, CreateIdentityMappingDto, UpdateIdentityMappingDto, IdentityMappingValidation, } from "./types/identity-linking.types"; import type { FederatedIdentity } from "./types/oidc.types"; @Injectable() export class IdentityLinkingService { private readonly logger = new Logger(IdentityLinkingService.name); constructor( private readonly prisma: PrismaService, private readonly oidcService: OIDCService, private readonly signatureService: SignatureService, private readonly auditService: FederationAuditService ) {} /** * Verify a user's identity from a remote instance * * Validates: * 1. Timestamp is recent (not expired) * 2. Signature is valid (signed by remote instance) * 3. OIDC token is valid * 4. Identity mapping exists */ async verifyIdentity( request: IdentityVerificationRequest ): Promise { this.logger.log(`Verifying identity: ${request.localUserId} from ${request.remoteInstanceId}`); // Validate timestamp (prevent replay attacks) if (!this.signatureService.validateTimestamp(request.timestamp)) { this.logger.warn(`Identity verification failed: Request timestamp expired`); return { verified: false, error: "Request timestamp expired", }; } // Verify signature const { signature, ...messageToVerify } = request; const signatureValidation = await this.signatureService.verifyMessage( messageToVerify, signature, request.remoteInstanceId ); if (!signatureValidation.valid) { const errorMessage = signatureValidation.error ?? "Invalid signature"; this.logger.warn(`Identity verification failed: ${errorMessage}`); return { verified: false, error: errorMessage, }; } // Validate OIDC token const tokenValidation = await this.oidcService.validateToken( request.oidcToken, request.remoteInstanceId ); if (!tokenValidation.valid) { const tokenError = tokenValidation.error ?? "Invalid OIDC token"; this.logger.warn(`Identity verification failed: ${tokenError}`); return { verified: false, error: tokenError, }; } // Check if identity mapping exists const identity = await this.oidcService.getFederatedIdentity( request.localUserId, request.remoteInstanceId ); if (!identity) { this.logger.warn( `Identity verification failed: Mapping not found for ${request.localUserId}` ); return { verified: false, error: "Identity mapping not found", }; } // Verify that the remote user ID matches if (identity.remoteUserId !== request.remoteUserId) { this.logger.warn( `Identity verification failed: Remote user ID mismatch (expected ${identity.remoteUserId}, got ${request.remoteUserId})` ); return { verified: false, error: "Remote user ID mismatch", }; } // Log successful verification this.auditService.logIdentityVerification(request.localUserId, request.remoteInstanceId, true); this.logger.log(`Identity verified successfully: ${request.localUserId}`); return { verified: true, localUserId: identity.localUserId, remoteUserId: identity.remoteUserId, remoteInstanceId: identity.remoteInstanceId, email: identity.email, }; } /** * Resolve a remote user to a local user * * Looks up the identity mapping by remote instance and user ID. */ async resolveLocalIdentity( remoteInstanceId: string, remoteUserId: string ): Promise { this.logger.debug(`Resolving local identity for ${remoteUserId}@${remoteInstanceId}`); // Query by remoteInstanceId and remoteUserId // Note: Prisma doesn't have a unique constraint for this pair, // so we use findFirst const identity = await this.prisma.federatedIdentity.findFirst({ where: { remoteInstanceId, remoteUserId, }, }); if (!identity) { this.logger.debug(`No local identity found for ${remoteUserId}@${remoteInstanceId}`); return null; } 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, }; } /** * Resolve a local user to a remote identity * * Looks up the identity mapping by local user ID and remote instance. */ async resolveRemoteIdentity( localUserId: string, remoteInstanceId: string ): Promise { this.logger.debug(`Resolving remote identity for ${localUserId}@${remoteInstanceId}`); const identity = await this.oidcService.getFederatedIdentity(localUserId, remoteInstanceId); if (!identity) { this.logger.debug(`No remote identity found for ${localUserId}@${remoteInstanceId}`); return null; } return identity; } /** * Create a new identity mapping * * Optionally validates OIDC token if provided. */ async createIdentityMapping( localUserId: string, dto: CreateIdentityMappingDto ): Promise { this.logger.log( `Creating identity mapping: ${localUserId} -> ${dto.remoteUserId}@${dto.remoteInstanceId}` ); // Validate OIDC token if provided if (dto.oidcToken) { const tokenValidation = await this.oidcService.validateToken( dto.oidcToken, dto.remoteInstanceId ); if (!tokenValidation.valid) { const validationError = tokenValidation.error ?? "Unknown validation error"; throw new UnauthorizedException(`Invalid OIDC token: ${validationError}`); } } // Create identity mapping via OIDCService const identity = await this.oidcService.linkFederatedIdentity( localUserId, dto.remoteUserId, dto.remoteInstanceId, dto.oidcSubject, dto.email, dto.metadata ?? {} ); // Log identity linking this.auditService.logIdentityLinking(localUserId, dto.remoteInstanceId, dto.remoteUserId); return identity; } /** * Update an existing identity mapping */ async updateIdentityMapping( localUserId: string, remoteInstanceId: string, dto: UpdateIdentityMappingDto ): Promise { this.logger.log(`Updating identity mapping: ${localUserId}@${remoteInstanceId}`); // Verify mapping exists const existing = await this.prisma.federatedIdentity.findUnique({ where: { localUserId_remoteInstanceId: { localUserId, remoteInstanceId, }, }, }); if (!existing) { throw new NotFoundException("Identity mapping not found"); } // Update metadata const updated = await this.prisma.federatedIdentity.update({ where: { localUserId_remoteInstanceId: { localUserId, remoteInstanceId, }, }, data: { metadata: (dto.metadata ?? existing.metadata) as Prisma.InputJsonValue, }, }); return { id: updated.id, localUserId: updated.localUserId, remoteUserId: updated.remoteUserId, remoteInstanceId: updated.remoteInstanceId, oidcSubject: updated.oidcSubject, email: updated.email, metadata: updated.metadata as Record, createdAt: updated.createdAt, updatedAt: updated.updatedAt, }; } /** * Validate an identity mapping exists and is valid */ async validateIdentityMapping( localUserId: string, remoteInstanceId: string ): Promise { const identity = await this.oidcService.getFederatedIdentity(localUserId, remoteInstanceId); if (!identity) { return { valid: false, error: "Identity mapping not found", }; } return { valid: true, localUserId: identity.localUserId, remoteUserId: identity.remoteUserId, remoteInstanceId: identity.remoteInstanceId, }; } /** * List all federated identities for a user */ async listUserIdentities(localUserId: string): Promise { return this.oidcService.getUserFederatedIdentities(localUserId); } /** * Revoke an identity mapping */ async revokeIdentityMapping(localUserId: string, remoteInstanceId: string): Promise { this.logger.log(`Revoking identity mapping: ${localUserId}@${remoteInstanceId}`); await this.oidcService.revokeFederatedIdentity(localUserId, remoteInstanceId); // Log revocation this.auditService.logIdentityRevocation(localUserId, remoteInstanceId); } }