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 { 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<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 {
const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) return null;
@@ -71,7 +102,7 @@ export async function publicContext(): Promise<AuthContext> {
export async function userContext(req: FastifyRequest): Promise<AuthContext> {
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<AuthContext> {
export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
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<AuthContext> {
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<AuthContext> {
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(", ")}`);
}
}