Replaced placeholder OIDC token validation with real JWT verification using the jose library. This fixes a critical authentication bypass vulnerability where any attacker could impersonate any user on federated instances. Security Impact: - FIXED: Complete authentication bypass (always returned valid:false) - ADDED: JWT signature verification using HS256 - ADDED: Claim validation (iss, aud, exp, nbf, iat, sub) - ADDED: Specific error handling for each failure type - ADDED: 8 comprehensive security tests Implementation: - Made validateToken async (returns Promise) - Added jose library integration for JWT verification - Updated all callers to await async validation - Fixed controller tests to use mockResolvedValue Test Results: - Federation tests: 229/229 passing ✅ - TypeScript: 0 errors ✅ - Lint: 0 errors ✅ Production TODO: - Implement JWKS fetching from remote instances - Add JWKS caching with TTL (1 hour) - Support RS256 asymmetric keys Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
271 lines
8.4 KiB
TypeScript
271 lines
8.4 KiB
TypeScript
/**
|
|
* 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.mockResolvedValue(mockValidation);
|
|
|
|
const result = await 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.mockResolvedValue(mockValidation);
|
|
|
|
const result = await controller.validateToken(dto);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
});
|
|
});
|