import { createRemoteJWKSet, errors, jwtVerify, type JWTPayload, type JWTVerifyOptions, } from "jose"; import mercurius, { type ErrorWithProps as MercuriusErrorWithProps, } from "mercurius"; import type { FastifyRequest } from "fastify"; const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || "https://auth.optovia.ru/oidc/jwks"; const LOGTO_ISSUER = process.env.LOGTO_ISSUER || "https://auth.optovia.ru/oidc"; const LOGTO_ORDERS_AUDIENCE = process.env.LOGTO_ORDERS_AUDIENCE || "https://orders.optovia.ru"; const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)); const { ErrorWithProps } = mercurius; export interface AuthContext { userId?: string; teamUuid?: string; scopes: string[]; } function getBearerToken(req: FastifyRequest): string { const auth = req.headers.authorization || ""; if (!auth.startsWith("Bearer ")) { throw unauthenticated("Missing Bearer token"); } const token = auth.slice(7); if (!token || token === "undefined") { throw unauthenticated("Empty Bearer token"); } return token; } function unauthenticated(message = "Unauthorized"): MercuriusErrorWithProps { return new ErrorWithProps(message, { code: "UNAUTHENTICATED" }, 401); } function forbidden(message: string): MercuriusErrorWithProps { return new ErrorWithProps(message, { code: "FORBIDDEN" }, 403); } async function verifyLogtoJwt( token: string, options: Omit = {}, ): Promise { try { const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, ...options, }); return payload; } catch (error) { if (error instanceof errors.JOSEError) { throw unauthenticated(); } throw error; } } function optionalBearerToken(req: FastifyRequest): string | null { const auth = req.headers.authorization || ""; if (!auth.startsWith("Bearer ")) return null; const token = auth.slice(7); if (!token || token === "undefined") return null; return token; } function scopesFromPayload(payload: JWTPayload): string[] { const scope = payload.scope; if (!scope) return []; if (typeof scope === "string") return scope.split(" "); if (Array.isArray(scope)) return scope as string[]; return []; } function clientScopes(payload: JWTPayload): string[] { return [ ...new Set([...scopesFromPayload(payload), "teams:user", "teams:member"]), ]; } function claimList(payload: JWTPayload, key: string): string[] { const value = (payload as Record)[key]; if (typeof value === "string") return value.split(" "); if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); return []; } function hasManagerClaim(payload: JWTPayload): boolean { const scopes = scopesFromPayload(payload); return ( scopes.includes("manager") || claimList(payload, "roles").includes("manager") || claimList(payload, "permissions").includes("manager") ); } export async function publicContext(): Promise { return { scopes: [] }; } export async function userContext(req: FastifyRequest): Promise { const token = getBearerToken(req); const payload = await verifyLogtoJwt(token); return { userId: payload.sub, scopes: scopesFromPayload(payload), }; } export async function teamContext(req: FastifyRequest): Promise { const token = getBearerToken(req); const payload = await verifyLogtoJwt(token, { audience: LOGTO_ORDERS_AUDIENCE, }); const claimedTeamUuid = (payload as Record).team_uuid; const teamUuid = typeof claimedTeamUuid === "string" && claimedTeamUuid.length > 0 ? claimedTeamUuid : payload.sub; const scopes = clientScopes(payload); if (!payload.sub || !teamUuid) { throw unauthenticated(); } return { userId: payload.sub, teamUuid, scopes, }; } export async function managerContext( req: FastifyRequest, ): Promise { const token = optionalBearerToken(req); if (token === null) return { scopes: [] }; const payload = await verifyLogtoJwt(token, { audience: LOGTO_ORDERS_AUDIENCE, }); const scopes = scopesFromPayload(payload); const teamUuid = (payload as Record).team_uuid as | string | undefined; if (!payload.sub || !hasManagerClaim(payload)) { throw unauthenticated(); } return { userId: payload.sub, teamUuid, scopes: [...new Set([...scopes, "manager"])], }; } export function requireScopes(ctx: AuthContext, ...required: string[]): void { const missing = required.filter((s) => !ctx.scopes.includes(s)); if (missing.length > 0) { throw forbidden(`Missing required scopes: ${missing.join(", ")}`); } }