From e5cbb8855d65147962b04bcf645562fc40fb29d2 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sun, 31 May 2026 22:26:58 +0500 Subject: [PATCH] Use Logto manager claims for orders --- src/auth.ts | 130 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 77 insertions(+), 53 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index c6030d6..849dfcf 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,99 +1,123 @@ -import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' -import { GraphQLError } from 'graphql' -import type { Request } from 'express' +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import { GraphQLError } from "graphql"; +import type { Request } from "express"; -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_ORDERS_AUDIENCE = process.env.LOGTO_ORDERS_AUDIENCE || 'https://orders.optovia.ru' -const APP_JWT_ISSUER = 'optovia:teams' +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_ORDERS_AUDIENCE = + 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)); export interface AuthContext { - userId?: string - teamUuid?: string - scopes: string[] + userId?: string; + teamUuid?: string; + scopes: string[]; } function getBearerToken(req: Request): string { - const auth = req.headers.authorization || '' - if (!auth.startsWith('Bearer ')) { - throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } }) + 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' } }) + const token = auth.slice(7); + if (!token || token === "undefined") { + throw new GraphQLError("Empty Bearer token", { + extensions: { code: "UNAUTHENTICATED" }, + }); } - return token + 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 [] + const scope = payload.scope; + if (!scope) return []; + if (typeof scope === "string") return scope.split(" "); + if (Array.isArray(scope)) return scope as string[]; + return []; +} + +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 publicContext(): Promise { - return { scopes: [] } + return { scopes: [] }; } export async function userContext(req: Request): Promise { - const token = getBearerToken(req) - const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }) + const token = getBearerToken(req); + const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }); return { userId: payload.sub, scopes: scopesFromPayload(payload), - } + }; } export async function teamContext(req: Request): Promise { - const token = getBearerToken(req) + const token = getBearerToken(req); const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_ORDERS_AUDIENCE, - }) + }); - const teamUuid = (payload as Record).team_uuid as string | undefined - const scopes = scopesFromPayload(payload) + 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' } }) + if (!teamUuid || !scopes.includes("teams:member")) { + throw new GraphQLError("Unauthorized", { + extensions: { code: "UNAUTHENTICATED" }, + }); } return { userId: payload.sub, teamUuid, scopes, - } -} - -function appJwtSecret(): Uint8Array { - const secret = process.env.APP_JWT_SECRET - if (!secret) throw new GraphQLError('APP_JWT_SECRET is required', { extensions: { code: 'INTERNAL_SERVER_ERROR' } }) - return new TextEncoder().encode(secret) + }; } export async function managerContext(req: Request): Promise { - const token = getBearerToken(req) - const { payload } = await jwtVerify(token, appJwtSecret(), { - issuer: APP_JWT_ISSUER, + const token = getBearerToken(req); + const { payload } = await jwtVerify(token, jwks, { + issuer: LOGTO_ISSUER, audience: LOGTO_ORDERS_AUDIENCE, - }) - const scopes = scopesFromPayload(payload) - const teamUuid = (payload as Record).team_uuid as string | undefined - if (!payload.sub || !scopes.includes('manager') || !teamUuid) { - throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } }) + }); + const scopes = scopesFromPayload(payload); + const teamUuid = (payload as Record).team_uuid as + | string + | undefined; + if (!payload.sub || !hasManagerClaim(payload) || !teamUuid) { + throw new GraphQLError("Unauthorized", { + extensions: { code: "UNAUTHENTICATED" }, + }); } - return { userId: payload.sub, teamUuid, scopes: ['teams:member', 'manager'] } + return { userId: payload.sub, teamUuid, scopes: ["teams:member", "manager"] }; } 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) { - throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, { - extensions: { code: 'FORBIDDEN' }, - }) + throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, { + extensions: { code: "FORBIDDEN" }, + }); } }