Files
stack/apps/api/src/federation/oidc.service.ts
Jason Woltje c30b4b1cc2 fix(#337): Replace hardcoded OIDC values in federation with env vars
- 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>
2026-02-05 16:03:09 -06:00

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,
};
}
}