From 1a3f72205f6a1be532b0928c6c7b41f6cea4cc04 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sun, 31 May 2026 22:00:52 +0500 Subject: [PATCH] Use shared app JWT for manager access --- src/auth.ts | 39 +++++++++++--------- src/schemas/manager.ts | 83 +----------------------------------------- src/schemas/user.ts | 18 +++------ 3 files changed, 29 insertions(+), 111 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index fe5d360..f5b9f62 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -6,8 +6,8 @@ import { prisma } from './db.js' 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_TEAMS_AUDIENCE = process.env.LOGTO_TEAMS_AUDIENCE || 'https://teams.optovia.ru' -const MANAGER_JWT_ISSUER = 'optovia:teams' -const MANAGER_JWT_AUDIENCES = ['https://teams.optovia.ru', 'https://orders.optovia.ru', 'https://logistics.optovia.ru'] +const APP_JWT_ISSUER = 'optovia:teams' +const APP_JWT_AUDIENCES = ['https://teams.optovia.ru', 'https://orders.optovia.ru', 'https://logistics.optovia.ru'] const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)) @@ -46,32 +46,33 @@ function scopesFromPayload(payload: JWTPayload): string[] { export async function publicContext(): Promise { return { scopes: [] } } -function managerJwtSecret(): Uint8Array { - const secret = process.env.MANAGER_JWT_SECRET - if (!secret) throw new GraphQLError('MANAGER_JWT_SECRET is required', { extensions: { code: 'INTERNAL_SERVER_ERROR' } }) +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 issueManagerJwt(input: { userId: string; teamUuid?: string | null }): Promise { +export async function issueAppJwt(input: { userId: string; teamUuid?: string | null; isManager: boolean }): Promise { + const scopes = ['teams:user'] + if (input.isManager) scopes.push('manager') return new SignJWT({ - scope: 'manager', - role: 'manager', + scope: scopes.join(' '), + roles: input.isManager ? ['manager'] : [], team_uuid: input.teamUuid ?? undefined, }) .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) - .setIssuer(MANAGER_JWT_ISSUER) - .setAudience(MANAGER_JWT_AUDIENCES) + .setIssuer(APP_JWT_ISSUER) + .setAudience(APP_JWT_AUDIENCES) .setSubject(input.userId) .setIssuedAt() .setExpirationTime(`${Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10)}d`) - .sign(managerJwtSecret()) + .sign(appJwtSecret()) } -async function verifyManagerJwt(token: string, audience: string = LOGTO_TEAMS_AUDIENCE): Promise { - const { payload } = await jwtVerify(token, managerJwtSecret(), { issuer: MANAGER_JWT_ISSUER, audience }) +async function verifyAppJwt(token: string, audience: string = LOGTO_TEAMS_AUDIENCE): Promise { + const { payload } = await jwtVerify(token, appJwtSecret(), { issuer: APP_JWT_ISSUER, audience }) const scopes = scopesFromPayload(payload) - const role = (payload as Record).role - if (!scopes.includes('manager') || role !== 'manager' || !payload.sub) { + if (!payload.sub) { throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } }) } return { @@ -92,7 +93,7 @@ export async function userContext(req: Request): Promise { return { userId: session.user.username, sessionToken: token, scopes: ['teams:user'] } } const unverifiedPayload = decodeJwt(token) - if (unverifiedPayload.iss === MANAGER_JWT_ISSUER) return verifyManagerJwt(token) + if (unverifiedPayload.iss === APP_JWT_ISSUER) return verifyAppJwt(token) const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }) return { userId: payload.sub, scopes: [] } } @@ -100,7 +101,11 @@ export async function userContext(req: Request): Promise { export async function managerContext(req: Request): Promise { const token = optionalBearerToken(req) if (token === null) return { scopes: [] } - return verifyManagerJwt(token) + const context = await verifyAppJwt(token) + if (!context.scopes.includes('manager')) { + throw new GraphQLError('Manager access required', { extensions: { code: 'FORBIDDEN' } }) + } + return context } export async function teamContext(req: Request): Promise { diff --git a/src/schemas/manager.ts b/src/schemas/manager.ts index 25a2ddd..99db2ed 100644 --- a/src/schemas/manager.ts +++ b/src/schemas/manager.ts @@ -1,8 +1,6 @@ import { GraphQLError } from 'graphql' import { prisma } from '../db.js' -import { issueManagerJwt, requireScopes, type AuthContext } from '../auth.js' - -const SESSION_TTL_DAYS = Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10) +import { requireScopes, type AuthContext } from '../auth.js' export const managerTypeDefs = `#graphql type ManagerUser { @@ -15,60 +13,16 @@ export const managerTypeDefs = `#graphql telegramNotificationsEnabled: Boolean! } - type ManagerAuthUser { - id: String - phone: String - displayName: String - isManager: Boolean! - activeTeamId: String - } - - type ManagerAuthPayload { - token: String! - sessionExpiresAt: String! - user: ManagerAuthUser! - } - - input ManagerTotpLoginInput { - phone: String! - code: String! - } - type Query { managerUsers: [ManagerUser!]! } - - type Mutation { - managerTotpLogin(input: ManagerTotpLoginInput!): ManagerAuthPayload! - } ` -function normalizePhone(phone: string): string { - const normalized = phone.replace(/[^\d+]/g, '') - if (normalized.length < 5) throw new GraphQLError('Invalid phone') - return normalized -} - function displayName(firstName: string, lastName: string, phone: string): string { const name = `${firstName} ${lastName}`.trim() return name.length > 0 ? name : phone } -async function getOrCreateProfileByPhone(phone: string) { - const logtoId = `phone:${phone}` - const existing = await prisma.userProfile.findFirst({ - where: { OR: [{ phone }, { logtoId }] }, - include: { user: true, activeTeam: true }, - }) - if (existing !== null) return existing - - const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } }) - return prisma.userProfile.create({ - data: { userId: user.id, logtoId, phone }, - include: { user: true, activeTeam: true }, - }) -} - async function isManagerUser(userId: number): Promise { const user = await prisma.user.findUnique({ where: { id: userId } }) if (user === null) return false @@ -117,39 +71,4 @@ export const managerResolvers = { }) }, }, - - Mutation: { - managerTotpLogin: async (_: unknown, args: { input: { phone: string; code: string } }) => { - const normalizedPhone = normalizePhone(args.input.phone) - const challenge = await prisma.loginChallenge.findFirst({ - where: { - phone: normalizedPhone, - code: args.input.code, - usedAt: null, - expiresAt: { gt: new Date() }, - }, - orderBy: { createdAt: 'desc' }, - }) - if (challenge === null) throw new GraphQLError('Invalid login code') - - await prisma.loginChallenge.update({ where: { id: challenge.id }, data: { usedAt: new Date() } }) - const profile = await getOrCreateProfileByPhone(normalizedPhone) - if (!(await isManagerUser(profile.userId))) { - throw new GraphQLError('Manager access required', { extensions: { code: 'FORBIDDEN' } }) - } - const token = await issueManagerJwt({ userId: profile.logtoId, teamUuid: profile.activeTeam?.uuid ?? null }) - const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000) - return { - token, - sessionExpiresAt: expiresAt.toISOString(), - user: { - id: profile.logtoId, - phone: profile.phone, - displayName: displayName(profile.user.firstName, profile.user.lastName, profile.phone), - isManager: true, - activeTeamId: profile.activeTeam?.uuid ?? null, - }, - } - }, - }, } diff --git a/src/schemas/user.ts b/src/schemas/user.ts index a53b791..a6112f5 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -1,7 +1,6 @@ import { GraphQLError } from 'graphql' -import { randomBytes } from 'crypto' import { prisma } from '../db.js' -import { SESSION_TOKEN_PREFIX, type AuthContext } from '../auth.js' +import { issueAppJwt, type AuthContext } from '../auth.js' const OTP_TTL_MINUTES = Number.parseInt(process.env.LOGIN_OTP_TTL_MINUTES || '10', 10) const SESSION_TTL_DAYS = Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10) @@ -181,13 +180,6 @@ async function mapProfileUser(profile: Awaited