bf7be65b03
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
197 lines
7.2 KiB
TypeScript
197 lines
7.2 KiB
TypeScript
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<ReturnType<typeof ctx.context.internalAdapter.createUser>> | 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;
|
|
};
|