aegida-console / lib / auth / user-service.ts
user-service.ts
Raw
import "server-only";

import type { ExternalUserId } from "@/lib/auth/types";

export type UserServiceProfile = {
  id: ExternalUserId;
  login: string;
  fullName: string;
  position: string;
  email: string;
  lastName: string;
  firstName: string;
  middleName: string;
  roles: string[];
  createdAt: string;
  updatedAt: string;
};

export type UserServiceAuthErrorReason =
  | "invalid_credentials"
  | "unavailable";

export class UserServiceAuthError extends Error {
  constructor(readonly reason: UserServiceAuthErrorReason) {
    super(reason === "invalid_credentials" ? "Invalid credentials" : "User service unavailable");
    this.name = "UserServiceAuthError";
  }
}

export async function authenticateWithUserService(
  identifier: string,
  password: string,
  signal?: AbortSignal,
): Promise<UserServiceProfile> {
  try {
    const response = await fetch(`${userServiceUrl()}/api/user/auth`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify({ identifier, password }),
      signal: AbortSignal.any([
        signal ?? new AbortController().signal,
        AbortSignal.timeout(5_000),
      ]),
      cache: "no-store",
    });

    if (response.status === 401) {
      throw new UserServiceAuthError("invalid_credentials");
    }

    if (!response.ok) {
      throw new UserServiceAuthError("unavailable");
    }

    return parseProfile(await response.json());
  } catch (error) {
    if (error instanceof UserServiceAuthError) {
      throw error;
    }

    throw new UserServiceAuthError("unavailable");
  }
}

function userServiceUrl(): string {
  const value = process.env.USER_SERVICE_URL?.trim();

  if (!value) {
    throw new Error("Missing required environment variable: USER_SERVICE_URL");
  }

  return value.replace(/\/+$/, "");
}

function parseProfile(value: unknown): UserServiceProfile {
  if (!isRecord(value) || !isPositiveSafeInteger(value.id) || value.is_active !== true) {
    throw new Error("Invalid user-service profile");
  }

  const fields = [
    ["login", "login"],
    ["full_name", "fullName"],
    ["position", "position"],
    ["email", "email"],
    ["last_name", "lastName"],
    ["first_name", "firstName"],
    ["middle_name", "middleName"],
    ["created_at", "createdAt"],
    ["updated_at", "updatedAt"],
  ] as const;

  if (!Array.isArray(value.roles) || !value.roles.every(isString)) {
    throw new Error("Invalid user-service profile");
  }

  const profile = { id: String(value.id), roles: value.roles } as Partial<UserServiceProfile>;

  for (const [source, target] of fields) {
    if (!isString(value[source])) {
      throw new Error("Invalid user-service profile");
    }

    profile[target] = value[source];
  }

  return profile as UserServiceProfile;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function isPositiveSafeInteger(value: unknown): value is number {
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
}

function isString(value: unknown): value is string {
  return typeof value === "string";
}