My App

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

packages/auth/src/index.ts
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).

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.

apps/pwa/src/middleware.ts
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 rolestaff/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/cron supprime 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 permissions
  • admin — GM, head of staff, presque toutes les permissions
  • manager — chef de service, CRUD menu, users, intégrations
  • staff — réceptionniste, cardex read + chat reply + order update
  • guest — 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.derive retourne 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é via openssl rand -base64 32, différent par env (dev / staging / prod)
  • Jamais commit en clair. En prod injecté via Dokploy secrets.

Lien

On this page