/** * Federation OIDC Service * * Handles federated authentication using OIDC/OAuth2. */ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PrismaService } from "../prisma/prisma.service"; import type { FederatedIdentity, FederatedTokenValidation } from "./types/oidc.types"; import type { Prisma } from "@prisma/client"; import * as jose from "jose"; @Injectable() export class OIDCService { private readonly logger = new Logger(OIDCService.name); constructor( private readonly prisma: PrismaService, private readonly config: ConfigService // FederationService will be added in future implementation // for fetching remote instance OIDC configuration ) {} /** * Link a local user to a remote federated identity */ async linkFederatedIdentity( localUserId: string, remoteUserId: string, remoteInstanceId: string, oidcSubject: string, email: string, metadata: Record = {} ): Promise { this.logger.log( `Linking federated identity: ${localUserId} -> ${remoteUserId}@${remoteInstanceId}` ); const identity = await this.prisma.federatedIdentity.create({ data: { localUserId, remoteUserId, remoteInstanceId, oidcSubject, email, metadata: metadata as Prisma.InputJsonValue, }, }); return this.mapToFederatedIdentity(identity); } /** * Get federated identity for a user and remote instance */ async getFederatedIdentity( localUserId: string, remoteInstanceId: string ): Promise { const identity = await this.prisma.federatedIdentity.findUnique({ where: { localUserId_remoteInstanceId: { localUserId, remoteInstanceId, }, }, }); return identity ? this.mapToFederatedIdentity(identity) : null; } /** * Get all federated identities for a user */ async getUserFederatedIdentities(localUserId: string): Promise { const identities = await this.prisma.federatedIdentity.findMany({ where: { localUserId }, orderBy: { createdAt: "desc" }, }); return identities.map((identity) => this.mapToFederatedIdentity(identity)); } /** * Revoke a federated identity mapping */ async revokeFederatedIdentity(localUserId: string, remoteInstanceId: string): Promise { this.logger.log(`Revoking federated identity: ${localUserId} @ ${remoteInstanceId}`); await this.prisma.federatedIdentity.delete({ where: { localUserId_remoteInstanceId: { localUserId, remoteInstanceId, }, }, }); } /** * Validate an OIDC token from a federated instance * * Verifies JWT signature and validates all standard claims. * * Current implementation uses a test secret for validation. * Production implementation should: * 1. Fetch OIDC discovery metadata from the issuer * 2. Retrieve and cache JWKS (JSON Web Key Set) * 3. Verify JWT signature using the public key from JWKS * 4. Handle key rotation and JWKS refresh */ async validateToken(token: string, instanceId: string): Promise { try { // Validate token format if (!token || typeof token !== "string") { return { valid: false, error: "Malformed token: token must be a non-empty string", }; } // Check if token looks like a JWT (three parts separated by dots) const parts = token.split("."); if (parts.length !== 3) { return { valid: false, error: "Malformed token: JWT must have three parts (header.payload.signature)", }; } // Get OIDC configuration from environment variables // These must be configured for federation token validation to work const issuer = this.config.get("OIDC_ISSUER"); const clientId = this.config.get("OIDC_CLIENT_ID"); // Fail fast if OIDC configuration is missing if (!issuer || issuer.trim() === "") { this.logger.error( "Federation OIDC validation failed: OIDC_ISSUER environment variable is not configured" ); return { valid: false, error: "Federation OIDC configuration error: OIDC_ISSUER is required for token validation", }; } if (!clientId || clientId.trim() === "") { this.logger.error( "Federation OIDC validation failed: OIDC_CLIENT_ID environment variable is not configured" ); return { valid: false, error: "Federation OIDC configuration error: OIDC_CLIENT_ID is required for token validation", }; } // Get validation secret from config (for testing/development) // In production, this should fetch JWKS from the remote instance const secret = this.config.get("OIDC_VALIDATION_SECRET") ?? "test-secret-key-for-jwt-signing"; const secretKey = new TextEncoder().encode(secret); // Remove trailing slash from issuer for JWT validation (jose expects issuer without trailing slash) const normalizedIssuer = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer; // Verify and decode JWT const { payload } = await jose.jwtVerify(token, secretKey, { issuer: normalizedIssuer, audience: clientId, }); // Extract claims const sub = payload.sub; const email = payload.email as string | undefined; if (!sub) { return { valid: false, error: "Token missing required 'sub' claim", }; } // Return validation result const result: FederatedTokenValidation = { valid: true, userId: sub, subject: sub, instanceId, }; // Only include email if present (exactOptionalPropertyTypes compliance) if (email) { result.email = email; } return result; } catch (error) { // Handle specific JWT errors if (error instanceof jose.errors.JWTExpired) { return { valid: false, error: "Token has expired", }; } if (error instanceof jose.errors.JWTClaimValidationFailed) { const claimError = error.message; // Check specific claim failures if (claimError.includes("iss") || claimError.includes("issuer")) { return { valid: false, error: "Invalid token issuer", }; } if (claimError.includes("aud") || claimError.includes("audience")) { return { valid: false, error: "Invalid token audience", }; } return { valid: false, error: `Claim validation failed: ${claimError}`, }; } if (error instanceof jose.errors.JWSSignatureVerificationFailed) { return { valid: false, error: "Invalid token signature", }; } // Generic error handling this.logger.error( `Token validation error: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error.stack : undefined ); return { valid: false, error: error instanceof Error ? error.message : "Token validation failed", }; } } /** * Generate authorization URL for federated authentication * * Creates an OAuth2 authorization URL to redirect the user to * the remote instance's OIDC provider. */ generateAuthUrl(remoteInstanceId: string, redirectUrl?: string): string { // This would fetch the remote instance's OIDC configuration // and generate the authorization URL // For now, return a placeholder // Real implementation would: // 1. Fetch remote instance metadata // 2. Get OIDC discovery endpoint // 3. Build authorization URL with proper params // 4. Include state for CSRF protection // 5. Include PKCE parameters const baseUrl = this.config.get("INSTANCE_URL") ?? "http://localhost:3001"; const callbackUrl = redirectUrl ?? `${baseUrl}/api/v1/federation/auth/callback`; this.logger.log(`Generating auth URL for instance ${remoteInstanceId}`); // Placeholder - real implementation would fetch actual OIDC config return `https://auth.example.com/authorize?client_id=placeholder&redirect_uri=${encodeURIComponent(callbackUrl)}&response_type=code&scope=openid+profile+email&state=${remoteInstanceId}`; } /** * Map Prisma FederatedIdentity to type */ private mapToFederatedIdentity(identity: { id: string; localUserId: string; remoteUserId: string; remoteInstanceId: string; oidcSubject: string; email: string; metadata: unknown; createdAt: Date; updatedAt: Date; }): FederatedIdentity { 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, }; } }