@accounter/server
Version:
Accounter GraphQL server
223 lines (190 loc) • 6.88 kB
text/typescript
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();
}
}
}