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

import { SignJWT, jwtVerify } from "jose";

import {
  isUuid,
  isExternalUserId,
  parseAuthIdentity,
  type AuthIdentity,
  type AuthTokenIdentity,
} from "@/lib/auth/types";
import type { DatabaseUser } from "@/lib/db/users";

export type VerifiedAuthToken = AuthTokenIdentity & {
  iat: number;
  exp: number;
};

export class AuthError extends Error {
  readonly status = 401;

  constructor() {
    super("Unauthorized");
    this.name = "AuthError";
  }
}

type AuthTokenInput = Pick<AuthIdentity, "id" | "email" | "externalUserId">;

export async function signAuthToken(user: AuthTokenInput): Promise<string> {
  const config = jwtConfig();
  return new SignJWT({
    email: user.email,
    tenant_id: config.tenantId,
    external_user_id: user.externalUserId,
  })
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setSubject(user.id)
    .setIssuedAt()
    .setIssuer(config.issuer)
    .setAudience(config.audience)
    .setExpirationTime("24h")
    .sign(config.secret);
}

export async function verifyAuthToken(token: string): Promise<VerifiedAuthToken> {
  try {
    const config = jwtConfig();
    const { payload } = await jwtVerify(token, config.secret, {
      algorithms: ["HS256"],
      issuer: config.issuer,
      audience: config.audience,
      requiredClaims: [
        "sub",
        "email",
        "iat",
        "exp",
        "tenant_id",
        "external_user_id",
      ],
    });

    if (
      !isUuid(payload.sub) ||
      typeof payload.email !== "string" ||
      payload.email.length === 0 ||
      payload.tenant_id !== config.tenantId ||
      !isExternalUserId(payload.external_user_id) ||
      payload.aud !== config.audience ||
      typeof payload.iat !== "number" ||
      typeof payload.exp !== "number"
    ) {
      throw new Error("Invalid token claims");
    }

    return {
      id: payload.sub,
      email: payload.email,
      externalUserId: payload.external_user_id,
      tenantId: payload.tenant_id,
      iat: payload.iat,
      exp: payload.exp,
    };
  } catch {
    throw unauthorized();
  }
}

export function toAuthIdentity(user: DatabaseUser): AuthIdentity {
  const identity = parseAuthIdentity({
    id: user.id,
    externalUserId: user.externalUserId,
    login: user.login,
    fullName: user.fullName,
    position: user.position,
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName,
    middleName: user.middleName,
    roles: user.roles,
    lastModelId: user.lastModelId,
  });

  if (!identity) {
    throw new Error("External user profile is incomplete");
  }

  return identity;
}

export async function authenticateRequest(request: Request): Promise<VerifiedAuthToken> {
  const authorization = request.headers.get("Authorization");
  const match = authorization?.match(/^Bearer\s+(\S+)$/i);

  if (!match) {
    throw unauthorized();
  }

  return verifyAuthToken(match[1]);
}

function jwtConfig(): {
  secret: Uint8Array;
  issuer: string;
  audience: string;
  tenantId: string;
} {
  const secret = new TextEncoder().encode(requireEnv("JWT_SECRET"));
  if (secret.byteLength < 32) {
    throw new Error("Invalid required environment variable: JWT_SECRET");
  }
  const issuer = requireEnv("JWT_ISSUER");
  const audience = requireEnv("JWT_AUDIENCE");
  const tenantId = requireEnv("AEGIDA_TENANT_ID");
  if (!validTenantId(tenantId)) {
    throw new Error("Invalid required environment variable: AEGIDA_TENANT_ID");
  }
  return { secret, issuer, audience, tenantId };
}

function requireEnv(name: string): string {
  const value = process.env[name];

  if (!value || value !== value.trim()) {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  return value;
}

function validTenantId(value: string): boolean {
  return /^[A-Za-z0-9_.-]{1,128}$/.test(value);
}

function unauthorized(): AuthError {
  return new AuthError();
}