Handle orders auth errors explicitly
Build and deploy Docker image / build (push) Successful in 2m31s
Build and deploy Docker image / build (push) Successful in 2m31s
This commit is contained in:
+45
-22
@@ -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" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user