UNPKG

@accounter/server

Version:
223 lines (190 loc) • 6.88 kB
import { createHash } from 'node:crypto'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import { DBProvider } from '../../app-providers/db.provider.js'; import { AuditLogsProvider } from '../../common/providers/audit-logs.provider.js'; import { alreadyAcceptedError, expiredTokenError, invalidTokenError, mapAuth0Error, } from '../helpers/invitations.helper.js'; import type { IGetInvitationByTokenQuery, IGetInvitationForAcceptanceQuery, IGetUserIdByAuth0UserIdQuery, IInsertAcceptedBusinessUserQuery, IUpdateBusinessUserAuth0IdQuery, IUpdateInvitationAcceptanceQuery, } from '../types.js'; import { Auth0ManagementProvider } from './auth0-management.provider.js'; const updateInvitationAcceptance = sql<IUpdateInvitationAcceptanceQuery>` UPDATE accounter_schema.invitations SET accepted_at = NOW() WHERE id = $id; `; const getInvitationForAcceptance = sql<IGetInvitationForAcceptanceQuery>` SELECT id, user_id, business_id, role_id, email, auth0_user_id, accepted_at, expires_at FROM accounter_schema.invitations WHERE token_hash = $tokenHash AND accepted_at IS NULL AND expires_at > NOW() FOR UPDATE; `; const getInvitationByToken = sql<IGetInvitationByTokenQuery>` SELECT id, user_id, business_id, role_id, email, auth0_user_id, accepted_at, expires_at FROM accounter_schema.invitations WHERE token_hash = $tokenHash; `; const getUserIdByAuth0UserId = sql<IGetUserIdByAuth0UserIdQuery>` SELECT user_id FROM accounter_schema.business_users WHERE auth0_user_id = $auth0UserId LIMIT 1; `; const insertAcceptedBusinessUser = sql<IInsertAcceptedBusinessUserQuery>` INSERT INTO accounter_schema.business_users (user_id, auth0_user_id, business_id, role_id) VALUES ($userId, $auth0UserId, $ownerId, $roleId); `; const updateBusinessUserAuth0Id = sql<IUpdateBusinessUserAuth0IdQuery>` UPDATE accounter_schema.business_users SET auth0_user_id = $auth0UserId, updated_at = NOW() WHERE user_id = $userId AND business_id = $ownerId; `; @Injectable({ scope: Scope.Operation, global: true, }) export class AcceptInvitationsProvider { constructor( private dbProvider: DBProvider, private auth0ManagementProvider: Auth0ManagementProvider, private auditLogsProvider: AuditLogsProvider, ) {} public async acceptInvitation( token: string, auth0UserId: string | null, authenticatedUserEmail: string | null, ) { const tokenHash = createHash('sha256').update(token).digest('hex'); const client = await this.dbProvider.pool.connect(); try { await client.query('BEGIN'); const activeInvitationResult = await getInvitationForAcceptance.run({ tokenHash }, client); if (activeInvitationResult.length === 0) { const invitationByTokenResult = await getInvitationByToken.run({ tokenHash }, client); if (invitationByTokenResult.length === 0) { throw invalidTokenError(); } const staleInvitation = invitationByTokenResult[0]; if (staleInvitation.accepted_at) { throw alreadyAcceptedError(); } if (new Date(staleInvitation.expires_at).getTime() <= Date.now()) { throw expiredTokenError(); } throw invalidTokenError(); } const invitation = activeInvitationResult[0]; const effectiveAuth0UserId = auth0UserId ?? invitation.auth0_user_id; if (!effectiveAuth0UserId) { throw invalidTokenError(); } // Defense in depth: authenticated users can claim only invitations for their own email. if (auth0UserId) { const normalizedInvitationEmail = invitation.email?.trim().toLowerCase(); let normalizedAuthenticatedEmail = authenticatedUserEmail?.trim().toLowerCase() ?? null; // Access tokens for custom APIs often omit `email` claims. // In that case, resolve the primary email from Auth0 profile for comparison. if (!normalizedAuthenticatedEmail) { const identity = await this.auth0ManagementProvider.getUserEmailById(auth0UserId); if (identity?.emailVerified && identity.email) { normalizedAuthenticatedEmail = identity.email.trim().toLowerCase(); } } if ( !normalizedInvitationEmail || !normalizedAuthenticatedEmail || normalizedInvitationEmail !== normalizedAuthenticatedEmail ) { throw invalidTokenError(); } } if (!invitation.user_id) { throw invalidTokenError(); } const assignAuth0UserToInvitedUser = async () => { await updateBusinessUserAuth0Id.run( { auth0UserId: effectiveAuth0UserId, userId: invitation.user_id, ownerId: invitation.business_id, }, client, ); }; let userId: string; if (auth0UserId) { const existingUserResult = await getUserIdByAuth0UserId.run({ auth0UserId }, client); if (existingUserResult.length > 0) { userId = existingUserResult[0].user_id; await insertAcceptedBusinessUser.run( { userId, auth0UserId: effectiveAuth0UserId, ownerId: invitation.business_id, roleId: invitation.role_id, }, client, ); } else { userId = invitation.user_id; await assignAuth0UserToInvitedUser(); } } else { userId = invitation.user_id; await assignAuth0UserToInvitedUser(); } if (invitation.auth0_user_id) { try { if (auth0UserId && auth0UserId !== invitation.auth0_user_id) { await this.auth0ManagementProvider.deleteUser(invitation.auth0_user_id); } else { await this.auth0ManagementProvider.unblockUser(invitation.auth0_user_id); } } catch (error) { throw mapAuth0Error(error); } } await updateInvitationAcceptance.run({ id: invitation.id }, client); await this.auditLogsProvider.log( { ownerId: invitation.business_id, userId, auth0UserId: effectiveAuth0UserId, action: 'INVITATION_ACCEPTED', entity: 'Invitation', entityId: invitation.id, details: { auth0_user_id: effectiveAuth0UserId, business_id: invitation.business_id, role_id: invitation.role_id, }, }, client, ); await client.query('COMMIT'); return { success: true, businessId: invitation.business_id, roleId: invitation.role_id, }; } catch (error) { await client.query('ROLLBACK').catch(() => null); throw error; } finally { client.release(); } } }