import * as z from 'zod'; import {APIError, createAuthEndpoint} from 'better-auth/api'; import {setSessionCookie} from 'better-auth/cookies'; import {createLocalAccountIssuer} from 'better-auth/db'; import {runWithTransaction} from '@better-auth/core/context'; import type {BetterAuthPlugin} from 'better-auth'; import * as InvitationsService from './invitations.service.js'; import * as UsersService from '../users/users.admin.service.js'; import logger from '../../../middleware/logger.js'; /** * The two public endpoints of the invitation flow, implemented as a better-auth * plugin rather than as plain Express routes on the admin router. * * Why a plugin: `emailAndPassword.disableSignUp` is on, which makes * `auth.api.signUpEmail` refuse - deliberately, there is no public sign-up. * Accepting an invitation still has to create a user, hash a password, write a * credential account and sign the person in. All four are better-auth * internals reachable only from inside an endpoint's context, so this is where * account creation lives. Nothing outside this file may create users. * * Because they are plugin endpoints they sit under better-auth's basePath: * POST /admin/auth/invitations/preview * POST /admin/auth/invitations/accept * * The token travels in the request *body*, never in the path or query, so it * cannot end up in an access log or a Referer header. */ // Unknown, expired, revoked and already-accepted tokens must be // indistinguishable to the caller: one shared error, one shared message. const invalidToken = (): APIError => { return new APIError('BAD_REQUEST', { code: 'INVALID_INVITATION', message: 'Diese Einladung ist nicht mehr gültig.' }); }; export const invitationsPlugin = () => { return { id: 'nachklang-invitations', endpoints: { /** * Lets the accept-invite page show who the invitation is for before * asking for a password. Returns only name and email - never the * granted apps, which is information the invitee has no need for * and an attacker with a stolen link should not get either. */ previewInvitation: createAuthEndpoint( '/invitations/preview', { method: 'POST', body: z.object({ token: z.string().min(1) }) }, async ctx => { const invitation = await InvitationsService.findByToken(ctx.body.token); if (!invitation) { throw invalidToken(); } return ctx.json({email: invitation.email, name: invitation.name}); } ), /** * Redeems the invitation: creates the user, its credential account * and its permissions, then signs the person straight in so they * land in the app instead of on a login form. */ acceptInvitation: createAuthEndpoint( '/invitations/accept', { method: 'POST', body: z.object({ token: z.string().min(1), password: z.string().min(8).max(128) }) }, async ctx => { const invitation = await InvitationsService.findByToken(ctx.body.token); if (!invitation) { throw invalidToken(); } // An account for this address already exists: the right fix // is for an admin to grant permissions on the existing user, // not to create a second one. Reported distinctly because // the person holds a valid token - this leaks nothing they // do not already know about their own mailbox. const existing = await UsersService.findUserByEmail(invitation.email); if (existing) { throw new APIError('CONFLICT', { code: 'USER_ALREADY_EXISTS', message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Melde dich stattdessen an.' }); } // Claim the invitation before creating anything. The update // is conditional on it still being open, so two concurrent // submissions of the same link cannot both end up creating a // user. const claimed = await InvitationsService.markAccepted(invitation.id); if (!claimed) { throw invalidToken(); } // The user and its credential account go in one better-auth // transaction, the way better-auth's own sign-up route does // it: a half-created account with no password is not // recoverable through any route this API exposes. // // It cannot cover everything, though. `user_app_permissions` // and `invitations` are written through this module's own // Kysely pool, which is a different connection, so no single // transaction spans both. What follows is therefore // compensated by hand rather than rolled back. let user: Awaited> | null = null; try { user = await runWithTransaction(ctx.context.adapter, async () => { const created = await ctx.context.internalAdapter.createUser( { email: invitation.email, name: invitation.name, // Accepting a link sent to that mailbox *is* // the proof of address ownership, so there is // no separate verification mail (plan // decision 15). emailVerified: true, disabled: false }, {method: 'email-password'} ); // Same call better-auth's own sign-up route makes, // down to the synthetic issuer - a credential account // written any other way is not found on sign-in. await ctx.context.internalAdapter.linkAccount({ userId: created.id, providerId: 'credential', issuer: createLocalAccountIssuer('credential'), accountId: created.id, password: await ctx.context.password.hash(ctx.body.password) }); return created; }); await UsersService.setPermissions(user.id, invitation.permissions, null); const session = await ctx.context.internalAdapter.createSession(user.id); await setSessionCookie(ctx, {session, user}); return ctx.json({ user: {id: user.id, email: user.email, name: user.name} }); } catch (e: any) { // Undo what committed, so the invitee can use their link // again instead of being stranded with a burnt token, an // account they cannot sign into, and an admin who cannot // re-invite them (the create route 409s on an existing // user, and there is no delete route by design). // // Deleting the user is safe here: it was created moments // ago in this request, and acceptance already established // that no account for this address existed before. try { if (user) { await ctx.context.internalAdapter.deleteUser(user.id); } await InvitationsService.unmarkAccepted(invitation.id); } catch (compensationError: any) { // Now the state really is inconsistent, and only a // human can sort it out. Say so loudly and precisely. logger.error('Admin: invitation acceptance failed AND its rollback failed', { invitationId: invitation.id, email: invitation.email, userId: user?.id, detail: e?.message, compensationDetail: compensationError?.message }); throw e; } logger.error('Admin: invitation acceptance failed and was rolled back', { invitationId: invitation.id, detail: e?.message }); throw e; } } ) } } satisfies BetterAuthPlugin; };