import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; import { GraphQLError } from "graphql"; 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 TEAMS_USER_GRAPHQL_URL = process.env.TEAMS_USER_GRAPHQL_URL || "https://teams.optovia.ru/graphql/user/"; const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)); export interface AuthContext { userId?: string; teamUuid?: string; scopes: string[]; } function getBearerToken(req: FastifyRequest): string { const auth = req.headers.authorization || ""; if (!auth.startsWith("Bearer ")) { throw new GraphQLError("Missing Bearer token", { extensions: { code: "UNAUTHENTICATED" }, }); } const token = auth.slice(7); if (!token || token === "undefined") { throw new GraphQLError("Empty Bearer token", { extensions: { code: "UNAUTHENTICATED" }, }); } return token; } 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 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") ); } async function activeTeamUuidFromTeams(token: string): Promise { const response = await fetch(TEAMS_USER_GRAPHQL_URL, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}`, }, body: JSON.stringify({ query: `query OrdersTeamMe { me { activeTeamId } }`, }), }); const body = (await response.json()) as { data?: { me?: { activeTeamId?: string | null } }; }; return body.data?.me?.activeTeamId ?? undefined; } export async function publicContext(): Promise { return { scopes: [] }; } export async function userContext(req: FastifyRequest): Promise { const token = getBearerToken(req); const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }); return { userId: payload.sub, scopes: scopesFromPayload(payload), }; } export async function teamContext(req: FastifyRequest): Promise { const token = getBearerToken(req); const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_ORDERS_AUDIENCE, }); const teamUuid = (payload as Record).team_uuid as | string | undefined; const scopes = scopesFromPayload(payload); const activeTeamUuid = teamUuid ?? (await activeTeamUuidFromTeams(token)); if (!activeTeamUuid || !scopes.includes("teams:member")) { throw new GraphQLError("Unauthorized", { extensions: { code: "UNAUTHENTICATED" }, }); } return { userId: payload.sub, teamUuid: activeTeamUuid, scopes, }; } export async function managerContext( req: FastifyRequest, ): Promise { const token = optionalBearerToken(req); if (token === null) return { scopes: [] }; const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_ORDERS_AUDIENCE, }); const scopes = scopesFromPayload(payload); const teamUuid = (payload as Record).team_uuid as | string | undefined; if (!payload.sub || !hasManagerClaim(payload)) { throw new GraphQLError("Unauthorized", { extensions: { code: "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 new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, { extensions: { code: "FORBIDDEN" }, }); } }