diff --git a/src/auth.ts b/src/auth.ts index b6c462c..6a60c84 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,5 +1,13 @@ -import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; -import { GraphQLError } from "graphql"; +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 = @@ -9,6 +17,7 @@ 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; @@ -19,19 +28,41 @@ export interface AuthContext { function getBearerToken(req: FastifyRequest): string { const auth = req.headers.authorization || ""; if (!auth.startsWith("Bearer ")) { - throw new GraphQLError("Missing Bearer token", { - extensions: { code: "UNAUTHENTICATED" }, - }); + throw unauthenticated("Missing Bearer token"); } const token = auth.slice(7); if (!token || token === "undefined") { - throw new GraphQLError("Empty Bearer token", { - extensions: { code: "UNAUTHENTICATED" }, - }); + 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; @@ -71,7 +102,7 @@ export async function publicContext(): Promise { export async function userContext(req: FastifyRequest): Promise { const token = getBearerToken(req); - const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }); + const payload = await verifyLogtoJwt(token); return { userId: payload.sub, scopes: scopesFromPayload(payload), @@ -80,8 +111,7 @@ export async function userContext(req: FastifyRequest): Promise { export async function teamContext(req: FastifyRequest): Promise { const token = getBearerToken(req); - const { payload } = await jwtVerify(token, jwks, { - issuer: LOGTO_ISSUER, + const payload = await verifyLogtoJwt(token, { audience: LOGTO_ORDERS_AUDIENCE, }); @@ -91,9 +121,7 @@ export async function teamContext(req: FastifyRequest): Promise { const scopes = scopesFromPayload(payload); if (!teamUuid || !scopes.includes("teams:member")) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); + throw unauthenticated(); } return { @@ -108,8 +136,7 @@ export async function managerContext( ): Promise { const token = optionalBearerToken(req); if (token === null) return { scopes: [] }; - const { payload } = await jwtVerify(token, jwks, { - issuer: LOGTO_ISSUER, + const payload = await verifyLogtoJwt(token, { audience: LOGTO_ORDERS_AUDIENCE, }); const scopes = scopesFromPayload(payload); @@ -117,9 +144,7 @@ export async function managerContext( | string | undefined; if (!payload.sub || !hasManagerClaim(payload)) { - throw new GraphQLError("Unauthorized", { - extensions: { code: "UNAUTHENTICATED" }, - }); + throw unauthenticated(); } return { userId: payload.sub, @@ -131,8 +156,6 @@ export async function managerContext( 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" }, - }); + throw forbidden(`Missing required scopes: ${missing.join(", ")}`); } }