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:
270
apps/api/src/federation/federation-auth.controller.spec.ts
Normal file
270
apps/api/src/federation/federation-auth.controller.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Federation Auth Controller Tests
|
||||
*
|
||||
* Tests for federated authentication API endpoints.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { FederationAuthController } from "./federation-auth.controller";
|
||||
import { OIDCService } from "./oidc.service";
|
||||
import { FederationAuditService } from "./audit.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { FederatedIdentity } from "./types/oidc.types";
|
||||
import {
|
||||
InitiateFederatedAuthDto,
|
||||
LinkFederatedIdentityDto,
|
||||
ValidateFederatedTokenDto,
|
||||
} from "./dto/federated-auth.dto";
|
||||
|
||||
describe("FederationAuthController", () => {
|
||||
let controller: FederationAuthController;
|
||||
let oidcService: OIDCService;
|
||||
let auditService: FederationAuditService;
|
||||
|
||||
const mockOIDCService = {
|
||||
generateAuthUrl: vi.fn(),
|
||||
linkFederatedIdentity: vi.fn(),
|
||||
getUserFederatedIdentities: vi.fn(),
|
||||
getFederatedIdentity: vi.fn(),
|
||||
revokeFederatedIdentity: vi.fn(),
|
||||
validateToken: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuditService = {
|
||||
logFederatedAuthInitiation: vi.fn(),
|
||||
logFederatedIdentityLinked: vi.fn(),
|
||||
logFederatedIdentityRevoked: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: "user-123",
|
||||
email: "user@example.com",
|
||||
workspaceId: "workspace-abc",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [FederationAuthController],
|
||||
providers: [
|
||||
{ provide: OIDCService, useValue: mockOIDCService },
|
||||
{ provide: FederationAuditService, useValue: mockAuditService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<FederationAuthController>(FederationAuthController);
|
||||
oidcService = module.get<OIDCService>(OIDCService);
|
||||
auditService = module.get<FederationAuditService>(FederationAuditService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/v1/federation/auth/initiate", () => {
|
||||
it("should initiate federated auth flow", () => {
|
||||
const dto: InitiateFederatedAuthDto = {
|
||||
remoteInstanceId: "remote-instance-123",
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
};
|
||||
|
||||
const mockAuthUrl = "https://auth.remote.com/authorize?client_id=abc&...";
|
||||
|
||||
mockOIDCService.generateAuthUrl.mockReturnValue(mockAuthUrl);
|
||||
|
||||
const req = { user: mockUser } as AuthenticatedRequest;
|
||||
const result = controller.initiateAuth(req, dto);
|
||||
|
||||
expect(result).toEqual({
|
||||
authUrl: mockAuthUrl,
|
||||
state: dto.remoteInstanceId,
|
||||
});
|
||||
expect(mockOIDCService.generateAuthUrl).toHaveBeenCalledWith(
|
||||
dto.remoteInstanceId,
|
||||
dto.redirectUrl
|
||||
);
|
||||
expect(mockAuditService.logFederatedAuthInitiation).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
dto.remoteInstanceId
|
||||
);
|
||||
});
|
||||
|
||||
it("should require authentication", () => {
|
||||
const dto: InitiateFederatedAuthDto = {
|
||||
remoteInstanceId: "remote-instance-123",
|
||||
};
|
||||
|
||||
const req = { user: null } as unknown as AuthenticatedRequest;
|
||||
|
||||
expect(() => controller.initiateAuth(req, dto)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/federation/auth/link", () => {
|
||||
it("should link federated identity", async () => {
|
||||
const dto: LinkFederatedIdentityDto = {
|
||||
remoteInstanceId: "remote-instance-123",
|
||||
remoteUserId: "remote-user-456",
|
||||
oidcSubject: "oidc-sub-abc",
|
||||
email: "user@example.com",
|
||||
metadata: { displayName: "John Doe" },
|
||||
};
|
||||
|
||||
const mockIdentity: FederatedIdentity = {
|
||||
id: "identity-uuid",
|
||||
localUserId: mockUser.id,
|
||||
remoteUserId: dto.remoteUserId,
|
||||
remoteInstanceId: dto.remoteInstanceId,
|
||||
oidcSubject: dto.oidcSubject,
|
||||
email: dto.email,
|
||||
metadata: dto.metadata ?? {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockOIDCService.linkFederatedIdentity.mockResolvedValue(mockIdentity);
|
||||
|
||||
const req = { user: mockUser } as AuthenticatedRequest;
|
||||
const result = await controller.linkIdentity(req, dto);
|
||||
|
||||
expect(result).toEqual(mockIdentity);
|
||||
expect(mockOIDCService.linkFederatedIdentity).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
dto.remoteUserId,
|
||||
dto.remoteInstanceId,
|
||||
dto.oidcSubject,
|
||||
dto.email,
|
||||
dto.metadata
|
||||
);
|
||||
expect(mockAuditService.logFederatedIdentityLinked).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
dto.remoteInstanceId
|
||||
);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const dto: LinkFederatedIdentityDto = {
|
||||
remoteInstanceId: "remote-instance-123",
|
||||
remoteUserId: "remote-user-456",
|
||||
oidcSubject: "oidc-sub-abc",
|
||||
email: "user@example.com",
|
||||
};
|
||||
|
||||
const req = { user: null } as unknown as AuthenticatedRequest;
|
||||
|
||||
await expect(controller.linkIdentity(req, dto)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/federation/auth/identities", () => {
|
||||
it("should return user's federated identities", async () => {
|
||||
const mockIdentities: FederatedIdentity[] = [
|
||||
{
|
||||
id: "identity-1",
|
||||
localUserId: mockUser.id,
|
||||
remoteUserId: "remote-1",
|
||||
remoteInstanceId: "instance-1",
|
||||
oidcSubject: "sub-1",
|
||||
email: mockUser.email,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "identity-2",
|
||||
localUserId: mockUser.id,
|
||||
remoteUserId: "remote-2",
|
||||
remoteInstanceId: "instance-2",
|
||||
oidcSubject: "sub-2",
|
||||
email: mockUser.email,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockOIDCService.getUserFederatedIdentities.mockResolvedValue(mockIdentities);
|
||||
|
||||
const req = { user: mockUser } as AuthenticatedRequest;
|
||||
const result = await controller.getIdentities(req);
|
||||
|
||||
expect(result).toEqual(mockIdentities);
|
||||
expect(mockOIDCService.getUserFederatedIdentities).toHaveBeenCalledWith(mockUser.id);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const req = { user: null } as unknown as AuthenticatedRequest;
|
||||
|
||||
await expect(controller.getIdentities(req)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/v1/federation/auth/identities/:instanceId", () => {
|
||||
it("should revoke federated identity", async () => {
|
||||
const instanceId = "remote-instance-123";
|
||||
|
||||
mockOIDCService.revokeFederatedIdentity.mockResolvedValue(undefined);
|
||||
|
||||
const req = { user: mockUser } as AuthenticatedRequest;
|
||||
const result = await controller.revokeIdentity(req, instanceId);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockOIDCService.revokeFederatedIdentity).toHaveBeenCalledWith(mockUser.id, instanceId);
|
||||
expect(mockAuditService.logFederatedIdentityRevoked).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
instanceId
|
||||
);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const req = { user: null } as unknown as AuthenticatedRequest;
|
||||
|
||||
await expect(controller.revokeIdentity(req, "instance-123")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/federation/auth/validate", () => {
|
||||
it("should validate federated token", async () => {
|
||||
const dto: ValidateFederatedTokenDto = {
|
||||
token: "valid-token",
|
||||
instanceId: "remote-instance-123",
|
||||
};
|
||||
|
||||
const mockValidation = {
|
||||
valid: true,
|
||||
userId: "user-subject-123",
|
||||
instanceId: dto.instanceId,
|
||||
email: "user@example.com",
|
||||
subject: "user-subject-123",
|
||||
};
|
||||
|
||||
mockOIDCService.validateToken.mockReturnValue(mockValidation);
|
||||
|
||||
const result = controller.validateToken(dto);
|
||||
|
||||
expect(result).toEqual(mockValidation);
|
||||
expect(mockOIDCService.validateToken).toHaveBeenCalledWith(dto.token, dto.instanceId);
|
||||
});
|
||||
|
||||
it("should return invalid for expired token", async () => {
|
||||
const dto: ValidateFederatedTokenDto = {
|
||||
token: "expired-token",
|
||||
instanceId: "remote-instance-123",
|
||||
};
|
||||
|
||||
const mockValidation = {
|
||||
valid: false,
|
||||
error: "Token has expired",
|
||||
};
|
||||
|
||||
mockOIDCService.validateToken.mockReturnValue(mockValidation);
|
||||
|
||||
const result = controller.validateToken(dto);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user