feat(#86): implement Authentik OIDC integration for federation
Implements federated authentication infrastructure using OIDC: - Add FederatedIdentity model to Prisma schema for identity mapping - Create OIDCService with identity linking and token validation - Add FederationAuthController with 5 endpoints: * POST /auth/initiate - Start federated auth flow * POST /auth/link - Link identity to remote instance * GET /auth/identities - List user's federated identities * DELETE /auth/identities/:id - Revoke identity * POST /auth/validate - Validate federated token - Create comprehensive type definitions for OIDC flows - Add audit logging for security events - Write 24 passing tests (14 service + 10 controller) - Achieve 79% coverage for OIDCService, 100% for controller Notes: - Token validation and auth URL generation are placeholder implementations - Full JWT validation will be added when federation OIDC is actively used - Identity mappings enforce workspace isolation - All endpoints require authentication except /validate Refs #86 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
193
apps/api/src/federation/oidc.service.ts
Normal file
193
apps/api/src/federation/oidc.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
@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
|
||||
*
|
||||
* NOTE: This is a simplified implementation for the initial version.
|
||||
* In production, this 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
|
||||
* 4. Validate claims (iss, aud, exp, etc.)
|
||||
* 5. Handle token refresh if needed
|
||||
*
|
||||
* For now, we provide the interface and basic structure.
|
||||
* Full JWT validation will be implemented when needed.
|
||||
*/
|
||||
validateToken(_token: string, _instanceId: string): FederatedTokenValidation {
|
||||
try {
|
||||
// TODO: Implement full JWT validation
|
||||
// For now, this is a placeholder that should be implemented
|
||||
// when federation OIDC is actively used
|
||||
|
||||
this.logger.warn("Token validation not fully implemented - returning mock validation");
|
||||
|
||||
// This is a placeholder response
|
||||
// Real implementation would decode and verify the JWT
|
||||
return {
|
||||
valid: false,
|
||||
error: "Token validation not yet implemented",
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Token validation error: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user