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 :
Credentialsd'idotp(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 unexpneuf à chaqueGET /api/auth/session, donc le studio épingle cetexpàauthAt + 7 jours(jwt.encode), refuse un jeton plus vieux dans le callbackjwt(NextAuth efface alors le cookie) et dansevaluateAccess(raisonsession-too-old). Règle dansauth/session-age.ts, preuve dansauth/options.test.ts, qui rafraîchit un cookie chaque jour et le voit refusé au septième (constat F2 / SEC-2 de l'audit). Cookieorbit.session(__Secure-orbit.sessionen HTTPS), signées par unNEXTAUTH_SECRETpropre 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 parcreateVerificationToken/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 defr.auth.errors.*que la page affiche telle quelle. Un éventuelturnstileTokenest 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 dex-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, auditworkspace.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_refusedau 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, parafter(), 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, suridentifiant:code(copie duotp.tsplateforme), 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.tsla vérifie sur neuf rondes. Compteurs enINCRatomique. 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
Userest trouvé ou créé par l'email canonique, leStudioMemberen attente est lié (userId), passeACTIVE, reçoitactivatedAt, et le journal reçoitmember.activatedpuisauth.signed_in. - Transport des emails (
lib/mail/transport.ts, portMailerdestudio.mailer, ADR 0016) : sur un déploiement Vercel (VERCEL_ENVproductionoupreview), Resend avec laRESEND_API_KEYque l'intégration Resend du Vercel Marketplace injecte, depuisconfig.fromdu registre ; partout ailleurs,next startsur 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
KeyValueetRateLimiterdestudio.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
- Le proxy (
apps/web/src/proxy.ts) : pas de cookie de session valide, pas de studio. Redirection vers/auth?from=<chemin>pour une page,401JSON 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/authorizereste derrière la session, voir MCP & API), les deux portes développeur exactes/api/v1/projectset/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/authanonyme dessine (PUBLIC_ASSETS: le logo, les deux icônes et le reste depublic/, un ensemble de chemins exacts, jamais une règle d'extension). Un visiteur déjà connecté qui ouvre/authest renvoyé versfrom(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. requireMember()/requireMemberApi(permission)(apps/web/src/auth/session.ts) : sur chaque page, action serveur et route, leStudioMemberest relu en base. Le JWT ne porte quesub(l'idUser),authAt(l'instant de la connexion) ettrusted(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 versGET /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 leToasterpar les providers : la page/authportée reste identique). Une API répond401et efface le cookie. La routesignoutde NextAuth répond404:POST /api/auth/sign-outest la seule sortie.GET /api/auth/sign-outn'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 unPOST /api/auth/sign-outde même origine (Originde l'application,Sec-Fetch-Siteàsame-origin), refusé en403sinon (auth/end-session.ts). L'effacement reprend les attributs de pose (Securepour__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,claimOncesur 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 balayagereleaseStaleHolds, lancé par le crongenerations-sweeptoutes 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.