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>
466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
/**
|
|
* Federation OIDC Service Tests
|
|
*
|
|
* Tests for federated authentication using OIDC.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { OIDCService } from "./oidc.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import type {
|
|
FederatedIdentity,
|
|
FederatedTokenValidation,
|
|
OIDCTokenClaims,
|
|
} from "./types/oidc.types";
|
|
import * as jose from "jose";
|
|
|
|
/**
|
|
* Helper function to create test JWTs for testing
|
|
*/
|
|
async function createTestJWT(
|
|
claims: OIDCTokenClaims,
|
|
secret: string = "test-secret-key-for-jwt-signing"
|
|
): Promise<string> {
|
|
const secretKey = new TextEncoder().encode(secret);
|
|
|
|
const jwt = await new jose.SignJWT(claims as Record<string, unknown>)
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setIssuedAt(claims.iat)
|
|
.setExpirationTime(claims.exp)
|
|
.setSubject(claims.sub)
|
|
.setIssuer(claims.iss)
|
|
.setAudience(claims.aud)
|
|
.sign(secretKey);
|
|
|
|
return jwt;
|
|
}
|
|
|
|
describe("OIDCService", () => {
|
|
let service: OIDCService;
|
|
let prisma: PrismaService;
|
|
let configService: ConfigService;
|
|
|
|
const mockPrismaService = {
|
|
federatedIdentity: {
|
|
create: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
findMany: vi.fn(),
|
|
delete: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const mockConfigService = {
|
|
get: vi.fn(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
OIDCService,
|
|
{ provide: PrismaService, useValue: mockPrismaService },
|
|
{ provide: ConfigService, useValue: mockConfigService },
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<OIDCService>(OIDCService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
configService = module.get<ConfigService>(ConfigService);
|
|
|
|
// Reset mocks
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("linkFederatedIdentity", () => {
|
|
it("should create a new federated identity mapping", async () => {
|
|
const userId = "local-user-123";
|
|
const remoteUserId = "remote-user-456";
|
|
const remoteInstanceId = "remote-instance-789";
|
|
const oidcSubject = "oidc-sub-abc";
|
|
const email = "user@example.com";
|
|
|
|
const mockIdentity: FederatedIdentity = {
|
|
id: "identity-uuid",
|
|
localUserId: userId,
|
|
remoteUserId,
|
|
remoteInstanceId,
|
|
oidcSubject,
|
|
email,
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
mockPrismaService.federatedIdentity.create.mockResolvedValue(mockIdentity);
|
|
|
|
const result = await service.linkFederatedIdentity(
|
|
userId,
|
|
remoteUserId,
|
|
remoteInstanceId,
|
|
oidcSubject,
|
|
email
|
|
);
|
|
|
|
expect(result).toEqual(mockIdentity);
|
|
expect(mockPrismaService.federatedIdentity.create).toHaveBeenCalledWith({
|
|
data: {
|
|
localUserId: userId,
|
|
remoteUserId,
|
|
remoteInstanceId,
|
|
oidcSubject,
|
|
email,
|
|
metadata: {},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should include optional metadata when provided", async () => {
|
|
const userId = "local-user-123";
|
|
const remoteUserId = "remote-user-456";
|
|
const remoteInstanceId = "remote-instance-789";
|
|
const oidcSubject = "oidc-sub-abc";
|
|
const email = "user@example.com";
|
|
const metadata = { displayName: "John Doe", roles: ["user"] };
|
|
|
|
mockPrismaService.federatedIdentity.create.mockResolvedValue({
|
|
id: "identity-uuid",
|
|
localUserId: userId,
|
|
remoteUserId,
|
|
remoteInstanceId,
|
|
oidcSubject,
|
|
email,
|
|
metadata,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
await service.linkFederatedIdentity(
|
|
userId,
|
|
remoteUserId,
|
|
remoteInstanceId,
|
|
oidcSubject,
|
|
email,
|
|
metadata
|
|
);
|
|
|
|
expect(mockPrismaService.federatedIdentity.create).toHaveBeenCalledWith({
|
|
data: {
|
|
localUserId: userId,
|
|
remoteUserId,
|
|
remoteInstanceId,
|
|
oidcSubject,
|
|
email,
|
|
metadata,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should throw error if identity already exists", async () => {
|
|
const userId = "local-user-123";
|
|
const remoteUserId = "remote-user-456";
|
|
const remoteInstanceId = "remote-instance-789";
|
|
|
|
mockPrismaService.federatedIdentity.create.mockRejectedValue({
|
|
code: "P2002",
|
|
message: "Unique constraint failed",
|
|
});
|
|
|
|
await expect(
|
|
service.linkFederatedIdentity(
|
|
userId,
|
|
remoteUserId,
|
|
remoteInstanceId,
|
|
"oidc-sub",
|
|
"user@example.com"
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("getFederatedIdentity", () => {
|
|
it("should retrieve federated identity by user and instance", async () => {
|
|
const userId = "local-user-123";
|
|
const remoteInstanceId = "remote-instance-789";
|
|
|
|
const mockIdentity: FederatedIdentity = {
|
|
id: "identity-uuid",
|
|
localUserId: userId,
|
|
remoteUserId: "remote-user-456",
|
|
remoteInstanceId,
|
|
oidcSubject: "oidc-sub-abc",
|
|
email: "user@example.com",
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
mockPrismaService.federatedIdentity.findUnique.mockResolvedValue(mockIdentity);
|
|
|
|
const result = await service.getFederatedIdentity(userId, remoteInstanceId);
|
|
|
|
expect(result).toEqual(mockIdentity);
|
|
expect(mockPrismaService.federatedIdentity.findUnique).toHaveBeenCalledWith({
|
|
where: {
|
|
localUserId_remoteInstanceId: {
|
|
localUserId: userId,
|
|
remoteInstanceId,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should return null if identity does not exist", async () => {
|
|
mockPrismaService.federatedIdentity.findUnique.mockResolvedValue(null);
|
|
|
|
const result = await service.getFederatedIdentity("user-123", "instance-456");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("getUserFederatedIdentities", () => {
|
|
it("should retrieve all federated identities for a user", async () => {
|
|
const userId = "local-user-123";
|
|
|
|
const mockIdentities: FederatedIdentity[] = [
|
|
{
|
|
id: "identity-1",
|
|
localUserId: userId,
|
|
remoteUserId: "remote-1",
|
|
remoteInstanceId: "instance-1",
|
|
oidcSubject: "sub-1",
|
|
email: "user@example.com",
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
{
|
|
id: "identity-2",
|
|
localUserId: userId,
|
|
remoteUserId: "remote-2",
|
|
remoteInstanceId: "instance-2",
|
|
oidcSubject: "sub-2",
|
|
email: "user@example.com",
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
];
|
|
|
|
mockPrismaService.federatedIdentity.findMany.mockResolvedValue(mockIdentities);
|
|
|
|
const result = await service.getUserFederatedIdentities(userId);
|
|
|
|
expect(result).toEqual(mockIdentities);
|
|
expect(mockPrismaService.federatedIdentity.findMany).toHaveBeenCalledWith({
|
|
where: { localUserId: userId },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
});
|
|
|
|
it("should return empty array if user has no federated identities", async () => {
|
|
mockPrismaService.federatedIdentity.findMany.mockResolvedValue([]);
|
|
|
|
const result = await service.getUserFederatedIdentities("user-123");
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("revokeFederatedIdentity", () => {
|
|
it("should delete federated identity", async () => {
|
|
const userId = "local-user-123";
|
|
const remoteInstanceId = "remote-instance-789";
|
|
|
|
const mockIdentity: FederatedIdentity = {
|
|
id: "identity-uuid",
|
|
localUserId: userId,
|
|
remoteUserId: "remote-user-456",
|
|
remoteInstanceId,
|
|
oidcSubject: "oidc-sub-abc",
|
|
email: "user@example.com",
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
mockPrismaService.federatedIdentity.delete.mockResolvedValue(mockIdentity);
|
|
|
|
await service.revokeFederatedIdentity(userId, remoteInstanceId);
|
|
|
|
expect(mockPrismaService.federatedIdentity.delete).toHaveBeenCalledWith({
|
|
where: {
|
|
localUserId_remoteInstanceId: {
|
|
localUserId: userId,
|
|
remoteInstanceId,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should throw error if identity does not exist", async () => {
|
|
mockPrismaService.federatedIdentity.delete.mockRejectedValue({
|
|
code: "P2025",
|
|
message: "Record not found",
|
|
});
|
|
|
|
await expect(service.revokeFederatedIdentity("user-123", "instance-456")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("validateToken - Real JWT Validation", () => {
|
|
it("should reject malformed token (not a JWT)", async () => {
|
|
const token = "not-a-jwt-token";
|
|
const instanceId = "remote-instance-123";
|
|
|
|
const result = await service.validateToken(token, instanceId);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("Malformed token");
|
|
});
|
|
|
|
it("should reject token with invalid format (missing parts)", async () => {
|
|
const token = "header.payload"; // Missing signature
|
|
const instanceId = "remote-instance-123";
|
|
|
|
const result = await service.validateToken(token, instanceId);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("Malformed token");
|
|
});
|
|
|
|
it("should reject expired token", async () => {
|
|
// Create an expired JWT (exp in the past)
|
|
const expiredToken = await createTestJWT({
|
|
sub: "user-123",
|
|
iss: "https://auth.example.com",
|
|
aud: "mosaic-client-id",
|
|
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
|
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
email: "user@example.com",
|
|
});
|
|
|
|
const result = await service.validateToken(expiredToken, "remote-instance-123");
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("expired");
|
|
});
|
|
|
|
it("should reject token with invalid signature", async () => {
|
|
// Create a JWT with a different key than what the service will validate
|
|
const invalidToken = await createTestJWT(
|
|
{
|
|
sub: "user-123",
|
|
iss: "https://auth.example.com",
|
|
aud: "mosaic-client-id",
|
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
email: "user@example.com",
|
|
},
|
|
"wrong-secret-key"
|
|
);
|
|
|
|
const result = await service.validateToken(invalidToken, "remote-instance-123");
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("signature");
|
|
});
|
|
|
|
it("should reject token with wrong issuer", async () => {
|
|
const token = await createTestJWT({
|
|
sub: "user-123",
|
|
iss: "https://wrong-issuer.com", // Wrong issuer
|
|
aud: "mosaic-client-id",
|
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
email: "user@example.com",
|
|
});
|
|
|
|
const result = await service.validateToken(token, "remote-instance-123");
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("issuer");
|
|
});
|
|
|
|
it("should reject token with wrong audience", async () => {
|
|
const token = await createTestJWT({
|
|
sub: "user-123",
|
|
iss: "https://auth.example.com",
|
|
aud: "wrong-audience", // Wrong audience
|
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
email: "user@example.com",
|
|
});
|
|
|
|
const result = await service.validateToken(token, "remote-instance-123");
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain("audience");
|
|
});
|
|
|
|
it("should validate a valid JWT token with correct signature and claims", async () => {
|
|
const validToken = await createTestJWT({
|
|
sub: "user-123",
|
|
iss: "https://auth.example.com",
|
|
aud: "mosaic-client-id",
|
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
email: "user@example.com",
|
|
email_verified: true,
|
|
name: "Test User",
|
|
});
|
|
|
|
const result = await service.validateToken(validToken, "remote-instance-123");
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.userId).toBe("user-123");
|
|
expect(result.subject).toBe("user-123");
|
|
expect(result.email).toBe("user@example.com");
|
|
expect(result.instanceId).toBe("remote-instance-123");
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it("should extract all user info from valid token", async () => {
|
|
const validToken = await createTestJWT({
|
|
sub: "user-456",
|
|
iss: "https://auth.example.com",
|
|
aud: "mosaic-client-id",
|
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
email: "test@example.com",
|
|
email_verified: true,
|
|
name: "Test User",
|
|
preferred_username: "testuser",
|
|
});
|
|
|
|
const result = await service.validateToken(validToken, "remote-instance-123");
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.userId).toBe("user-456");
|
|
expect(result.email).toBe("test@example.com");
|
|
expect(result.subject).toBe("user-456");
|
|
});
|
|
});
|
|
|
|
describe("generateAuthUrl", () => {
|
|
it("should generate authorization URL for federated authentication", () => {
|
|
const remoteInstanceId = "remote-instance-123";
|
|
const redirectUrl = "http://localhost:3000/callback";
|
|
|
|
mockConfigService.get.mockReturnValue("http://localhost:3001");
|
|
|
|
const result = service.generateAuthUrl(remoteInstanceId, redirectUrl);
|
|
|
|
// Current implementation is a placeholder
|
|
// Real implementation would fetch remote instance OIDC config
|
|
expect(result).toContain("client_id=placeholder");
|
|
expect(result).toContain("response_type=code");
|
|
expect(result).toContain("scope=openid");
|
|
expect(result).toContain(`state=${remoteInstanceId}`);
|
|
expect(result).toContain(encodeURIComponent(redirectUrl));
|
|
});
|
|
});
|
|
});
|