PlaywrightでOkta MFA(TOTP)ログインを自動化するときにハマったこと

PlaywrightでOkta MFA(TOTP)ログインを自動化するときにハマったこと

PlaywrightでOkta MFA(TOTP)ログインを自動化する際の実装パターンを紹介。TOTPの30秒ウィンドウ管理、Promise.raceによるUI分岐処理、動画・スクリーンショットによるデバッグ手法を解説します。
2026.07.23

はじめに

業務自動化のスクリプトを作っていると、どうしても「MFAが必要なサービスへのログインを自動化したい」場面があります。OAuthの認可フローを通じてトークンを取得する処理で、毎回手動でMFAコードを入力するのは現実的ではありません。

この記事では、PlaywrightでOkta MFA(TOTP方式)のログインを自動化したときの実装と、いくつかのハマりポイントを紹介します。

TOTP自動生成のしくみ

Okta Verifyアプリで表示されるワンタイムパスワードは TOTP(Time-based One-Time Password) という規格に基づいています。シークレットキーと現在時刻から決定的に計算されるため、同じシークレットキーがあれば自分で生成できます。

Okta.ts
import { TOTP } from "totp-generator";

class Okta {
  private lastOTPCallTime: number = 0;
  private readonly OTP_LIFESPAN_SECONDS = 30; // TOTPの有効期間

  private async waitForNextOTP(lastCallTime: number): Promise<void> {
    const now = Date.now();
    const timeSinceLastCall = (now - lastCallTime) / 1000;

    if (timeSinceLastCall < this.OTP_LIFESPAN_SECONDS) {
      // 前回生成から30秒経っていなければ待機
      const timeToWait = this.OTP_LIFESPAN_SECONDS - timeSinceLastCall;
      await new Promise((resolve) => setTimeout(resolve, timeToWait * 1000));
    }
  }

  public async generateOTP(): Promise<string> {
    await this.waitForNextOTP(this.lastOTPCallTime);
    const { otp } = TOTP.generate(process.env.TOTP_SECRET_KEY!);
    this.lastOTPCallTime = Date.now();
    return otp;
  }
}

export default new Okta();

playwright-okta-totp-automation-totp-window

なぜ waitForNextOTP が必要か?

TOTPは30秒ごとに更新されますが、同じ30秒ウィンドウ内で生成したOTPは通常1回しか使えません(リプレイアタック防止)。連続してOTPを生成・使用する場面では、前回の生成から30秒経過していることを確認してから生成する必要があります。

lastOTPCallTime を記録しておき、次回生成時に「前回生成からの経過時間 < 30秒」であれば残り時間だけ待ってから生成します。

Oktaログインフローの自動化

TOTPが生成できたら、Playwrightでログインフローを自動化します。

Playwright.ts
public async handleOktaLogin(url: string) {
  await this.checkContext(); // ブラウザコンテキストの遅延初期化

  const page = await this.context.newPage();

  try {
    await page.goto(url);

    // ユーザー名入力
    const usernameInput = page.getByLabel("Username");
    await usernameInput.waitFor({ state: "visible", timeout: 10000 });
    await usernameInput.fill(process.env.USERNAME!);

    // パスワード入力
    const passwordInput = page.getByLabel("Password");
    await passwordInput.waitFor({ state: "visible", timeout: 10000 });
    await passwordInput.fill(process.env.PASSWORD!);

    await page.getByRole("button", { name: "Sign in" }).click();

    // MFAメソッド選択: "Enter a code from Okta Verify"
    const mfaLink = page.getByRole("link", {
      name: "Select to enter a code from",
    });
    await mfaLink.waitFor({ state: "visible", timeout: 10000 });
    await mfaLink.click();

    // OTPを生成して入力
    const otpInput = page.getByRole("textbox", {
      name: "Enter code from Okta Verify",
    });
    await otpInput.waitFor({ state: "visible", timeout: 10000 });
    const mfaOtp = await Okta.generateOTP(); // ← ここで生成
    await otpInput.fill(mfaOtp);

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

    // ログイン完了確認
    await page
      .getByText("login complete, you may close this page")
      .waitFor({ timeout: 100000 }); // OAuthリダイレクト完了まで時間がかかる場合があるため長めに設定

    Logger.success("Oktaログイン完了");
  } catch (error) {
    // エラー時にスクリーンショットを保存
    await this.handleException(page, "Oktaログインに失敗しました。", error);
  } finally {
    await this.handleVideo(page); // 動画を保存してコンテキストを閉じる
  }
}

Promise.race でUIの分岐を処理する

ログインフローで難しいのは、次に表示される画面がサービスによって異なる ことです。

たとえばOAuthフローで別サービスへのログイン後、次に表示される画面が「初回ログイン → パスワード入力画面」の場合もあれば「セッションが残っている → すでにログイン済みの画面」の場合もあります。どちらが表示されるか、実行タイミングによって変わります。

playwright-okta-totp-automation-promise-race

if/else で分岐するには「どちらが先に表示されるか」を知る必要があります。Promise.race を使うと、最初に表示された要素に応じて分岐できます。

AzureAuth.ts
public async getAzureCode(callback: string): Promise<string> {
  const page = await this.context.newPage();
  await page.goto(callback);

  const emailInput = page.getByLabel("Type your email address here");
  const noButton = page.getByRole("button", { name: "No" }); // 既存セッション時に表示

  // どちらかが先に表示されたら処理を進める
  await Promise.race([
    this.waitForLocator(emailInput),
    this.waitForLocator(noButton),
  ]);

  if (await emailInput.isVisible()) {
    // 初回ログイン: メールアドレスとOTPを入力
    await emailInput.fill(process.env.EMAIL!);
    await page.getByRole("button", { name: "Next" }).click();

    await page.getByRole("link", {
      name: "Select to enter a code from",
    }).click();

    const otpInput = page.getByLabel("Enter code from Okta Verify");
    const otp = await Okta.generateOTP();
    await otpInput.fill(otp);
    await page.getByRole("button", { name: "Verify" }).click();
  }

  // どちらのパスでも最終的に "No" ボタンに到達する
  await noButton.click();

  // 認証コードをURLから取得
  await page.getByText("Your call is authenticated").waitFor();
  const currentUrl = page.url();
  return currentUrl.split("code=")[1].split("&")[0];
}

private waitForLocator = (locator: Locator): Promise<Locator> => {
  return locator.waitFor().then(() => locator);
};

waitForLocatorLocator.waitFor() をPromiseに包んだヘルパーです。Promise.race は最初に解決されたPromiseの値を返しますが、ここでは「どちらが表示されたか」ではなく、その後の isVisible() チェックで分岐します。

別サービスではログイン後の画面パターンがさらに多く、3つ以上を Promise.race に渡すことがありました。

// Box の場合: 3パターンを同時に監視
await Promise.race([
  this.waitForLocator(boxMailInput), // Box独自のメール入力
  this.waitForLocator(oktaMailInput),    // Okta認証画面
  this.waitForLocator(grantButton),      // すでに認証済みでアクセス許可画面
]);

デバッグのための動画・スクリーンショット記録

ブラウザ自動化はデバッグが難しいです。エラーが起きたとき「どの画面でどの要素が見つからなかったか」が分からないと原因追跡に時間がかかります。

エラー時のスクリーンショット:

private async handleException(page: Page, message: string, error: any) {
  const screenshotPath = `output/screenshots/error-${dayjs().format("YYYYMMDDHHmmss")}.png`;
  await page.screenshot({ path: screenshotPath, fullPage: true });
  Logger.error(message);
  throw new Error();
}

セッション全体の動画記録:

private async checkContext() {
  if (this.context) return;

  const dir = `output/videos/${dayjs().format("YYYY-MM-DD")}`;
  const screenSize = await PowerShell.getScreenSize(); // 画面サイズ取得は環境依存(Windows: PowerShell, Mac: screencapture等)

  this.context = await this.browser.newContext({
    recordVideo: {
      dir,
      size: { width: screenSize.width, height: screenSize.height },
    },
  });
}

private async handleVideo(page: Page) {
  await page.close();
  await this.context.close(); // コンテキストを閉じると動画が保存される
  this.context = null;
}

page.close() だけでは動画が保存されません。context.close() を呼ぶことで、そのコンテキストで記録した動画がファイルに書き出されます。

リトライ実装

ブラウザ自動化は不安定になることがあります(ネットワーク遅延、要素の描画タイミングなど)。重要なフローには最大3回のリトライを実装しました。

public async getAzureCode(callback: string): Promise<string> {
  const maxRetries = 3;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    Logger.task(`試行 ${attempt}/${maxRetries}`);
    let page: Page | null = null;

    try {
      page = await this.context.newPage();
      // ... ログイン処理 ...
      return code; // 成功したらすぐ return
    } catch (error) {
      if (attempt < maxRetries) {
        Logger.warn(`失敗。2秒後にリトライします...`);
        if (page) await page.close();
        await new Promise((resolve) => setTimeout(resolve, 2000));
      } else {
        // 最終試行も失敗
        await this.handleException(page!, "全リトライが失敗しました。", error);
        throw error;
      }
    } finally {
      if (page) await this.handleVideo(page); // 各試行の動画を保存
    }
  }
  throw new Error("unreachable"); // TypeScriptの型チェック用
}

finally ブロックで各試行の動画を保存します。失敗した試行の動画も残るため、「何回目のリトライでどの画面で詰まったか」を後から確認できます。

まとめ

Playwright + Okta TOTPの自動化で押さえたポイントです。

TOTPのウィンドウ管理: 同じ30秒ウィンドウのOTPは1回しか使えない。lastCallTime を記録して、次回生成前に必要なら待機する。

Promise.race でUI分岐を処理: 「次にどの画面が出るか分からない」ときは、複数の要素を同時に監視して最初に表示されたものに応じて分岐する。

動画・スクリーンショットで可観測性を確保: context.close() で動画が確定する。エラー時のスクリーンショットと合わせて、ヘッドレスブラウザのデバッグを現実的にする。

リトライは各試行で動画を残す: 「成功した試行の動画」だけでなく「失敗した試行の動画」が原因追跡に役立つ。

MFAを含むログインフローは複雑ですが、Promise.race によるUI分岐処理とTOTP管理を組み合わせることで、実用に耐えるレベルで安定した自動化が実現できました。

この記事をシェアする

関連記事