From 221b66e0d392b2097978567fd3df2b71050bca8d Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:10:47 +0700 Subject: [PATCH] Materialize teams from Logto JWT claims --- graphql-contracts | 2 +- src/auth.ts | 44 ++++++++++++++++++----- src/schemas/m2m.ts | 18 ---------- src/schemas/team.ts | 7 ++++ src/schemas/user.ts | 25 ++++---------- src/team-materialization.ts | 69 +++++++++++++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 46 deletions(-) create mode 100644 src/team-materialization.ts diff --git a/graphql-contracts b/graphql-contracts index c632d41..b1c5a3f 160000 --- a/graphql-contracts +++ b/graphql-contracts @@ -1 +1 @@ -Subproject commit c632d41e35674597d2bfbfa2b814dd969574c612 +Subproject commit b1c5a3f885c2910d49b7c305b00099fb187577f9 diff --git a/src/auth.ts b/src/auth.ts index ff955c4..cf5432a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -13,6 +13,9 @@ const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)); export interface AuthContext { userId?: string; teamUuid?: string; + teamName?: string; + teamType?: string; + logtoOrgId?: string; scopes: string[]; isM2M?: boolean; } @@ -59,6 +62,29 @@ function claimList(payload: JWTPayload, key: string): string[] { return []; } +function claimString(payload: JWTPayload, key: string): string | undefined { + const value = (payload as Record)[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function teamClaims(payload: JWTPayload): Pick< + AuthContext, + "teamUuid" | "teamName" | "teamType" | "logtoOrgId" +> { + return { + teamUuid: claimString(payload, "team_uuid"), + teamName: + claimString(payload, "team_name") ?? + claimString(payload, "organization_name") ?? + claimString(payload, "org_name"), + teamType: claimString(payload, "team_type"), + logtoOrgId: + claimString(payload, "logto_org_id") ?? + claimString(payload, "organization_id") ?? + claimString(payload, "org_id"), + }; +} + function hasManagerClaim(payload: JWTPayload): boolean { const scopes = scopesFromPayload(payload); return ( @@ -72,7 +98,11 @@ export async function userContext(req: FastifyRequest): Promise { const token = optionalBearerToken(req); if (token === null) return { scopes: [] }; const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }); - return { userId: payload.sub, scopes: scopesFromPayload(payload) }; + return { + userId: payload.sub, + scopes: scopesFromPayload(payload), + ...teamClaims(payload), + }; } export async function managerContext( @@ -90,9 +120,7 @@ export async function managerContext( } return { userId: payload.sub, - teamUuid: (payload as Record).team_uuid as - | string - | undefined, + ...teamClaims(payload), scopes: [...new Set([...scopesFromPayload(payload), "manager"])], }; } @@ -103,15 +131,13 @@ export async function teamContext(req: FastifyRequest): Promise { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE, }); - const teamUuid = (payload as Record).team_uuid as - | string - | undefined; + const claims = teamClaims(payload); const scopes = scopesFromPayload(payload); - if (!teamUuid || !scopes.includes("teams:member")) + if (!claims.teamUuid || !scopes.includes("teams:member")) throw new GraphQLError("Unauthorized", { extensions: { code: "UNAUTHENTICATED" }, }); - return { userId: payload.sub, teamUuid, scopes }; + return { userId: payload.sub, scopes, ...claims }; } export async function m2mContext(): Promise { diff --git a/src/schemas/m2m.ts b/src/schemas/m2m.ts index 4afe378..783a918 100644 --- a/src/schemas/m2m.ts +++ b/src/schemas/m2m.ts @@ -46,7 +46,6 @@ export const m2mTypeDefs = `#graphql } type SetLogtoOrgIdResult { team: Team, success: Boolean! } - type CreateTeamResult { success: Boolean!, teamId: String, teamUuid: String, message: String } type CreateAddressResult { success: Boolean!, addressUuid: String, teamType: String, message: String } type UpdateAddressStatusResult { success: Boolean! } type CreateInvitationResult { success: Boolean!, message: String, invitationUuid: String, invitation: TeamInvitation } @@ -60,7 +59,6 @@ export const m2mTypeDefs = `#graphql type Mutation { setLogtoOrgId(teamId: String!, logtoOrgId: String!): SetLogtoOrgIdResult - createTeamFromWorkflow(teamName: String!, ownerId: String!, teamType: String, countryCode: String): CreateTeamResult createAddressFromWorkflow(workflowId: String!, teamUuid: String!, name: String!, address: String!, latitude: Float, longitude: Float, countryCode: String, isDefault: Boolean): CreateAddressResult updateAddressStatus(addressUuid: String!, status: String!, errorMessage: String): UpdateAddressStatusResult createInvitationFromWorkflow(input: CreateInvitationFromWorkflowInput!): CreateInvitationResult @@ -90,22 +88,6 @@ export const m2mResolvers = { return { team: { uuid: team.uuid, name: team.name, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString(), updatedAt: team.updatedAt.toISOString() }, success: true } }, - createTeamFromWorkflow: async (_: unknown, args: { teamName: string; ownerId: string; teamType?: string; countryCode?: string }) => { - try { - let profile = await prisma.userProfile.findUnique({ where: { logtoId: args.ownerId }, include: { user: true } }) - if (!profile) { - const user = await prisma.user.create({ data: { username: args.ownerId } }) - profile = await prisma.userProfile.create({ data: { userId: user.id, logtoId: args.ownerId }, include: { user: true } }) - } - const team = await prisma.team.create({ data: { name: args.teamName, teamType: args.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 { success: true, teamId: team.id.toString(), teamUuid: team.uuid, message: 'Team created' } - } catch (e) { - return { success: false, teamId: null, teamUuid: null, message: String(e) } - } - }, - createAddressFromWorkflow: async (_: unknown, args: { workflowId: string; teamUuid: string; name: string; address: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean }) => { const team = await prisma.team.findUnique({ where: { uuid: args.teamUuid } }) if (!team) return { success: false, addressUuid: null, teamType: null, message: 'Team not found' } diff --git a/src/schemas/team.ts b/src/schemas/team.ts index 93b8eea..a62f0c1 100644 --- a/src/schemas/team.ts +++ b/src/schemas/team.ts @@ -2,6 +2,7 @@ import { GraphQLError } from 'graphql' import { randomUUID, createHash } from 'crypto' import { prisma } from '../db.js' import { requireScopes, type AuthContext } from '../auth.js' +import { materializeTeamFromContext } from '../team-materialization.js' export const teamTypeDefs = `#graphql type TeamUser { @@ -153,6 +154,7 @@ export const teamResolvers = { team: async (_: unknown, __: unknown, ctx: AuthContext) => { requireScopes(ctx, 'teams:member') if (!ctx.teamUuid) throw new GraphQLError('Team UUID not found') + await materializeTeamFromContext(ctx) const team = await getTeamByUuid(ctx.teamUuid) return team ? mapTeam(team) : null }, @@ -166,6 +168,7 @@ export const teamResolvers = { teamMembers: async (_: unknown, __: unknown, ctx: AuthContext) => { requireScopes(ctx, 'teams:member') if (!ctx.teamUuid) return [] + await materializeTeamFromContext(ctx) const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } }) if (!team) return [] const members = await prisma.teamMember.findMany({ @@ -186,6 +189,7 @@ export const teamResolvers = { teamAddresses: async (_: unknown, __: unknown, ctx: AuthContext) => { requireScopes(ctx, 'teams:member') if (!ctx.teamUuid) return [] + await materializeTeamFromContext(ctx) const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } }) if (!team) return [] const addrs = await prisma.teamAddress.findMany({ where: { teamId: team.id }, orderBy: { createdAt: 'desc' } }) @@ -204,6 +208,7 @@ export const teamResolvers = { inviteMember: async (_: unknown, args: { input: { email: string; role?: string } }, ctx: AuthContext) => { requireScopes(ctx, 'teams:member') if (!ctx.teamUuid || !ctx.userId) throw new GraphQLError('Not authenticated') + await materializeTeamFromContext(ctx) const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } }) if (!team) throw new GraphQLError('Team not found') const invitation = await prisma.teamInvitation.create({ @@ -229,6 +234,7 @@ export const teamResolvers = { createTeamAddress: async (_: unknown, args: { input: { name: string; address: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean } }, ctx: AuthContext) => { requireScopes(ctx, 'teams:member') if (!ctx.teamUuid) throw new GraphQLError('Not authenticated') + await materializeTeamFromContext(ctx) const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } }) if (!team) throw new GraphQLError('Team not found') const addr = await prisma.teamAddress.create({ @@ -275,6 +281,7 @@ export const teamResolvers = { setSelectedLocation: async (_: unknown, args: { input: { type: string; uuid: string; name: string; latitude: number; longitude: number } }, ctx: AuthContext) => { requireScopes(ctx, 'teams:member') if (!ctx.teamUuid) throw new GraphQLError('Not authenticated') + await materializeTeamFromContext(ctx) await prisma.team.update({ where: { uuid: ctx.teamUuid }, data: { diff --git a/src/schemas/user.ts b/src/schemas/user.ts index d63dbb5..70bf323 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -1,6 +1,10 @@ import { GraphQLError } from "graphql"; import { prisma } from "../db.js"; import { type AuthContext } from "../auth.js"; +import { + getOrCreateProfile, + materializeTeamFromContext, +} from "../team-materialization.js"; export const userTypeDefs = `#graphql type UserTeam { @@ -86,23 +90,6 @@ export const userTypeDefs = `#graphql } `; -async function getOrCreateProfile(logtoId: string) { - 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: "" }, - }); - profile = await prisma.userProfile.create({ - data: { userId: user.id, logtoId: logtoId }, - include: { user: true, activeTeam: true }, - }); - } - return profile; -} - function displayName( firstName: string, lastName: string, @@ -156,7 +143,9 @@ export const userResolvers = { Query: { me: async (_: unknown, __: unknown, ctx: AuthContext) => { if (!ctx.userId) throw new GraphQLError("Not authenticated"); - const profile = await getOrCreateProfile(ctx.userId); + const profile = ctx.teamUuid + ? (await materializeTeamFromContext(ctx)).profile + : await getOrCreateProfile(ctx.userId); return mapProfileUser(profile, ctx); }, diff --git a/src/team-materialization.ts b/src/team-materialization.ts new file mode 100644 index 0000000..6c227f0 --- /dev/null +++ b/src/team-materialization.ts @@ -0,0 +1,69 @@ +import { GraphQLError } from "graphql"; +import type { Prisma } from "@prisma/client"; +import { prisma } from "./db.js"; +import type { AuthContext } from "./auth.js"; + +export async function getOrCreateProfile(logtoId: string) { + let profile = await prisma.userProfile.findUnique({ + where: { logtoId }, + include: { user: true, activeTeam: true }, + }); + if (!profile) { + const user = await prisma.user.create({ + data: { username: logtoId, firstName: "", lastName: "" }, + }); + profile = await prisma.userProfile.create({ + data: { userId: user.id, logtoId }, + include: { user: true, activeTeam: true }, + }); + } + return profile; +} + +function teamName(ctx: AuthContext): string { + return ctx.teamName ?? ctx.logtoOrgId ?? ctx.teamUuid ?? "Team"; +} + +function teamUpdate(ctx: AuthContext): Prisma.TeamUpdateInput { + const data: Prisma.TeamUpdateInput = {}; + if (ctx.teamName) data.name = ctx.teamName; + if (ctx.teamType) data.teamType = ctx.teamType; + if (ctx.logtoOrgId) data.logtoOrgId = ctx.logtoOrgId; + return data; +} + +export async function materializeTeamFromContext(ctx: AuthContext) { + if (!ctx.userId) throw new GraphQLError("Not authenticated"); + if (!ctx.teamUuid) throw new GraphQLError("Team UUID not found in token"); + + const profile = await getOrCreateProfile(ctx.userId); + const team = await prisma.team.upsert({ + where: { uuid: ctx.teamUuid }, + create: { + uuid: ctx.teamUuid, + name: teamName(ctx), + teamType: ctx.teamType ?? "BUYER", + logtoOrgId: ctx.logtoOrgId, + ownerId: profile.userId, + }, + update: teamUpdate(ctx), + }); + + await prisma.teamMember.upsert({ + where: { + teamId_userId: { + teamId: team.id, + userId: profile.userId, + }, + }, + create: { teamId: team.id, userId: profile.userId, role: "OWNER" }, + update: {}, + }); + const updatedProfile = await prisma.userProfile.update({ + where: { id: profile.id }, + data: { activeTeamId: team.id }, + include: { user: true, activeTeam: true }, + }); + + return { profile: updatedProfile, team }; +}