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:
Jason Woltje
2026-02-03 12:34:24 -06:00
parent df2086ffe8
commit 6878d57c83
13 changed files with 1452 additions and 10 deletions

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