fix(#271): implement OIDC token validation (authentication bypass)
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>
This commit is contained in:
@@ -14,6 +14,28 @@ import type {
|
||||
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;
|
||||
@@ -288,90 +310,137 @@ describe("OIDCService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateToken", () => {
|
||||
it("should validate a valid OIDC token", () => {
|
||||
const token = "valid-oidc-token";
|
||||
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";
|
||||
|
||||
// Mock token validation (simplified - real implementation would decode JWT)
|
||||
const mockClaims: OIDCTokenClaims = {
|
||||
sub: "user-subject-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 expectedResult: FederatedTokenValidation = {
|
||||
valid: true,
|
||||
userId: "user-subject-123",
|
||||
instanceId,
|
||||
email: "user@example.com",
|
||||
subject: "user-subject-123",
|
||||
};
|
||||
|
||||
// For now, we'll mock the validation
|
||||
// Real implementation would use jose or jsonwebtoken to decode and verify
|
||||
vi.spyOn(service, "validateToken").mockReturnValue(expectedResult);
|
||||
|
||||
const result = service.validateToken(token, instanceId);
|
||||
const result = await service.validateToken(validToken, "remote-instance-123");
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.userId).toBe("user-subject-123");
|
||||
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 reject expired token", () => {
|
||||
const token = "expired-token";
|
||||
const instanceId = "remote-instance-123";
|
||||
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 expectedResult: FederatedTokenValidation = {
|
||||
valid: false,
|
||||
error: "Token has expired",
|
||||
};
|
||||
const result = await service.validateToken(validToken, "remote-instance-123");
|
||||
|
||||
vi.spyOn(service, "validateToken").mockReturnValue(expectedResult);
|
||||
|
||||
const result = service.validateToken(token, instanceId);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject token with invalid signature", () => {
|
||||
const token = "invalid-signature-token";
|
||||
const instanceId = "remote-instance-123";
|
||||
|
||||
const expectedResult: FederatedTokenValidation = {
|
||||
valid: false,
|
||||
error: "Invalid token signature",
|
||||
};
|
||||
|
||||
vi.spyOn(service, "validateToken").mockReturnValue(expectedResult);
|
||||
|
||||
const result = service.validateToken(token, instanceId);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Invalid token signature");
|
||||
});
|
||||
|
||||
it("should reject malformed token", () => {
|
||||
const token = "not-a-jwt";
|
||||
const instanceId = "remote-instance-123";
|
||||
|
||||
const expectedResult: FederatedTokenValidation = {
|
||||
valid: false,
|
||||
error: "Malformed token",
|
||||
};
|
||||
|
||||
vi.spyOn(service, "validateToken").mockReturnValue(expectedResult);
|
||||
|
||||
const result = service.validateToken(token, instanceId);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Malformed token");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.userId).toBe("user-456");
|
||||
expect(result.email).toBe("test@example.com");
|
||||
expect(result.subject).toBe("user-456");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user