- Use OIDC_ISSUER and OIDC_CLIENT_ID from environment for JWT validation - Federation OIDC properly configured from environment variables - Fail fast with clear error when OIDC config is missing - Handle trailing slash normalization for issuer URL - Add tests verifying env var usage and missing config error handling Refs #337 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
304 lines
9.2 KiB
TypeScript
304 lines
9.2 KiB
TypeScript
/**
|
|
* 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<string, unknown> = {}
|
|
): Promise<FederatedIdentity> {
|
|
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<FederatedIdentity | null> {
|
|
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<FederatedIdentity[]> {
|
|
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<void> {
|
|
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<FederatedTokenValidation> {
|
|
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<string>("OIDC_ISSUER");
|
|
const clientId = this.config.get<string>("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<string>("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<string>("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<string, unknown>,
|
|
createdAt: identity.createdAt,
|
|
updatedAt: identity.updatedAt,
|
|
};
|
|
}
|
|
}
|