Authentification
Better Auth 1.5.5, cookies HttpOnly cross-subdomain, magic link, organization plugin, middleware serveur
Cookies HttpOnly uniquement, signés, cross-subdomain
.hoaiy.com. Pas de Bearer token, pas de flag localStorage, pas de race d'hydration. Le guard est un middleware Next.js serveur, pas côté client (cf. ADR-02).
Stack
- Better Auth 1.5.5 — framework d'auth (sessions, accounts, verification, plugins)
- Drizzle adapter — persistence Postgres
- Organization plugin — multi-tenant natif (member role + activeOrganizationId)
- Magic Link plugin — envoi d'un lien unique pour se connecter sans password
- Email/Password — flow classique avec reset + verification
Config
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization, magicLink } from "better-auth/plugins";
import { db } from "@bell/db";
import * as schema from "@bell/db/schema/auth";
import { sendMail } from "./mail";
const baseDomain = process.env.NODE_ENV === "production" ? ".hoaiy.com" : undefined;
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema }),
trustedOrigins: [
"http://localhost:3001",
"http://localhost:3002",
"https://bell-app.hoaiy.com",
"https://bell-staff.hoaiy.com",
"https://bell-docs.hoaiy.com",
],
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
await sendMail({
to: user.email,
template: "passwordReset",
data: { url },
});
},
},
advanced: {
defaultCookieAttributes: {
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
domain: baseDomain, // ".hoaiy.com" en prod, undefined en dev
},
},
plugins: [
organization({
sendInvitationEmail: async (data) => {
const acceptUrl = `${process.env.BETTER_AUTH_URL}/api/auth/organization/accept-invitation?invitationId=${data.invitation.id}`;
await sendMail({
to: data.email,
template: "invitation",
data: {
orgName: data.organization.name,
inviterName: data.inviter.user.name ?? data.inviter.user.email,
role: data.role,
acceptUrl,
},
});
},
}),
magicLink({
expiresIn: 600, // 10 min
sendMagicLink: async ({ email, url }) => {
await sendMail({ to: email, template: "magicLink", data: { url } });
},
}),
],
});
export type Session = Awaited<ReturnType<typeof auth.api.getSession>>;Flows auth
Signup email/password (Guest)
Après signup, on lie le guest au user via cardex.linkGuestToUser (appelé post-signup dans l'UI).
Signin magic link (Staff ou Guest)
Staff préfère souvent magic link à password :
Magic link : 10 minutes TTL, one-shot (token consommé).
Invitation staff (Organization plugin)
Cookies cross-subdomain
Clé de voûte du SSO entre les 3 apps web :
bell-app.hoaiy.com(PWA)bell-staff.hoaiy.com(Dashboard)bell-api.hoaiy.com(API)
Tous partagent le domaine .hoaiy.com. Les cookies sont signés avec domain: .hoaiy.com, httpOnly: true, secure: true, sameSite: "lax".
Résultat : un staff qui se login sur bell-staff.hoaiy.com est aussi authentifié sur bell-api.hoaiy.com pour les requêtes API (puisque les cookies sont envoyés automatiquement).
En dev : pas de domaine parent commun (localhost:3001, :3002, :3000). On utilise Next.js rewrites pour faire apparaître l'API comme same-origin — les cookies marchent alors en SameSite=Lax standard. Voir ADR-02.
Middleware serveur (guard)
Pas de guard client. Les règles d'accès sont appliquées en middleware Next.js côté serveur, avant le render.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const AUTH_PATHS = ["/auth/sign-in", "/auth/sign-up", "/auth/forgot-password"];
const CHECK_IN_FLOW = ["/auth/waiting", "/auth/fast-check-in", "/auth/create-password",
"/auth/check-in-confirmation", "/auth/check-in-upsells", "/auth/check-in-payment"];
export async function middleware(req: NextRequest) {
const sessionCookie = req.cookies.get("better-auth.session_token");
// Pas de session → /auth/waiting (le seul point d'entrée public)
if (!sessionCookie) {
if (req.nextUrl.pathname === "/auth/waiting" || AUTH_PATHS.includes(req.nextUrl.pathname)) {
return NextResponse.next();
}
return NextResponse.redirect(new URL("/auth/waiting", req.url));
}
// Session → fetch check-in status depuis l'API
const statusRes = await fetch(`${process.env.SERVER_URL}/cardex/my-status`, {
headers: { cookie: req.headers.get("cookie") ?? "" },
});
const { status } = await statusRes.json();
// Pas encore "arrived" → bloque sur le flow check-in
if (status !== "arrived") {
if (CHECK_IN_FLOW.includes(req.nextUrl.pathname)) return NextResponse.next();
return NextResponse.redirect(new URL("/auth/waiting", req.url));
}
// "arrived" → accès total
if (AUTH_PATHS.includes(req.nextUrl.pathname) || CHECK_IN_FLOW.includes(req.nextUrl.pathname)) {
return NextResponse.redirect(new URL("/", req.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/((?!_next|api|.*\\..*).*)"] };Pour apps/dashboard, le middleware vérifie role ∈ staff/manager/admin/owner au lieu de checkInStatus.
Session shape
type Session = {
user: {
id: string;
email: string;
name: string;
emailVerified: boolean;
image?: string;
createdAt: Date;
};
session: {
id: string;
token: string; // le cookie value
userId: string;
expiresAt: Date;
activeOrganizationId: string | null; // rempli par organization plugin
ipAddress: string;
userAgent: string;
};
};Dans les routes Elysia, on résout :
// packages/api/src/plugins/auth.ts
export const authPlugin = new Elysia()
.derive({ as: "global" }, async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return { auth: null };
// Si activeOrganizationId manquant, fallback sur la première membership
let orgId = session.session.activeOrganizationId;
let role: string | null = null;
if (!orgId) {
const [m] = await db.select().from(member)
.where(eq(member.userId, session.user.id))
.limit(1);
orgId = m?.organizationId ?? null;
role = m?.role ?? null;
} else {
const [m] = await db.select().from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId)))
.limit(1);
role = m?.role ?? null;
}
return {
auth: { userId: session.user.id, email: session.user.email, organizationId: orgId, role },
};
});Sessions & sécurité
- Expiration : 7 jours par défaut (configurable)
- Remember Me : cookie persistent si flag coché, 30 jours
- Rotation : token tourne à chaque login
- Révocation :
auth.api.revokeSession({ token })— logout - Cleanup : cron
@elysiajs/cronsupprime les sessions expirées chaque nuit - Protection CSRF : gérée par Better Auth (double-submit cookie pattern)
- Brute force : rate limiter sur
/api/auth/sign-in/email(5 tentatives / 15 min par IP)
Envoi des emails d'auth
Tous les emails Better Auth passent par la même queue BullMQ que les emails applicatifs (check-in, notifications). Ne pas envoyer directement via nodemailer → on perd les retries + DLQ + Reacher.
// Dans les callbacks Better Auth (sendInvitationEmail, sendMagicLink, etc.)
await queues.email.add("magic-link", {
to: email,
template: "magicLink",
data: { url },
skipValidation: true, // magic link reçu depuis user authentifié, skip Reacher
});Multi-tenant
Organization (hôtel)
Chaque hôtel = un organization. Créé par admin HOAIY à l'onboarding.
Member role
member.role :
owner— fondateur / CEO hôtel, toutes les permissionsadmin— GM, head of staff, presque toutes les permissionsmanager— chef de service, CRUD menu, users, intégrationsstaff— réceptionniste, cardex read + chat reply + order updateguest— invité, PWA uniquement
Active organization
Un user peut être dans plusieurs orgs (staff multi-hôtel). session.activeOrganizationId est l'org courante. Switcher d'org = auth.organization.setActive({ organizationId }) côté client (Better Auth) → update session.
Tests
- Unitaire auth plugin :
auth.deriveretourne le bon shape selon cookie présent/absent - Intégration signup/signin : routes Better Auth via
app.handle(new Request(...))avec cookies - Intégration invitation : flow complet create invite → accept → member créé
- E2E Playwright : flow signup complet + redirections middleware
Secrets
BETTER_AUTH_SECRET: 32 bytes aléatoires, généré viaopenssl rand -base64 32, différent par env (dev / staging / prod)- Jamais commit en clair. En prod injecté via Dokploy secrets.
Lien
- ADR-02 cookies only
- Auth client Better Auth
- Environments — env vars, cookies domain
- Onboarding hôtel