
APIがない企業SSOにPlaywrightでサーバーサイド認証プロキシを作った話
はじめに
企業内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がない社内サービスに連携する必要があるケースです。
実装のアプローチ
全体の流れ

PlaywrightAuthServiceの実装
サービスクラスとして、ブラウザのライフサイクル管理とログインフローを分離しました。
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のログイン画面を操作します。
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からトークンを取得します。
// ブラウザストレージからトークンを抽出
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を抽出しています。
静的メソッドによるクレデンシャル管理
認証サービスはステートレスに使いたいが、クレデンシャルはアプリケーションのライフサイクルで保持する必要があります。静的フィールドでクレデンシャルを管理し、認証実行時にインスタンスを都度生成するアプローチにしました。
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()メソッドで確実にリソースを解放します。
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が提供されるまでの橋渡し」として使うべきパターンだと考えています。






