Ajoute le flux d'invitation avec activation de compte

This commit is contained in:
2026-07-19 19:40:38 +02:00
parent 08b9bb8095
commit 18bf4e60d1
13 changed files with 747 additions and 97 deletions
+1
View File
@@ -1,2 +1,3 @@
export { createAuthModule } from "./module.js";
export { createAccountInviteToken } from "./invites.js";
export { registerAuthApiRoutes } from "./routes.js";
+109
View File
@@ -0,0 +1,109 @@
import { createHash, randomBytes } from "node:crypto";
type AccountInviteContext = {
verificationToken: { identifier: string; expires: Date };
user: {
id: string;
email: string | null;
name: string | null;
passwordHash: string | null;
emailVerified: Date | null;
accounts: Array<{ id: string }>;
};
};
type AccountInviteOptions = {
prisma: any;
tokenTtlMs?: number;
identifierPrefix?: string;
};
const DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX = "account-invite:";
const DEFAULT_ACCOUNT_INVITE_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function hashAccountInviteToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function buildAccountInviteIdentifier(prefix: string, userId: string): string {
return `${prefix}${userId}`;
}
export async function createAccountInviteToken(
options: AccountInviteOptions & { userId: string }
): Promise<{ token: string; expiresAt: Date }> {
const identifierPrefix = options.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
const identifier = buildAccountInviteIdentifier(identifierPrefix, options.userId);
const token = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + (options.tokenTtlMs ?? DEFAULT_ACCOUNT_INVITE_TOKEN_TTL_MS));
await options.prisma.verificationToken.deleteMany({
where: {
OR: [{ identifier }, { expires: { lt: new Date() } }]
}
});
await options.prisma.verificationToken.create({
data: {
identifier,
token: hashAccountInviteToken(token),
expires: expiresAt
}
});
return { token, expiresAt };
}
export async function getAccountInviteContext(
options: AccountInviteOptions & { token: string }
): Promise<AccountInviteContext | null> {
const identifierPrefix = options.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
const verificationToken = await options.prisma.verificationToken.findUnique({
where: { token: hashAccountInviteToken(options.token) },
select: { identifier: true, expires: true }
});
if (
!verificationToken ||
verificationToken.expires <= new Date() ||
!verificationToken.identifier.startsWith(identifierPrefix)
) {
return null;
}
const userId = verificationToken.identifier.slice(identifierPrefix.length);
if (!userId) {
return null;
}
const user = await options.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
passwordHash: true,
emailVerified: true,
accounts: {
select: { id: true },
take: 1
}
}
});
if (!user?.email) {
return null;
}
return {
verificationToken,
user
};
}
export {
DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX,
DEFAULT_ACCOUNT_INVITE_TOKEN_TTL_MS,
buildAccountInviteIdentifier,
hashAccountInviteToken
};
+119 -17
View File
@@ -1,6 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
import type { Express, RequestHandler } from "express";
import { z } from "zod";
import { DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX, getAccountInviteContext } from "./invites.js";
type RegisterAuthApiRoutesOptions = {
app: Express;
@@ -31,6 +32,10 @@ type RegisterAuthApiRoutesOptions = {
expiresAt: Date;
}) => Promise<void>;
};
accountInvite?: {
enabled: boolean;
identifierPrefix?: string;
};
onUserRegistered?: (user: { id: string; email: string | null; name: string | null }) => Promise<void> | void;
onPasswordResetConfirmed?: (user: { id: string; email: string | null; name: string | null }) => Promise<void> | void;
};
@@ -44,6 +49,8 @@ type AuthRouteMessages = {
passwordResetUnavailable: string;
invalidResetLink: string;
expiredResetLink: string;
invalidInviteLink: string;
inviteAlreadyAccepted: string;
};
const defaultNormalizeEmail = (email: string) => email.trim();
@@ -56,7 +63,9 @@ const defaultMessages: AuthRouteMessages = {
invalidPassword: "Invalid password",
passwordResetUnavailable: "Email service is not configured.",
invalidResetLink: "Invalid reset link",
expiredResetLink: "Invalid or expired reset link"
expiredResetLink: "Invalid or expired reset link",
invalidInviteLink: "Invalid or expired invite link",
inviteAlreadyAccepted: "This invite has already been accepted"
};
function hashPasswordResetToken(token: string): string {
@@ -75,8 +84,25 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
const passwordHasher = options.passwordHasher ?? ((password: string) => Promise.resolve(password));
const passwordComparator = options.passwordComparator ?? ((password: string, hash: string) => Promise.resolve(password === hash));
const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix;
const accountInviteIdentifierPrefix = options.accountInvite?.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
const messages = { ...defaultMessages, ...(options.messages ?? {}) };
const buildSession = (userId: string) => ({
sessionToken: randomBytes(32).toString("hex"),
userId,
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
});
const applySessionCookie = (res: any, session: { sessionToken: string; expires: Date }) => {
res.cookie(options.sessionCookieName, session.sessionToken, {
httpOnly: true,
sameSite: "lax",
secure: options.sessionCookieSecure,
path: "/",
expires: session.expires
});
};
const findUserByEmail = async (email: string) => {
const normalized = normalizeEmail(email);
const lowered = normalized.toLowerCase();
@@ -207,24 +233,11 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
return res.status(401).json({ error: messages.invalidPassword });
}
const sessionToken = randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const session = buildSession(user.id);
await options.prisma.session.create({
data: {
sessionToken,
userId: user.id,
expires
}
});
res.cookie(options.sessionCookieName, sessionToken, {
httpOnly: true,
sameSite: "lax",
secure: options.sessionCookieSecure,
path: "/",
expires
data: session
});
applySessionCookie(res, session);
return res.status(200).json({ ok: true });
});
@@ -352,6 +365,95 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
return res.status(200).json({ ok: true });
});
options.app.get(`${authApiBasePath}/invite/validate`, async (req, res) => {
if (!options.accountInvite?.enabled) {
return res.status(404).json({ error: messages.invalidInviteLink });
}
const parsed = z.object({ token: z.string().min(1) }).safeParse({
token: Array.isArray(req.query.token) ? req.query.token[0] : req.query.token
});
if (!parsed.success) {
return res.status(400).json({ error: messages.invalidInviteLink });
}
const context = await getAccountInviteContext({
prisma: options.prisma,
token: parsed.data.token,
identifierPrefix: accountInviteIdentifierPrefix
});
if (!context) {
return res.status(400).json({ error: messages.invalidInviteLink });
}
if (context.user.passwordHash || context.user.accounts.length > 0) {
return res.status(409).json({ error: messages.inviteAlreadyAccepted });
}
return res.status(200).json({
ok: true,
email: context.user.email,
name: context.user.name
});
});
options.app.post(`${authApiBasePath}/invite/accept`, async (req, res) => {
if (!options.accountInvite?.enabled) {
return res.status(404).json({ error: messages.invalidInviteLink });
}
const parsed = z
.object({
token: z.string().min(1),
name: z.string().min(2).max(60),
password: z.string().min(8)
})
.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: messages.invalidPayload });
}
const context = await getAccountInviteContext({
prisma: options.prisma,
token: parsed.data.token,
identifierPrefix: accountInviteIdentifierPrefix
});
if (!context) {
return res.status(400).json({ error: messages.invalidInviteLink });
}
if (context.user.passwordHash || context.user.accounts.length > 0) {
return res.status(409).json({ error: messages.inviteAlreadyAccepted });
}
const passwordHash = await passwordHasher(parsed.data.password);
const session = buildSession(context.user.id);
await options.prisma.$transaction([
options.prisma.verificationToken.deleteMany({
where: { identifier: context.verificationToken.identifier }
}),
options.prisma.session.create({
data: session
}),
options.prisma.user.update({
where: { id: context.user.id },
data: {
name: parsed.data.name,
passwordHash,
emailVerified: context.user.emailVerified ?? new Date()
}
})
]);
applySessionCookie(res, session);
return res.status(200).json({ ok: true });
});
options.app.post(`${authApiBasePath}/logout`, async (req, res) => {
const token = options.extractSessionToken(req.headers.cookie);