
PlaywrightでOkta MFA(TOTP)ログインを自動化する — OAuthフローからSSHトンネルまで
はじめに
業務自動化のスクリプトを作っていると、「MFAが必要なサービスへのログインを自動化したい」場面が繰り返し出てきます。OAuthの認可フローを通じてトークンを取得する処理や、SSHトンネルの認証を通過するために、毎回手動でMFAコードを入力するのは現実的ではありません。
この記事では、PlaywrightでOkta MFA(TOTP方式)のログインを自動化した実装と、2つのユースケース(OAuthトークン取得、Pomerium SSHトンネル認証)での適用パターンを紹介します。
TOTP自動生成のしくみ
Okta Verifyアプリで表示されるワンタイムパスワードは TOTP(Time-based One-Time Password) という規格に基づいています。シークレットキーと現在時刻から決定的に計算されるため、同じシークレットキーがあれば自分で生成できます。
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();

なぜ waitForNextOTP が必要か?
TOTPは30秒ごとに更新されますが、同じ30秒ウィンドウ内で生成したOTPは通常1回しか使えません(リプレイアタック防止)。連続してOTPを生成・使用する場面では、前回の生成から30秒経過していることを確認してから生成する必要があります。
NTP時刻同期の重要性: TOTPは時刻ベースのワンタイムパスワードです。サーバーの時刻がずれていると、生成したコードが無効になります。NTPで時刻同期されている環境であることを確認してください。
Oktaログインフローの自動化
TOTPが生成できたら、Playwrightでログインフローを自動化します。
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 });
Logger.success("Oktaログイン完了");
} catch (error) {
await this.handleException(page, "Oktaログインに失敗しました。", error);
} finally {
await this.handleVideo(page);
}
}
実装上の注意点:
locale: "en-US"を指定しないと、OktaのUIが日本語になりgetByLabelのセレクタが一致しなくなりますwaitFor({ state: "visible" })でMFA入力欄の表示を待つ。ログイン後のページ遷移には時間がかかるため- OktaのUIは定期的にアップデートされます。CIで定期テスト実行するか、失敗時にスクリーンショットを通知する仕組みを入れておくと安心です
ユースケース1: Promise.race でOAuth UIの分岐を処理する
ログインフローで難しいのは、次に表示される画面がサービスによって異なる ことです。
たとえばOAuthフローで別サービスへのログイン後、次に表示される画面が「初回ログイン → パスワード入力画面」の場合もあれば「セッションが残っている → すでにログイン済みの画面」の場合もあります。どちらが表示されるか、実行タイミングによって変わります。

if/else で分岐するには「どちらが先に表示されるか」を知る必要があります。Promise.race を使うと、最初に表示された要素に応じて分岐できます。
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);
};
別サービスではログイン後の画面パターンがさらに多く、3つ以上を Promise.race に渡すことがありました。
// Box の場合: 3パターンを同時に監視
await Promise.race([
this.waitForLocator(boxMailInput), // Box独自のメール入力
this.waitForLocator(oktaMailInput), // Okta認証画面
this.waitForLocator(grantButton), // すでに認証済みでアクセス許可画面
]);
ユースケース2: Pomerium SSHトンネルの認証を無人化する
自動化システムがSSHトンネル経由でKubernetesクラスターにアクセスする必要があるケースでは、Pomerium(ゼロトラストプロキシ)を経由し、Okta SSO + TOTP MFAの認証を通過する必要があります。

Pomeriumプロセスの起動と認証URLの取得
Pomerium CLIを子プロセスとして起動すると、認証が必要な場合に標準エラー出力にURLを出力します。
import { spawn, ChildProcess } from "child_process";
class PomeriumController {
private isConnected = false;
private isLocked = false;
private pomeriumProcess: ChildProcess | null = null;
private getAuthUrl(): Promise<string> {
return new Promise((resolve, reject) => {
this.pomeriumProcess = spawn("pomerium-cli", [
"tcp",
"YOUR_TARGET_HOST",
"--listen",
":2222",
]);
// 認証URLは stderr に出力される
this.pomeriumProcess.stderr?.on("data", (chunk) => {
const message = chunk.toString();
const match = message.match(/https?:\/\/[^\s]+/g);
if (match && match[0]) {
resolve(match[0]);
}
});
this.pomeriumProcess.on("error", (err) =>
reject(new Error(`Failed to spawn Pomerium: ${err.message}`))
);
});
}
}
二重認証防止のロック機構
自動化システムでは、複数のサブシステムが同時にトンネル接続を要求する場合があります。2つのフラグで二重認証を防ぎます。
public async start(): Promise<void> {
// ロック中 or 接続済みなら即リターン
if (this.isLocked || this.isConnected) {
console.log("Process is locked or already connected.");
return;
}
try {
this.isLocked = true;
const authUrl = await this.getAuthUrl();
await this.handleOktaLogin(authUrl);
this.isConnected = true;
} catch (error) {
console.error("Failed to establish tunnel:", error);
this.isConnected = false;
} finally {
this.isLocked = false; // 必ずロック解放
}
}
public stop(): void {
if (this.pomeriumProcess) {
this.pomeriumProcess.kill();
this.pomeriumProcess = null;
this.isConnected = false;
this.isLocked = false;
}
}
isLocked と isConnected の使い分け:
| フラグ | 目的 | trueになるタイミング |
|---|---|---|
isLocked |
認証処理中の再入防止 | start() 開始時 → finally で解放 |
isConnected |
認証済みの再実行防止 | 認証成功時 → stop() で解放 |
isLocked だけでは不十分です。認証が完了した後に別のサブシステムが start() を呼ぶと、再度ブラウザを起動してしまいます。isConnected が true ならすでにトンネルは確立されているため、即座にリターンします。
デバッグのための動画・スクリーンショット記録
ブラウザ自動化はデバッグが難しいです。エラーが起きたとき「どの画面でどの要素が見つからなかったか」が分からないと原因追跡に時間がかかります。
エラー時のスクリーンショット:
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();
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 ブロックで各試行の動画を保存します。失敗した試行の動画も残るため、「何回目のリトライでどの画面で詰まったか」を後から確認できます。
まとめ
TOTPのウィンドウ管理: 同じ30秒ウィンドウのOTPは1回しか使えない。lastCallTime を記録して、次回生成前に必要なら待機する。
Promise.race でUI分岐を処理: 「次にどの画面が出るか分からない」ときは、複数の要素を同時に監視して最初に表示されたものに応じて分岐する。
動画・スクリーンショットで可観測性を確保: context.close() で動画が確定する。エラー時のスクリーンショットと合わせて、ヘッドレスブラウザのデバッグを現実的にする。
Pomerium SSHトンネル: stderrから認証URLを取得し、isLocked + isConnected の2段階フラグで二重認証を防止する。
MFAを含むログインフローは複雑ですが、TOTPの自動生成、Promise.raceによるUI分岐処理、適切なロック機構を組み合わせることで、OAuthフローからSSHトンネル認証まで、実用に耐えるレベルで安定した自動化が実現できました。







