じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

RustでOry Hydra用認証プロバイダーを実装する

はじめに

年が明けた。月曜日。エディタを開いている。

認証プロバイダーを自分で実装できるか、と聞かれたら、たぶん「できる」と答える。OAuth2のRFCは読んだ。フローも理解している、と思う。ただ、「じゃあ書いて」と言われたとき、キーボードに手を置いたまま止まってしまうことがある。頭では分かっている。手が動かない。

10年近くインフラやプラットフォームを触ってきた。認可の仕組みは何度も設計した。Kubernetesの認証、サービスメッシュの認可、アクセストークンの検証。それでも「Login Providerをゼロから書け」と言われると、急に自信がなくなる。分かっているはずなのに、分かっていない気がする。

Ory Hydraのドキュメントを開く。Login ProviderとConsent Providerを自分で実装しろ、と書いてある。Node.jsのサンプルがある。Goのサンプルもある。どちらも動く。でも私はRustで書きたかった。

年末年始、ぼんやり考えていて気づいたことがある。止まっているのは、技術的に難しいからではない気がする。「何をどの順番で実装すればいいのか」が見えていないのだ。全体像が掴めないまま、最初の一歩が踏み出せずにいる。

だからこの記事を書くことにした。過去の自分に向けて。最初の一歩を、順番に。

前提知識: この記事は前回の記事の続編です。OAuth2認可コードフローの基礎知識と、Ory Hydraのアーキテクチャ(Login/Consent Providerの役割)を理解している前提で進めます。

syu-m-5151.hatenablog.com

作るもの

Login/Consent Providerとは、Ory Hydraと連携してOAuth2認証フローを処理するWebアプリケーションだ。以下の5つのエンドポイントを実装する。

エンドポイント 役割
GET /login ログインフォームを表示する
POST /login 認証処理を行い、Hydraに結果を通知する
GET /consent スコープ承認画面を表示する
POST /consent 承認結果をHydraに通知し、トークン発行へ進む
GET /logout ログアウト処理を行い、セッションを破棄する

全体の流れ

OAuth2認可コードフローの中で、Login/Consent Providerがどう動くかを示す。

1. ユーザーがクライアントアプリで「ログイン」をクリック
2. クライアントがHydraの /oauth2/auth にリダイレクト
3. Hydra が Login Provider の GET /login にリダイレクト(login_challenge付き)
4. Login Provider がログインフォームを表示
5. ユーザーがメール・パスワードを入力して送信
6. Login Provider が認証し、Hydra に accept_login を送信
7. Hydra が Consent Provider の GET /consent にリダイレクト(consent_challenge付き)
8. Consent Provider がスコープ承認画面を表示
9. ユーザーが承認
10. Consent Provider が Hydra に accept_consent を送信
11. Hydra がクライアントにリダイレクト(認可コード付き)
12. クライアントが認可コードをトークンに交換

Login/Consent Providerが担当するのは3〜10だ。Hydraとの通信には6つのAPIを使う。

API 役割
GET /admin/oauth2/auth/requests/login login_challengeからリクエスト情報を取得
PUT /admin/oauth2/auth/requests/login/accept 認証成功をHydraに通知
GET /admin/oauth2/auth/requests/consent consent_challengeからリクエスト情報を取得
PUT /admin/oauth2/auth/requests/consent/accept 承認結果をHydraに通知
GET /admin/oauth2/auth/requests/logout logout_challengeからリクエスト情報を取得
PUT /admin/oauth2/auth/requests/logout/accept ログアウトをHydraに通知

www.ory.com

Login Handler

Login Handlerは2つのエンドポイントで構成される。

GET /login

  1. クエリパラメータからlogin_challengeを取得する
  2. Hydra APIlogin_challengeを検証し、リクエスト情報を取得する
  3. skipフラグが立っていれば(既にセッションがあれば)、フォームを表示せず即座にaccept_login
  4. そうでなければログインフォームを表示する

POST /login

  1. フォームからemailpasswordlogin_challengeを受け取る
  2. 認証サービスでパスワードを検証する
  3. 認証成功なら、ユーザー情報をcontextに詰めてaccept_loginを呼ぶ
  4. Hydraが返すリダイレクトURLへ転送する
pub async fn login_submit(
    State(state): State<AppState>,
    Form(form): Form<LoginForm>,
) -> Result<Redirect, AppError> {
    // 1. 認証処理
    let user = state.auth.authenticate(&form.email, &form.password).await?;

    // 2. ユーザー情報をcontextに保存(Consent時にDBルックアップ不要)
    let user_context = UserContext {
        email: user.email.clone(),
        role: "customer".to_string(),
        tenant_id: None,
    };

    // 3. Hydraに認証成功を通知
    let completed = state
        .hydra
        .accept_login(
            &form.login_challenge,
            &user.id.to_string(),
            false,
            Some(serde_json::to_value(&user_context)?),
        )
        .await?;

    // 4. Consent画面へリダイレクト
    Ok(Redirect::to(&completed.redirect_to))
}

ポイントはcontextだ。Login時に認証したユーザー情報(email、role、tenant_id)をJSON形式で保存し、Consent Providerへ受け渡す。これにより、Consent処理でDBルックアップが不要になる。

Consent Handlerも2つのエンドポイントで構成される。

  1. クエリパラメータからconsent_challengeを取得する
  2. Hydra APIでリクエスト情報(要求されたスコープ、クライアント情報)を取得する
  3. skipフラグが立っていれば(既に承認済みなら)、即座にaccept_consent
  4. そうでなければスコープ承認画面を表示する

POST /consent

  1. フォームからconsent_challengeと承認するスコープを受け取る
  2. Login時に保存したcontextからユーザー情報を取得する
  3. IDトークンにカスタムクレーム(emailroletenant_id)を追加する
  4. accept_consentを呼び、Hydraが返すリダイレクトURLへ転送する

IDトークンにクレームを追加することで、クライアントアプリケーションはトークンをデコードするだけでユーザー情報を取得できる。

Logout Handler

Logout Handlerは1つのエンドポイントで構成される。Login/Consentと比べてシンプルだ。

GET /logout

  1. クエリパラメータからlogout_challengeを取得する
  2. Hydra APIaccept_logoutを呼び出す
  3. Hydraが返すリダイレクトURLへ転送する
pub async fn logout_handler(
    State(state): State<AppState>,
    Query(query): Query<LogoutQuery>,
) -> Result<Redirect, AppError> {
    let completed = state.hydra.accept_logout(&query.logout_challenge).await?;
    Ok(Redirect::to(&completed.redirect_to))
}

ログアウトフローはLogin/Consentと異なり、確認画面を表示せずに即座にaccept_logoutを呼んでいる。本番環境では「本当にログアウトしますか?」という確認画面を挟むことを検討してもよい。

動作確認

docker compose up -d
./scripts/e2e-test.sh

IDトークンにemailrolesubが含まれていれば成功だ。

ここまでが「何を作るか」「どう動くか」の説明だ。以降は実装の詳細に入る。

認証サービスの実装

Login Handlerから呼び出される認証サービスの実装に入る。パスワード認証にはOWASPのガイドラインに従い、Argon2idを採用した。

cheatsheetseries.owasp.org

Argon2::default()を使っているが、これは意図的だ。argon2クレートのデフォルト値はOWASP推奨設定に準拠している。「専門家が作ったものを信頼する方が合理的」という前回の記事と同じ論理だ。

認証部分で見落としがちなのが次の点だ。

pub async fn authenticate(&self, email: &str, password: &str) -> Result<User, AppError> {
    let users = self.users.read().await;
    let user = users.get(email).ok_or(AppError::InvalidCredentials)?;

    Argon2::default()
        .verify_password(password.as_bytes(), &parsed_hash)
        .map_err(|_| AppError::InvalidCredentials)?;

    Ok(user.clone())
}

ユーザーが存在しない場合も、パスワードが間違っている場合も、返すエラーは同じInvalidCredentialsだ。「ユーザーが見つかりません」というエラーを返したくなるが、それは攻撃者に情報を与えてしまう。

これはユーザー列挙攻撃(User Enumeration Attack)への対策だ。攻撃者はまず有効なメールアドレスを特定しようとする。エラーメッセージが違えば、登録済みかどうかが分かってしまう。

なお、完全な対策にはタイミング攻撃への考慮も必要だ。ユーザーが存在しない場合はArgon2の検証が走らないため、レスポンス時間の差で存在を推測される可能性がある。本番環境では、ユーザー不在時もダミーハッシュを検証することを検討してほしい。

owasp.org

テスト設計

認証システムのバグは「静かに」起きる。だからテストの考え方も変わる。普通の機能開発では「この操作をしたらこうなる」というテストを書く。でも認証システムでは「この操作をしてもこうならない」というテストの方に価値がある。

#[tokio::test]
async fn test_login_does_not_reveal_user_existence() {
    let service = AuthService::new();
    service.register("exists@example.com", "password").await.unwrap();

    let err1 = service.authenticate("exists@example.com", "wrong").await.unwrap_err();
    let err2 = service.authenticate("nobody@example.com", "password").await.unwrap_err();

    assert_eq!(err1.to_string(), err2.to_string());
}

このテストは「エラーメッセージが同じ」という実装の意図を明示化している。将来誰かが「親切なエラーメッセージにしよう」と思って変更しても、このテストが警告を出す。

責任分界点

全ての攻撃をアプリケーション層で防ぐ必要はない。何を守り、何をインフラに任せるかを明確にする。

プロジェクト構成

今回はAxumを使った。

github.com

src/
├── main.rs          # サーバーエントリーポイント
├── auth.rs          # 認証サービス
├── handlers.rs      # Login/Consent/Logoutハンドラー
├── hydra.rs         # Hydra Admin APIクライアント
├── models.rs        # Hydra API型定義
└── error.rs         # エラー型定義

ハンドラー層とサービス層を分離している。認証ロジックはauth.rsに置き、ハンドラーはHTTPリクエストの受け取りとレスポンスの返却だけを担う。

フルコードはGitHubリポジトリを参照してほしい。

github.com

実装チェックリスト

必須の実装

  • [ ] Hydra APIクライアント - 6つのAPI呼び出し
  • [ ] GET /login - login_challenge検証、skipフラグ確認、フォーム表示
  • [ ] POST /login - 認証、contextにユーザー情報、accept_login
  • [ ] GET /consent - consent_challenge検証、skipフラグ確認、承認画面表示
  • [ ] POST /consent - context取得、IDトークンにクレーム追加、accept_consent
  • [ ] GET /logout - logout_challenge取得、accept_logout
  • [ ] 認証サービス - Argon2id、ユーザー列挙攻撃対策

忘れがちなポイント

  • login_challengeconsent_challengeはhiddenフィールドでフォームに埋め込む
  • skipフラグが立っている場合は画面を表示せず即座にacceptする
  • contextでLogin→Consent間のユーザー情報受け渡し
  • エラーメッセージはユーザーの存在を漏らさない

おわりに

この文章を書き終えて、ターミナルに戻った。docker compose up -dを叩く。コンテナが立ち上がる。E2Eテストを走らせる。グリーン。IDトークンにemailroleが入っている。動いた。

正直に言うと、書いている途中で何度か不安になった。これで説明になっているのか。Login HandlerとConsent Handlerの違いが曖昧になっていないか。contextの使い方は2回書き直した。

それでも、動いた。

冒頭で書いた「キーボードに手を置いたまま止まってしまう」感覚は、たぶん、また来る。次に認証システムを書くときも、OAuth2のフローを思い出すところから始めるだろう。login_challengeって何だっけ、と調べ直すかもしれない。

それでいいのだと思う。認証は「一度理解したら終わり」という領域ではない気がする。毎回、RFCを確認しながら、慎重に実装する。ユーザー列挙攻撃のテストを書いたのも、将来の自分が「親切なエラーメッセージ」を入れようとしたときに止めるためだ。

年が明けて、また仕事が始まる。本番の認証システムはOry Hydraに任せる。Login Providerは自分で書く。その境界線が、今の私には見えている気がする。