Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· Webentwicklung  · 5 minuten Lesezeit

React-Frontend mit react-oidc-context und Zitadel: sechs Auth-Zustände sauber verwalten

react-oidc-context übernimmt den PKCE-Flow im Browser. Die eigentliche Herausforderung ist nicht der Login-Button, sondern die korrekte Behandlung aller sechs Auth-Zustände in App.tsx und eine JWT-Middleware im Backend, die Zitadels JWKS-Endpoint nutzt.

react-oidc-context übernimmt den PKCE-Flow im Browser. Die eigentliche Herausforderung ist nicht der Login-Button, sondern die korrekte Behandlung aller sechs Auth-Zustände in App.tsx und eine JWT-Middleware im Backend, die Zitadels JWKS-Endpoint nutzt.

Inhalt

react-oidc-context als OIDC-Client

Die Bibliothek react-oidc-context wrappt oidc-client-ts und stellt einen React Context bereit. Der Setup ist minimal:

// src/lib/auth.ts
export const AUTH_ENABLED = import.meta.env.VITE_AUTH_ENABLED === 'true';

const CLIENT_ID = import.meta.env.VITE_ZITADEL_CLIENT_ID;
const ZITADEL_DOMAIN = import.meta.env.VITE_ZITADEL_DOMAIN;

export const oidcConfig: AuthProviderProps = {
  authority: ZITADEL_DOMAIN,
  client_id: AUTH_ENABLED ? CLIENT_ID : 'auth-disabled',
  redirect_uri: `${window.location.origin}/callback`,
  scope: 'openid profile email',
  response_type: 'code',
};

Der 'auth-disabled'-Placeholder ist wichtig: Wenn AUTH_ENABLED=false, erwartet oidc-client-ts trotzdem eine nicht-leere client_id. Ohne Placeholder würde die Bibliothek beim Initialisieren einen Fehler werfen, auch wenn Auth komplett deaktiviert ist.

In main.tsx wird der Provider gesetzt:

import { AuthProvider } from 'react-oidc-context';
import { oidcConfig } from './lib/auth';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <AuthProvider {...oidcConfig}>
    <App />
  </AuthProvider>
);

AuthProvider lädt automatisch die OIDC-Konfiguration vom /.well-known/openid-configuration-Endpoint von Zitadel und verwaltet den Token-Lebenszyklus.

Die sechs Auth-Zustände in App.tsx

Der häufige Fehler beim Einbinden von react-oidc-context: Es wird nur zwischen “eingeloggt” und “nicht eingeloggt” unterschieden. In der Praxis gibt es sechs relevante Zustände:

const App = () => {
  const auth = useAuth();

  // 1. Auth ist deaktiviert: direkt in die App
  if (!AUTH_ENABLED) {
    return <Dashboard />;
  }

  // 2. Auth lädt: OIDC-Konfiguration wird vom Server geholt
  if (auth.isLoading) {
    return <LoadingSpinner />;
  }

  // 3. Auth-Fehler: z.B. Zitadel nicht erreichbar
  if (auth.error) {
    return <ErrorPage message={auth.error.message} />;
  }

  // 4. Callback-Verarbeitung: Code gegen Token tauschen
  if (hasAuthParams()) {
    return <LoadingSpinner message="Anmeldung wird abgeschlossen..." />;
  }

  // 5. Nicht authentifiziert: Login-Seite zeigen
  if (!auth.isAuthenticated) {
    return <LoginPage />;
  }

  // 6. Authentifiziert: App anzeigen
  return <Dashboard user={auth.user} />;
};

hasAuthParams() prüft, ob die aktuelle URL einen code-Parameter enthält, also ob der Redirect von Zitadel gerade verarbeitet wird. Ohne diesen Check flackert die App kurz die Login-Seite an, bevor die Callback-Verarbeitung abgeschlossen ist.

const hasAuthParams = (): boolean => {
  const searchParams = new URLSearchParams(window.location.search);
  return searchParams.has('code') || searchParams.has('error');
};

Der Login-Button

Mit react-oidc-context ist der Login-Button zwei Zeilen:

const LoginPage = () => {
  const auth = useAuth();
  return <button onClick={() => auth.signinRedirect()}>Mit Zitadel anmelden</button>;
};

signinRedirect() baut die PKCE Authorization URL (inklusive code_verifier und code_challenge), speichert den State in sessionStorage und leitet den Browser zur Zitadel Login V2 UI weiter. Nach dem Login kommt der Nutzer zurück zur redirect_uri.

Der Callback ist kein separater React-Router-Route. AuthProvider verarbeitet den Callback automatisch, wenn hasAuthParams() zutrifft.

JWT-Middleware im Backend

Das React-Frontend sendet bei jedem API-Call das Access Token als Bearer-Token:

// api-client.ts
const getAuthHeaders = async (): Promise<Record<string, string>> => {
  const auth = getAuthContext(); // aus dem React Context
  if (!auth.isAuthenticated || !auth.user?.access_token) return {};
  return { Authorization: `Bearer ${auth.user.access_token}` };
};

Das Backend validiert dieses Token gegen Zitadels JWKS-Endpoint:

// backend/src/middleware/auth.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS_URL = `${process.env.ZITADEL_DOMAIN}/oauth/v2/keys`;
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));

export const authMiddleware = async (req, res, next) => {
  if (process.env.AUTH_ENABLED === 'false') {
    req.auth = { userId: 'dev-user-local', email: 'dev@localhost' };
    return next();
  }

  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  const token = authHeader.slice(7);

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: process.env.ZITADEL_DOMAIN,
    });
    req.auth = {
      userId: payload.sub,
      email: payload.email as string,
    };
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
};

createRemoteJWKSet cached den öffentlichen Schlüssel von Zitadels JWKS-Endpoint. Das JWKS wird nicht bei jedem Request neu geladen. Bei einem Schlüssel-Rotation-Event holt jose automatisch den neuen Schlüssel.

Die userId aus dem JWT-sub-Claim wird dann in allen Datenbankoperationen verwendet. Sie stammt nicht aus dem Request-Body, sondern ausschließlich aus dem validierten Token. Das verhindert, dass ein Nutzer Daten eines anderen Nutzers manipulieren kann.

AUTH_ENABLED=false für lokale Entwicklung

Wenn AUTH_ENABLED=false (Backend), injiziert die Middleware eine synthetische Identität:

req.auth = { userId: 'dev-user-local', email: 'dev@localhost' };

Das bedeutet: Alle Daten, die lokal ohne Auth erstellt wurden, gehören zu dev-user-local. Sobald Auth aktiviert wird, hat der echte Nutzer eine andere userId. Bestehende lokale Testdaten sind dann für den eingeloggten Nutzer unsichtbar. Das ist gewollt.

Verifizierung: auth-disabled aus dem Bundle entfernen

Nach der Einrichtung gibt es eine einfache Methode, um zu prüfen, ob Auth wirklich aktiviert ist:

# Im Docker-Build oder nach npm run build:
grep -r "auth-disabled" dist/

Wenn AUTH_ENABLED=true korrekt gesetzt war, kommt kein Treffer. Der 'auth-disabled'-Placeholder landet nicht im Bundle, weil Vite den Branch zur Compile-Zeit entfernt, wenn VITE_AUTH_ENABLED === 'true' immer false ergibt.

Wenn doch ein Treffer kommt: frontend/.env wurde nicht richtig in den Docker-Build-Context einbezogen. Dazu mehr im Artikel über Vite-Umgebungsvariablen in Docker.

Frontend: Login-Seite und Dashboard nach erfolgreichem Login Abbildung: Links die Login-Seite mit Zitadel-Button, rechts das Dashboard nach erfolgreichem Login mit dem echten Zitadel-Benutzernamen in der Navigation.


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel (dieser Artikel)
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen

Du baust ein OIDC-gesichertes React-Frontend mit eigenem Identity Provider? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen