Application OrbitAccès et sessions

Accès et sessions

Connexion OTP, inscription libre, proxy, relecture du membre à chaque requête et gestion des membres de l’application Orbit.

Connexion par code à six chiffres, deux portes (le proxy, puis la relecture du membre en base) et gestion des membres. La matrice des rôles et permissions est dans Permissions Studio ; le contrat des clients MCP/API dans Authentification & permissions.

3.1 Connexion : OTP à six chiffres, inscription libre

  • NextAuth v4, un seul fournisseur : Credentials d'id otp (apps/web/src/auth/options.ts). Pas de lien magique (la plateforme a retiré le sien, security-identity/0267). Sessions JWT de 7 jours à compter de la connexion (authAt), non glissantes : NextAuth v4 réémet le JWT avec un exp neuf à chaque GET /api/auth/session, donc le studio épingle cet exp à authAt + 7 jours (jwt.encode), refuse un jeton plus vieux dans le callback jwt (NextAuth efface alors le cookie) et dans evaluateAccess (raison session-too-old). Règle dans auth/session-age.ts, preuve dans auth/options.test.ts, qui rafraîchit un cookie chaque jour et le voit refusé au septième (constat F2 / SEC-2 de l'audit). Cookie orbit.session (__Secure-orbit.session en HTTPS), signées par un NEXTAUTH_SECRET propre au studio. Sans secret, la production refuse de démarrer une session ; le développement prend une constante.
  • Adaptateur Prisma (@auth/prisma-adapter) sur @orbit/db : il possède les tables NextAuth ; le flux OTP stocke et consomme ses codes par createVerificationToken / useVerificationToken (usage unique, le code est supprimé par la requête qui le trouve).
  • Demande de code : POST /api/auth/send-otp { email } (auth/otp-flow.ts, requestOtp), dans la forme que lit la page /auth (portée du cockpit d'origine) : 200 { data: { ok: true }, error: null }, un seul corps constant pour un membre et un inconnu, ou { data: null, error: { message, code } } (400, 429 avec les en-têtes de limite, 403 hors origine, 503), message étant la phrase française de fr.auth.errors.* que la page affiche telle quelle. Un éventuel turnstileToken est ignoré (aucun widget). Adresse canonique (canonicalEmail, identique à la plateforme), adresse .invalid (compte de service review) refusée (400, même réponse qu'une adresse mal formée), limite de 5 demandes par minute et par adresse et de 10 par minute et par IP de confiance (x-real-ip, puis dernier saut de x-forwarded-for), vérification de l'Origin.
  • Inscription libre : toute adresse valide reçoit un code. Le premier code vérifié d'une adresse inconnue crée son propre workspace et l'en fait propriétaire (workspace/sign-up.ts, dans la transaction de connexion, audit workspace.created). Rien d'autre n'est accordé : les crédits Studio sont l'enveloppe mensuelle du workspace (Workspace.studioMonthlyCreditsUsd, 0 par défaut, personne n'est illimité), et le droit d'administrer la plateforme (PlatformAdmin, ADR 0017) n'est jamais celui d'une inscription. Après la connexion, on atterrit sur /brain.
  • Aucun oracle d'existence : un membre et une adresse inconnue reçoivent la même réponse (statut, corps, en-têtes). La différence (écrire le code et envoyer l'email, ou, pour un membre révoqué ou échu, écrire auth.otp_refused au journal ; une adresse inconnue n'écrit aucune ligne de membre ni de journal avant la vérification du code, pour qu'un anonyme ne puisse pas remplir la base d'adresses de son choix) se fait après la réponse, par after(), donc le temps de réponse ne dit rien non plus. Les limites comptent aussi les adresses inconnues.
  • Code : randomInt à six chiffres, stocké sous forme de HMAC-SHA256 clé par le secret, sur identifiant:code (copie du otp.ts plateforme), durée de vie 10 minutes, le dernier code demandé annule les précédents.
  • Vérification : verifyOtp, appelé par le fournisseur NextAuth. Verrouillage avant tout essai (lib/security/lockout.ts, copie corrigée de la plateforme) : 5 échecs en 15 minutes verrouillent. Deux clés, et les deux escaladent x2 à chaque verrou : le couple adresse + IP (15 min, 30 min, 1 h, ... jusqu'à 24 h) et l'adresse seule (15 min, 30 min, puis 1 h au plus). L'escalade est mémorisée 24 h après la fin du dernier verrou ; une connexion réussie l'efface. La borne d'une heure sur l'adresse seule est le compromis : sans verrou par adresse, un code se devine en changeant d'IP ; avec un verrou par adresse de 24 h, n'importe qui connaissant l'adresse fermerait le studio à un membre pour une journée avec quelques requêtes. Borné à une heure, il faut insister cinq fois par heure pour le maintenir, et le membre rentre au plus une heure après l'arrêt. La plateforme promettait cette escalade sans la faire (sa mémoire expirait avec le verrou, constat F1 de l'audit) ; lockout.test.ts la vérifie sur neuf rondes. Compteurs en INCR atomique. Un code valide pour un membre révoqué ou expiré entre-temps donne la même réponse qu'un code faux (otp_invalid).
  • Premier atterrissage : dans une transaction, le User est trouvé ou créé par l'email canonique, le StudioMember en attente est lié (userId), passe ACTIVE, reçoit activatedAt, et le journal reçoit member.activated puis auth.signed_in.
  • Transport des emails (lib/mail/transport.ts, port Mailer de studio.mailer, ADR 0016) : sur un déploiement Vercel (VERCEL_ENV production ou preview), Resend avec la RESEND_API_KEY que l'intégration Resend du Vercel Marketplace injecte, depuis config.from du registre ; partout ailleurs, next start sur un poste compris, le transport console imprime chaque message sur une ligne [mail:console] du journal serveur (c'est ainsi qu'un développeur et la suite Playwright lisent un code). Sur Vercel sans crédit : erreur qui nomme la raison, jamais un code dans un journal.
  • Upstash (ports KeyValue et RateLimiter de studio.kv, intégration Marketplace : KV_REST_API_URL, KV_REST_API_TOKEN) porte les limites et le verrouillage entre instances sur Vercel ; hors de Vercel, et 30 s après une panne d'Upstash pour les limites, un repli en mémoire aux mêmes sémantiques, prouvé par les mêmes suites de contrat.

3.2 Deux portes, deux rôles

  1. Le proxy (apps/web/src/proxy.ts) : pas de cookie de session valide, pas de studio. Redirection vers /auth?from=<chemin> pour une page, 401 JSON pour une API. Publics (PUBLIC_PATHS) : l'accueil marketing /, les fichiers de marque /orbit/*, /auth, /api/auth/*, /api/health, la découverte et les portes OAuth (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource[/api/mcp], /api/oauth/register, /api/oauth/token, /api/oauth/revoke ; le consentement /oauth/authorize reste derrière la session, voir MCP & API), les deux portes développeur exactes /api/v1/projects et /api/mcp (authentifiées ensuite par clé orb_… ou jeton OAuth), les portes machine qui vérifient leur propre secret (rappel de rendu, webhook vidéo, crons, rappel du téléversement Blob), et exactement les fichiers publics que la page /auth anonyme dessine (PUBLIC_ASSETS : le logo, les deux icônes et le reste de public/, un ensemble de chemins exacts, jamais une règle d'extension). Un visiteur déjà connecté qui ouvre /auth est renvoyé vers from (auth/return-path.ts, safeNext : la règle de la plateforme, plus le refus de /auth… et de /api/…, donc pas de boucle). Il ne lit pas la base : un membre révoqué a encore un cookie authentique, et passe cette porte.
  2. requireMember() / requireMemberApi(permission) (apps/web/src/auth/session.ts) : sur chaque page, action serveur et route, le StudioMember est relu en base. Le JWT ne porte que sub (l'id User), authAt (l'instant de la connexion) et trusted (la case « Mémoriser cet appareil » de /auth) : ni rôle, ni statut, ni email. Refus si le membre est absent, REVOKED, expiré (expiresAt <= maintenant), PENDING, ou si la session précède son activation courante (authAt < activatedAt : un membre révoqué puis réinvité ne récupère pas son ancienne session), ou si la connexion a plus de 7 jours, ou de 24 heures quand la case était décochée (session-too-old, auth/session-age.ts). Un refus redirige vers GET /api/auth/sign-out?reason=…, qui efface le cookie puis renvoie à /auth?reason=… ; le motif s'affiche en toast (cockpit/session-ended-toast.tsx, monté après le Toaster par les providers : la page /auth portée reste identique). Une API répond 401 et efface le cookie. La route signout de NextAuth répond 404 : POST /api/auth/sign-out est la seule sortie. GET /api/auth/sign-out n'efface que une session déjà finie : une session vivante est renvoyée à l'accueil sans toucher au cookie, donc un lien posé sur un autre site ne déconnecte personne (constat SEC-4). La déconnexion volontaire est un POST /api/auth/sign-out de même origine (Origin de l'application, Sec-Fetch-Site à same-origin), refusé en 403 sinon (auth/end-session.ts). L'effacement reprend les attributs de pose (Secure pour __Secure-orbit.session, sinon le navigateur l'ignore en HTTPS : auth/session-cookie.ts), pour la déconnexion comme pour une session coupée. Le geste est journalisé (auth.session_cut) une fois par membre et par motif et par heure (auth/session-cut.ts, claimOnce sur Upstash ou en mémoire) : un cookie révoqué rejoué 25 fois écrivait 25 lignes (constat SEC-3). Pour une échéance dépassée, les réserves ouvertes sont libérées à ce moment (ou à la prochaine tentative de réserve du membre, ou par le balayage releaseStaleHolds, lancé par le cron generations-sweep toutes les dix minutes depuis la phase 3).

Les pages rendent elles-mêmes le shell avec le membre qu'elles viennent de lire : un layout n'est pas rejoué à la navigation client, donc une vérification ou un rail construits dans un layout ne suivraient pas un changement de rôle. src/test/every-door-checks.test.ts refuse une page sans requireMember(), une route hors liste publique sans requireMemberApi(, et une action serveur qui ne commence pas par relire la session.

3.3 Rôles et permissions

La matrice typée et fermée par défaut (apps/web/src/auth/permissions.ts) est décrite dans Permissions Studio.

3.4 Membres

/members (fondateur) : inviter (email, rôle, fin d'accès facultative), puis, sur la fiche d'un membre (/members?member=<id>), changer son rôle, poser ses plafonds, le révoquer avec un motif (confirmé par la boîte de dialogue destructive de la plateforme), lui allouer des crédits. Un membre lit sa propre fiche (le « Profil » du menu compte mène à /account, qui y redirige) ; sans members.manage, /members renvoie au cockpit. Chaque geste passe par apps/web/src/members/service.ts, qui revérifie la permission de l'acteur (une action serveur est un point d'entrée public : la page qui affiche le formulaire n'est pas une garde) et écrit sa ligne StudioAuditLog dans la même transaction. Une révocation libère toutes les réserves ouvertes du membre ; son allocation est gelée (plus de dépense, plus d'allocation). Une réinvitation d'un membre révoqué ou expiré le repasse PENDING.