From b076343c57d07e6b4545287adde2fa6d32c5a4cc Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sun, 31 May 2026 22:26:58 +0500 Subject: [PATCH] Use Logto claims for teams auth --- src/auth.ts | 198 +++++++++++++++------------ src/schemas/user.ts | 317 ++++++++++++++++++++++---------------------- 2 files changed, 270 insertions(+), 245 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index f5b9f62..9dded40 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,125 +1,149 @@ -import { createRemoteJWKSet, jwtVerify, SignJWT, decodeJwt, type JWTPayload } from 'jose' -import { GraphQLError } from 'graphql' -import type { Request } from 'express' -import { prisma } from './db.js' +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import { GraphQLError } from "graphql"; +import type { Request } from "express"; +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 APP_JWT_ISSUER = 'optovia:teams' -const APP_JWT_AUDIENCES = ['https://teams.optovia.ru', 'https://orders.optovia.ru', 'https://logistics.optovia.ru'] +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 jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)) +const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)); export interface AuthContext { - userId?: string - teamUuid?: string - sessionToken?: string - scopes: string[] - isM2M?: boolean + userId?: string; + teamUuid?: string; + sessionToken?: string; + scopes: string[]; + isM2M?: boolean; } -export const SESSION_TOKEN_PREFIX = 'optovia-session:' +export const SESSION_TOKEN_PREFIX = "optovia-session:"; function getBearerToken(req: Request): string { - 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' } }) - return token + 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" }, + }); + return token; } function optionalBearerToken(req: Request): string | null { - const auth = req.headers.authorization || '' - if (!auth.startsWith('Bearer ')) return null - const token = auth.slice(7) - if (!token || token === 'undefined') return null - return token + const auth = req.headers.authorization || ""; + if (!auth.startsWith("Bearer ")) return null; + const token = auth.slice(7); + if (!token || token === "undefined") return null; + return token; } function scopesFromPayload(payload: JWTPayload): string[] { - const scope = payload.scope - if (!scope) return [] - if (typeof scope === 'string') return scope.split(' ') - return [] + const scope = payload.scope; + if (!scope) return []; + if (typeof scope === "string") return scope.split(" "); + if (Array.isArray(scope)) return scope as string[]; + return []; } -export async function publicContext(): Promise { return { 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 publicContext(): Promise { + return { scopes: [] }; } -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: scopes.join(' '), - roles: input.isManager ? ['manager'] : [], - team_uuid: input.teamUuid ?? undefined, - }) - .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) - .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(appJwtSecret()) +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 []; } -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) - if (!payload.sub) { - throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } }) - } - return { - userId: payload.sub, - teamUuid: (payload as Record).team_uuid as string | undefined, - scopes, - } +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 userContext(req: Request): Promise { - const token = optionalBearerToken(req) - if (token === null) return { scopes: [] } + const token = optionalBearerToken(req); + if (token === null) return { scopes: [] }; if (token.startsWith(SESSION_TOKEN_PREFIX)) { - const session = await prisma.authSession.findUnique({ where: { token }, include: { user: true } }) - if (session === null || session.revokedAt !== null || session.expiresAt <= new Date()) { - throw new GraphQLError('Session expired', { extensions: { code: 'UNAUTHENTICATED' } }) + const session = await prisma.authSession.findUnique({ + where: { token }, + include: { user: true }, + }); + if ( + session === null || + session.revokedAt !== null || + session.expiresAt <= new Date() + ) { + throw new GraphQLError("Session expired", { + extensions: { code: "UNAUTHENTICATED" }, + }); } - return { userId: session.user.username, sessionToken: token, scopes: ['teams:user'] } + return { + userId: session.user.username, + sessionToken: token, + scopes: ["teams:user"], + }; } - const unverifiedPayload = decodeJwt(token) - if (unverifiedPayload.iss === APP_JWT_ISSUER) return verifyAppJwt(token) - const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }) - return { userId: payload.sub, scopes: [] } + const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }); + return { userId: payload.sub, scopes: scopesFromPayload(payload) }; } export async function managerContext(req: Request): Promise { - const token = optionalBearerToken(req) - if (token === null) return { scopes: [] } - const context = await verifyAppJwt(token) - if (!context.scopes.includes('manager')) { - throw new GraphQLError('Manager access required', { extensions: { code: 'FORBIDDEN' } }) + const token = getBearerToken(req); + const { payload } = await jwtVerify(token, jwks, { + issuer: LOGTO_ISSUER, + audience: LOGTO_TEAMS_AUDIENCE, + }); + if (!payload.sub || !hasManagerClaim(payload)) { + throw new GraphQLError("Manager access required", { + extensions: { code: "FORBIDDEN" }, + }); } - return context + return { + userId: payload.sub, + teamUuid: (payload as Record).team_uuid as + | string + | undefined, + scopes: [...new Set([...scopesFromPayload(payload), "manager"])], + }; } export async function teamContext(req: Request): Promise { - const token = getBearerToken(req) - const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE }) - 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' } }) - return { userId: payload.sub, teamUuid, scopes } + const token = getBearerToken(req); + const { payload } = await jwtVerify(token, jwks, { + issuer: LOGTO_ISSUER, + audience: LOGTO_TEAMS_AUDIENCE, + }); + 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" }, + }); + return { userId: payload.sub, teamUuid, scopes }; } -export async function m2mContext(): Promise { return { scopes: [], isM2M: true } } +export async function m2mContext(): Promise { + return { scopes: [], isM2M: true }; +} 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' } }) + const missing = required.filter((s) => !ctx.scopes.includes(s)); + if (missing.length > 0) + throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, { + extensions: { code: "FORBIDDEN" }, + }); } diff --git a/src/schemas/user.ts b/src/schemas/user.ts index a6112f5..00edc00 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -1,10 +1,6 @@ -import { GraphQLError } from 'graphql' -import { prisma } from '../db.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) -const DEV_LOGIN_OTP_CODE = process.env.LOGIN_OTP_DEV_CODE || '000000' +import { GraphQLError } from "graphql"; +import { prisma } from "../db.js"; +import { type AuthContext } from "../auth.js"; export const userTypeDefs = `#graphql type UserTeam { @@ -28,29 +24,6 @@ export const userTypeDefs = `#graphql teams: [UserTeam] } - type AuthChallenge { - challengeType: String! - phone: String! - expiresAt: String! - codeForDev: String - totpConfigured: Boolean! - } - - type AuthPayload { - token: String! - sessionExpiresAt: String! - user: User! - } - - input RequestLoginOtpInput { - phone: String! - } - - input VerifyLoginOtpInput { - phone: String! - code: String! - } - type TeamMemberInfo { uuid: String! role: String! @@ -106,201 +79,229 @@ export const userTypeDefs = `#graphql } type Mutation { - requestLoginOtp(input: RequestLoginOtpInput!): AuthChallenge! - verifyLoginOtp(input: VerifyLoginOtpInput!): AuthPayload! logout: Boolean! createTeam(input: CreateTeamInput!): CreateTeamResult updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult switchTeam(teamId: String!): SwitchTeamResult } -` +`; async function getOrCreateProfile(logtoId: string) { - let profile = await prisma.userProfile.findUnique({ where: { logtoId: logtoId }, include: { user: true, activeTeam: true } }) + let profile = await prisma.userProfile.findUnique({ + where: { logtoId: logtoId }, + include: { user: true, activeTeam: true }, + }); if (!profile) { - const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } }) + const user = await prisma.user.create({ + data: { username: logtoId, firstName: "", lastName: "" }, + }); profile = await prisma.userProfile.create({ data: { userId: user.id, logtoId: logtoId }, include: { user: true, activeTeam: true }, - }) + }); } - return profile + return profile; } -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 }, - }) +function displayName( + firstName: string, + lastName: string, + phone: string, +): string { + const name = `${firstName} ${lastName}`.trim(); + return name.length > 0 ? name : phone; } async function isManagerUser(userId: number): Promise { - const user = await prisma.user.findUnique({ where: { id: userId } }) - if (user === null) return false - if (user.isStaff || user.isSuperuser) return true + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (user === null) return false; + if (user.isStaff || user.isSuperuser) return true; const managerMembership = await prisma.teamMember.findFirst({ - where: { userId, role: { in: ['OWNER', 'MANAGER', 'ADMIN'] } }, - }) - return managerMembership !== null + where: { userId, role: { in: ["OWNER", "MANAGER", "ADMIN"] } }, + }); + return managerMembership !== null; } -async function mapProfileUser(profile: Awaited>) { - const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } }) - const isManager = await isManagerUser(profile.userId) +async function mapProfileUser( + profile: Awaited>, +) { + const memberships = await prisma.teamMember.findMany({ + where: { userId: profile.userId }, + include: { team: true }, + }); + const isManager = await isManagerUser(profile.userId); return { id: profile.logtoId, firstName: profile.user.firstName, lastName: profile.user.lastName, - displayName: displayName(profile.user.firstName, profile.user.lastName, profile.phone), + displayName: displayName( + profile.user.firstName, + profile.user.lastName, + profile.phone, + ), phone: profile.phone, avatarId: profile.avatarId, isManager, activeTeamId: profile.activeTeam?.uuid ?? null, - activeTeam: profile.activeTeam ? { id: profile.activeTeam.uuid, name: profile.activeTeam.name, teamType: profile.activeTeam.teamType, logtoOrgId: profile.activeTeam.logtoOrgId, createdAt: profile.activeTeam.createdAt.toISOString() } : null, - teams: memberships.map(m => ({ id: m.team.uuid, name: m.team.name, teamType: m.team.teamType, logtoOrgId: m.team.logtoOrgId, createdAt: m.team.createdAt.toISOString() })), - } -} - -async function verifyPhoneLogin(phone: string, code: string) { - const normalizedPhone = normalizePhone(phone) - const challenge = await prisma.loginChallenge.findFirst({ - where: { - phone: normalizedPhone, - 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) - const isManager = await isManagerUser(profile.userId) - const token = await issueAppJwt({ userId: profile.logtoId, teamUuid: profile.activeTeam?.uuid ?? null, isManager }) - const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000) - return { - token, - sessionExpiresAt: expiresAt.toISOString(), - user: await mapProfileUser(profile), - } + activeTeam: profile.activeTeam + ? { + id: profile.activeTeam.uuid, + name: profile.activeTeam.name, + teamType: profile.activeTeam.teamType, + logtoOrgId: profile.activeTeam.logtoOrgId, + createdAt: profile.activeTeam.createdAt.toISOString(), + } + : null, + teams: memberships.map((m) => ({ + id: m.team.uuid, + name: m.team.name, + teamType: m.team.teamType, + logtoOrgId: m.team.logtoOrgId, + createdAt: m.team.createdAt.toISOString(), + })), + }; } export const userResolvers = { Query: { me: async (_: unknown, __: unknown, ctx: AuthContext) => { - if (!ctx.userId) throw new GraphQLError('Not authenticated') - const profile = await getOrCreateProfile(ctx.userId) - return mapProfileUser(profile) + if (!ctx.userId) throw new GraphQLError("Not authenticated"); + const profile = await getOrCreateProfile(ctx.userId); + return mapProfileUser(profile); }, getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => { - if (!ctx.userId) throw new GraphQLError('Not authenticated') + if (!ctx.userId) throw new GraphQLError("Not authenticated"); const team = await prisma.team.findUnique({ where: { uuid: args.teamId }, include: { members: { include: { user: true } }, invitations: true, }, - }) - if (!team) return null + }); + if (!team) return null; return { uuid: team.uuid, name: team.name, - members: team.members.map(m => ({ uuid: m.uuid, role: m.role, joinedAt: m.joinedAt.toISOString() })), - invitations: team.invitations.map(i => ({ - uuid: i.uuid, email: i.email, role: i.role, status: i.status, - invitedBy: i.invitedBy, expiresAt: i.expiresAt?.toISOString() ?? null, + members: team.members.map((m) => ({ + uuid: m.uuid, + role: m.role, + joinedAt: m.joinedAt.toISOString(), + })), + invitations: team.invitations.map((i) => ({ + uuid: i.uuid, + email: i.email, + role: i.role, + status: i.status, + invitedBy: i.invitedBy, + expiresAt: i.expiresAt?.toISOString() ?? null, createdAt: i.createdAt.toISOString(), })), - } + }; }, }, Mutation: { - requestLoginOtp: async (_: unknown, args: { input: { phone: string } }) => { - const phone = normalizePhone(args.input.phone) - const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000) - await prisma.loginChallenge.create({ data: { phone, code: DEV_LOGIN_OTP_CODE, expiresAt } }) - return { - challengeType: 'otp', - phone, - expiresAt: expiresAt.toISOString(), - codeForDev: process.env.NODE_ENV === 'production' ? null : DEV_LOGIN_OTP_CODE, - totpConfigured: true, - } - }, - - verifyLoginOtp: async (_: unknown, args: { input: { phone: string; code: string } }) => { - return verifyPhoneLogin(args.input.phone, args.input.code) - }, - logout: async (_: unknown, __: unknown, ctx: AuthContext) => { if (ctx.sessionToken !== undefined) { - await prisma.authSession.updateMany({ where: { token: ctx.sessionToken, revokedAt: null }, data: { revokedAt: new Date() } }) + await prisma.authSession.updateMany({ + where: { token: ctx.sessionToken, revokedAt: null }, + data: { revokedAt: new Date() }, + }); } - return true + return true; }, - createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => { - if (!ctx.userId) throw new GraphQLError('Not authenticated') - const profile = await getOrCreateProfile(ctx.userId) + createTeam: async ( + _: unknown, + args: { input: { name: string; teamType?: string } }, + ctx: AuthContext, + ) => { + if (!ctx.userId) throw new GraphQLError("Not authenticated"); + const profile = await getOrCreateProfile(ctx.userId); const team = await prisma.team.create({ - data: { name: args.input.name, teamType: args.input.teamType || 'BUYER', ownerId: profile.userId }, - }) - await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } }) - await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } }) + data: { + name: args.input.name, + teamType: args.input.teamType || "BUYER", + ownerId: profile.userId, + }, + }); + await prisma.teamMember.create({ + data: { teamId: team.id, userId: profile.userId, role: "OWNER" }, + }); + await prisma.userProfile.update({ + where: { id: profile.id }, + data: { activeTeamId: team.id }, + }); return { - team: { id: team.uuid, name: team.name, teamType: team.teamType, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString() }, + team: { + id: team.uuid, + name: team.name, + teamType: team.teamType, + logtoOrgId: team.logtoOrgId, + createdAt: team.createdAt.toISOString(), + }, success: true, - } + }; }, - updateUser: async (_: unknown, args: { userId: string; input: { firstName?: string; lastName?: string; phone?: string; avatarId?: string } }, ctx: AuthContext) => { - if (!ctx.userId) throw new GraphQLError('Not authenticated') - const profile = await getOrCreateProfile(ctx.userId) - const userUpdate: Record = {} - if (args.input.firstName !== undefined) userUpdate.firstName = args.input.firstName - if (args.input.lastName !== undefined) userUpdate.lastName = args.input.lastName + updateUser: async ( + _: unknown, + args: { + userId: string; + input: { + firstName?: string; + lastName?: string; + phone?: string; + avatarId?: string; + }; + }, + ctx: AuthContext, + ) => { + if (!ctx.userId) throw new GraphQLError("Not authenticated"); + const profile = await getOrCreateProfile(ctx.userId); + const userUpdate: Record = {}; + if (args.input.firstName !== undefined) + userUpdate.firstName = args.input.firstName; + if (args.input.lastName !== undefined) + userUpdate.lastName = args.input.lastName; if (Object.keys(userUpdate).length > 0) { - await prisma.user.update({ where: { id: profile.userId }, data: userUpdate }) + await prisma.user.update({ + where: { id: profile.userId }, + data: userUpdate, + }); } - const profileUpdate: Record = {} - if (args.input.phone !== undefined) profileUpdate.phone = args.input.phone - if (args.input.avatarId !== undefined) profileUpdate.avatarId = args.input.avatarId + const profileUpdate: Record = {}; + if (args.input.phone !== undefined) + profileUpdate.phone = args.input.phone; + if (args.input.avatarId !== undefined) + profileUpdate.avatarId = args.input.avatarId; if (Object.keys(profileUpdate).length > 0) { - await prisma.userProfile.update({ where: { id: profile.id }, data: profileUpdate }) + await prisma.userProfile.update({ + where: { id: profile.id }, + data: profileUpdate, + }); } - return { user: { id: ctx.userId }, success: true } + return { user: { id: ctx.userId }, success: true }; }, - switchTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => { - if (!ctx.userId) throw new GraphQLError('Not authenticated') - const profile = await getOrCreateProfile(ctx.userId) - const team = await prisma.team.findUnique({ where: { uuid: args.teamId } }) - if (!team) throw new GraphQLError('Team not found') - await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } }) - return { success: true } + switchTeam: async ( + _: unknown, + args: { teamId: string }, + ctx: AuthContext, + ) => { + if (!ctx.userId) throw new GraphQLError("Not authenticated"); + const profile = await getOrCreateProfile(ctx.userId); + const team = await prisma.team.findUnique({ + where: { uuid: args.teamId }, + }); + if (!team) throw new GraphQLError("Team not found"); + await prisma.userProfile.update({ + where: { id: profile.id }, + data: { activeTeamId: team.id }, + }); + return { success: true }; }, }, -} +};