aegida-console / lib / auth / server.test.ts
server.test.ts
Raw
// @vitest-environment node

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { decodeProtectedHeader, jwtVerify, SignJWT } from "jose";

vi.mock("server-only", () => ({}));
import {
  authenticateRequest,
  signAuthToken,
  toAuthIdentity,
  verifyAuthToken,
} from "@/lib/auth/server";

beforeEach(() => {
  vi.stubEnv("JWT_SECRET", "ai-control-center-local-development-secret-2026");
  vi.stubEnv("JWT_ISSUER", "corp-ui");
  vi.stubEnv("JWT_AUDIENCE", "aegida-gate");
  vi.stubEnv("AEGIDA_TENANT_ID", "tenant-1");
});

afterEach(() => vi.unstubAllEnvs());

describe("server authentication", () => {
  it("issues the exact gate-verifiable HS256 identity for 24 hours", async () => {
    const token = await signAuthToken({
      id: "11111111-1111-4111-8111-111111111111",
      email: "user@example.corp",
      externalUserId: "42",
    });
    const user = await verifyAuthToken(token);
    const { payload } = await jwtVerify(
      token,
      new TextEncoder().encode("ai-control-center-local-development-secret-2026"),
      { algorithms: ["HS256"], issuer: "corp-ui", audience: "aegida-gate" },
    );

    expect(user).toEqual({
      id: "11111111-1111-4111-8111-111111111111",
      email: "user@example.corp",
      externalUserId: "42",
      tenantId: "tenant-1",
      iat: expect.any(Number),
      exp: expect.any(Number),
    });
    expect(user.exp - user.iat).toBe(86_400);
    expect(payload).toEqual({
      sub: "11111111-1111-4111-8111-111111111111",
      email: "user@example.corp",
      tenant_id: "tenant-1",
      external_user_id: "42",
      iss: "corp-ui",
      aud: "aegida-gate",
      iat: expect.any(Number),
      exp: expect.any(Number),
    });
    expect(decodeProtectedHeader(token)).toEqual({ alg: "HS256", typ: "JWT" });
  });

  it.each([
    ["wrong issuer", { iss: "other-ui" }],
    ["missing issuer", { iss: undefined }],
    ["wrong audience", { aud: "other-gate" }],
    ["missing audience", { aud: undefined }],
    ["additional audience", { aud: ["aegida-gate", "other-gate"] }],
    ["wrong tenant", { tenant_id: "tenant-2" }],
    ["missing tenant", { tenant_id: undefined }],
    ["invalid external ID", { external_user_id: "042" }],
    ["missing external ID", { external_user_id: undefined }],
    ["expired", { exp: 1 }],
  ])("rejects a token with %s", async (_name, patch) => {
    await expect(verifyAuthToken(await testToken(patch))).rejects.toMatchObject({
      status: 401,
    });
  });

  it("rejects a wrong algorithm and signature", async () => {
    await expect(
      verifyAuthToken(await testToken({}, "HS384")),
    ).rejects.toMatchObject({ status: 401 });
    await expect(
      verifyAuthToken(await testToken({}, "HS256", "different-secret-that-is-still-at-least-32-bytes")),
    ).rejects.toMatchObject({ status: 401 });
  });

  it("requires a JWT secret of at least 32 UTF-8 bytes", async () => {
    vi.stubEnv("JWT_SECRET", "short-secret");
    await expect(
      signAuthToken({
        id: "11111111-1111-4111-8111-111111111111",
        email: "user@example.corp",
        externalUserId: "42",
      }),
    ).rejects.toThrow("JWT_SECRET");
  });

  it.each(["tenant/unsafe", "tenant-нет", "t".repeat(129)])(
    "rejects a Gate-incompatible configured tenant %j",
    async (tenantId) => {
      vi.stubEnv("AEGIDA_TENANT_ID", tenantId);
      await expect(
        signAuthToken({
          id: "11111111-1111-4111-8111-111111111111",
          email: "user@example.corp",
          externalUserId: "42",
        }),
      ).rejects.toThrow("AEGIDA_TENANT_ID");
    },
  );

  it("rejects missing and malformed bearer authorization", async () => {
    await expect(
      authenticateRequest(new Request("http://localhost/api/chat")),
    ).rejects.toMatchObject({ status: 401 });
    await expect(
      authenticateRequest(
        new Request("http://localhost/api/chat", {
          headers: { Authorization: "Token invalid" },
        }),
      ),
    ).rejects.toMatchObject({ status: 401 });
  });

  it("accepts the bearer scheme without regard to letter case", async () => {
    const token = await signAuthToken({
      id: "00000000-0000-4000-8000-000000000001",
      email: "demo@ai-control.local",
      externalUserId: "42",
    });

    await expect(
      authenticateRequest(
        new Request("http://localhost/api/chat", {
          headers: { Authorization: `bEaReR ${token}` },
        }),
      ),
    ).resolves.toMatchObject({ id: "00000000-0000-4000-8000-000000000001" });
  });

  it("rejects a signed token with a non-UUID subject", async () => {
    const token = await signAuthToken({
      id: "legacy-user",
      email: "demo@ai-control.local",
      externalUserId: "42",
    });

    await expect(verifyAuthToken(token)).rejects.toMatchObject({ status: 401 });
  });

  it("projects synchronized external profiles into complete app identities", () => {
    expect(toAuthIdentity({
      id: "00000000-0000-4000-8000-000000000001",
      externalUserId: "42",
      login: "alice",
      fullName: "Alice Smith",
      position: "Engineer",
      email: "alice@example.com",
      firstName: "Alice",
      lastName: "Smith",
      middleName: "Jane",
      roles: ["admin", "employee"],
      lastModelId: "chatgpt",
      passwordHash: "",
      createdAt: "2026-07-01T12:00:00.000Z",
      updatedAt: "2026-07-02T12:00:00.000Z",
    })).toEqual({
      id: "00000000-0000-4000-8000-000000000001",
      externalUserId: "42",
      login: "alice",
      fullName: "Alice Smith",
      position: "Engineer",
      email: "alice@example.com",
      firstName: "Alice",
      lastName: "Smith",
      middleName: "Jane",
      roles: ["admin", "employee"],
      lastModelId: "chatgpt",
    });
  });
});

async function testToken(
  changes: Record<string, unknown>,
  algorithm = "HS256",
  secret = "ai-control-center-local-development-secret-2026",
): Promise<string> {
  const claims: Record<string, unknown> = {
    email: "user@example.corp",
    tenant_id: "tenant-1",
    external_user_id: "42",
    iss: "corp-ui",
    aud: "aegida-gate",
    sub: "11111111-1111-4111-8111-111111111111",
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600,
    ...changes,
  };
  for (const [claim, value] of Object.entries(claims)) {
    if (value === undefined) delete claims[claim];
  }
  return new SignJWT(claims)
    .setProtectedHeader({ alg: algorithm, typ: "JWT" })
    .sign(new TextEncoder().encode(secret));
}