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_TEAMS_AUDIENCE = process.env.LOGTO_TEAMS_AUDIENCE || "https://teams.optovia.ru"; const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)); export interface AuthContext { userId?: string; teamUuid?: string; scopes: string[]; isM2M?: boolean; } 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 []; } export async function publicContext(): Promise { return { scopes: [] }; } 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 userContext(req: FastifyRequest): Promise { const token = optionalBearerToken(req); if (token === null) return { scopes: [] }; const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }); return { userId: payload.sub, scopes: scopesFromPayload(payload) }; } export async function managerContext( req: FastifyRequest, ): Promise { const token = getBearerToken(req); const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE, }); if (!payload.sub || !hasManagerClaim(payload)) { throw new GraphQLError("Manager access required", { extensions: { code: "FORBIDDEN" }, }); } return { userId: payload.sub, teamUuid: (payload as Record).team_uuid as | string | undefined, scopes: [...new Set([...scopesFromPayload(payload), "manager"])], }; } export async function teamContext(req: FastifyRequest): Promise { const token = getBearerToken(req); const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE, }); const teamUuid = (payload as Record).team_uuid as | string | undefined; const scopes = scopesFromPayload(payload); if (!teamUuid || !scopes.includes("teams:member")) throw new GraphQLError("Unauthorized", { extensions: { code: "UNAUTHENTICATED" }, }); return { userId: payload.sub, teamUuid, scopes }; } export async function m2mContext(): Promise { return { scopes: [], isM2M: true }; } 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" }, }); }