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

· Webentwicklung  · 6 minuten Lesezeit

PKCE OAuth in einer Chrome MV3 Extension mit chrome.identity.launchWebAuthFlow

chrome.identity.launchWebAuthFlow ist der richtige Einstiegspunkt für PKCE OAuth in Chrome Extensions. Wie Token-Speicherung in chrome.storage.sync, automatische Erneuerung und die Extension-spezifische Redirect-URI zusammenspielen.

chrome.identity.launchWebAuthFlow ist der richtige Einstiegspunkt für PKCE OAuth in Chrome Extensions. Wie Token-Speicherung in chrome.storage.sync, automatische Erneuerung und die Extension-spezifische Redirect-URI zusammenspielen.

Inhalt

Warum eine Chrome Extension eigene Auth-Logik braucht

Eine normale Web-App nutzt Redirects: Der Browser verlässt die Seite, geht zur Login-UI, kommt mit einem Code zurück, die App tauscht den Code gegen ein Token.

In einer Chrome Extension gibt es keinen “Verlassen und Zurückkehren”-Mechanismus. Der Background Service Worker hat kein DOM. Das Content Script hat keinen kontrollierten Lebenszyklus, der einen Auth-Flow sauber abwarten kann. Das Popup schließt sich bei jeder Interaktion. Die Drei-Kontext-Architektur einer Chrome Extension MV3 macht OAuth zu einem eigenen Problem.

Die Lösung ist chrome.identity.launchWebAuthFlow. Die Chrome-API öffnet einen kontrollierten Browser-Tab für den OAuth-Flow, fängt den Redirect ab und gibt die Redirect-URI mit allen Query-Parametern zurück, ohne dass die Extension selbst eine öffentliche URL bereitstellen muss.

Die PKCE-Implementierung

PKCE (Proof Key for Code Exchange) ist der richtige Flow für native Apps und Extensions, weil kein Client Secret gespeichert werden muss. Der Flow läuft so:

  1. Extension generiert einen code_verifier (zufällige Zeichenkette)
  2. Extension berechnet den code_challenge (SHA-256-Hash des Verifiers, base64url-kodiert)
  3. Extension öffnet die Authorization URL mit code_challenge und code_challenge_method=S256
  4. Nutzer loggt sich ein, Zitadel redirectet zur redirect_uri mit einem code
  5. Extension tauscht code + code_verifier gegen Access Token und Refresh Token
// code_verifier: cryptographically random string
const generateCodeVerifier = (): string => {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
};

// code_challenge: SHA-256 of the verifier
const generateCodeChallenge = async (verifier: string): Promise<string> => {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(hash));
};

Die Redirect-URI

Zitadel muss wissen, wohin es nach dem Login redirecten soll. Für Chrome Extensions ist das keine http://localhost-URL, sondern eine Extension-URL:

https://{extension-id}.chromiumapp.org/callback

chrome.identity.getRedirectURL() gibt diese URL zurück. Sie ist stabil für eine Extension-ID, ändert sich aber mit jeder neuen Extension (jede unpublished extension hat eine andere zufällige ID).

const REDIRECT_URI = chrome.identity.getRedirectURL('callback');
// Example: "https://abcdefghijklmnop.chromiumapp.org/callback"

Diese URL muss in der Zitadel-Konsole als erlaubte Redirect-URI für die Native/PKCE-App eingetragen werden. Ohne das lehnt Zitadel den Authorization Request ab.

Der Login-Flow

export const signIn = async (): Promise<void> => {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  const authUrl = new URL(`${ZITADEL_DOMAIN}/oauth/v2/authorize`);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.set('scope', 'openid profile email offline_access');
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  const responseUrl = await chrome.identity.launchWebAuthFlow({
    url: authUrl.toString(),
    interactive: true,
  });

  if (!responseUrl) throw new Error('Auth flow cancelled');

  const url = new URL(responseUrl);
  const code = url.searchParams.get('code');
  if (!code) throw new Error('No authorization code in response');

  const tokens = await exchangeCodeForTokens(code, codeVerifier);
  await storeTokens(tokens);
};

launchWebAuthFlow mit interactive: true öffnet ein Popup-Fenster für den Login. Das Fenster schließt sich automatisch, sobald Zitadel zur Redirect-URI weiterleitet. Der Rückgabewert ist die vollständige Redirect-URL mit dem Code.

Interaktiver PKCE-Flow und Container-Topologie

Die folgende Sandbox zeigt den Flow Schritt für Schritt im Browser und blendet die kompakten Container-Details erst auf Desktop ein.

Token-Speicherung in chrome.storage.sync

Tokens werden in chrome.storage.sync gespeichert, nicht in localStorage. Der Unterschied: chrome.storage.sync synchronisiert den Stand über alle Chrome-Instanzen desselben Google-Accounts. Nutzer sind auf allen Geräten gleichzeitig eingeloggt.

const storeTokens = async (tokens: TokenResponse): Promise<void> => {
  await chrome.storage.sync.set({
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + tokens.expires_in * 1000,
    idToken: tokens.id_token,
  });
};

Die expiresAt-Zeit ist wichtig für die automatische Token-Erneuerung.

Automatische Token-Erneuerung

Access Tokens sind kurzlebig (typisch 1 Stunde). Refresh Tokens sind langlebig. Vor jedem API-Call prüft die Extension, ob das Access Token noch gültig ist:

export const getValidAccessToken = async (): Promise<string | null> => {
  const { accessToken, refreshToken, expiresAt } = await chrome.storage.sync.get([
    'accessToken',
    'refreshToken',
    'expiresAt',
  ]);

  if (!accessToken || !refreshToken) return null;

  // Token still valid (60s buffer for clock drift)
  if (expiresAt && Date.now() < expiresAt - 60_000) {
    return accessToken;
  }

  // Token expired: refresh with the refresh token
  try {
    const tokens = await refreshAccessToken(refreshToken);
    await storeTokens(tokens);
    return tokens.access_token;
  } catch {
    // Refresh token invalid: user must sign in again
    await clearTokens();
    return null;
  }
};

Die 60-Sekunden-Puffer verhindert, dass ein Token in dem Moment abläuft, in dem der API-Call abgesetzt wird.

Bearer Token in API-Calls

Alle API-Calls aus dem Background Service Worker hängen das Token automatisch an:

const token = await getValidAccessToken();
const headers: Record<string, string> = {
  'Content-Type': 'application/json',
};
if (token) {
  headers['Authorization'] = `Bearer ${token}`;
}

const response = await fetch(`${API_BASE_URL}/ingest`, {
  method: 'POST',
  headers,
  body: JSON.stringify(payload),
});

Wenn getValidAccessToken() null zurückgibt, wird der Request ohne Token abgesetzt. Das Backend lehnt ihn mit 401 ab. Die Extension zeigt dann den Login-Status im Popup.

Auth-State im Popup

Das Popup zeigt den aktuellen Auth-Status und einen Login/Logout-Button. Da das Popup keinen persistenten State hat, liest es den Status direkt aus chrome.storage.sync:

const loadAuthState = async () => {
  const { accessToken, expiresAt } = await chrome.storage.sync.get(['accessToken', 'expiresAt']);
  const isAuthenticated = !!accessToken && expiresAt > Date.now();

  if (isAuthenticated) {
    statusEl.textContent = 'Angemeldet';
    loginBtn.textContent = 'Abmelden';
  } else {
    statusEl.textContent = 'Nicht angemeldet';
    loginBtn.textContent = 'Anmelden';
  }
};

Der Login-Button delegiert den eigentlichen OAuth-Flow an den Background Service Worker, weil chrome.identity.launchWebAuthFlow eine Nutzer-Geste erfordert und im Popup-Kontext mit Zuverlässigkeitsproblemen kämpft:

loginBtn.addEventListener('click', async () => {
  await chrome.runtime.sendMessage({ action: 'sign-in' });
  await loadAuthState();
});

Der Background Service Worker hält den Auth-State-kontext stabiler als das Popup.

VITE_AUTH_ENABLED Flag

Für lokale Entwicklung ohne laufenden Zitadel-Container gibt es einen Feature-Flag:

const AUTH_ENABLED = import.meta.env.VITE_AUTH_ENABLED === 'true';

Wenn false, werden keine Token geprüft, kein Login gezeigt, alle API-Calls gehen ohne Bearer-Token raus. Das Backend injiziert dann eine synthetische Identität (dev-user-local).

Das ermöglicht lokale Entwicklung an Features, die mit Auth nichts zu tun haben, ohne den vollen Zitadel-Stack starten zu müssen.

Popup mit Auth-Status: angemeldet vs. abgemeldet Abbildung: Das Popup zeigt zwei Zustaende: links “Nicht angemeldet” mit Anmelden-Button, rechts “Angemeldet” mit Benutzername und Abmelden-Button. Der Status wird direkt aus chrome.storage.sync gelesen.


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 (dieser Artikel)
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen

Du baust OAuth-Authentifizierung für eine Chrome Extension? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen