Ory Kratos API Exposure and the Small-Sample Problem
Ory Kratos API Exposure and the Small-Sample Problem
Ory Kratos is an API-first identity system, so its public surface may not look like a conventional login portal. A small external count can still be useful, provided the analyst does not use it to estimate adoption or risk. Sparse observations require more caution, not less.
The selected count-only query for title="Ory Kratos" used scope all, page 1, and one result on 2026-11-03. It returned two matches. The application fingerprint query returned zero in the same run. That combination is a weak external signal. It tells us that a title keyword produced a very small candidate set, not that only two Kratos deployments exist.
Why the sample stays small
Kratos can be deployed headless, behind a custom frontend, or under a product or tenant name that does not include "Ory Kratos" in the page title. A title search therefore misses most deployments by design. Some APIs return JSON or minimal responses rather than pages with titles. A small count is evidence about the query, not a statement about the product's global footprint.
Use the two candidates only as leads. Confirm ownership and product identity through an authorized endpoint or inventory. Do not scan for API paths or test authentication behavior without permission. The fact that an identity service has a public API does not make it safe to probe arbitrary tenants.
Review identity configuration
If the service is owned, review tenant isolation, public API routes, CORS settings, session and cookie configuration, and the exposure of administrative functions. Confirm the identity schema and recovery flows match the business policy. Check logs for unexpected account creation, recovery attempts, and session anomalies.
Identity systems also need version and migration discipline. A small sample can hide that a deployment is behind on security updates or uses a custom frontend with a different control surface. Keep version evidence and configuration ownership separate from an external title count.
Report the evidence plainly
Write the query, scope, page, timestamp, and count. Say "two title matches were observed" rather than "two Kratos services were found." Record the zero-result application query as part of the method. That context prevents a reader from treating a sparse dataset as a complete census.
ZoomEye is useful here as a reminder to check an identity system's external population and to compare it with internal inventory, even when the public count is small. The next step is an owner-led configuration review. For a service that handles authentication, a small number of unknown hosts can still deserve prompt attention.
References
- Ory, Ory Kratos documentation
- Ory, Ory Kratos project
- ZoomEye, ZoomEye
HỆ THỐNG XÁC THỰC HEADLESS CLOUD-NATIVE VIẾT BẰNG GO: ORY KRATOS TRAO TRỌN QUYỀN TÙY BIẾN GIAO DIỆN ĐĂNG NHẬP CHO FRONTEND
Nhiều giải pháp xác thực mã nguồn mở truyền thống (như Keycloak) thường ép bạn phải sử dụng giao diện web mặc định của họ hoặc bắt bạn phải viết các template giao diện Java/Freemarker phức tạp để tùy biến thương hiệu. Điều này phá vỡ trải nghiệm người dùng liền mạch trên các ứng dụng Single Page App (SPA) hay Mobile App hiện đại.
Ory Kratos là máy chủ quản lý danh tính và xác thực người dùng (Headless Identity & User Management) mã nguồn mở thế hệ mới, hoạt động theo triết lý API-First thuần túy.
Tech Stack:
Được viết hoàn toàn bằng Golang cho hiệu năng cao và độ trễ cực thấp. Hoạt động độc lập không có giao diện cố định, hỗ trợ cơ sở dữ liệu PostgreSQL, MySQL, CockroachDB và SQLite.
Tại sao nên dùng?
- Tự do thiết kế giao diện đăng nhập 100%: Bạn tự code màn hình Login, Register, Forgot Password bằng chính framework frontend yêu thích (React, Vue, Flutter, Next.js). Ory Kratos chỉ đóng vai trò backend cung cấp API xác thực và bảo mật.
- Chuẩn mực bảo mật không thỏa hiệp: Tích hợp sẵn xác thực hai lớp (2FA TOTP), đăng nhập không mật khẩu (Passkeys / WebAuthn), phục hồi tài khoản qua email và phát hiện tấn công brute-force.
- Kiến trúc Cloud-Native siêu nhẹ: Đóng gói thành một file binary duy nhất hoặc container Docker siêu gọn, dễ dàng mở rộng theo chiều ngang trên Kubernetes mà không ngốn tài nguyên.
Dự án sở hữu hơn 10.000 stars trên GitHub và là thành phần cốt lõi trong hệ sinh thái an ninh mạng mã nguồn mở của tổ chức Ory.
Link tham khảo: ory/kratos
#OpenSourceVN #VNOSS #opensourcevn #vnoss #DevCommunity #OryKratos #Identity #Authentication #Security #Golang #Headless
1 point
Laravel を IdP にして Ory Hydra で SSO を組み、PHP 5.6 のレガシーアプリも認証ブローカーで乗せる
背景
Laravel で作ったアプリにユーザー台帳とログイン画面があり、他のアプリにもそのログインを使わせたい。OIDC で SSO を組むとして、Laravel 側をどうやって OP にするかを決める必要がありました。もうひとつ、SSO に乗せたい側に PHP 5 系のアプリが残っていて、OIDC のクライアントライブラリが動きません。
この 2 つをローカルで試しました。コードは公開しています。
kikegamisprix/laravel-ory-hydra-sample
OP は Hydra、台帳と画面は Laravel のまま
最初は Laravel Passport に OIDC 拡張パッケージを足して Laravel の中で完結させる案を考えていました。調べると Passport 自体には ID トークンを出す機能がなく、拡張パッケージは個人保守のものが中心で、RP-Initiated Logout や Introspection が無いものもあります。軽く始めて後から別の OP に移す案も考えましたが、署名鍵、sub、セッション、リフレッシュトークンが移行対象になり、全クライアントの再設定と全員再ログインが発生します。それなら最初から分けた方が安いと判断して、プロトコル処理は Ory Hydra に任せることにしました。
Hydra はユーザー DB を持ちません。ログイン画面と同意画面は外部アプリ(login / consent provider)に委ね、その結果を Admin API で受け取る設計です。Laravel 側はいつものログイン画面を出し、ログインと同意のそれぞれで Admin API の取得と受理を呼ぶだけで、台帳を動かさずに済みます。
PHP 5 のアプリの方は、OIDC クライアントライブラリ(jumbojett/openid-connect-php)の現行版が PHP 7.0 以上を要求するので直接は組み込めません。0.9 系なら PHP 5.4 以上で動きますが、2022 年で止まっている版をレガシー側に入れるのは避けました。選択肢は 2 つあります。ひとつは mod_auth_openidc や oauth2-proxy のように Web サーバーやリバースプロキシの層で OIDC を済ませ、アプリにはヘッダーでユーザー識別子を渡す方法。アプリ側の改修はヘッダーを読む部分だけで済みますが、Web サーバーの構成に手を入れる必要があり、アプリ自身のログイン画面やセッション管理とは別の仕組みが前に立つことになります。もうひとつは、OIDC の処理を PHP 8 の小さなブローカーに寄せ、レガシー側には「ブローカーから受け取った一回限りのコードをサーバー間で引き換える」処理を足す方法。アプリのセッション管理をそのまま使え、Web サーバーの層を触らずに済みます。今回はアプリ側でセッションを持ち続けたかったので後者にしました。
ブローカーを置いても、レガシー側の改修がゼロになるわけではありません。ログイン開始とコールバックの 2 ファイルを足し、セッションの初期化部分に手を入れて(Cookie 名の分離、session.use_strict_mode、HttpOnly、ログイン開始時のセッション ID 再生成)、state をセッションで管理します。実際のシステムではさらに、OIDC の sub と既存のユーザーテーブルをどう対応づけるか(初回ログイン時の紐づけ)が一番大きな作業になります。
構成
ブラウザ
├─ localhost:8082 rp-modern PHP 8.3。OIDC クライアント。Hydra と直接通信
├─ localhost:8084 rp-legacy PHP 5.6。OIDC 非対応。ブローカーに委ねる
├─ localhost:8083 broker PHP 8.3。OIDC を代行し、引換コードを rp-legacy に渡す
├─ localhost:8081 idp Laravel 12。Hydra のログイン / 同意プロバイダ。台帳を持つ
└─ localhost:4444 hydra Ory Hydra v26.2.0 + PostgreSQL 16
対象
バージョン
Ory Hydra
v26.2.0(2026-03-20)
Laravel
12.x(composer の platform は PHP 8.2、コンテナは 8.3)
jumbojett/openid-connect-php
1.0.2
PHP(rp-legacy)
5.6.40
rp-modern は、OIDC 対応済みのアプリならブローカー無しで済むことを見せるために置いています。
./scripts/setup.sh # 秘密値を生成して .env を作る。デモユーザーのパスワードが表示される
docker compose up -d --build # 初回は composer install が走る
./scripts/create-clients.sh # Hydra に rp-modern と broker を登録し、client_id と secret を各 .env に書く
秘密値(DB パスワード、Hydra の SECRETS_SYSTEM、ブローカーの共有シークレット、デモユーザーのパスワード)はすべて setup.sh が生成し、リポジトリには置いていません。公開ポートは 127.0.0.1 にだけ開けています。
Laravel 側
Hydra は認可要求を受けると login_challenge を付けて Laravel の /login にリダイレクトしてきます。Laravel は Admin API でリクエストの中身を取り、認証できたら subject を付けて受理します。
// app/Http/Controllers/Hydra/LoginController.php(抜粋)
public function show(Request $request): View|RedirectResponse
{
$challenge = (string) $request->query('login_challenge', '');
$loginRequest = $this->hydra->getLoginRequest($challenge);
if ($loginRequest['skip'] ?? false) {
$subject = (string) $loginRequest['subject'];
$user = User::find($subject);
if ($user === null || ! $user->isActive()) {
return redirect()->away($this->hydra->rejectLoginRequest(
$challenge, 'access_denied', 'このアカウントは無効です'
));
}
if (Auth::check() && (string) Auth::id() !== $subject) {
$this->hydra->revokeLoginSessions($subject);
return redirect()->away($this->hydra->rejectLoginRequest(
$challenge, 'login_required', 'ログインし直してください'
));
}
return redirect()->away($this->hydra->acceptLoginRequest($challenge, [
'subject' => $subject,
]));
}
// remember は付けない。Hydra 側に認証時刻の新しいセッションを作らないため
if (Auth::check() && Auth::user()->isActive() && ! $this->reauthRequired($loginRequest, $request)) {
return redirect()->away($this->hydra->acceptLoginRequest($challenge, [
'subject' => (string) Auth::id(),
]));
}
return view('hydra.login', ['challenge' => $challenge, /* ... */]);
}
Laravel 側の受理には 3 つの経路があります。
経路
条件
Hydra の remember
Hydra から見た認証時刻
skip
Hydra のログインセッションが残っている
付けない(既存セッションを使う)
元のセッションの時刻
Laravel セッションの再利用
Laravel にログイン済みで、再認証要求も絶対上限超過もない
付けない
受理した時刻
パスワード入力
上のどちらでもない
付ける(1 時間)
入力した時刻
skip は Hydra 側のログインセッションが残っているときに true で来るので、画面を出さずに受理します。受理する前に台帳でユーザーがまだ有効かを見ているのは、これを省くと台帳で無効化した後も Hydra のセッションが切れるまでログインできてしまうからです。あわせて、Laravel 側が別のユーザーでログインしていれば、残っていた Hydra のセッションを失効させて reject します。失効は subject 単位なので、前のユーザーが他の端末に持つ Hydra セッションも切れます。Hydra を通らないログイン画面(既存のポータルのもの)で別のユーザーが入れ直したとき、前のユーザーの Hydra セッションが残って RP に前のユーザーとして通るのを防ぐためです。
その次の Auth::check() で、Laravel 自身のセッションでログイン済みならそのまま受理します。Laravel にログイン済みなら他のアプリもログイン済み、という動きはここで決まります。この経路で受理すると、Hydra から見た認証時刻は Laravel でログインした時刻ではなく受理した時刻になります(accept login に認証時刻を渡す項目はありません)。そのため remember は付けず、Hydra のログインセッションはパスワードを入力したときだけ作ります。ID トークンの auth_time もこの経路では受理時刻になるので、RP が「最近認証したか」を判断するときは auth_time を自分で見ず、max_age か prompt=login を使います。
最初は再利用経路でも remember を付けていました。その状態だと、2 時間前のログインを再利用した直後に別の RP が max_age=600 で要求したとき、Hydra が自分の新しいセッションを見て skip で回し、パスワード入力なしで通ります。
prompt=login と max_age 超過は Hydra 自身が処理します。v26.2.0 のソースでは、どちらの場合もセッションを使わずに login provider へ回す(skip が立たない)ので、prompt=login と max_age を Laravel 側で判定するのは再利用経路だけです。受理した subject が Hydra セッションの subject と違う場合も、Hydra が prompt=login を付けて要求をやり直させます。reauthRequired() は認可要求の URL から prompt=login と max_age を読み、あわせて最後のパスワード入力からの絶対上限(8 時間)を見ます。Laravel のセッションは無操作タイムアウトで延び続けるので、上限を別に持たないとパスワード入力なしの SSO が無期限に続きます。
この判定は Laravel セッションの auth_time を前提にしていて、サンプルではパスワード認証時に submit() が入れています。既存のログイン処理に組み込むときは、そちらにも auth_time を入れる 1 行が要ります。無ければ再利用経路は一度も通りません。
prompt=none(サイレント認証)は、Hydra にログインセッションがないと login provider を呼ばずに login_required を返します。再利用経路は Hydra のセッションを更新しないので、パスワードを入れ直すまで Hydra のセッション(1 時間)が切れたままになり、Laravel のセッションが生きていても prompt=none は失敗します。prompt=none を使う RP があるなら、Hydra のセッション寿命が SSO の上限になります。
メールは変わることがあり、変わると全クライアントで別人扱いになるので、subject にはメールやログイン ID ではなく users.id を入れています。
逆方向も要ります。Laravel からログアウトしたときに Hydra のログインセッションを消さないと、remember_for の間は他のアプリから再ログインなしで通ります。ログアウト処理で Admin API の DELETE /admin/oauth2/auth/sessions/login?subject=... を呼んでいます。ユーザーを無効化するときは同じことをコマンド(user:disable)でやり、ログインセッションに加えて同意セッションも消します。Hydra の API 仕様では、同意セッションの失効で紐づくアクセストークンも無効化されます(リフレッシュトークンについては仕様に明記がありません)。ログイン POST には throttle:5,1 を付けています。
同意画面は出していません。クライアント登録時に --skip-consent を付けると consent request の client.skip_consent が true で来る(skip 自体は初回なら false)ので、それを見て要求されたスコープをそのまま許可し、ID トークンに入れる claim を台帳から詰めて受理します。許可されるスコープの上限はクライアント登録時の --scope で決まります。
// app/Http/Controllers/Hydra/ConsentController.php(抜粋)
return $this->hydra->acceptConsentRequest($challenge, [
'grant_scope' => $grantScope,
'grant_access_token_audience' => $consentRequest['requested_access_token_audience'] ?? [],
'remember' => true,
'remember_for' => config('hydra.remember_for'),
'session' => ['id_token' => (object) $idToken],
]);
Admin API は Http ファサードで包んだだけで、Hydra の SDK は使っていません。login の取得・受理・拒否、consent の取得・受理、セッション失効 2 本の計 7 本です(app/Services/HydraAdmin.php)。
rp-modern
PHP 8 側は jumbojett/openid-connect-php の authenticate() を呼ぶだけで、state、nonce、PKCE(S256)、ID トークンの署名と iss / aud / nonce の検証はライブラリが行います。
ひとつ引っかかったのは issuer です。Hydra の issuer はブラウザから見える localhost ですが、コンテナからトークンエンドポイントを叩くときの宛先は hydra です。Discovery に頼るとエンドポイントが localhost で返ってきてコンテナから届かないので、Discovery を使わずに各エンドポイントを直接指定しました。実コードは環境変数から読んでいますが、値を展開して示します。
// rp-modern/oidc.php(抜粋、環境変数の値を展開して表示)
$oidc = new OpenIDConnectClient($issuer, $clientId, $clientSecret, $issuer);
$oidc->providerConfigParam([
'issuer' => $issuer, // localhost
'authorization_endpoint' => 'localhost', // ブラウザ向け
'token_endpoint' => 'hydra', // コンテナ間
'jwks_uri' => 'hydra',
'userinfo_endpoint' => 'hydra',
'token_endpoint_auth_methods_supported' => ['client_secret_basic'],
'code_challenge_methods_supported' => ['S256'],
]);
最後の code_challenge_methods_supported を入れないと、PKCE の方式を確認しにライブラリが Discovery を取りに行って localhost:4444 に繋がらず落ちます。最初はここで止まりました。本番で issuer のドメインがどこからでも同じに引けるなら、この指定は要りません。
broker と rp-legacy
ブローカーが認可要求から ID トークン検証までを済ませ、レガシー側には OIDC のトークンを渡しません。渡すのは 60 秒で切れる一回限りの引換コードです。
最初の版では state を入れていませんでした。OIDC の区間はライブラリが state と PKCE を見ていますが、ブローカーからレガシーに戻る区間は自分で作った部分で、ここに同じ仕組みが要ることを見落としていました。見直す中で出てきた攻撃と対策をまとめます。
攻撃
前提
対策
1. ログイン CSRF
被害者に攻撃者のページを開かせられる
レガシー側で state を生成してセッションに置き、戻りで照合する
2. 盗まれた引換コードの横取り
引換コードを 60 秒以内に拾える
ブローカーが state のハッシュをコードと一緒に保存し、引き換え時にレガシー側がセッションの state を送って照合する
3. 攻撃者が始めたフローを被害者に完了させる
開始 URL を踏ませられ、かつ引換コードを拾える
不一致の引き換え要求でもコードを失効させる。コードは URL に載せず POST フォームで渡す
4. 3 を Hydra の認可コードで行う
認可コードをアクセスログから拾える
ttl.auth_code を 1 分に短縮、Referrer-Policy: no-referrer、コールバック URL のクエリをログに残さない運用。閉じ切れないので残るリスクとして扱う
それぞれの中身です。
攻撃者が自分のアカウントでログインを始めて引換コードを取り、攻撃者のページから rp-legacy のコールバックへそのコードを入れたフォームを自動送信させると、被害者が攻撃者としてログインした状態になります。state の照合で拒否され、そのコードも失効します
state は URL に出るので秘密ではありません。効いているのは、攻撃者が自分のセッションに被害者の state を入れられないことです
攻撃者が自分のセッションで state を作り、ブローカーの開始 URL を被害者に踏ませます。被害者はログイン済みなので画面は出ず、「被害者の sub、攻撃者の state」でコードが発行されます。被害者側のコールバックは不一致で失敗しますが、未使用のコードが残れば攻撃者が引き換えられます。被害者のブラウザがレガシー側に到達すれば失効するので、残る漏洩経路は POST 本文や HTML を記録する中間装置に限られます
攻撃者が自分のブローカーセッションで作った Hydra の認可 URL を被害者に踏ませます。被害者は 1 クリックで broker/callback.php?code=<認可コード> に戻りますが、開始情報がないので 400 で止まり、認可コードは未使用で残ります。攻撃者のセッションには state と PKCE の verifier があるので、同じ URL を開けばトークン交換が通ります。PKCE では防げません(challenge が攻撃者のもの)。php の組み込みサーバーはクエリをログに残すので、本番はリバースプロキシのログ形式で落とします。Referrer-Policy は、ブローカーからレガシー側へ POST するときの Referer に認可コードが載り、レガシー側のアクセスログに残るのを防ぐためのものです
4 は rp-modern にも同じ構造があり、ブローカー固有の問題ではありません。OIDC の認可コードをログに残さない運用は、どのクライアントでも前提になります。
POST フォームで渡す方式には構成上の前提があります。ブローカーとレガシーアプリが別サイト(登録可能ドメインが違う)だと、自動 POST はクロスサイトになります。SameSite 属性の付かない PHP 5.6 のセッション Cookie は、Chrome 系のように省略時を Lax 扱いするブラウザでは送られないことがあり、送られなければ state が空になってログインが失敗します。成否がブラウザや Cookie の経過時間に依存するので、両者は同一サイトに置きます。フォームには「続行」ボタンも常に出していて、CSP でインラインスクリプトを止めている環境ではボタンで進みます。
レガシー側に足したのは login.php と callback.php の 2 ファイルと、セッション初期化の変更です。PHP 5.6 なので null 合体演算子と random_bytes(どちらも 7.0 以降)は使っていません。
// rp-legacy/public/callback.php(抜粋)。POST でしか受けない
$code = isset($_POST['code']) ? $_POST['code'] : '';
$state = isset($_POST['state']) ? $_POST['state'] : '';
$expected = isset($_SESSION['sso_state']) ? $_SESSION['sso_state'] : '';
unset($_SESSION['sso_state']);
$stateOk = ($state !== '' && $expected !== '' && hash_equals($expected, $state));
// state が合わなくてもブローカーに送る。ブローカーは要求を受けた時点でコードを失効させる
$ch = curl_init(getenv('BROKER_EXCHANGE_URL')); // broker
curl_setopt_array($ch, array(
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(array(
'app_id' => getenv('BROKER_APP_ID'),
'secret' => getenv('BROKER_EXCHANGE_SECRET'),
'code' => $code,
'state' => $expected, // URL やフォームの値ではなくセッションの値を送る
)),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
));
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$json = json_decode($body, true);
if (!$stateOk || $status !== 200 || !isset($json['user']['sub'])) {
http_response_code(400);
exit;
}
session_regenerate_id(true);
$_SESSION['user'] = $json['user'];
ブローカー側の引き換えは、判定と使用済み化を UPDATE 1 文で行い、更新できた場合だけ state を照合してユーザー情報を返します。SELECT してから UPDATE すると、文と文の間に別のリクエストが割り込めるので二重引き換えが起きます。SQLite でも同じです。保存先を Redis に変えるなら GETDEL か Lua で判定と削除を 1 操作にします。
// broker/store.php(抜粋)
$stmt = $pdo->prepare('UPDATE exchange_codes SET used = 1
WHERE code = ? AND app_id = ? AND used = 0 AND expires_at >= ?');
$stmt->execute([$code, $appId, time()]);
if ($stmt->rowCount() !== 1) {
return null; // 未発行・使用済み・期限切れ・アプリ違い
}
$row = $pdo->prepare('SELECT state_hash, claims FROM exchange_codes WHERE code = ?');
$row->execute([$code]);
$found = $row->fetch(PDO::FETCH_ASSOC);
if (!$found || !hash_equals($found['state_hash'], stateHash($state))) {
return null; // 失効はさせたうえで拒否
}
戻り先は登録した URL との完全一致にしています。前方一致だと、レガシーアプリ側にオープンリダイレクトがひとつでもあれば引換コードが外に漏れます。ブローカーを使うアプリは id、シークレット、戻り先の組を環境変数で持ち、アプリごとに別のシークレットにします。
動かした結果
未ログインの rp-modern です。
ログインをクリックすると、Hydra を経由して Laravel のログイン画面に来ます。URL は localhost:8081/login?login_challenge=... です。
デモユーザーでログインすると rp-modern に戻り、ID トークンの claim が出ます。sub は users.id、iss は Hydra の issuer、aud はクライアント ID です。
続けて rp-legacy を開くと未ログインです。
「ブローカー経由でログイン」をクリックすると、ログイン画面を通らずに戻ってきて、PHP 5.6 側のセッションにユーザー情報が入っています。
このときのログを整形して抜粋します(値はマスク)。ブローカーが start.php と callback.php を処理し、rp-legacy のコンテナ(172.20.0.7)から exchange.php が POST されています。rp-legacy の callback.php は POST で受けるので引換コードはログに出ませんが、ブローカーの callback.php には Hydra の認可コードがクエリで残ります。上の表の 4 つ目はこれが前提です。php の組み込みサーバーはレスポンス完了時に 1 行出すので、rp-legacy の処理中に呼ばれる exchange.php の方が先に記録されています。
rp-legacy | GET /login.php [302]
broker | GET /start.php?return_to=...&state=<masked> [302]
hydra | GET /oauth2/auth 302 -> localhost
idp | GET /login (Hydra のセッションが有効なため画面なしで受理)
hydra | GET /oauth2/auth 302 -> localhost
idp | GET /consent (skip_consent のため画面なしで受理)
hydra | GET /oauth2/auth 303 -> localhost
hydra | POST /oauth2/token 200 (broker コンテナから)
broker | GET /callback.php?code=<Hydra の認可コード>&scope=...&state=<masked> [200] 自動送信の POST フォームを返す
broker | POST /exchange.php [200] (172.20.0.7 = rp-legacy コンテナから)
rp-legacy | POST /callback.php [302]
引換コードの方は、ブローカーのコンテナ内で未使用のコードを発行してから exchange.php を直接叩いて確かめました。
S_attacker(攻撃者が始めたフロー想定)と S_victim(正常系)でそれぞれ発行する
docker compose exec broker php -r 'require "/app/store.php"; echo issueExchangeCode("rp-legacy", "S_attacker", ["sub"=>"1","email"=>"demo@example.com","name"=>"Demo User"]);'
docker compose exec broker php -r 'require "/app/store.php"; echo issueExchangeCode("rp-legacy", "S_victim", ["sub"=>"1","email"=>"demo@example.com","name"=>"Demo User"]);'
攻撃者が始めたフロー: 発行時と違う state(被害者側のセッションには何もない)で引き換えると 400 で、コードは失効する。続けて発行時の state で引き換えても 400
正常系: 発行時の state で 1 回だけ 200、再利用は 400
未登録のアプリ id は 401、登録と一致しない戻り先での開始は 400、callback.php を GET で開くと 405
$ ex() { curl -s -X POST localhost -d "secret=<値>" -w ' http=%{http_code}\n' "$@"; }
$ ex -d app_id=rp-legacy -d "code=<S_attacker で発行>" -d "state="
{"error":"invalid_or_expired_code"} http=400
$ ex -d app_id=rp-legacy -d "code=<同じコード>" -d "state=S_attacker"
{"error":"invalid_or_expired_code"} http=400
$ ex -d app_id=rp-legacy -d "code=<S_victim で発行>" -d "state=S_victim"
{"user":{"sub":"1","email":"demo@example.com","name":"Demo User"}} http=200
$ ex -d app_id=rp-legacy -d "code=<同じコード>" -d "state=S_victim"
{"error":"invalid_or_expired_code"} http=400
$ ex -d app_id=other-app -d code=x -d state=x
{"error":"unauthorized"} http=401
$ curl -s -o /dev/null -w '%{http_code}\n' 'localhost'
400
$ curl -s -o /dev/null -w '%{http_code}\n' 'localhost'
405
セッションまわりの確認結果です。
Laravel からログアウトすると Hydra のログインセッションのレコードが 0 件になり、次のログインでは画面が出る
台帳で disabled_at だけ立てて Hydra のセッションを残した状態で rp-legacy からログインすると、Hydra は skip で Laravel に回すが、Laravel が reject する。ブローカーに返るエラーは次のとおり
localhost...
rp-modern のログイン後画面にある「再認証」リンク(prompt=login / max_age=10)から始めると、Laravel にログイン済みでもパスワード入力の画面が出る(max_age=10 はパスワード入力から 10 秒以上たってから試す)
Hydra のログインセッションを Admin API で消し、Laravel のセッションだけ残した状態で rp-modern からログインすると画面なしで通る。そのあとの Hydra のセッション行は remember=false で、次の要求で skip にはならない
ハマったところ
composer の platform で一度止まりました。ローカルの PHP 8.4 で composer create-project したため Symfony 8 系(PHP 8.4 以上)で lock され、PHP 8.3 のコンテナで依存解決に失敗しました。composer config platform.php 8.2.0 で lock し直しています。
セッション Cookie の名前も衝突します。同じ localhost で複数の PHP アプリを動かすと Cookie はポートを区別しないので PHPSESSID が共有されます。アプリごとに session_name() を分けました。
スクリーンショットを撮り直そうとして未ログインに戻せず、Hydra のログインセッション(remember で 1 時間)と Laravel のセッションが別々に生きていることに気づきました。ログアウトと無効化で Hydra 側も消す処理は、ここから入れたものです。
Laravel の HTTP クライアントで delete($url, $data) と書くと、$data はクエリではなく本文に入ります。Hydra の失効 API はクエリの subject を要求するので 400 が返り、withQueryParameters() に直しました。
このサンプルで省いたもの
クライアント側から始めるログアウト(RP-Initiated Logout)。Hydra は対応しているので、必要になったら urls.logout とログアウトプロバイダを足す
Token Introspection。Hydra の Admin API にあり、RP が使うときに呼ぶだけ
TLS。Hydra は --dev で HTTPS なしで動かしている。本番は TLS 終端を前に置く
Admin API(4445)と exchange.php の隔離。サンプルでは 127.0.0.1 に開けている。本番は Admin API を外に出さず、exchange.php は内部向けを別のポートかバーチャルホストに分けるか、リバースプロキシでパス単位に遮断する
ブローカーで失敗したときにレガシー側へ戻す処理。今はブローカーの画面で止まる
ブローカーで進行中のフローを複数持つこと。今はセッションに 1 件だけで、タブを 2 つ開いて同時に始めると先のフローが壊れる。rp-legacy からブラウザの「戻る」でブローカーのコールバックに戻った場合も 400 になる
Request Object や PAR で渡された prompt / max_age の読み取り。再利用経路の判定は request_url のクエリしか見ていない
先に決めること
sub の採番規則。全利用者種別を横断する不変の内部 ID を先に決める
既存のユーザーテーブルと sub の対応づけ。レガシー側の作業で一番大きいのはここ
SSO の実効寿命。Hydra の remember_for、Laravel の無操作タイムアウト、再利用経路の絶対上限(サンプルでは 8 時間)のどれで制御するかを決める。prompt=none を使う RP があるなら Hydra のセッション寿命が上限になる
認可コードをログに残さない運用。リバースプロキシのログ形式でクエリを落とす
ブローカーとレガシーアプリの配置。同一サイトに置く
クライアント側のセッション寿命。無効化しても各アプリのセッションはその寿命まで残るので短くする
issuer のドメイン。変えると全クライアントの再設定と再ログインになるので、サービス名が変わっても影響しないドメインにする
Hydra の OpenID Foundation 認定は、現行バージョンで取れているか確認できていない。README は認定を謳っているが、認定実装一覧で現行版の記載を見つけられなかった。採用判断に使うなら Ory に確認する
片付け
docker compose down -v
参考
Ory Hydra: ory/hydra
Login & Consent フロー: ory.com
Hydra Admin API: ory.com
jumbojett/openid-connect-php: packagist.org
RFC 9700(OAuth 2.0 Security Best Current Practice): datatracker.ietf.org
サンプルコード: kikegamisprix/laravel-ory-hydra-sample
Mentions
Ory Kratos API Exposure and the Small-Sample Problem Ory Kratos API Exposure and the Small-Sample Problem Ory Kratos is an API-first identity system, so its public surface may not look like a conventional login portal. A small external count can still be useful, provided the analyst does not use it to estimate adoption or risk. Sparse observations require more caution, not less. The selected count-only query for title="Ory Kratos" used scope all, page 1, and one result on 2026-11-03. It returned two matches. The application fingerprint query returned zero in the same run. That combination is a weak external signal. It tells us that a title keyword produced a very small candidate set, not that only two Kratos deployments exist. Why the sample stays small Kratos can be deployed headless, behind a custom frontend, or under a product or tenant name that does not include "Ory Kratos" in the page title. A title search therefore misses most deployments by design. Some APIs return JSON or minimal responses rather than pages with titles. A small count is evidence about the query, not a statement about the product's global footprint. Use the two candidates only as leads. Confirm ownership and product identity through an authorized endpoint or inventory. Do not scan for API paths or test authentication behavior without permission. The fact that an identity service has a public API does not make it safe to probe arbitrary tenants. Review identity configuration If the service is owned, review tenant isolation, public API routes, CORS settings, session and cookie configuration, and the exposure of administrative functions. Confirm the identity schema and recovery flows match the business policy. Check logs for unexpected account creation, recovery attempts, and session anomalies. Identity systems also need version and migration discipline. A small sample can hide that a deployment is behind on security updates or uses a custom frontend with a different control surface. Keep version evidence and configuration ownership separate from an external title count. Report the evidence plainly Write the query, scope, page, timestamp, and count. Say "two title matches were observed" rather than "two Kratos services were found." Record the zero-result application query as part of the method. That context prevents a reader from treating a sparse dataset as a complete census. ZoomEye is useful here as a reminder to check an identity system's external population and to compare it with internal inventory, even when the public count is small. The next step is an owner-led configuration review. For a service that handles authentication, a small number of unknown hosts can still deserve prompt attention. References - Ory, Ory Kratos documentation - Ory, Ory Kratos project - ZoomEye, ZoomEye
HỆ THỐNG XÁC THỰC HEADLESS CLOUD-NATIVE VIẾT BẰNG GO: ORY KRATOS TRAO TRỌN QUYỀN TÙY BIẾN GIAO DIỆN ĐĂNG NHẬP CHO FRONTEND Nhiều giải pháp xác thực mã nguồn mở truyền thống (như Keycloak) thường ép bạn phải sử dụng giao diện web mặc định của họ hoặc bắt bạn phải viết các template giao diện Java/Freemarker phức tạp để tùy biến thương hiệu. Điều này phá vỡ trải nghiệm người dùng liền mạch trên các ứng dụng Single Page App (SPA) hay Mobile App hiện đại. Ory Kratos là máy chủ quản lý danh tính và xác thực người dùng (Headless Identity & User Management) mã nguồn mở thế hệ mới, hoạt động theo triết lý API-First thuần túy. Tech Stack: Được viết hoàn toàn bằng Golang cho hiệu năng cao và độ trễ cực thấp. Hoạt động độc lập không có giao diện cố định, hỗ trợ cơ sở dữ liệu PostgreSQL, MySQL, CockroachDB và SQLite. Tại sao nên dùng? - Tự do thiết kế giao diện đăng nhập 100%: Bạn tự code màn hình Login, Register, Forgot Password bằng chính framework frontend yêu thích (React, Vue, Flutter, Next.js). Ory Kratos chỉ đóng vai trò backend cung cấp API xác thực và bảo mật. - Chuẩn mực bảo mật không thỏa hiệp: Tích hợp sẵn xác thực hai lớp (2FA TOTP), đăng nhập không mật khẩu (Passkeys / WebAuthn), phục hồi tài khoản qua email và phát hiện tấn công brute-force. - Kiến trúc Cloud-Native siêu nhẹ: Đóng gói thành một file binary duy nhất hoặc container Docker siêu gọn, dễ dàng mở rộng theo chiều ngang trên Kubernetes mà không ngốn tài nguyên. Dự án sở hữu hơn 10.000 stars trên GitHub và là thành phần cốt lõi trong hệ sinh thái an ninh mạng mã nguồn mở của tổ chức Ory. Link tham khảo: ory/kratos #OpenSourceVN #VNOSS #opensourcevn #vnoss #DevCommunity #OryKratos #Identity #Authentication #Security #Golang #Headless
Laravel を IdP にして Ory Hydra で SSO を組み、PHP 5.6 のレガシーアプリも認証ブローカーで乗せる 背景 Laravel で作ったアプリにユーザー台帳とログイン画面があり、他のアプリにもそのログインを使わせたい。OIDC で SSO を組むとして、Laravel 側をどうやって OP にするかを決める必要がありました。もうひとつ、SSO に乗せたい側に PHP 5 系のアプリが残っていて、OIDC のクライアントライブラリが動きません。 この 2 つをローカルで試しました。コードは公開しています。 kikegamisprix/laravel-ory-hydra-sample OP は Hydra、台帳と画面は Laravel のまま 最初は Laravel Passport に OIDC 拡張パッケージを足して Laravel の中で完結させる案を考えていました。調べると Passport 自体には ID トークンを出す機能がなく、拡張パッケージは個人保守のものが中心で、RP-Initiated Logout や Introspection が無いものもあります。軽く始めて後から別の OP に移す案も考えましたが、署名鍵、sub、セッション、リフレッシュトークンが移行対象になり、全クライアントの再設定と全員再ログインが発生します。それなら最初から分けた方が安いと判断して、プロトコル処理は Ory Hydra に任せることにしました。 Hydra はユーザー DB を持ちません。ログイン画面と同意画面は外部アプリ(login / consent provider)に委ね、その結果を Admin API で受け取る設計です。Laravel 側はいつものログイン画面を出し、ログインと同意のそれぞれで Admin API の取得と受理を呼ぶだけで、台帳を動かさずに済みます。 PHP 5 のアプリの方は、OIDC クライアントライブラリ(jumbojett/openid-connect-php)の現行版が PHP 7.0 以上を要求するので直接は組み込めません。0.9 系なら PHP 5.4 以上で動きますが、2022 年で止まっている版をレガシー側に入れるのは避けました。選択肢は 2 つあります。ひとつは mod_auth_openidc や oauth2-proxy のように Web サーバーやリバースプロキシの層で OIDC を済ませ、アプリにはヘッダーでユーザー識別子を渡す方法。アプリ側の改修はヘッダーを読む部分だけで済みますが、Web サーバーの構成に手を入れる必要があり、アプリ自身のログイン画面やセッション管理とは別の仕組みが前に立つことになります。もうひとつは、OIDC の処理を PHP 8 の小さなブローカーに寄せ、レガシー側には「ブローカーから受け取った一回限りのコードをサーバー間で引き換える」処理を足す方法。アプリのセッション管理をそのまま使え、Web サーバーの層を触らずに済みます。今回はアプリ側でセッションを持ち続けたかったので後者にしました。 ブローカーを置いても、レガシー側の改修がゼロになるわけではありません。ログイン開始とコールバックの 2 ファイルを足し、セッションの初期化部分に手を入れて(Cookie 名の分離、session.use_strict_mode、HttpOnly、ログイン開始時のセッション ID 再生成)、state をセッションで管理します。実際のシステムではさらに、OIDC の sub と既存のユーザーテーブルをどう対応づけるか(初回ログイン時の紐づけ)が一番大きな作業になります。 構成 ブラウザ ├─ localhost:8082 rp-modern PHP 8.3。OIDC クライアント。Hydra と直接通信 ├─ localhost:8084 rp-legacy PHP 5.6。OIDC 非対応。ブローカーに委ねる ├─ localhost:8083 broker PHP 8.3。OIDC を代行し、引換コードを rp-legacy に渡す ├─ localhost:8081 idp Laravel 12。Hydra のログイン / 同意プロバイダ。台帳を持つ └─ localhost:4444 hydra Ory Hydra v26.2.0 + PostgreSQL 16 対象 バージョン Ory Hydra v26.2.0(2026-03-20) Laravel 12.x(composer の platform は PHP 8.2、コンテナは 8.3) jumbojett/openid-connect-php 1.0.2 PHP(rp-legacy) 5.6.40 rp-modern は、OIDC 対応済みのアプリならブローカー無しで済むことを見せるために置いています。 ./scripts/setup.sh # 秘密値を生成して .env を作る。デモユーザーのパスワードが表示される docker compose up -d --build # 初回は composer install が走る ./scripts/create-clients.sh # Hydra に rp-modern と broker を登録し、client_id と secret を各 .env に書く 秘密値(DB パスワード、Hydra の SECRETS_SYSTEM、ブローカーの共有シークレット、デモユーザーのパスワード)はすべて setup.sh が生成し、リポジトリには置いていません。公開ポートは 127.0.0.1 にだけ開けています。 Laravel 側 Hydra は認可要求を受けると login_challenge を付けて Laravel の /login にリダイレクトしてきます。Laravel は Admin API でリクエストの中身を取り、認証できたら subject を付けて受理します。 // app/Http/Controllers/Hydra/LoginController.php(抜粋) public function show(Request $request): View|RedirectResponse { $challenge = (string) $request->query('login_challenge', ''); $loginRequest = $this->hydra->getLoginRequest($challenge); if ($loginRequest['skip'] ?? false) { $subject = (string) $loginRequest['subject']; $user = User::find($subject); if ($user === null || ! $user->isActive()) { return redirect()->away($this->hydra->rejectLoginRequest( $challenge, 'access_denied', 'このアカウントは無効です' )); } if (Auth::check() && (string) Auth::id() !== $subject) { $this->hydra->revokeLoginSessions($subject); return redirect()->away($this->hydra->rejectLoginRequest( $challenge, 'login_required', 'ログインし直してください' )); } return redirect()->away($this->hydra->acceptLoginRequest($challenge, [ 'subject' => $subject, ])); } // remember は付けない。Hydra 側に認証時刻の新しいセッションを作らないため if (Auth::check() && Auth::user()->isActive() && ! $this->reauthRequired($loginRequest, $request)) { return redirect()->away($this->hydra->acceptLoginRequest($challenge, [ 'subject' => (string) Auth::id(), ])); } return view('hydra.login', ['challenge' => $challenge, /* ... */]); } Laravel 側の受理には 3 つの経路があります。 経路 条件 Hydra の remember Hydra から見た認証時刻 skip Hydra のログインセッションが残っている 付けない(既存セッションを使う) 元のセッションの時刻 Laravel セッションの再利用 Laravel にログイン済みで、再認証要求も絶対上限超過もない 付けない 受理した時刻 パスワード入力 上のどちらでもない 付ける(1 時間) 入力した時刻 skip は Hydra 側のログインセッションが残っているときに true で来るので、画面を出さずに受理します。受理する前に台帳でユーザーがまだ有効かを見ているのは、これを省くと台帳で無効化した後も Hydra のセッションが切れるまでログインできてしまうからです。あわせて、Laravel 側が別のユーザーでログインしていれば、残っていた Hydra のセッションを失効させて reject します。失効は subject 単位なので、前のユーザーが他の端末に持つ Hydra セッションも切れます。Hydra を通らないログイン画面(既存のポータルのもの)で別のユーザーが入れ直したとき、前のユーザーの Hydra セッションが残って RP に前のユーザーとして通るのを防ぐためです。 その次の Auth::check() で、Laravel 自身のセッションでログイン済みならそのまま受理します。Laravel にログイン済みなら他のアプリもログイン済み、という動きはここで決まります。この経路で受理すると、Hydra から見た認証時刻は Laravel でログインした時刻ではなく受理した時刻になります(accept login に認証時刻を渡す項目はありません)。そのため remember は付けず、Hydra のログインセッションはパスワードを入力したときだけ作ります。ID トークンの auth_time もこの経路では受理時刻になるので、RP が「最近認証したか」を判断するときは auth_time を自分で見ず、max_age か prompt=login を使います。 最初は再利用経路でも remember を付けていました。その状態だと、2 時間前のログインを再利用した直後に別の RP が max_age=600 で要求したとき、Hydra が自分の新しいセッションを見て skip で回し、パスワード入力なしで通ります。 prompt=login と max_age 超過は Hydra 自身が処理します。v26.2.0 のソースでは、どちらの場合もセッションを使わずに login provider へ回す(skip が立たない)ので、prompt=login と max_age を Laravel 側で判定するのは再利用経路だけです。受理した subject が Hydra セッションの subject と違う場合も、Hydra が prompt=login を付けて要求をやり直させます。reauthRequired() は認可要求の URL から prompt=login と max_age を読み、あわせて最後のパスワード入力からの絶対上限(8 時間)を見ます。Laravel のセッションは無操作タイムアウトで延び続けるので、上限を別に持たないとパスワード入力なしの SSO が無期限に続きます。 この判定は Laravel セッションの auth_time を前提にしていて、サンプルではパスワード認証時に submit() が入れています。既存のログイン処理に組み込むときは、そちらにも auth_time を入れる 1 行が要ります。無ければ再利用経路は一度も通りません。 prompt=none(サイレント認証)は、Hydra にログインセッションがないと login provider を呼ばずに login_required を返します。再利用経路は Hydra のセッションを更新しないので、パスワードを入れ直すまで Hydra のセッション(1 時間)が切れたままになり、Laravel のセッションが生きていても prompt=none は失敗します。prompt=none を使う RP があるなら、Hydra のセッション寿命が SSO の上限になります。 メールは変わることがあり、変わると全クライアントで別人扱いになるので、subject にはメールやログイン ID ではなく users.id を入れています。 逆方向も要ります。Laravel からログアウトしたときに Hydra のログインセッションを消さないと、remember_for の間は他のアプリから再ログインなしで通ります。ログアウト処理で Admin API の DELETE /admin/oauth2/auth/sessions/login?subject=... を呼んでいます。ユーザーを無効化するときは同じことをコマンド(user:disable)でやり、ログインセッションに加えて同意セッションも消します。Hydra の API 仕様では、同意セッションの失効で紐づくアクセストークンも無効化されます(リフレッシュトークンについては仕様に明記がありません)。ログイン POST には throttle:5,1 を付けています。 同意画面は出していません。クライアント登録時に --skip-consent を付けると consent request の client.skip_consent が true で来る(skip 自体は初回なら false)ので、それを見て要求されたスコープをそのまま許可し、ID トークンに入れる claim を台帳から詰めて受理します。許可されるスコープの上限はクライアント登録時の --scope で決まります。 // app/Http/Controllers/Hydra/ConsentController.php(抜粋) return $this->hydra->acceptConsentRequest($challenge, [ 'grant_scope' => $grantScope, 'grant_access_token_audience' => $consentRequest['requested_access_token_audience'] ?? [], 'remember' => true, 'remember_for' => config('hydra.remember_for'), 'session' => ['id_token' => (object) $idToken], ]); Admin API は Http ファサードで包んだだけで、Hydra の SDK は使っていません。login の取得・受理・拒否、consent の取得・受理、セッション失効 2 本の計 7 本です(app/Services/HydraAdmin.php)。 rp-modern PHP 8 側は jumbojett/openid-connect-php の authenticate() を呼ぶだけで、state、nonce、PKCE(S256)、ID トークンの署名と iss / aud / nonce の検証はライブラリが行います。 ひとつ引っかかったのは issuer です。Hydra の issuer はブラウザから見える localhost ですが、コンテナからトークンエンドポイントを叩くときの宛先は hydra です。Discovery に頼るとエンドポイントが localhost で返ってきてコンテナから届かないので、Discovery を使わずに各エンドポイントを直接指定しました。実コードは環境変数から読んでいますが、値を展開して示します。 // rp-modern/oidc.php(抜粋、環境変数の値を展開して表示) $oidc = new OpenIDConnectClient($issuer, $clientId, $clientSecret, $issuer); $oidc->providerConfigParam([ 'issuer' => $issuer, // localhost 'authorization_endpoint' => 'localhost', // ブラウザ向け 'token_endpoint' => 'hydra', // コンテナ間 'jwks_uri' => 'hydra', 'userinfo_endpoint' => 'hydra', 'token_endpoint_auth_methods_supported' => ['client_secret_basic'], 'code_challenge_methods_supported' => ['S256'], ]); 最後の code_challenge_methods_supported を入れないと、PKCE の方式を確認しにライブラリが Discovery を取りに行って localhost:4444 に繋がらず落ちます。最初はここで止まりました。本番で issuer のドメインがどこからでも同じに引けるなら、この指定は要りません。 broker と rp-legacy ブローカーが認可要求から ID トークン検証までを済ませ、レガシー側には OIDC のトークンを渡しません。渡すのは 60 秒で切れる一回限りの引換コードです。 最初の版では state を入れていませんでした。OIDC の区間はライブラリが state と PKCE を見ていますが、ブローカーからレガシーに戻る区間は自分で作った部分で、ここに同じ仕組みが要ることを見落としていました。見直す中で出てきた攻撃と対策をまとめます。 攻撃 前提 対策 1. ログイン CSRF 被害者に攻撃者のページを開かせられる レガシー側で state を生成してセッションに置き、戻りで照合する 2. 盗まれた引換コードの横取り 引換コードを 60 秒以内に拾える ブローカーが state のハッシュをコードと一緒に保存し、引き換え時にレガシー側がセッションの state を送って照合する 3. 攻撃者が始めたフローを被害者に完了させる 開始 URL を踏ませられ、かつ引換コードを拾える 不一致の引き換え要求でもコードを失効させる。コードは URL に載せず POST フォームで渡す 4. 3 を Hydra の認可コードで行う 認可コードをアクセスログから拾える ttl.auth_code を 1 分に短縮、Referrer-Policy: no-referrer、コールバック URL のクエリをログに残さない運用。閉じ切れないので残るリスクとして扱う それぞれの中身です。 攻撃者が自分のアカウントでログインを始めて引換コードを取り、攻撃者のページから rp-legacy のコールバックへそのコードを入れたフォームを自動送信させると、被害者が攻撃者としてログインした状態になります。state の照合で拒否され、そのコードも失効します state は URL に出るので秘密ではありません。効いているのは、攻撃者が自分のセッションに被害者の state を入れられないことです 攻撃者が自分のセッションで state を作り、ブローカーの開始 URL を被害者に踏ませます。被害者はログイン済みなので画面は出ず、「被害者の sub、攻撃者の state」でコードが発行されます。被害者側のコールバックは不一致で失敗しますが、未使用のコードが残れば攻撃者が引き換えられます。被害者のブラウザがレガシー側に到達すれば失効するので、残る漏洩経路は POST 本文や HTML を記録する中間装置に限られます 攻撃者が自分のブローカーセッションで作った Hydra の認可 URL を被害者に踏ませます。被害者は 1 クリックで broker/callback.php?code=<認可コード> に戻りますが、開始情報がないので 400 で止まり、認可コードは未使用で残ります。攻撃者のセッションには state と PKCE の verifier があるので、同じ URL を開けばトークン交換が通ります。PKCE では防げません(challenge が攻撃者のもの)。php の組み込みサーバーはクエリをログに残すので、本番はリバースプロキシのログ形式で落とします。Referrer-Policy は、ブローカーからレガシー側へ POST するときの Referer に認可コードが載り、レガシー側のアクセスログに残るのを防ぐためのものです 4 は rp-modern にも同じ構造があり、ブローカー固有の問題ではありません。OIDC の認可コードをログに残さない運用は、どのクライアントでも前提になります。 POST フォームで渡す方式には構成上の前提があります。ブローカーとレガシーアプリが別サイト(登録可能ドメインが違う)だと、自動 POST はクロスサイトになります。SameSite 属性の付かない PHP 5.6 のセッション Cookie は、Chrome 系のように省略時を Lax 扱いするブラウザでは送られないことがあり、送られなければ state が空になってログインが失敗します。成否がブラウザや Cookie の経過時間に依存するので、両者は同一サイトに置きます。フォームには「続行」ボタンも常に出していて、CSP でインラインスクリプトを止めている環境ではボタンで進みます。 レガシー側に足したのは login.php と callback.php の 2 ファイルと、セッション初期化の変更です。PHP 5.6 なので null 合体演算子と random_bytes(どちらも 7.0 以降)は使っていません。 // rp-legacy/public/callback.php(抜粋)。POST でしか受けない $code = isset($_POST['code']) ? $_POST['code'] : ''; $state = isset($_POST['state']) ? $_POST['state'] : ''; $expected = isset($_SESSION['sso_state']) ? $_SESSION['sso_state'] : ''; unset($_SESSION['sso_state']); $stateOk = ($state !== '' && $expected !== '' && hash_equals($expected, $state)); // state が合わなくてもブローカーに送る。ブローカーは要求を受けた時点でコードを失効させる $ch = curl_init(getenv('BROKER_EXCHANGE_URL')); // broker curl_setopt_array($ch, array( CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(array( 'app_id' => getenv('BROKER_APP_ID'), 'secret' => getenv('BROKER_EXCHANGE_SECRET'), 'code' => $code, 'state' => $expected, // URL やフォームの値ではなくセッションの値を送る )), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, )); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $json = json_decode($body, true); if (!$stateOk || $status !== 200 || !isset($json['user']['sub'])) { http_response_code(400); exit; } session_regenerate_id(true); $_SESSION['user'] = $json['user']; ブローカー側の引き換えは、判定と使用済み化を UPDATE 1 文で行い、更新できた場合だけ state を照合してユーザー情報を返します。SELECT してから UPDATE すると、文と文の間に別のリクエストが割り込めるので二重引き換えが起きます。SQLite でも同じです。保存先を Redis に変えるなら GETDEL か Lua で判定と削除を 1 操作にします。 // broker/store.php(抜粋) $stmt = $pdo->prepare('UPDATE exchange_codes SET used = 1 WHERE code = ? AND app_id = ? AND used = 0 AND expires_at >= ?'); $stmt->execute([$code, $appId, time()]); if ($stmt->rowCount() !== 1) { return null; // 未発行・使用済み・期限切れ・アプリ違い } $row = $pdo->prepare('SELECT state_hash, claims FROM exchange_codes WHERE code = ?'); $row->execute([$code]); $found = $row->fetch(PDO::FETCH_ASSOC); if (!$found || !hash_equals($found['state_hash'], stateHash($state))) { return null; // 失効はさせたうえで拒否 } 戻り先は登録した URL との完全一致にしています。前方一致だと、レガシーアプリ側にオープンリダイレクトがひとつでもあれば引換コードが外に漏れます。ブローカーを使うアプリは id、シークレット、戻り先の組を環境変数で持ち、アプリごとに別のシークレットにします。 動かした結果 未ログインの rp-modern です。 ログインをクリックすると、Hydra を経由して Laravel のログイン画面に来ます。URL は localhost:8081/login?login_challenge=... です。 デモユーザーでログインすると rp-modern に戻り、ID トークンの claim が出ます。sub は users.id、iss は Hydra の issuer、aud はクライアント ID です。 続けて rp-legacy を開くと未ログインです。 「ブローカー経由でログイン」をクリックすると、ログイン画面を通らずに戻ってきて、PHP 5.6 側のセッションにユーザー情報が入っています。 このときのログを整形して抜粋します(値はマスク)。ブローカーが start.php と callback.php を処理し、rp-legacy のコンテナ(172.20.0.7)から exchange.php が POST されています。rp-legacy の callback.php は POST で受けるので引換コードはログに出ませんが、ブローカーの callback.php には Hydra の認可コードがクエリで残ります。上の表の 4 つ目はこれが前提です。php の組み込みサーバーはレスポンス完了時に 1 行出すので、rp-legacy の処理中に呼ばれる exchange.php の方が先に記録されています。 rp-legacy | GET /login.php [302] broker | GET /start.php?return_to=...&state=<masked> [302] hydra | GET /oauth2/auth 302 -> localhost idp | GET /login (Hydra のセッションが有効なため画面なしで受理) hydra | GET /oauth2/auth 302 -> localhost idp | GET /consent (skip_consent のため画面なしで受理) hydra | GET /oauth2/auth 303 -> localhost hydra | POST /oauth2/token 200 (broker コンテナから) broker | GET /callback.php?code=<Hydra の認可コード>&scope=...&state=<masked> [200] 自動送信の POST フォームを返す broker | POST /exchange.php [200] (172.20.0.7 = rp-legacy コンテナから) rp-legacy | POST /callback.php [302] 引換コードの方は、ブローカーのコンテナ内で未使用のコードを発行してから exchange.php を直接叩いて確かめました。 S_attacker(攻撃者が始めたフロー想定)と S_victim(正常系)でそれぞれ発行する docker compose exec broker php -r 'require "/app/store.php"; echo issueExchangeCode("rp-legacy", "S_attacker", ["sub"=>"1","email"=>"demo@example.com","name"=>"Demo User"]);' docker compose exec broker php -r 'require "/app/store.php"; echo issueExchangeCode("rp-legacy", "S_victim", ["sub"=>"1","email"=>"demo@example.com","name"=>"Demo User"]);' 攻撃者が始めたフロー: 発行時と違う state(被害者側のセッションには何もない)で引き換えると 400 で、コードは失効する。続けて発行時の state で引き換えても 400 正常系: 発行時の state で 1 回だけ 200、再利用は 400 未登録のアプリ id は 401、登録と一致しない戻り先での開始は 400、callback.php を GET で開くと 405 $ ex() { curl -s -X POST localhost -d "secret=<値>" -w ' http=%{http_code}\n' "$@"; } $ ex -d app_id=rp-legacy -d "code=<S_attacker で発行>" -d "state=" {"error":"invalid_or_expired_code"} http=400 $ ex -d app_id=rp-legacy -d "code=<同じコード>" -d "state=S_attacker" {"error":"invalid_or_expired_code"} http=400 $ ex -d app_id=rp-legacy -d "code=<S_victim で発行>" -d "state=S_victim" {"user":{"sub":"1","email":"demo@example.com","name":"Demo User"}} http=200 $ ex -d app_id=rp-legacy -d "code=<同じコード>" -d "state=S_victim" {"error":"invalid_or_expired_code"} http=400 $ ex -d app_id=other-app -d code=x -d state=x {"error":"unauthorized"} http=401 $ curl -s -o /dev/null -w '%{http_code}\n' 'localhost' 400 $ curl -s -o /dev/null -w '%{http_code}\n' 'localhost' 405 セッションまわりの確認結果です。 Laravel からログアウトすると Hydra のログインセッションのレコードが 0 件になり、次のログインでは画面が出る 台帳で disabled_at だけ立てて Hydra のセッションを残した状態で rp-legacy からログインすると、Hydra は skip で Laravel に回すが、Laravel が reject する。ブローカーに返るエラーは次のとおり localhost... rp-modern のログイン後画面にある「再認証」リンク(prompt=login / max_age=10)から始めると、Laravel にログイン済みでもパスワード入力の画面が出る(max_age=10 はパスワード入力から 10 秒以上たってから試す) Hydra のログインセッションを Admin API で消し、Laravel のセッションだけ残した状態で rp-modern からログインすると画面なしで通る。そのあとの Hydra のセッション行は remember=false で、次の要求で skip にはならない ハマったところ composer の platform で一度止まりました。ローカルの PHP 8.4 で composer create-project したため Symfony 8 系(PHP 8.4 以上)で lock され、PHP 8.3 のコンテナで依存解決に失敗しました。composer config platform.php 8.2.0 で lock し直しています。 セッション Cookie の名前も衝突します。同じ localhost で複数の PHP アプリを動かすと Cookie はポートを区別しないので PHPSESSID が共有されます。アプリごとに session_name() を分けました。 スクリーンショットを撮り直そうとして未ログインに戻せず、Hydra のログインセッション(remember で 1 時間)と Laravel のセッションが別々に生きていることに気づきました。ログアウトと無効化で Hydra 側も消す処理は、ここから入れたものです。 Laravel の HTTP クライアントで delete($url, $data) と書くと、$data はクエリではなく本文に入ります。Hydra の失効 API はクエリの subject を要求するので 400 が返り、withQueryParameters() に直しました。 このサンプルで省いたもの クライアント側から始めるログアウト(RP-Initiated Logout)。Hydra は対応しているので、必要になったら urls.logout とログアウトプロバイダを足す Token Introspection。Hydra の Admin API にあり、RP が使うときに呼ぶだけ TLS。Hydra は --dev で HTTPS なしで動かしている。本番は TLS 終端を前に置く Admin API(4445)と exchange.php の隔離。サンプルでは 127.0.0.1 に開けている。本番は Admin API を外に出さず、exchange.php は内部向けを別のポートかバーチャルホストに分けるか、リバースプロキシでパス単位に遮断する ブローカーで失敗したときにレガシー側へ戻す処理。今はブローカーの画面で止まる ブローカーで進行中のフローを複数持つこと。今はセッションに 1 件だけで、タブを 2 つ開いて同時に始めると先のフローが壊れる。rp-legacy からブラウザの「戻る」でブローカーのコールバックに戻った場合も 400 になる Request Object や PAR で渡された prompt / max_age の読み取り。再利用経路の判定は request_url のクエリしか見ていない 先に決めること sub の採番規則。全利用者種別を横断する不変の内部 ID を先に決める 既存のユーザーテーブルと sub の対応づけ。レガシー側の作業で一番大きいのはここ SSO の実効寿命。Hydra の remember_for、Laravel の無操作タイムアウト、再利用経路の絶対上限(サンプルでは 8 時間)のどれで制御するかを決める。prompt=none を使う RP があるなら Hydra のセッション寿命が上限になる 認可コードをログに残さない運用。リバースプロキシのログ形式でクエリを落とす ブローカーとレガシーアプリの配置。同一サイトに置く クライアント側のセッション寿命。無効化しても各アプリのセッションはその寿命まで残るので短くする issuer のドメイン。変えると全クライアントの再設定と再ログインになるので、サービス名が変わっても影響しないドメインにする Hydra の OpenID Foundation 認定は、現行バージョンで取れているか確認できていない。README は認定を謳っているが、認定実装一覧で現行版の記載を見つけられなかった。採用判断に使うなら Ory に確認する 片付け docker compose down -v 参考 Ory Hydra: ory/hydra Login & Consent フロー: ory.com Hydra Admin API: ory.com jumbojett/openid-connect-php: packagist.org RFC 9700(OAuth 2.0 Security Best Current Practice): datatracker.ietf.org サンプルコード: kikegamisprix/laravel-ory-hydra-sample