Zum Inhalt springen

OAuth 2.0 und OpenID Connect: Ein praktischer Leitfaden

OAuth 2.0 und OpenID Connect korrekt umsetzen: Authorization Code Flow mit PKCE, Token-Management, Refresh-Rotation und typische Sicherheitsfehler.

4 Min. Lesezeit
Diagramm des OAuth-2.0-Authorization-Code-Flows, das die Interaktionen zwischen Client, Autorisierungsserver und Ressourcenserver zeigt

OAuth 2.0 ist Autorisierung, keine Authentifizierung

OAuth 2.0 beantwortet die Frage „Worauf darf diese Anwendung zugreifen?", nicht „Wer ist dieser Benutzer?". OpenID Connect (OIDC) legt eine Authentifizierungsschicht über OAuth und liefert Identity Token, die zeigen, wer der Nutzer ist. Wer beides verwechselt, riskiert Sicherheitslücken – etwa wenn ein Access Token, der eigentlich für eine API bestimmt war, bei einer anderen als Identitätsnachweis verwendet wird.

Authorization Code Flow mit PKCE

Der Authorization Code Flow mit PKCE (Proof Key for Code Exchange) ist der empfohlene Flow für alle Client-Typen – Webanwendungen, mobile Apps und SPAs. Er verhindert, dass ein abgefangener Authorization Code missbraucht werden kann.

tstypescript
import { randomBytes, createHash } from "node:crypto";
 
// Step 1: Generate PKCE challenge
function generatePKCE(): { verifier: string; challenge: string } {
  const verifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 128);
 
  const challenge = createHash("sha256")
    .update(verifier)
    .digest("base64url");
 
  return { verifier, challenge };
}
 
// Step 2: Build authorization URL
function buildAuthorizationURL(config: OAuthConfig): {
  url: string;
  state: string;
  verifier: string;
} {
  const { verifier, challenge } = generatePKCE();
  const state = randomBytes(16).toString("hex");
 
  const params = new URLSearchParams({
    response_type: "code",
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: "openid profile email",
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
  });
 
  return {
    url: `${config.authorizationEndpoint}?${params}`,
    state,
    verifier,
  };
}
tstypescript
// ❌ Implicit flow — tokens exposed in URL fragment
// DEPRECATED: access_token appears in browser history and referrer headers
const badUrl = `${authEndpoint}?response_type=token&client_id=${clientId}`;
 
// ✅ Authorization Code + PKCE — tokens never in URLs
const { url, state, verifier } = buildAuthorizationURL(config);
// Store state and verifier in session, redirect user to url

Token-Austausch

Nachdem der Nutzer die Autorisierung erteilt hat, leitet der Autorisierungsserver mit einem Code zurück. Dieser Code wird serverseitig gegen Tokens eingetauscht.

tstypescript
async function exchangeCodeForTokens(
  code: string,
  verifier: string,
  config: OAuthConfig
): Promise<TokenResponse> {
  const response = await fetch(config.tokenEndpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: config.redirectUri,
      client_id: config.clientId,
      client_secret: config.clientSecret,
      code_verifier: verifier,
    }),
  });
 
  if (!response.ok) {
    const error = await response.json();
    throw new AuthError(
      `Token exchange failed: ${error.error_description ?? error.error}`
    );
  }
 
  const tokens: TokenResponse = await response.json();
 
  // Validate the ID token
  if (tokens.id_token) {
    await validateIdToken(tokens.id_token, config);
  }
 
  return tokens;
}
 
interface TokenResponse {
  access_token: string;
  token_type: "Bearer";
  expires_in: number;
  refresh_token?: string;
  id_token?: string;
  scope: string;
}

Validierung des ID Tokens

Der ID Token ist ein JWT, das Identity Claims enthält. Er muss gründlich validiert werden – Signatur, Issuer, Audience, Ablaufzeit und Nonce.

tstypescript
import { jwtVerify, createRemoteJWKSet } from "jose";
 
async function validateIdToken(
  idToken: string,
  config: OAuthConfig
): Promise<UserInfo> {
  const jwks = createRemoteJWKSet(
    new URL(config.jwksUri)
  );
 
  const { payload } = await jwtVerify(idToken, jwks, {
    issuer: config.issuer,
    audience: config.clientId,
    maxTokenAge: "5m",
    clockTolerance: "30s",
  });
 
  // Verify required claims exist
  if (!payload.sub) {
    throw new AuthError("ID token missing sub claim");
  }
 
  return {
    id: payload.sub,
    email: payload.email as string | undefined,
    name: payload.name as string | undefined,
    emailVerified: payload.email_verified as boolean | undefined,
  };
}
 
interface UserInfo {
  id: string;
  email?: string;
  name?: string;
  emailVerified?: boolean;
}

Rotation von Refresh Tokens

Access Tokens sind nur kurz gültig (Minuten). Refresh Tokens beschaffen neue Access Tokens, ohne dass der Nutzer eingreifen muss. Refresh Tokens sollten bei jeder Verwendung rotiert werden – jeder Refresh Token ist nur einmal gültig, und die Wiederverwendung eines alten Tokens macht die gesamte Sitzung ungültig.

tstypescript
class TokenManager {
  private refreshTimer: ReturnType<typeof setTimeout> | null = null;
 
  constructor(
    private readonly config: OAuthConfig,
    private readonly storage: TokenStorage
  ) {}
 
  async refreshAccessToken(): Promise<string> {
    const refreshToken = await this.storage.getRefreshToken();
 
    if (!refreshToken) {
      throw new AuthError("No refresh token available — re-authentication required");
    }
 
    const response = await fetch(this.config.tokenEndpoint, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: refreshToken,
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
      }),
    });
 
    if (!response.ok) {
      // Refresh token was revoked or expired
      await this.storage.clearTokens();
      throw new AuthError("Session expired — please log in again");
    }
 
    const tokens: TokenResponse = await response.json();
 
    // Store the NEW refresh token (rotation)
    await this.storage.setAccessToken(tokens.access_token, tokens.expires_in);
    if (tokens.refresh_token) {
      await this.storage.setRefreshToken(tokens.refresh_token);
    }
 
    this.scheduleRefresh(tokens.expires_in);
    return tokens.access_token;
  }
 
  private scheduleRefresh(expiresInSeconds: number): void {
    if (this.refreshTimer) clearTimeout(this.refreshTimer);
 
    // Refresh 60 seconds before expiration
    const refreshInMs = (expiresInSeconds - 60) * 1000;
    this.refreshTimer = setTimeout(
      () => this.refreshAccessToken(),
      Math.max(refreshInMs, 0)
    );
  }
}

Sichere Speicherung von Tokens

Wo Tokens gespeichert werden, ist entscheidend. Bei Webanwendungen HttpOnly-Cookies. Bei Mobilgeräten sichere Storage-APIs. Sensible Tokens gehören niemals in den localStorage.

tstypescript
// ❌ localStorage — accessible to any JavaScript on the page (XSS risk)
localStorage.setItem("access_token", tokens.access_token);
 
// ✅ HttpOnly, Secure, SameSite cookies — inaccessible to JavaScript
function setTokenCookie(
  res: Response,
  name: string,
  value: string,
  maxAge: number
): void {
  res.setHeader("Set-Cookie", [
    `${name}=${value}`,
    "HttpOnly",
    "Secure",
    "SameSite=Lax",
    `Max-Age=${maxAge}`,
    "Path=/",
  ].join("; "));
}
 
// Set tokens as HttpOnly cookies after exchange
function handleCallback(req: Request, res: Response): void {
  // ... exchange code for tokens ...
  setTokenCookie(res, "access_token", tokens.access_token, tokens.expires_in);
  if (tokens.refresh_token) {
    setTokenCookie(res, "refresh_token", tokens.refresh_token, 30 * 24 * 3600);
  }
  res.redirect("/dashboard");
}

Häufige Sicherheitsfallen

Diese Fehler tauchen in produktiven OAuth-Implementierungen immer wieder auf und führen zur Übernahme von Konten.

tstypescript
interface SecurityChecklist {
  check: string;
  risk: string;
  mitigation: string;
}
 
const oauthChecklist: SecurityChecklist[] = [
  {
    check: "State parameter validation",
    risk: "CSRF attack — attacker forces victim to link their account",
    mitigation: "Generate random state, store in session, validate on callback",
  },
  {
    check: "PKCE on all flows",
    risk: "Authorization code interception on public clients",
    mitigation: "Always use S256 code challenge, even for confidential clients",
  },
  {
    check: "Redirect URI exact match",
    risk: "Open redirect allows token theft via crafted URLs",
    mitigation: "Register exact redirect URIs, never wildcard or partial match",
  },
  {
    check: "ID token audience validation",
    risk: "Token confusion — token from another app used as identity proof",
    mitigation: "Verify aud claim matches your client_id",
  },
  {
    check: "Refresh token rotation",
    risk: "Stolen refresh token grants indefinite access",
    mitigation: "Rotate on use, detect reuse as compromise signal",
  },
];

Die wichtigsten Punkte

Verwenden Sie für alle Clients den Authorization Code Flow mit PKCE – Web, Mobile und SPA. Verzichten Sie grundsätzlich auf den Implicit Flow; er legt Tokens in URLs offen. Validieren Sie ID Tokens gründlich: Signatur, Issuer, Audience, Ablaufzeit. Speichern Sie Tokens bei Webanwendungen in HttpOnly-Cookies, niemals im localStorage.

Rotieren Sie Refresh Tokens bei jeder Verwendung, damit ein gestohlener Token nur einmal nutzbar ist. Validieren Sie den state-Parameter bei jedem Callback, um CSRF zu verhindern. Registrieren Sie Redirect URIs exakt – ein teilweiser Abgleich ermöglicht Open-Redirect-Angriffe. OAuth und OIDC sind komplexe Protokolle mit vielen Fallstricken, aber eine korrekte Implementierung ist bei jeder Anwendung, die mit Nutzeridentitäten arbeitet, nicht verhandelbar.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX