APIがない企業SSOにPlaywrightでサーバーサイド認証プロキシを作った話

APIがない企業SSOにPlaywrightでサーバーサイド認証プロキシを作った話

APIが提供されていない企業SSOに対して、Playwrightをサーバーサイドの認証プロキシとして使い、OKTA SSO + TOTP MFAのログインフローを自動化してJWTを取得する実装パターンを紹介します。
2026.08.02

はじめに

企業内AIサービスと連携するWebアプリを開発していたとき、認証で壁にぶつかりました。そのサービスは企業のIdP(Identity Provider)を経由したSSOでログインする必要があるのですが、プログラムからトークンを取得するAPIが提供されていません。ログインの唯一の手段はブラウザUIです。

「ブラウザでしかログインできないなら、サーバーサイドでブラウザを動かせばいい」——そう考えて、Playwrightをサーバーサイドの認証プロキシとして使う仕組みを作りました。

前提・環境

  • SvelteKit (Node.js adapter)
  • Playwright
  • 企業IdP(SSO + TOTP MFA)
  • TypeScript

なぜPlaywrightなのか

一般的な認証連携では、OAuth 2.0やSAMLのAPIフローを使います。しかし今回のケースでは:

  • サービス側がプログラム用の認証エンドポイントを公開していない
  • IdPのAPI直接利用は組織のポリシーで制限されている
  • ブラウザUIでのログインが唯一の公式フロー

このような状況は、社内ツールの開発では珍しくありません。公式SDKがない社内サービスに連携する必要があるケースです。

実装のアプローチ

全体の流れ

playwright-server-side-auth-proxy-enterprise-sso-auth-flow

PlaywrightAuthServiceの実装

サービスクラスとして、ブラウザのライフサイクル管理とログインフローを分離しました。

playwright-auth.ts
import { chromium, type Browser, type BrowserContext } from "playwright";
import { TOTP } from "totp-generator";

export class PlaywrightAuthService {
  private static credentials: LoginCredentials | null = null;
  private browser: Browser | null = null;
  private context: BrowserContext | null = null;

  async initialize() {
    if (!this.browser) {
      this.browser = await chromium.launch({
        headless: false,
        args: ["--no-sandbox", "--disable-setuid-sandbox"]
      });
      this.context = await this.browser.newContext({ locale: "en-US" });
    }
  }

ポイントは initialize()ブラウザインスタンスを再利用可能にしていることです。毎回ブラウザを起動するとオーバーヘッドが大きいため、既存のインスタンスがあればそれを使います。

ログインフローの自動化

Playwrightのロケーター機能を使い、IdPのログイン画面を操作します。

playwright-auth.ts
async login(credentials: LoginCredentials): Promise<AuthResult> {
  await this.initialize();
  const page = await this.context!.newPage();

  try {
    await page.goto(GPT_SERVICE_URL);

    // SSOログインボタンをクリック
    await page.getByRole("button", { name: "Login with SSO" }).click();

    // ユーザー名を入力
    await page.getByLabel("Username").fill(credentials.username);
    await page.getByRole("button", { name: "Next" }).click();

    // TOTP MFAの処理
    if (credentials.totpSecret) {
      await page.getByRole("link", { name: "Select to enter a code" }).click();

      const otpInput = await page.getByRole("textbox", {
        name: "Enter verification code"
      });
      const mfaOtp = (await TOTP.generate(credentials.totpSecret)).otp;

      await otpInput.fill(mfaOtp);
      await page.getByRole("button", { name: "Verify" }).click();
    }

    // ログイン完了を待機
    await page.waitForURL(
      (url) => url.toString().includes("/chats") || url.toString().includes("/dashboard"),
      { timeout: 60000 }
    );

TOTP MFAの処理がポイントです。totp-generatorライブラリを使い、TOTPシークレットからワンタイムパスワードをサーバーサイドで生成しています。ユーザーはスマホの認証アプリを開く必要がありません。

ブラウザのlocalStorageからJWTを抽出

ログイン後、ブラウザのstorageState()APIを使ってlocalStorageからトークンを取得します。

playwright-auth.ts
    // ブラウザストレージからトークンを抽出
    const storage = await page.context().storageState();
    const tokenStorage = storage.origins
      .find((origin) => origin.origin === GPT_SERVICE_URL)
      ?.localStorage.find((storage) => storage.name === "idp-token-storage");

    if (!tokenStorage?.value) {
      throw new AuthError("Failed to extract token from browser storage", "LOGIN_FAILED");
    }

    const tokenData = JSON.parse(tokenStorage.value);
    const jwt = tokenData.idToken;

    tokenStore.setToken("default", jwt.idToken);

    return {
      accessToken: jwt.idToken,
      userInfo: jwt.claims || {}
    };

storageState() はPlaywrightが提供するAPIで、Cookie、localStorage、sessionStorageの内容を一括で取得できます。ここではlocalStorageに保存されたIdPのトークンストレージを探し、JWTを抽出しています。

静的メソッドによるクレデンシャル管理

認証サービスはステートレスに使いたいが、クレデンシャルはアプリケーションのライフサイクルで保持する必要があります。静的フィールドでクレデンシャルを管理し、認証実行時にインスタンスを都度生成するアプローチにしました。

playwright-auth.ts
  static async authenticateAI(): Promise<JWTTokens> {
    if (!this.credentials) {
      throw new AuthError("No credentials available. Please login first.", "NO_CREDENTIALS");
    }

    const authService = new PlaywrightAuthService();
    const result = await authService.login(this.credentials);

    // JWTをパースして有効期限を取得
    const tokenParts = result.accessToken.split(".");
    const payload = JSON.parse(atob(tokenParts[1]));

    return {
      idToken: result.accessToken,
      expiresAt: payload.exp || Math.floor(Date.now() / 1000) + 3600
    };
  }

本番運用で気をつけること

ブラウザのリソース管理

サーバーサイドでChromiumを起動するため、メモリ消費に注意が必要です。cleanup()メソッドで確実にリソースを解放します。

playwright-auth.ts
  async cleanup() {
    try {
      await this.context?.close();
      await this.browser?.close();
    } catch (error) {
      console.error("Error during cleanup:", error);
    } finally {
      this.context = null;
      this.browser = null;
    }
  }

ログインUIの変更への脆弱性

ロケーター(getByRole, getByLabel)はUIのテキストに依存しています。IdPのログイン画面が更新されると壊れる可能性があります。セマンティックなロケーター(roleベース)を使うことで、CSSクラス変更には強いですが、ラベルテキストの変更には弱いです。

対策:

  • ログイン失敗時のエラーハンドリングを充実させる
  • 定期的にE2Eテストを実行してログインフローが正常か確認する
  • エラー発生時にスクリーンショットを保存して調査しやすくする

headlessモードについて

開発時は headless: false でブラウザの動きを目視確認できますが、本番環境では headless: true に切り替えます。ただし、一部のSSOプロバイダーはheadlessブラウザを検知してブロックする場合があります。その場合は headless: "new" (Chromiumの新しいheadlessモード)を試してください。

まとめ

Playwrightをサーバーサイドの認証プロキシとして使うことで、APIが提供されていない企業SSOでもプログラムからトークンを取得できるようになりました。

この手法が有効な場面:

  • 社内サービスにプログラム用のOAuth/APIキーがない
  • ブラウザUIが唯一のログイン手段
  • TOTP MFAが必要

この手法のリスク:

  • ログインUIの変更で壊れる可能性がある
  • サーバーにブラウザ(Chromium)が必要でリソース消費が大きい
  • サービス側やIdPの利用規約でブラウザ自動化が禁止されていないか要確認

「正しいAPI」が存在しないなら、ブラウザを動かすのも立派なエンジニアリングです。ただし、あくまで「公式APIが提供されるまでの橋渡し」として使うべきパターンだと考えています。

この記事をシェアする

関連記事