Zum Inhalt springen

Authentifizierung

Authentifizierung besteht aus drei Seiten, da sie drei Aufgaben umfasst. Diese Seite behandelt die Konfiguration: was in den auth-Block und in die Umgebungsvariablen gehört.

  • Endpunkte und Tokens — die vom Backend bereitgestellten Routen, die Antwortformate, MFA, der Datenbankkontext für Richtlinien, JWKS und Service-Keys.
  • Benutzerdefinierte Auth-Adapter — Ersetzen des integrierten Providers durch Clerk, Firebase Auth oder eine eigene Lösung.

Rebase enthält ein vollständiges Backend-Authentifizierungssystem:

  • JWT-Tokens — Access- und Refresh-Token-Flow mit konfigurierbarer Ablaufzeit
  • OAuth-Provider — Google, LinkedIn, GitHub, Microsoft, Apple und mehr
  • SMTP-E-Mail — Passwort-Reset- und E-Mail-Verifizierungs-Flows
  • Auth-Hooks — Lifecycle-Hooks für die Benutzererstellung und mehr
  • Benutzerdefinierte Auth-Adapter — Binden Sie Firebase Auth, Auth0, Clerk oder beliebige externe Provider an
  • Service-Key — Statischer Schlüssel für Server-zu-Server-Authentifizierung
  • Auto-Bootstrapping — Außerhalb der Produktion erhält der erste Benutzer automatisch die Admin-Rolle; ein Produktions-Deployment benennt seinen Admin über REBASE_ADMIN_EMAIL / REBASE_ADMIN_PASSWORD

Der auth-Block in initializeRebaseBackend steuert die gesamte Backend-Authentifizierung:

const backend = await initializeRebaseBackend({
// ...
auth: {
collection: usersCollection, // Your users collection definition
jwtSecret: env.JWT_SECRET, // Required — signing secret
accessExpiresIn: "1h", // Access token lifetime (default: 1h)
refreshExpiresIn: "30d", // Refresh token lifetime (default: 30d)
serviceKey: env.REBASE_SERVICE_KEY, // Optional — for server-to-server calls
allowRegistration: true, // Allow new signups (default: false)
// OAuth providers
google: env.GOOGLE_CLIENT_ID
? { clientId: env.GOOGLE_CLIENT_ID }
: undefined,
// SMTP email (for password reset, email verification)
email: env.SMTP_HOST
? {
from: env.SMTP_FROM || `${env.APP_NAME} <noreply@example.com>`,
smtp: {
host: env.SMTP_HOST,
port: env.SMTP_PORT, // 587 for TLS, 465 for SSL
secure: env.SMTP_SECURE, // true for port 465
auth: env.SMTP_USER
? { user: env.SMTP_USER, pass: env.SMTP_PASS! }
: undefined,
name: env.SMTP_NAME, // Optional EHLO/HELO hostname
timeoutMs: 30_000, // Connect, greeting and silence limit (the default)
},
appName: env.APP_NAME,
logoUrl: env.EMAIL_LOGO_URL, // Logo shown atop the default templates
resetPasswordUrl: env.FRONTEND_URL, // URL for password reset page
}
: undefined,
// Lifecycle hooks
hooks: {
afterUserCreate: async (user) => {
console.log(`New user registered: ${user.email}`);
}
}
}
});
Schlüssel Typ Standard Beschreibung
collection CollectionConfig — Die Users-Collection. Siehe Auth-Konfiguration auf Collection-Ebene
jwtSecret string — HS256-Signiergeheimnis. In der Produktion erforderlich
signingKeys JwtSigningKeyConfig[] — Asymmetrische Signierschlüssel — siehe Asymmetrische Tokens und JWKS
activeKid string erster Schlüssel Welcher Schlüssel aus signingKeys neue Tokens signiert
accessExpiresIn string 1h Lebensdauer des Access-Tokens
refreshExpiresIn string 30d Lebensdauer des Refresh-Tokens. Gleitend: Jede Rotation verlängert ihn wieder, daher bestimmt dieser Wert, wie lange eine Sitzung Inaktivität übersteht. Die Runtime übergibt JWT_REFRESH_EXPIRES_IN, dessen Standardwert ebenfalls 30d ist. Browser begrenzen das Refresh-Cookie auf 400 Tage, ein längerer Wert bringt im Cookie-Modus also nichts
refreshTokenReuseIntervalSeconds number 10 Wie lange ein Refresh-Token, das bereits rotiert wurde, noch ein Geschwister seiner Sitzung ausstellt, damit ein Client, der eine Refresh-Antwort verloren hat, nicht abgemeldet wird
refreshTokenReuse "reject" | "revoke-session" "reject" Was mit einem Refresh-Token passiert, das nach diesem Zeitfenster vorgelegt wird. "reject" lehnt es ab (TOKEN_ALREADY_USED) und protokolliert es, und die Sitzung bleibt bestehen: Dies ist keine Wiederverwendungserkennung, denn wer zuerst aktualisiert hat, behält die Sitzung. "revoke-session" beendet bei einer solchen Wiederholung die gesamte Anmeldung (SESSION_REVOKED), wie es GoTrue tut, und der Besitzer meldet sich erneut an. AUTH_REFRESH_TOKEN_REUSE
requireAuth boolean true Erfordert eine Sitzung für die Daten-API
allowRegistration boolean false Gibt POST /api/auth/register frei. Außerhalb der Produktion wird der erste Benutzer bei einer leeren Tabelle in jedem Fall zugelassen; in der Produktion wird der Admin über REBASE_ADMIN_EMAIL festgelegt
disableSelfRegistration boolean false Kill-Switch: Schließt auch das Bootstrap-Fenster für den ersten Benutzer, das allowRegistration: false offen lässt
allowAnonymous boolean false Aktiviert POST /api/auth/anonymous. Bewusst nicht an allowRegistration gekoppelt — eine öffentlich lesbare App benötigt möglicherweise Sitzungen ohne Benutzerkonten. Einen Gast in ein Konto zu verwandeln (POST /api/auth/anonymous/link) ist eine Registrierung und braucht ebenfalls allowRegistration — und allowAnonymous muss weiterhin aktiv sein: Ist es abgeschaltet, weist es auch die bereits angemeldeten Gäste ab, sodass keiner zu einem Konto werden kann (403 ANONYMOUS_AUTH_DISABLED)
allowUserLookup boolean false Stellt POST /api/auth/find-user für Einladungs-Flows per E-Mail bereit
captcha CaptchaConfig — Bot-Schutz auf den Routen, die E-Mails versenden — siehe Bot-Schutz
defaultRole string — Rolle, die einem neu registrierten Benutzer zugewiesen wird, wenn keine angegeben ist. Sie darf weder admin noch eine deklarierte Rolle sein, die einen Scope der Admin-Ebene hält: Der Boot lehnt beides ab
serviceKey string — Statischer Schlüssel für Server-zu-Server-Aufrufe — siehe Service-Key-Authentifizierung
email EmailConfig — SMTP für Passwort-Reset, Verifizierung, Einladungen und Magic Links
magicLink boolean false Aktiviert passwortlose E-Mail-Anmeldung. Erfordert konfiguriertes email; andernfalls antworten die Routen mit 503 EMAIL_NOT_CONFIGURED
emailOtp boolean false Aktiviert sechsstellige Anmeldecodes per E-Mail — siehe Einmalcodes per E-Mail. Gleiche E-Mail-Voraussetzung
magicLinkCreatesUsers boolean false Passwortlose Registrierung: Eine Magic-Link- oder E-Mail-Code-Anfrage für eine Adresse ohne Konto erstellt eines (kein Passwort, unverifiziert, bis der Link oder Code verwendet wird), solange allowRegistration aktiv ist. Führt beforeUserCreate und die Standardrolle aus. Ist die Option aus, erstellen diese Anfragen nichts und beantworten eine unbekannte Adresse genauso wie eine bekannte. AUTH_MAGIC_LINK_CREATES_USERS
requireEmailVerification boolean false Verweigert die Passwort-Anmeldung, bis die Adresse verifiziert ist, und lässt die Registrierung erst nach der Bestätigung zu (“confirm-first”) — siehe E-Mail-Verifizierung. Braucht email; ohne das verweigert der Boot
cookieAuth CookieAuthConfig — Liefert das Refresh-Token als httpOnly Secure SameSite-Cookie anstelle im JSON-Body aus — siehe unten
providers OAuthProvider[] [] Das kanonische OAuth-Array; die benannten Provider-Felder werden darin zusammengeführt
allowedRedirectUris string[] — Schränkt ein, welche Redirect-URIs die OAuth-Routen akzeptieren
hooks AuthHooks — beforeUserCreate, afterUserCreate, afterUserDelete, …
auth: { cookieAuth: { sameSite: "Lax" } }

Das Refresh-Token ist die langlebige Anmeldeinformation, und im standardmäßigen JSON-Body-Modus kann jeder XSS-Angriff auf der Seite dieses auslesen. cookieAuth verschiebt es in ein Cookie, auf das das JavaScript der Seite keinen Zugriff hat. Das Access-Token verbleibt im JSON-Body, da der Client es in einem Authorization-Header mitsenden muss.

Zwei Dinge müssen beachtet werden, da die Anmeldung sonst fehlschlägt statt sanft abzustufen: Client-Fetches an die Auth-Endpunkte erfordern credentials: "include", und CORS muss Credentials zulassen — was eine explizite Liste von Origins erfordert, niemals origin: "*". AUTH_COOKIE_SAME_SITE ist die Umgebungsvariablen-Schreibweise für sameSite und AUTH_COOKIE_SECURE für secure.

Das Cookie trägt das Secure-Attribut, sofern Sie es nicht deaktivieren, und keine Eigenschaft des Requests kann dies ändern: Das Flag wurde früher aus dem Request-Protokoll gelesen, welches hinter jedem TLS-terminierenden Proxy http ist. Dadurch wurde das Refresh-Token in der gängigsten Produktionstopologie im Klartext übertragen. AUTH_COOKIE_SECURE=false ist der einzige Ausweg für Deployments, die tatsächlich über einfaches HTTP betrieben werden — etwa eine LAN-Adresse oder eine Appliance — und es gibt beim Booten eine Warnung aus. http://localhost benötigt dies nicht: Browser behandeln dies als vertrauenswürdige Origin und akzeptieren Secure-Cookies dort.

Schlüssel Standard
cookieName __rb_refresh
domain aktuelle Domain
path /
sameSite Lax None ist nur für ein echtes Cross-Site-Frontend gedacht
secure true Standardmäßig sicher; AUTH_COOKIE_SECURE=false für reines HTTP

Rate-Limiting beschränkt einen einzelnen Aufrufer. Tausend Adressen, die jeweils eine einzige Anfrage senden, erreichen niemals ein Limit pro IP-Fenster — und /auth/register, /auth/forgot-password und /auth/magic-link versenden alle E-Mails. Die Zeche für ein ungeschütztes Formular zahlt man daher mit der Reputation der sendenden Domain.

auth: {
captcha: {
enabled: true,
provider: "turnstile", // or "hcaptcha"
secret: process.env.CAPTCHA_SECRET
}
}

Oder über Umgebungsvariablen, wie es bei einem Managed Deployment der Fall ist:

CAPTCHA_PROVIDER=turnstile
CAPTCHA_SECRET=...
CAPTCHA_ROUTES=register,forgotPassword,magicLink,emailOtp # optional; this is the default

Der Client sendet das Token des Widgets als captchaToken im JSON-Body oder im widget-eigenen Header cf-turnstile-response / h-captcha-response. Beide Varianten werden akzeptiert; setzen Sie tokenField, um einen anderen Schlüssel im Body zu verwenden.

login ist standardmäßig nicht geschützt. Eine Challenge bei jedem Login belastet alle echten Benutzer, und gegen Credential Stuffing sind Rate-Limiter und Kontosperren gedacht. Fügen Sie es zu routes hinzu, wenn Sie es wünschen.

POST /auth/anonymous/link, wo ein Gast eine E-Mail-Adresse und ein Passwort erhält, zählt als Registrierung: Es verlangt die register-Challenge und führt beforeUserCreate aus, wie /auth/register. Die Gast-Anmeldung selbst (POST /auth/anonymous) verlangt keine Challenge.

Kann der Provider nicht erreicht werden, schlägt die Verifizierung fehl und die Anfrage wird abgewiesen. Ein Angreifer, der diesen Ausfall herbeiführen kann, könnte den Schutz sonst ausschalten — genau das darf eine Challenge niemals zulassen.

Der Nachteil ist, dass ein Provider-Ausfall Neuregistrierungen blockiert. Das ist laut, sichtbar und lässt sich durch Entfernen eines Konfigurationsschlüssels rückgängig machen — ein weitaus besseres Fehlerbild als ein stiller Ausfall, den man erst bemerkt, wenn die Mail-Domain auf einer Blockliste landet.

enabled: true ohne Provider, mit unbekanntem Provider oder ohne Secret verweigert den Start. Eine Challenge, die stillschweigend fehlt, während die Konfiguration ihre Anwesenheit meldet, ist der eine Fehler, den es hier nicht geben darf.

Dem Aufrufer wird lediglich mitgeteilt, dass die Challenge fehlgeschlagen ist — niemals, ob das Token fehlte, fehlerhaft war, bereits verwendet wurde oder nicht verifiziert werden konnte. Die genaue Ursache wird geloggt, denn eine detaillierte Rückmeldung an ein Skript würde diesem verraten, wie es sich anpassen muss.

Ohne SMTP_HOST können Authentifizierungs-Mails nirgendwohin gesendet werden. Anstatt die Anfrage abzulehnen, fängt ein Entwicklungsserver die Nachricht ab und gibt ihre Links aus:

⚠️ No SMTP is configured, so auth email is being captured here instead of sent.
ℹ️ [email] Sign in to Acme → you@example.com
http://localhost:5173/auth/magic-link?token=…

Folgen Sie dem Link, und der Flow wird abgeschlossen, auf welcher Seite auch immer diesen Pfad bedient: Das CMS liefert /reset-password, /verify-email, /confirm-email-change und, für einen Magic Link, /auth/magic-link aus; ein anderes Frontend braucht für jeden davon eine eigene Seite (siehe Magic Links im SDK). Am Token selbst ändert sich nichts — es wird exakt so generiert, gespeichert und validiert wie bei einem echten Posteingang; lediglich die Zustellung unterscheidet sich.

Dieses Verhalten ist aktiv, wenn alle drei folgenden Bedingungen erfüllt sind (es gibt keine Einstellung, die dies überschreibt):

  • SMTP_HOST ist nicht gesetzt — ein konfigurierter Mailserver hat immer Vorrang;
  • NODE_ENV ist nicht production. Eine abgefangene E-Mail zum Zurücksetzen des Passworts enthält ein gültiges Reset-Token; der Erfassungsspeicher ist somit ein Speicher für Zugangsdaten und darf in der Produktion keinesfalls existieren;
  • FRONTEND_URL ist eine absolute http(s)-URL, da der per E-Mail gesendete Link sonst keine Basis hat und ungültig wäre.

Trifft eine dieser Bedingungen nicht zu, antworten POST /auth/magic-link und POST /auth/forgot-password wie gewohnt mit 503 EMAIL_NOT_CONFIGURED. Setzen Sie in der Produktion SMTP_HOST (oder auth.email.sendEmail), um E-Mails tatsächlich zu versenden.

Das Terminal-Log ist nur für denjenigen nützlich, der es aktiv beobachtet. Ein Server in Docker, ein zweites Fenster oder eine vorbeigescrollte Zeile führen dazu, dass ein ausgegebener Link nicht mehr auffindbar ist — daher wird dieselbe Erfassung über HTTP bereitgestellt:

GET /api/admin/dev/emails → { enabled: true, messages: [ … ] }
DELETE /api/admin/dev/emails → empties the mailbox

Jede Nachricht enthält to, subject, at, die html- und text-Teile sowie links — die im Text gefundenen absoluten URLs in Dokumentreihenfolge, was genau der Teil ist, den man benötigt.

Dieser Endpunkt braucht den Scope users:write (über dieselbe Zugriffskontrolle wie Cron, Logs und Backups) und antwortet mit 501 DEV_MAILBOX_UNAVAILABLE, wenn nichts auszuliefern ist — bei konfiguriertem SMTP wurden die E-Mails zugestellt statt abgefangen. NODE_ENV=production lehnt den Zugriff unabhängig von allen anderen Parametern ab: Der Inhalt dieser Nachrichten ermöglicht einen funktionierenden Login.

Ein Magic Link öffnet die Sitzung auf dem Gerät, auf dem sich das E-Mail-Postfach befindet. Das ist auf einem Laptop das richtige Gerät, aber fast überall sonst das falsche — auf einem Fernseher, einem Terminal, einem Zweitbrowser oder einem Kiosk-Terminal. Ein Code überbrückt diese Lücke, da eine Person ihn übertragen kann.

auth: {
emailOtp: true, // or AUTH_EMAIL_OTP=true
email: { /* … */ }
}
await rebase.auth.sendEmailOtp("someone@example.com");
// …the person reads six digits out of their inbox…
const { user } = await rebase.auth.verifyEmailOtp("someone@example.com", "384102");

Die Adresse wird zusammen mit dem Code erneut übermittelt, und das ist kein bloßer Komfort. Gespeichert wird ein Hash aus der Adresse zusammen mit dem Code. Ein Rateversuch ist somit ein Rateversuch gegen ein bestimmtes Konto — und nicht gegen alle Konten in der Tabelle gleichzeitig, was bei einer reinen Code-Suche aus einer Million Möglichkeiten entstehen würde.

Weitere Maßnahmen, die dafür sorgen, dass sechs Ziffern ausreichend sicher sind:

  • Zehn Minuten Gültigkeit und einmalige Verwendung.
  • Fünf Verifizierungsversuche pro Adresse und Zeitfenster, gekoppelt an die Adresse und nicht an die IP des Aufrufers: Eine IP kann vom Angreifer gewechselt werden, das angegriffene Konto jedoch nicht. Die Zähler befinden sich dort, wo auch der Rate-Limit-Speicher des Deployments liegt — standardmäßig pro Replikat, geteilt bei REBASE_RATE_LIMIT_STORE=sql.
  • Gleichverteilte Ziffern, erzeugt über randomInt anstelle eines Modulo auf Zufallsbytes.
  • POST /auth/otp antwortet bei einer Adresse ohne Konto absolut identisch, sodass es nicht dazu missbraucht werden kann, herauszufinden, ob jemand registriert ist.

Das Auslesen eines Codes aus dem Postfach bestätigt den Besitz der Adresse. Ein erfolgreicher Login markiert sie daher als verifiziert — genau wie das Anklicken eines Magic Links. Bei einem noch nicht verifizierten Konto entfernt dieser erste Nachweis außerdem, was niemand nachgewiesen hat: das Passwort und jede verknüpfte Identität, deren Provider die Adresse nicht verifiziert hat. Siehe Account-Verknüpfung.

Die integrierten Vorlagen für Passwort-Reset, Verifizierung, Einladung, Begrüßung, Magic Links und E-Mail-Änderung stellen ein Logo über der Infokarte dar. Dieses stammt aus email.logoUrl:

email: {
// …
appName: "Acme",
logoUrl: "https://acme.example/logo.png" // 48×48, absolute https URL
}

Es muss sich um ein PNG oder JPG unter einer absoluten http(s)-URL handeln. E-Mail-Clients stellen kein SVG dar und blockieren data:-URIs. Zudem wird das Bild vom Client des Empfängers abgerufen und nicht von Ihrem Server — ein relativer Pfad, eine Data-URI oder eine lokale Datei führt daher dazu, dass gar kein Logo statt eines defekten Bildes angezeigt wird. appName wird als alt-Text verwendet, sodass ein Client mit deaktivierten Bildern dennoch den Namen anzeigt.

Das Fallback-Verhalten ist bewusst asymmetrisch. appName fällt auf Rebase zurück, aber das Logo fällt nur dann auf das Rebase-Zeichen zurück, wenn die Installation nicht umbenannt wurde. Setzen Sie appName auf einen anderen Wert, erhalten Sie kein Logo, bis Sie logoUrl definieren — andernfalls würden die Benutzer von Acme ein Rebase-Logo in E-Mails erhalten, die von der Acme-Domain signiert wurden.

Wenn Sie eine Vorlage über email.templates ersetzen, gilt dies alles nicht: Ihre Funktion steuert den gesamten Body. templates.emailChange(confirmUrl, user, newEmail) ist der Link, der an die neue Adresse gesendet wird, wenn ein Benutzer seine ändert, und templates.emailChangeNotice(user, newEmail) der Hinweis, der an die alte gesendet wird; user.email ist in beiden die aktuelle Adresse.

Jeder OAuth-Provider wird mindestens mit einer clientId konfiguriert. Einige Provider erfordern zusätzlich ein clientSecret:

auth: {
google: { clientId: "..." },
linkedin: { clientId: "...", clientSecret: "..." },
github: { clientId: "...", clientSecret: "..." },
microsoft: { clientId: "...", clientSecret: "...", tenantId: "..." },
apple: { clientId: "...", bundleIds: ["..."], teamId: "...", keyId: "...", privateKey: "..." },
facebook: { clientId: "...", clientSecret: "..." },
twitter: { clientId: "...", clientSecret: "..." },
discord: { clientId: "...", clientSecret: "..." },
gitlab: { clientId: "...", clientSecret: "..." },
bitbucket: { clientId: "...", clientSecret: "..." },
slack: { clientId: "...", clientSecret: "..." },
spotify: { clientId: "...", clientSecret: "..." },
}

gitlab akzeptiert außerdem eine optionale baseUrl für selbst gehostete GitLab-Instanzen.

Ein Managed- oder Bundle-Deployment hat keinen auth-Block im Code — der Server wird vollständig über Umgebungsvariablen konfiguriert. Daher besitzt jeder oben genannte Provider ein <PROVIDER>_CLIENT_ID / <PROVIDER>_CLIENT_SECRET-Paar, und beide Teile müssen gesetzt sein, damit der Provider überhaupt konfiguriert wird:

DISCORD_CLIENT_ID=…
DISCORD_CLIENT_SECRET=…

GET /api/auth/config listet anschließend discord unter enabledProviders auf. So lässt sich überprüfen, ob das Schlüsselpaar übernommen wurde.

Google wird allein durch GOOGLE_CLIENT_ID konfiguriert: Sein ID-Token-Flow benötigt kein Secret, daher ist GOOGLE_CLIENT_SECRET optional.

Apple hat kein Client Secret: Rebase signiert pro Austausch ein ES256-JWT. Benötigt werden APPLE_TEAM_ID, APPLE_KEY_ID und APPLE_PRIVATE_KEY (der Inhalt der .p8-Datei inklusive Zeilenumbrüchen), dazu APPLE_CLIENT_ID (die Services-ID, für das Web), APPLE_BUNDLE_IDS (kommagetrennte IDs Ihrer nativen iOS- und macOS-Apps) oder beides. Die Services-ID ist öffentlich, daher veröffentlicht /auth/config sie, und der Admin-Login zeigt Sign in with Apple ohne Frontend-Variable.

Zwei Optionen haben keine Entsprechung in Umgebungsvariablen und erfordern den auth-Block (also ein Ejected- oder code-konfiguriertes Backend): microsoft.tenantId (welches sonst standardmäßig auf common gesetzt wird und jeden Entra-Tenant akzeptiert; bei einem beliebigen Tenant gilt eine Adresse nur dann als verifiziert, wenn die App-Registrierung den Claim xms_edov ausgibt) und gitlab.baseUrl für selbst gehostete Instanzen.

Jedes benannte Feld wird beim Start in auth.providers aufgelöst. Dies ist das kanonische Array und der Erweiterungspunkt für alles, was die benannten Felder nicht abdecken. Einträge werden mit den create*Provider-Factories erstellt, und beide Formen werden zusammengeführt — benannte Felder werden an explizite Einträge angehängt:

import { createGoogleProvider, createGitHubProvider } from "@rebasepro/server";
auth: {
providers: [
createGoogleProvider({ clientId: "…", clientSecret: "…" }),
createGitHubProvider({ clientId: "…", clientSecret: "…" })
]
}
auth: { allowedRedirectUris: ["https://admin.example.com/"] }

Bleibt diese Option ungesetzt, erfolgt die einzige Prüfung eines OAuth-Redirects über den Abgleich der im Provider hinterlegten URIs. Dies autorisiert jede URI, die auf diesem OAuth-Client registriert ist — einschließlich des localhost-Eintrags, den jemand für die Entwicklung hinzugefügt hat, und des Staging-Hosts, den niemand entfernt hat. Indem Sie die Origins auflisten, die dieses Backend tatsächlich bedient, schränken Sie die Weiterleitung darauf ein. URIs werden anhand von Origin plus Pfad verglichen; Query-Parameter, Fragmente und abschließende Slashes werden ignoriert.

Account-Verknüpfung über verschiedene Anmeldemethoden hinweg

Abschnitt betitelt „Account-Verknüpfung über verschiedene Anmeldemethoden hinweg“

Was passiert, wenn sich jemand mit E-Mail/Passwort als ada@example.com registriert und später auf „Mit Google anmelden“ mit einem Google-Konto derselben Adresse klickt? Rebase verknüpft beide zu einem einzigen Konto, jedoch nur, wenn der Provider die E-Mail als verifiziert bestätigt. Es wird niemals stillschweigend ein zweites Konto für dieselbe Adresse erstellt.

Bei POST /api/auth/<provider> gilt folgende Auflösungsreihenfolge:

  1. Bekannte Provider-Identität: Hat sich genau diese Provider-Identität schon einmal angemeldet, wird dieser Benutzer zurückgegeben. Die E-Mail-Adresse wird nicht herangezogen.
  2. Bestehendes Konto mit gleicher E-Mail, Provider hat die E-Mail verifiziert: Die Identität wird verknüpft und der Benutzer damit angemeldet. Ein Konto, zwei Zugangswege. Ein nicht verifiziertes Konto wird zuerst verifiziert (siehe unten).
  3. Bestehendes Konto mit gleicher E-Mail, Provider hat sie nicht verifiziert: Wird mit 403 EMAIL_NOT_VERIFIED und details.reason provider-email-unverified abgewiesen. Es wird nichts erstellt oder geändert.
  4. Kein Konto mit dieser E-Mail: Ein neues Konto wird erstellt, als verifiziert, wenn der Provider die E-Mail verifiziert hat.

Schritt 3 ist der sicherheitskritische Fall. Wäre eine unbestätigte Provider-E-Mail für eine Verknüpfung ausreichend, könnte jeder, der einen Provider dazu bringt, eine fremde Adresse auszugeben, das entsprechende Rebase-Konto übernehmen. Google bestätigt email_verified bei echten Google-Konten immer, weshalb Schritt 2 der Normalfall für den Google-Login ist. Schritt 3 fängt vor allem Provider ab, bei denen Benutzer eine beliebige unbestätigte Adresse angeben können.

Die Seite des Kontos zählt ebenfalls. Nichts verifiziert die Adresse, die POST /auth/register erhält. Jeder kann also die Adresse einer anderen Person mit einem Passwort registrieren oder sich über einen Provider, der nicht für sie bürgt, damit anmelden, und abwarten. Die Verknüpfung der Google-Anmeldung des Eigentümers mit diesem Konto darf den Zugang der anderen Person darauf nicht bestehen lassen.

Ein Provider, der die Adresse verifiziert hat, ein Verifizierungslink, ein Magic Link, ein E-Mail-Code oder ein Passwort-Reset weisen alle die Adresse nach und verifizieren das Konto. Bei einem noch nicht verifizierten Konto entfernt der erste solche Nachweis das Passwort (ein Reset setzt das neue) und jede verknüpfte Identität, deren Provider diese Adresse nicht verifiziert hat, und beendet jede Sitzung, bevor er das Konto als verifiziert markiert: Ein Passwort, das niemand verifiziert hat, ist nach der ersten Google-Anmeldung weg.

Ein Verifizierungslink behält, was die Person, die ihm folgt, zusätzlich beweist: eine aktive Sitzung des Kontos oder sein Passwort. Siehe E-Mail-Verifizierung. Konten, die ein Admin mit POST /api/admin/users anlegt, werden als verifiziert gespeichert, sodass eingeladene Personen „Mit Google anmelden“ sofort nutzen können. Ein eigenes Auth-Repository ohne unlinkUserIdentity lehnt einen solchen Nachweis mit 409 UNVERIFIED_IDENTITIES ab, wenn eine Identität zu entfernen ist.

Dieses Verhalten ist nicht konfigurierbar — es gibt bewusst keine Option, Verknüpfungen anhand unbestätigter E-Mails zuzulassen.

Die Registrierung sendet dem neuen Konto einen Verifizierungslink, und das Folgen behält nur, was die Person, die ihm folgt, zusätzlich beweist. requireEmailVerification lässt die Registrierung erst nach der Bestätigung zu (“confirm-first”). Siehe E-Mail-Verifizierung.

Beim ersten Start richtet Rebase automatisch das rebase-Schema und die folgenden Tabellen in der Datenbank ein (gebunden an das in Ihrer Collection definierte Schema, z. B. rebase):

  • rebase.users — Benutzerkonten mit E-Mail, Passwort-Hash, Metadaten und einer roles-Spalte vom Typ text[] (Rollen werden als Inline-Text-Arrays gespeichert, um Abfragen zu optimieren und Joins zu vermeiden).
  • rebase.user_identities — Die mit jedem Konto verknüpften OAuth-Identitäten, eine Zeile pro Provider-Identität.
  • rebase.refresh_tokens — Langlebige Sitzungen mit gehashten Refresh-Tokens, User-Agents und IP-Adressen. Enthält einen Unique Index auf token_hash. Eine Anmeldung ist eine session_id, die von jedem Token geteilt wird, das aus ihr herausrotiert wurde; es gibt keinen Constraint pro Gerät, sodass zwei Browser hinter derselben Adresse zwei Sitzungen sind. Jedes Token einer Sitzung trägt außerdem seine Vertrauensstufe (aal) und wie es angemeldet wurde (method, was providerId meldet).
  • rebase.password_reset_tokens — Ablaufbare Einmal-Tokens für Abläufe zur Passwortwiederherstellung.
  • rebase.magic_link_tokens — Ablaufbare Einmal-Tokens für die Anmeldung per Magic Link.
  • rebase.mfa_factors — Eingerichtete Multi-Faktor-Authentifizierungsmethoden (z. B. mit AES-256 verschlüsselte TOTP-Secrets).
  • rebase.mfa_challenges — Verifizierungsprotokolle zur Nachverfolgung aktiver MFA-Verifizierungsversuche.
  • rebase.recovery_codes — Gehashte Multi-Faktor-Backup-/Recovery-Codes.
  • rebase.app_config — Key-Value-Speicher für Systemkonfigurationen.

Tokens, die ablaufen — Reset-Links, Magic Links und E-Mail-Codes, Refresh-Tokens, MFA-Challenges — werden eine Stunde nach ihrem Ablauf gelöscht. Der Durchlauf läuft auf den Prozessen, denen die Timer gehören (jede Rolle außer functions bei einem aufgeteilten Deployment, es sei denn, REBASE_CRON_SCHEDULER=false nimmt sie dem api ab) und auf einer Instanz der Flotte: Jede Stunde wird in rebase.cron_claims unter der Job-ID rebase:auth:expired-tokens beansprucht, genau wie ein Cron-Slot. Bei cronPersistence: false gibt es keine Claims-Tabelle, und jede solche Instanz räumt auf. Ein abgelaufenes Token wird bei Vorlage abgelehnt, unabhängig davon, ob der Durchlauf bereits gelaufen ist; der Durchlauf verhindert nur, dass die Tabellen weiterwachsen.

Wenn noch keine Benutzer in der Datenbank existieren und der Server nicht mit NODE_ENV=production läuft, wird die erste registrierte Person automatisch zum Admin. Danach wird die Registrierung über die Einstellung allowRegistration gesteuert.

In der Produktion ist dieses Zeitfenster geschlossen, da ein Host mit öffentlichem Namen erreichbar ist, bevor der Betreiber sich registriert hat — wer zuerst käme, hätte die Kontrolle. Ein Produktions-Deployment benennt seinen ersten Admin stattdessen über Umgebungsvariablen — REBASE_ADMIN_EMAIL und REBASE_ADMIN_PASSWORD, die beim Booten erstellt werden, solange die Tabelle noch leer ist — oder weist die Rolle über den Service-Key zu. Ist das Fenster geschlossen, weist eine leere Tabelle die Bootstrap-Registrierung mit SETUP_REQUIRED ab (und gibt dies an), ein über die offene Registrierung erstelltes erstes Konto ist ein normales Konto, GET /api/auth/config meldet niemals needsSetup, POST /api/admin/bootstrap schlägt fehl, und das Boot-Log warnt, wenn die Tabelle leer ist und kein Admin benannt wurde.

Auf einem Entwicklungsrechner bedeutet dies, dass Sie eine frische Datenbank jederzeit bootstrappen können, ohne sie manuell mit Seed-Daten zu befüllen. Um gleichzeitige Ausführungen und Race Conditions bei der Schema-Generierung beim Hot Reloading (HMR) oder beim Start zu verhindern, werden Bootstrapping-Vorgänge über einen Postgres Advisory Lock synchronisiert:

SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'));

Anstatt sich ausschließlich auf die standardmäßigen Datenbank-Auth-Regeln zu verlassen, können Sie jede Postgres-Collection (wie users.ts oder eine benutzerdefinierte members.ts-Collection) als Authentifizierungs-Collection deklarieren. Dies wird über die Eigenschaft auth direkt an der Collection konfiguriert:

import { randomBytes } from "node:crypto";
import { defineCollection } from "@rebasepro/cms-types";
const membersCollection = defineCollection({
name: "Members",
slug: "members",
table: "members",
auth: {
enabled: true,
// Customize what happens when an admin creates a user via the REST API
onCreateUser: async (values, ctx) => {
const hash = await ctx.hashPassword("welcome123");
return {
values: { ...values, passwordHash: hash, emailVerified: true },
temporaryPassword: "welcome123"
};
},
// Customize what happens when an admin resets a user's password in the admin panel
onResetPassword: async (userId, ctx) => {
const tempPassword = randomBytes(12).toString("base64url");
return {
temporaryPassword: tempPassword, // saved as the new password, then shown to the admin
invitationSent: false
};
},
// Inject/override auth-specific actions (e.g. show/hide the reset password button)
actions: {
resetPassword: true // Or false to disable, or a custom EntityAction
},
// What each role may do beyond its rows, the app's own scopes,
// and whether accounts may create personal API keys
roles: {
support: { name: "Support", scopes: ["users:read", "users:write", "logs:read"] }
},
scopes: {
"project:deploy": { label: "Deploy projects", target: "project" }
},
personalKeys: true
},
properties: { ... }
});

roles, scopes und personalKeys deklarieren das Zugriffsmodell: welche Scopes der Admin-Ebene jede Rolle hält, die Scopes, die die App für ihre eigenen Operationen definiert, und ob jedes Konto persönliche API-Schlüssel erstellen darf. Siehe Rollen und Scopes.

Ein von onResetPassword zurückgegebenes temporaryPassword wird zum Passwort des Kontos. Rebase hasht es mit dem konfigurierten Algorithmus, speichert es, meldet den Benutzer von allen bestehenden Sitzungen ab und zeigt es dem Admin zur Weitergabe an. Der Hook speichert es nicht selbst und hat auch keine Möglichkeit dazu. Geben Sie kein temporaryPassword zurück, wenn der Hook stattdessen einen eigenen Link zum Zurücksetzen per E-Mail versendet: Das Passwort bleibt dann unverändert, bis der Benutzer ein neues festlegt – seine Sitzungen enden trotzdem.

Wenn benutzerdefinierte Hooks (onCreateUser, onResetPassword) aufgerufen werden, erhalten sie eine AuthCollectionContext-Fassade, die Folgendes enthält:

  • hashPassword(password: string): Promise<string> — Hasht das Passwort mit dem konfigurierten Hashing-Algorithmus (z. B. scrypt).
  • sendEmail?: (options) => Promise<EmailSendResult> — Versendet eine E-Mail (nur verfügbar, wenn der E-Mail-Dienst konfiguriert ist). Gibt zurück, was der Provider gemeldet hat — messageId, accepted, rejected — sodass ein Hook die ID speichern und später Antworten zuordnen kann.
  • emailConfigured: boolean — Gibt an, ob der E-Mail-Dienst konfiguriert ist.
  • appName: string — Der App-Name aus der E-Mail-Konfiguration.
  • resetPasswordUrl: string — Die Basis-URL für den Link zum Zurücksetzen des Passworts.