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();
}