İçeriğe atla

Auth0 ile Çoklu Audience: Micro Frontend'lerde Token Yönetimi

Micro frontend'lerde Auth0 multi-audience authentication: token yönetim stratejileri ve React Native'de WebView tabanlı uygulamalarla silent authentication.

Ayhan Sipahi Ayhan Sipahi

Auth0 access token’ı tek bir audience taşır. Üç ayrı API ile konuşan bir micro frontend kümesi bu yüzden üç token’a ihtiyaç duyar ve kısıtın ilk okunuşu “üç kez login” der. Gerek yok. Auth0 session’ını shell application’da tek noktada tutun. Shell, bu session’ı istendiğinde audience başına access token’a çevirir ve token’ları origin’i doğrulanmış bir postMessage kanalıyla micro frontend’lere dağıtır. Diğer düzenlemeler (micro frontend başına ayrı Auth0 application, ince taneli scope’larla tek geniş audience, sunucu tarafında token değişimi) daha pahalıya mal olur ve daha azını verir.

Tarayıcı tarafı işin kolay yarısı. İş React Native’e gelince zorlaşır: aynı micro frontend’ler WebView içinde çalışır ve WebView, Auth0 session cookie’sine güvenilir biçimde erişemez. Bu yüzden token’ların sahibi native katman olur, WebView da token’ı ondan ister.

Tek Audience Kısıtı#

Bu dağıtık sistem mimarisini düşünün:

React Native

Backend API'ler

Micro Frontend'ler

Shell Application

Kullanıcı

Shell App auth.myapp.com

Token Manager

Billing MFE billing.myapp.com

Dashboard MFE dashboard.myapp.com

Analytics MFE analytics.myapp.com

Billing API Audience: billing-api

Core API Audience: core-api

Analytics API Audience: analytics-api

React Native App

WebView: Billing

WebView: Dashboard

Her API farklı bir aud claim’i doğrular ve tek bir /authorize turu yalnızca tek audience için token üretir. Billing API için üretilmiş bir token’ı analytics API reddeder, tersi de geçerlidir. Koordinasyon katmanı olmadan bu, API başına bir interaktif login demektir.

Sistemin ayrıca React Native WebView’lar içinde çalışması gerekir; orada alışıldık web kalıpları cookie kısıtları ve cross-origin sınırları yüzünden bozulur.

Alternatifler ve Sınırları#

Kağıt üzerinde makul görünen üç düzenleme, pratikte shell broker’dan daha pahalıya çıkar:

Micro frontend başına ayrı Auth0 application#

Fikir: her micro frontend kendi Auth0 application’ına ve audience’ına sahip olur. Sınırı: kullanıcı yine application başına authenticate olur ve konfigürasyon farkları sistemin kendisinden hızlı büyür. Application’lar arası session paylaşımı, kontrol etmediğiniz cookie davranışına bağlıdır.

İnce taneli scope’larla tek audience#

Fikir: tüm API’leri kapsayan tek bir audience, erişimi scope’lar belirler. Sınırı: aud artık API’leri birbirinden ayırmadığı için her API, başka bir API’ye ait token’ı reddetme yeteneğini kaybeder. Scope sahipliği takımlar arasında bulanıklaşır.

Sunucu tarafında token değişimi#

Fikir: bir backend servisi gelen token’ı audience’a özel token’la takas eder. Sınırı: her audience geçişine bir network hop ekler, onu tüketen her serviste değişiklik ister ve Auth0 session’ının zaten yaptığı işi tekrarlar.

Koordineli Multi-Audience Token Yönetimi#

Auth0 client’ının sahibi shell application’dır; authentication’ı tüm micro frontend’ler adına o yürütür:

1. Token Manager Mimarisi#

// token-manager.ts - Token yönetiminin çekirdeği
import { Auth0Client } from '@auth0/auth0-spa-js'; // ^2.1.3

interface TokenSet {
  accessToken: string;
  expiresAt: number;
  audience: string;
  scope: string;
}

class MultiAudienceTokenManager {
  private tokens: Map<string, TokenSet> = new Map();
  // Yalnızca refresh token'ı elinde tutan React Native köprüsü doldurur.
  // Tarayıcıda SPA SDK'sı token'ı dışarı vermez, bu alan null kalır.
  private primaryRefreshToken: string | null = null;
  private auth0Client: Auth0Client;

  constructor(private config: Auth0Config) {
    this.auth0Client = new Auth0Client({
      domain: config.domain,
      clientId: config.clientId,
      cacheLocation: 'memory', // Micro frontend'ler için kritik
      useRefreshTokens: true,
      authorizeTimeoutInSeconds: 60
    });
  }

  // Sistemin ihtiyaç duyduğu tüm scope'ları isteyen tek interaktif login.
  // Sayfadan ayrılır; akış callback rotasında completeLogin() ile devam eder.
  async startLogin(primaryAudience: string): Promise<void> {
    await this.auth0Client.loginWithRedirect({
      authorizationParams: {
        audience: primaryAudience,
        scope: this.getAllRequiredScopes(),
        redirect_uri: window.location.origin
      }
    });
  }

  // Callback rotasında çalışır, ardından kalan audience'ları ısıtır
  async completeLogin(audiences: string[]): Promise<void> {
    await this.auth0Client.handleRedirectCallback();

    for (const audience of audiences) {
      await this.getTokenForAudience(audience);
    }
  }

  async getTokenForAudience(audience: string): Promise<string> {
    // Önce cache'i kontrol et
    const cached = this.tokens.get(audience);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.accessToken;
    }

    const scope = this.getScopeForAudience(audience);

    try {
      // SDK mevcut session'ı yeni audience için tekrar kullanır
      const result = await this.auth0Client.getTokenSilently({
        authorizationParams: { audience, scope },
        detailedResponse: true // Düz string yerine expires_in de döner
      });

      this.storeToken(audience, {
        accessToken: result.access_token,
        expiresAt: Date.now() + result.expires_in * 1000,
        audience,
        scope
      });

      return result.access_token;
    } catch (error) {
      // Session yoksa veya third-party cookie engelliyse silent auth düşer
      if (this.primaryRefreshToken) {
        return this.refreshTokenForAudience(audience);
      }
      throw error;
    }
  }

  // Doğrudan refresh_token grant'i: refresh token'ın sahibi uygulamanın
  // kendisi olduğu durum için. Yani React Native köprüsü, tarayıcı değil.
  private async refreshTokenForAudience(audience: string): Promise<string> {
    const scope = this.getScopeForAudience(audience);

    const response = await fetch(`https://${this.config.domain}/oauth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'refresh_token',
        client_id: this.config.clientId,
        refresh_token: this.primaryRefreshToken,
        audience,
        scope
      })
    });

    if (!response.ok) {
      throw new Error(`${audience} için refresh başarısız: ${response.status}`);
    }

    const data = await response.json();

    this.storeToken(audience, {
      accessToken: data.access_token,
      expiresAt: Date.now() + data.expires_in * 1000,
      audience,
      scope: data.scope
    });

    return data.access_token;
  }
}

2. Micro Frontend’ler için Cross-Domain Token Paylaşımı#

Zor kısım, token’ları farklı subdomain’ler arasında paylaşmak. Sırayla denenen üç fallback:

// shared-auth-context.tsx - Tüm micro frontend'ler tarafından kullanılır
import { createContext, useContext, useEffect, useState } from 'react';

interface SharedAuthState {
  isAuthenticated: boolean;
  tokens: Map<string, string>;
  user: any;
}

const SharedAuthContext = createContext<SharedAuthState | null>(null);

// Cross-tab/cross-iframe iletişim için broadcast channel
const authChannel = new BroadcastChannel('auth-sync');

export function SharedAuthProvider({ children, audience }: Props) {
  const [authState, setAuthState] = useState<SharedAuthState>();
  const [tokenManager] = useState(() => new MultiAudienceTokenManager(auth0Config));

  useEffect(() => {
    // Diğer micro frontend'lerden auth güncellemelerini dinle
    authChannel.onmessage = (event) => {
      if (event.data.type === 'AUTH_UPDATE') {
        setAuthState(event.data.payload);
      }
    };

    // Shared storage üzerinden authenticate olup olmadığımızı kontrol et
    checkSharedAuthentication();
  }, []);

  const checkSharedAuthentication = async () => {
    // Birden fazla storage stratejisi dene

    // Strateji 1: iframe postMessage ile shared localStorage
    const sharedToken = await getTokenFromShell();

    // Strateji 2: Server-side session kontrolü
    if (!sharedToken) {
      const session = await checkServerSession();
      if (session) {
        await silentAuthentication();
      }
    }

    // Strateji 3: Auth0 session kontrolü
    if (!sharedToken) {
      const auth0Session = await checkAuth0Session();
      if (auth0Session) {
        await getTokenSilently();
      }
    }
  };

  const getTokenFromShell = (): Promise<string | null> => {
    return new Promise((resolve) => {
      // Shell application'a mesaj gönder
      window.parent.postMessage(
        { type: 'GET_TOKEN', audience },
        'https://auth.myapp.com'
      );

      // Cevabı dinle
      const handler = (event: MessageEvent) => {
        if (event.origin !== 'https://auth.myapp.com') return;
        if (event.data.type === 'TOKEN_RESPONSE') {
          window.removeEventListener('message', handler);
          resolve(event.data.token);
        }
      };

      window.addEventListener('message', handler);

      // 1 saniye sonra timeout
      setTimeout(() => {
        window.removeEventListener('message', handler);
        resolve(null);
      }, 1000);
    });
  };

  return (
    <SharedAuthContext.Provider value={authState}>
      {children}
    </SharedAuthContext.Provider>
  );
}

3. Shell Application - Authentication Orkestratörü#

// shell-application.tsx - Authentication orkestratörü
class ShellAuthOrchestrator {
  private microFrontends: Map<string, MicroFrontendConfig> = new Map();
  private tokenManager: MultiAudienceTokenManager;
  private sessionManager: SessionManager;

  async initialize() {
    // Tüm micro frontend'leri ve gereken audience'larını kaydet
    this.registerMicroFrontends([
      {
        name: 'billing',
        url: 'https://billing.myapp.com',
        audience: 'https://api.myapp.com/billing',
        scopes: ['read:invoices', 'write:payments']
      },
      {
        name: 'dashboard',
        url: 'https://dashboard.myapp.com',
        audience: 'https://api.myapp.com/core',
        scopes: ['read:profile', 'read:data']
      },
      {
        name: 'analytics',
        url: 'https://analytics.myapp.com',
        audience: 'https://api.myapp.com/analytics',
        scopes: ['read:reports', 'read:metrics']
      }
    ]);

    // Micro frontend token request'leri için message handler kur
    window.addEventListener('message', this.handleTokenRequest);

    // Authentication durumunu kontrol et
    await this.checkAuthentication();
  }

  private handleTokenRequest = async (event: MessageEvent) => {
    // Origin'i validate et
    const mfe = this.getMicroFrontendByOrigin(event.origin);
    if (!mfe) return;

    if (event.data.type === 'GET_TOKEN') {
      const token = await this.tokenManager.getTokenForAudience(
        event.data.audience
      );

      // Token'ı isteyen micro frontend'e geri gönder
      event.source?.postMessage(
        {
          type: 'TOKEN_RESPONSE',
          token: token,
          audience: event.data.audience
        },
        event.origin
      );
    }
  };

  async performLogin() {
    // Tüm gereken audience'ları topla
    const audiences = Array.from(this.microFrontends.values())
      .map(mfe => mfe.audience);

    // Tek interaktif login; tarayıcı burada sayfadan ayrılır
    await this.tokenManager.startLogin(audiences[0]);
  }

  // Auth0 geri yönlendirdikten sonra callback rotasından çağrılır
  async finishLogin(audiences: string[]) {
    await this.tokenManager.completeLogin(audiences);

    // Tüm micro frontend'lere bildir
    this.broadcastAuthUpdate();
  }

  private broadcastAuthUpdate() {
    const authChannel = new BroadcastChannel('auth-sync');
    authChannel.postMessage({
      type: 'AUTH_UPDATE',
      payload: {
        isAuthenticated: true,
        user: this.tokenManager.getUser()
      }
    });
  }
}

Token Refresh Stratejisi#

Refresh burada zor, çünkü aynı token’ın süresinin dolmak üzere olduğunu birden fazla frame aynı anda fark edebilir. Tek koordinatör, audience başına tek aktif promise:

// token-refresh-coordinator.ts
import { jwtDecode } from 'jwt-decode'; // ^4.0.0 default export'u kaldırdı

class TokenRefreshCoordinator {
  private refreshPromises: Map<string, Promise<string>> = new Map();
  private refreshTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();

  setupAutoRefresh(audience: string, expiresIn: number) {
    // Mevcut timer'ı temizle
    const existingTimer = this.refreshTimers.get(audience);
    if (existingTimer) clearTimeout(existingTimer);

    // Expiry'den 5 dakika önce refresh et
    const refreshIn = (expiresIn - 300) * 1000;

    const timer = setTimeout(() => {
      this.refreshToken(audience);
    }, refreshIn);

    this.refreshTimers.set(audience, timer);
  }

  async refreshToken(audience: string): Promise<string> {
    // Aynı audience için concurrent refresh'i önle
    const existing = this.refreshPromises.get(audience);
    if (existing) return existing;

    const refreshPromise = this.performRefresh(audience);
    this.refreshPromises.set(audience, refreshPromise);

    try {
      const token = await refreshPromise;
      return token;
    } finally {
      this.refreshPromises.delete(audience);
    }
  }

  private async performRefresh(audience: string): Promise<string> {
    try {
      // Önce silent refresh dene. v1'deki ignoreCache'in yerini cacheMode aldı.
      const token = await auth0Client.getTokenSilently({
        authorizationParams: { audience },
        cacheMode: 'off'
      });

      // Expiry almak için decode et
      const decoded = jwtDecode<{ exp: number }>(token);
      const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);

      // Sonraki refresh'i kur
      this.setupAutoRefresh(audience, expiresIn);

      // Storage'ı güncelle
      this.updateTokenStorage(audience, token);

      // Micro frontend'lere bildir
      this.notifyTokenRefresh(audience, token);

      return token;
    } catch (error) {
      console.error(`${audience} için token refresh başarısız:`, error);

      // Refresh başarısız olursa, re-authentication dene
      if (error.error === 'login_required') {
        await this.handleLoginRequired();
      }

      throw error;
    }
  }

  private notifyTokenRefresh(audience: string, token: string) {
    // BroadcastChannel ile bildir
    const channel = new BroadcastChannel('auth-sync');
    channel.postMessage({
      type: 'TOKEN_REFRESHED',
      audience: audience,
      token: token
    });

    // iframe'lere bildir. Token'ı asla targetOrigin '*' ile göndermeyin.
    document.querySelectorAll('iframe').forEach(iframe => {
      const target = new URL(iframe.src, window.location.href).origin;
      if (!ALLOWED_MFE_ORIGINS.includes(target)) return;

      iframe.contentWindow?.postMessage(
        { type: 'TOKEN_REFRESHED', audience, token },
        target
      );
    });
  }
}

Multi-Audience Desteği için Auth0 Actions#

Rules ve Hooks, Actions lehine kullanımdan kaldırıldı; yeni tenant’larda yalnızca Actions var. Audience’a özel claim’lerin yeri post-login Action’ıdır:

// auth0-action.js - Actions kullanarak tüm audience'lar için custom claim'ler ekle
exports.onExecutePostLogin = async (event, api) => {
  const { user, request } = event;

  // Audience-specific permission'ları tanımla
  const audiencePermissions = {
    'https://api.myapp.com/billing': ['read:invoices', 'write:payments'],
    'https://api.myapp.com/core': ['read:profile', 'read:data'],
    'https://api.myapp.com/analytics': ['read:reports', 'read:metrics']
  };

  // Hangi audience istendiğini kontrol et
  const requestedAudience = request.query?.audience || request.body?.audience;

  // Collision önlemek için namespace ekle
  const namespace = 'https://myapp.com/';

  // Tüm token'lara user metadata ekle
  api.accessToken.setCustomClaim(namespace + 'email', user.email);
  api.accessToken.setCustomClaim(namespace + 'roles', user.app_metadata?.roles || []);

  // Audience-specific permission'ları ekle
  if (audiencePermissions[requestedAudience]) {
    api.accessToken.setCustomClaim(namespace + 'permissions', audiencePermissions[requestedAudience]);
  }

  // Sadece primary audience için refresh token göstergesi ekle
  if (requestedAudience === 'https://api.myapp.com/core') {
    api.accessToken.setCustomClaim(namespace + 'can_refresh', true);
  }
};

Tarayıcıda Silent Authentication#

Tek session’ı birden çok audience token’ına çeviren mekanizma, gizli bir iframe içindeki prompt=none. @auth0/auth0-spa-js bunu zaten içeride yapıyor, o yüzden önce SDK’ya bakın. Elle yazılmış hali okumaya değer, çünkü sonunda debug edeceğiniz hata durumları burada görünür:

// silent-auth-handler.ts
class SilentAuthHandler {
  private iframe: HTMLIFrameElement | null = null;
  private pendingVerifier: string | null = null;
  private timeoutMs = 60000; // 60 saniye

  async performSilentAuth(options: SilentAuthOptions): Promise<TokenSet> {
    // Silent auth için hidden iframe oluştur
    this.iframe = this.createAuthIframe();

    const authUrl = await this.buildAuthUrl(options);

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        this.cleanup();
        reject(new Error('Silent authentication timeout'));
      }, this.timeoutMs);

      // Auth response'u dinle
      const handleMessage = (event: MessageEvent) => {
        if (event.origin !== `https://${AUTH0_DOMAIN}`) return;

        clearTimeout(timeout);

        if (event.data.type === 'authorization_response') {
          this.handleAuthResponse(event.data, options)
            .then(resolve)
            .catch(reject)
            .finally(() => this.cleanup());
        }

        if (event.data.type === 'authorization_error') {
          this.cleanup();
          reject(new Error(event.data.error));
        }
      };

      window.addEventListener('message', handleMessage);

      // iframe'i auth URL'e navigate et
      this.iframe.src = authUrl;
    });
  }

  private createAuthIframe(): HTMLIFrameElement {
    const iframe = document.createElement('iframe');
    iframe.style.display = 'none';
    iframe.style.visibility = 'hidden';
    iframe.style.position = 'fixed';
    iframe.style.width = '0';
    iframe.style.height = '0';
    document.body.appendChild(iframe);
    return iframe;
  }

  private async buildAuthUrl(options: SilentAuthOptions): Promise<string> {
    // Authorization code + PKCE. Güncel Auth0 application'larında implicit
    // grant varsayılan olarak kapalı; response_type=token reddedilir.
    this.pendingVerifier = this.createCodeVerifier();

    const params = new URLSearchParams({
      client_id: AUTH0_CLIENT_ID,
      response_type: 'code',
      code_challenge: await this.sha256UrlSafe(this.pendingVerifier),
      code_challenge_method: 'S256',
      redirect_uri: `${window.location.origin}/silent-callback.html`,
      audience: options.audience,
      scope: options.scope,
      state: this.generateState(),
      nonce: this.generateNonce(),
      prompt: 'none', // Silent auth için kritik
      response_mode: 'web_message' // postMessage kullan
    });

    return `https://${AUTH0_DOMAIN}/authorize?${params}`;
  }

  private async handleAuthResponse(
    response: any,
    options: SilentAuthOptions
  ): Promise<TokenSet> {
    // Code'a dokunmadan önce state'i doğrula
    if (!this.validateState(response.state)) {
      throw new Error('State validation failed');
    }

    // Authorization code'u token ile takas et
    const res = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        client_id: AUTH0_CLIENT_ID,
        code: response.code,
        code_verifier: this.pendingVerifier,
        redirect_uri: `${window.location.origin}/silent-callback.html`
      })
    });

    if (!res.ok) {
      throw new Error(`Code takası başarısız: ${res.status}`);
    }

    const data = await res.json();

    return {
      accessToken: data.access_token,
      expiresAt: Date.now() + data.expires_in * 1000,
      audience: options.audience,
      scope: data.scope
    };
  }

  private cleanup() {
    if (this.iframe && this.iframe.parentNode) {
      this.iframe.parentNode.removeChild(this.iframe);
      this.iframe = null;
    }
  }
}

React Native’de WebView Micro Frontend’ler#

React Native içinde aynı micro frontend’ler WebView’da açılır ve WebView, native katmanın az önce kurduğu Auth0 session cookie’sini gördüğüne güvenemez. Bu yüzden token’ların sahibi native taraf olur; WebView da onları mesaj köprüsü üzerinden ister:

// react-native-auth-bridge.tsx
import React, { useRef, useState } from 'react';
import { WebView } from 'react-native-webview'; // ^13.8.6
import AsyncStorage from '@react-native-async-storage/async-storage'; // ^1.23.1
import { authorize, refresh } from 'react-native-app-auth'; // ^7.1.0

interface AuthBridge {
  webViewRef: React.RefObject<WebView>;
  tokens: Map<string, string>;
}

export function AuthenticatedMicroFrontend({ url, audience }: Props) {
  const webViewRef = useRef<WebView>(null);
  const [tokens, setTokens] = useState<Map<string, string>>(new Map());

  // react-native-app-auth config: Auth0 uyumluluğu ve iOS/Android tarafındaki
  // native authentication session desteği için tercih edildi
  const auth0Config = {
    issuer: `https://${AUTH0_DOMAIN}`,
    clientId: AUTH0_CLIENT_ID,
    redirectUrl: 'com.myapp://auth/callback',
    scopes: ['openid', 'profile', 'email', 'offline_access'],
    additionalParameters: {
      audience: audience
    }
  };

  // Native authentication
  const performNativeAuth = async () => {
    try {
      // Native Auth0 flow için react-native-app-auth kullan
      const result = await authorize(auth0Config);

      // Token'ları sakla
      await AsyncStorage.setItem('auth_tokens', JSON.stringify({
        accessToken: result.accessToken,
        idToken: result.idToken,
        refreshToken: result.refreshToken,
        expiresAt: new Date(result.accessTokenExpirationDate).getTime()
      }));

      // Gerekirse diğer audience'lar için token'ları al
      await getMultipleAudienceTokens(result.refreshToken);

      return result;
    } catch (error) {
      console.error('Native auth başarısız:', error);
      throw error;
    }
  };

  // React Native ve WebView arasında köprü
  const injectedJavaScript = `
    (function() {
      // Native bridge kullanmak için Auth0 client'ı override et
      window.nativeAuth = {
        getToken: function(audience) {
          return new Promise((resolve, reject) => {
            // Unique request ID oluştur
            const requestId = Math.random().toString(36).substr(2, 9);

            // Response handler kur
            window.handleTokenResponse = function(id, token, error) {
              if (id !== requestId) return;

              if (error) {
                reject(new Error(error));
              } else {
                resolve(token);
              }

              delete window.handleTokenResponse;
            };

            // React Native'den token iste
            window.ReactNativeWebView.postMessage(JSON.stringify({
              type: 'GET_TOKEN',
              audience: audience,
              requestId: requestId
            }));
          });
        },

        silentAuth: function(options) {
          return new Promise((resolve, reject) => {
            window.ReactNativeWebView.postMessage(JSON.stringify({
              type: 'SILENT_AUTH',
              options: options
            }));

            window.handleSilentAuthResponse = function(result, error) {
              if (error) {
                reject(error);
              } else {
                resolve(result);
              }
              delete window.handleSilentAuthResponse;
            };
          });
        }
      };

      // Auth0 client initialization'ı intercept et
      if (window.createAuth0Client) {
        const originalCreate = window.createAuth0Client;
        window.createAuth0Client = async function(config) {
          // Native bridge kullanan mock client döndür
          return {
            getTokenSilently: async (options) => {
              return window.nativeAuth.getToken(options.audience);
            },
            loginWithRedirect: async () => {
              window.ReactNativeWebView.postMessage(JSON.stringify({
                type: 'LOGIN_REQUIRED'
              }));
            },
            isAuthenticated: async () => {
              return window.nativeAuth.isAuthenticated();
            }
          };
        };
      }
    })();

    true; // Injection'ın çalışması için gerekli
  `;

  // WebView'dan mesajları handle et
  const handleWebViewMessage = async (event: any) => {
    const message = JSON.parse(event.nativeEvent.data);

    switch (message.type) {
      case 'GET_TOKEN':
        await handleTokenRequest(message);
        break;

      case 'SILENT_AUTH':
        await handleSilentAuth(message);
        break;

      case 'LOGIN_REQUIRED':
        await performNativeAuth();
        break;
    }
  };

  const handleTokenRequest = async (message: any) => {
    try {
      // İstenen audience için token al
      let token = tokens.get(message.audience);

      if (!token || isTokenExpired(token)) {
        // Native auth kullanarak token'ı refresh et
        token = await refreshTokenForAudience(message.audience);
        setTokens(prev => new Map(prev).set(message.audience, token!));
      }

      // Tırnak yerine JSON.stringify: içinde tırnak geçen bir değer aksi
      // halde string'i kapatır ve WebView içinde kod olarak çalışır.
      webViewRef.current?.injectJavaScript(`
        window.handleTokenResponse(
          ${JSON.stringify(message.requestId)},
          ${JSON.stringify(token)},
          null
        );
        true;
      `);
    } catch (error) {
      // Error'u WebView'a geri gönder
      webViewRef.current?.injectJavaScript(`
        window.handleTokenResponse(
          ${JSON.stringify(message.requestId)},
          null,
          ${JSON.stringify((error as Error).message)}
        );
        true;
      `);
    }
  };

  const handleSilentAuth = async (message: any) => {
    try {
      // Valid session var mı kontrol et
      const storedTokens = await AsyncStorage.getItem('auth_tokens');

      if (storedTokens) {
        const tokens = JSON.parse(storedTokens);

        if (tokens.expiresAt > Date.now()) {
          // Valid token'larımız var, istenen audience için token al
          const audienceToken = await getTokenForAudience(
            message.options.audience
          );

          webViewRef.current?.injectJavaScript(`
            window.handleSilentAuthResponse({
              accessToken: ${JSON.stringify(audienceToken)}
            }, null);
            true;
          `);
          return;
        }
      }

      // Refresh'i dene
      const refreshed = await refreshAuth();
      if (refreshed) {
        const audienceToken = await getTokenForAudience(
          message.options.audience
        );

        webViewRef.current?.injectJavaScript(`
          window.handleSilentAuthResponse({
            accessToken: ${JSON.stringify(audienceToken)}
          }, null);
          true;
        `);
      } else {
        throw new Error('Silent auth başarısız, login gerekli');
      }
    } catch (error) {
      webViewRef.current?.injectJavaScript(`
        window.handleSilentAuthResponse(
          null,
          ${JSON.stringify((error as Error).message)}
        );
        true;
      `);
    }
  };

  const refreshAuth = async () => {
    try {
      const storedTokens = await AsyncStorage.getItem('auth_tokens');
      if (!storedTokens) return false;

      const { refreshToken } = JSON.parse(storedTokens);

      // Refresh için react-native-app-auth kullan
      const result = await refresh(auth0Config, {
        refreshToken: refreshToken
      });

      // Saklanan token'ları güncelle
      await AsyncStorage.setItem('auth_tokens', JSON.stringify({
        accessToken: result.accessToken,
        idToken: result.idToken,
        refreshToken: result.refreshToken || refreshToken,
        expiresAt: new Date(result.accessTokenExpirationDate).getTime()
      }));

      return true;
    } catch (error) {
      console.error('Token refresh başarısız:', error);
      return false;
    }
  };

  return (
    <WebView
      ref={webViewRef}
      source={{ uri: url }}
      injectedJavaScript={injectedJavaScript}
      onMessage={handleWebViewMessage}
      sharedCookiesEnabled={true} // Session paylaşımı için önemli
      thirdPartyCookiesEnabled={true} // Auth0 cookie'leri için
      domStorageEnabled={true} // localStorage için
    />
  );
}

React Native’de Silent Login Akışı#

Uygulama login ekranını göstermeden önce sırayla denenen dört fallback:

// silent-login-flow.ts
class SilentLoginFlow {
  private auth0: Auth0Native;
  private tokenCache: TokenCache;
  private webViewBridge: WebViewBridge;

  async performSilentLogin(): Promise<boolean> {
    // Adım 1: Native token cache'i kontrol et
    const cachedTokens = await this.tokenCache.getTokens();

    if (cachedTokens && !this.isExpired(cachedTokens)) {
      // Valid token'larımız var, WebView bridge'i kur
      await this.setupWebViewBridge(cachedTokens);
      return true;
    }

    // Adım 2: Refresh token var mı kontrol et
    const refreshToken = await this.tokenCache.getRefreshToken();

    if (refreshToken) {
      try {
        // Refresh'i dene
        const newTokens = await this.auth0.refreshTokens(refreshToken);
        await this.tokenCache.storeTokens(newTokens);
        await this.setupWebViewBridge(newTokens);
        return true;
      } catch (error) {
        console.log('Refresh başarısız, Auth0 session deneniyor');
      }
    }

    // Adım 3: Auth0 session'ı kontrol et (SSO)
    try {
      const ssoTokens = await this.checkAuth0Session();
      if (ssoTokens) {
        await this.tokenCache.storeTokens(ssoTokens);
        await this.setupWebViewBridge(ssoTokens);
        return true;
      }
    } catch (error) {
      console.log('Auth0 session bulunamadı');
    }

    // Adım 4: Biometric authentication fallback
    if (await this.isBiometricAvailable()) {
      const bioTokens = await this.attemptBiometricAuth();
      if (bioTokens) {
        await this.setupWebViewBridge(bioTokens);
        return true;
      }
    }

    return false; // Silent login başarısız, explicit login gerekli
  }

  private async checkAuth0Session(): Promise<TokenSet | null> {
    // SSO kontrolü için custom tab / ASWebAuthenticationSession kullan
    const ssoCheckUrl = `https://${AUTH0_DOMAIN}/authorize?` +
      `client_id=${CLIENT_ID}&` +
      `response_type=token&` +
      `redirect_uri=${REDIRECT_URI}&` +
      `scope=openid profile email&` +
      `prompt=none&` + // Silent auth için kritik
      `response_mode=query`;

    try {
      // Bu hidden web session'da açılır
      const result = await InAppBrowser.openAuth(ssoCheckUrl, REDIRECT_URI, {
        ephemeralWebSession: false, // Shared session kullan
        preferEphemeralSession: false
      });

      if (result.type === 'success' && result.url) {
        const tokens = this.parseAuthResponse(result.url);
        return tokens;
      }

      return null;
    } catch (error) {
      return null;
    }
  }

  private async setupWebViewBridge(tokens: TokenSet) {
    // WebView yüklenmeden önce token'ları inject et
    const script = `
      window.__AUTH_TOKENS__ = {
        accessToken: ${JSON.stringify(tokens.accessToken)},
        expiresAt: ${tokens.expiresAt}
      };

      // Auto-renewal kur
      window.__AUTH_BRIDGE__ = {
        renewToken: async function(audience) {
          return new Promise((resolve) => {
            window.ReactNativeWebView.postMessage(JSON.stringify({
              type: 'RENEW_TOKEN',
              audience: audience
            }));
            window.__pendingRenewal = resolve;
          });
        }
      };
    `;

    this.webViewBridge.injectScript(script);
  }
}

Multi-Resource Refresh Token’lar (MRRT)#

Multi-Resource Refresh Token, application başına tek bir refresh token’ın birden fazla API için access token almasını sağlar; yukarıdaki muhasebenin çoğunu ortadan kaldırır. Uygulanabilirliğini üç kısıt belirler: MRRT yalnızca first-party application’larla çalışır, her access token hâlâ tek bir API’ye scope’lanır ve Management API bir MRRT politikasına dahil edilemez.

// Birden fazla audience için verimli token yenileme (MRRT)
class MRRTTokenManager {
  async refreshMultipleAudiences(refreshToken: string, audiences: string[]): Promise<Map<string, TokenSet>> {
    const tokens = new Map<string, TokenSet>();

    // MRRT, tek bir refresh token ile birden fazla audience için token almayı sağlar
    for (const audience of audiences) {
      try {
        const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            grant_type: 'refresh_token',
            client_id: CLIENT_ID,
            refresh_token: refreshToken,
            audience: audience
          })
        });

        const data = await response.json();
        tokens.set(audience, {
          accessToken: data.access_token,
          expiresAt: Date.now() + (data.expires_in * 1000),
          audience: audience
        });
      } catch (error) {
        console.error(`${audience} için MRRT yenileme başarısız:`, error);
      }
    }

    return tokens;
  }
}

Güvenlik Konuları#

Kritik Uyarı: JWT token’ları her zaman backend’de validate edin. Asla sadece client-side token validation’a güvenmeyin.

// Hata yönetimiyle birlikte backend JWT validation
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

const client = jwksClient({
  jwksUri: `https://${AUTH0_DOMAIN}/.well-known/jwks.json`,
  cache: true,
  cacheMaxAge: 600000 // 10 dakika
});

function getKey(header: any, callback: Function) {
  client.getSigningKey(header.kid, (err: Error | null, key: any) => {
    if (err) {
      callback(err);
      return;
    }
    const signingKey = key.publicKey || key.rsaPublicKey;
    callback(null, signingKey);
  });
}

// Token'ı validate et
const verifyToken = (token: string, audience: string): Promise<any> => {
  return new Promise((resolve, reject) => {
    jwt.verify(token, getKey, {
      audience: audience,
      issuer: `https://${AUTH0_DOMAIN}/`,
      algorithms: ['RS256']
    }, (err: Error | null, decoded: any) => {
      if (err) {
        console.error('JWT doğrulaması başarısız:', err.message);
        reject(err);
      } else {
        resolve(decoded);
      }
    });
  });
};

Temel Güvenlik Önlemleri:

  1. Token Depolama: Token’lar için güvenli, şifrelenmiş depolama kullanın
  2. Origin Validation: postMessage handler’larda her zaman mesaj origin’lerini validate edin
  3. Sadece HTTPS: Token’ları asla şifrelenmemiş bağlantılar üzerinden iletmeyin
  4. Token Rotation: Uygun refresh token rotation uygulayın
  5. Audience Validation: Audience claim’lerinin beklenen değerlerle eşleştiğini doğrulayın

Uygulamada Çıkan Tuzaklar#

Auth0 session yönetimi için cookie kullanır. React Native WebView’larda third-party cookie’ler genelde engellenir. Çözüm:

// WebView'lar arasında cookie paylaşımını etkinleştir
const cookieManager = require('@react-native-cookies/cookies');

// Auth0 cookie'lerini WebView'lar arasında paylaş
await cookieManager.setFromResponse(
  `https://${AUTH0_DOMAIN}`,
  'auth0_session=...; SameSite=None; Secure'
);

2. Race Condition#

Birden fazla micro frontend aynı audience’ı aynı anda ister ve her biri kendi network çağrısını başlatır. Hepsini tek promise üzerinde toplayın:

class TokenRequestQueue {
  private queue: Map<string, Promise<string>> = new Map();

  async getToken(audience: string): Promise<string> {
    // Zaten fetch ediliyorsa, mevcut promise'i döndür
    const existing = this.queue.get(audience);
    if (existing) return existing;

    // Yeni fetch promise'i oluştur
    const fetchPromise = this.fetchToken(audience);
    this.queue.set(audience, fetchPromise);

    try {
      const token = await fetchPromise;
      return token;
    } finally {
      // Resolution'dan sonra temizle
      this.queue.delete(audience);
    }
  }
}

React Native Tarafında Güvenlik#

  1. Güvenli Token Depolama: Token’ları asla düz metin olarak saklamayın. Şifreli depolama kullanın:
import * as Keychain from 'react-native-keychain';

// Token'ları biyometrik korumayla sakla
const storeTokensSecurely = async (tokens: TokenSet) => {
  try {
    await Keychain.setInternetCredentials(
      'auth.myapp.com',
      'tokens',
      JSON.stringify(tokens),
      {
        accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
        accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
        service: 'myapp-auth' // Benzersiz servis tanımlayıcısı
      }
    );
  } catch (error) {
    console.error('Güvenli depolama başarısız:', error);
    throw new Error('Authentication token\'ları güvenli biçimde saklanamadı');
  }
};
  1. Mesaj Doğrulamasıyla WebView Güvenliği:
const validateWebViewMessage = (event: any): boolean => {
  // İzin verilen origin'leri whitelist'le
  const allowedOrigins = [
    'https://billing.myapp.com',
    'https://dashboard.myapp.com',
    'https://analytics.myapp.com'
  ];

  if (!allowedOrigins.includes(event.origin)) {
    console.error('Geçersiz origin:', event.origin);
    return false;
  }

  // Mesaj yapısını validate et
  if (!event.data || typeof event.data !== 'object') {
    console.error('Geçersiz mesaj yapısı');
    return false;
  }

  // Zorunlu alanları kontrol et
  const requiredFields = ['type', 'requestId'];
  for (const field of requiredFields) {
    if (!event.data[field]) {
      console.error(`Eksik zorunlu alan: ${field}`);
      return false;
    }
  }

  return true;
};

Bu Kalıp Nerede İşe Yarar#

Shell’de tek session, origin’i doğrulanmış kanal üzerinden audience başına token ve WebView’lar için native köprü: micro frontend’ler first-party olduğu, kontrol ettiğiniz bir parent frame’i ya da ortak bir kayıtlı alan adını paylaştığı ve login sırasında tüm audience’ları ısıtmak ucuz kaldığı sürece bu kurulum yeterlidir. Application’lar first-party ise MRRT bunun üzerine refresh muhasebesinin çoğunu ortadan kaldırır.

Bu koşullardan biri bozulduğunda kalıbı değiştirin. İş ortağına ait frontend’ler kendi Auth0 application’ları ve kendi consent ekranlarıyla yaşamalı. Onlarca audience’ı olan bir sistemde token’ları login’de toplu ısıtmak yerine route başına tembel almak gerekir. Bir micro frontend kendi backend-for-frontend’ine erişebiliyorsa, token’ları tarayıcıda dolaştırmak yerine o BFF’e karşı httpOnly cookie session kullanmak daha basittir.

Kaynaklar#

İlgili yazılar