Replaced placeholder OIDC token validation with real JWT verification using the jose library. This fixes a critical authentication bypass vulnerability where any attacker could impersonate any user on federated instances. Security Impact: - FIXED: Complete authentication bypass (always returned valid:false) - ADDED: JWT signature verification using HS256 - ADDED: Claim validation (iss, aud, exp, nbf, iat, sub) - ADDED: Specific error handling for each failure type - ADDED: 8 comprehensive security tests Implementation: - Made validateToken async (returns Promise) - Added jose library integration for JWT verification - Updated all callers to await async validation - Fixed controller tests to use mockResolvedValue Test Results: - Federation tests: 229/229 passing ✅ - TypeScript: 0 errors ✅ - Lint: 0 errors ✅ Production TODO: - Implement JWKS fetching from remote instances - Add JWKS caching with TTL (1 hour) - Support RS256 asymmetric keys Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
324 lines
9.2 KiB
TypeScript
324 lines
9.2 KiB
TypeScript
/**
|
|
* 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<IdentityVerificationResponse> {
|
|
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<FederatedIdentity | null> {
|
|
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<string, unknown>,
|
|
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<FederatedIdentity | null> {
|
|
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<FederatedIdentity> {
|
|
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<FederatedIdentity> {
|
|
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<string, unknown>,
|
|
createdAt: updated.createdAt,
|
|
updatedAt: updated.updatedAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate an identity mapping exists and is valid
|
|
*/
|
|
async validateIdentityMapping(
|
|
localUserId: string,
|
|
remoteInstanceId: string
|
|
): Promise<IdentityMappingValidation> {
|
|
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<FederatedIdentity[]> {
|
|
return this.oidcService.getUserFederatedIdentities(localUserId);
|
|
}
|
|
|
|
/**
|
|
* Revoke an identity mapping
|
|
*/
|
|
async revokeIdentityMapping(localUserId: string, remoteInstanceId: string): Promise<void> {
|
|
this.logger.log(`Revoking identity mapping: ${localUserId}@${remoteInstanceId}`);
|
|
|
|
await this.oidcService.revokeFederatedIdentity(localUserId, remoteInstanceId);
|
|
|
|
// Log revocation
|
|
this.auditService.logIdentityRevocation(localUserId, remoteInstanceId);
|
|
}
|
|
}
|