Handle orders auth errors explicitly
Build and deploy Docker image / build (push) Successful in 2m31s

This commit is contained in:
Ruslan Bakiev
2026-06-06 13:40:54 +07:00
parent 2cc18940d6
commit 6912d15063
+45 -22
View File
@@ -1,5 +1,13 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; import {
import { GraphQLError } from "graphql"; createRemoteJWKSet,
errors,
jwtVerify,
type JWTPayload,
type JWTVerifyOptions,
} from "jose";
import mercurius, {
type ErrorWithProps as MercuriusErrorWithProps,
} from "mercurius";
import type { FastifyRequest } from "fastify"; import type { FastifyRequest } from "fastify";
const LOGTO_JWKS_URL = const LOGTO_JWKS_URL =
@@ -9,6 +17,7 @@ const LOGTO_ORDERS_AUDIENCE =
process.env.LOGTO_ORDERS_AUDIENCE || "https://orders.optovia.ru"; process.env.LOGTO_ORDERS_AUDIENCE || "https://orders.optovia.ru";
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)); const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
const { ErrorWithProps } = mercurius;
export interface AuthContext { export interface AuthContext {
userId?: string; userId?: string;
@@ -19,19 +28,41 @@ export interface AuthContext {
function getBearerToken(req: FastifyRequest): string { function getBearerToken(req: FastifyRequest): string {
const auth = req.headers.authorization || ""; const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) { if (!auth.startsWith("Bearer ")) {
throw new GraphQLError("Missing Bearer token", { throw unauthenticated("Missing Bearer token");
extensions: { code: "UNAUTHENTICATED" },
});
} }
const token = auth.slice(7); const token = auth.slice(7);
if (!token || token === "undefined") { if (!token || token === "undefined") {
throw new GraphQLError("Empty Bearer token", { throw unauthenticated("Empty Bearer token");
extensions: { code: "UNAUTHENTICATED" },
});
} }
return 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<JWTVerifyOptions, "issuer"> = {},
): Promise<JWTPayload> {
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 { function optionalBearerToken(req: FastifyRequest): string | null {
const auth = req.headers.authorization || ""; const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) return null; if (!auth.startsWith("Bearer ")) return null;
@@ -71,7 +102,7 @@ export async function publicContext(): Promise<AuthContext> {
export async function userContext(req: FastifyRequest): Promise<AuthContext> { export async function userContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req); const token = getBearerToken(req);
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }); const payload = await verifyLogtoJwt(token);
return { return {
userId: payload.sub, userId: payload.sub,
scopes: scopesFromPayload(payload), scopes: scopesFromPayload(payload),
@@ -80,8 +111,7 @@ export async function userContext(req: FastifyRequest): Promise<AuthContext> {
export async function teamContext(req: FastifyRequest): Promise<AuthContext> { export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req); const token = getBearerToken(req);
const { payload } = await jwtVerify(token, jwks, { const payload = await verifyLogtoJwt(token, {
issuer: LOGTO_ISSUER,
audience: LOGTO_ORDERS_AUDIENCE, audience: LOGTO_ORDERS_AUDIENCE,
}); });
@@ -91,9 +121,7 @@ export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
const scopes = scopesFromPayload(payload); const scopes = scopesFromPayload(payload);
if (!teamUuid || !scopes.includes("teams:member")) { if (!teamUuid || !scopes.includes("teams:member")) {
throw new GraphQLError("Unauthorized", { throw unauthenticated();
extensions: { code: "UNAUTHENTICATED" },
});
} }
return { return {
@@ -108,8 +136,7 @@ export async function managerContext(
): Promise<AuthContext> { ): Promise<AuthContext> {
const token = optionalBearerToken(req); const token = optionalBearerToken(req);
if (token === null) return { scopes: [] }; if (token === null) return { scopes: [] };
const { payload } = await jwtVerify(token, jwks, { const payload = await verifyLogtoJwt(token, {
issuer: LOGTO_ISSUER,
audience: LOGTO_ORDERS_AUDIENCE, audience: LOGTO_ORDERS_AUDIENCE,
}); });
const scopes = scopesFromPayload(payload); const scopes = scopesFromPayload(payload);
@@ -117,9 +144,7 @@ export async function managerContext(
| string | string
| undefined; | undefined;
if (!payload.sub || !hasManagerClaim(payload)) { if (!payload.sub || !hasManagerClaim(payload)) {
throw new GraphQLError("Unauthorized", { throw unauthenticated();
extensions: { code: "UNAUTHENTICATED" },
});
} }
return { return {
userId: payload.sub, userId: payload.sub,
@@ -131,8 +156,6 @@ export async function managerContext(
export function requireScopes(ctx: AuthContext, ...required: string[]): void { export function requireScopes(ctx: AuthContext, ...required: string[]): void {
const missing = required.filter((s) => !ctx.scopes.includes(s)); const missing = required.filter((s) => !ctx.scopes.includes(s));
if (missing.length > 0) { if (missing.length > 0) {
throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, { throw forbidden(`Missing required scopes: ${missing.join(", ")}`);
extensions: { code: "FORBIDDEN" },
});
} }
} }