🦀

AxumでOpenID Connectクライアントを実装してみた

7 min read

はじめに

最近、素人の手習いでWebアプリケーションの技術を勉強しています。

Webサイトのログイン機能を実装したいと思った時に、Basic認証かOpenID Connect(OIDC)になると思います。

Basic認証について調べたところ、セキュアに実装するのは非常に難しそうです。なのでOpenID Connectのクライアント実装について勉強してみることにしました。

この記事ではAxum+openidクレートで実装したサンプルコードを見ながらOIDCクライアントの動作について解説します。
コードは以下にあります。warp+openidクレートで実装したコードを基に実装しました。

OpenID Connectとは

OpenID Conent(OIDC)とはOAuth 2.0の上で構築された認証のための技術仕様です。
まずそもそもOpenID Connectってなに?という人は以下の記事を御覧ください。

前提

このコードはGoogle OpenID Connectで動作を試してみました。
動作させるには事前にクライアントIDとクライアントシークレットを入手し環境変数に設定させる必要があります。またリダイレクトURLの登録も必要です。

登場人物

あるユーザーがあるWebサイトにログインしたいという状況を想定してください。OpenID Connectを使う場合、登場人物が3人出てきます。ブラウザ(ユーザー)、クライアント(Webサイト)、IdPです。

ブラウザ

ブラウザはクライアントに自分が誰なのかを証明(認証)したいです。

クライアント

クライアントは例えばショッピングサイトでブラウザが誰なのかを知りたいです。
ここでいうクライアントは、クライアントサーバーモデルというところのサーバーに当たるのでちょっと紛らわしいです。

IdP

IdPは「ブラウザは〇〇やで」とクライアントに教えてくれる人です。
Googleでログインする、Facebookでログインするなどの表示を見たことがあると思いますが、あれでいうGoogleやFacebookがIdPです。

目標

OpenID Connectの目標はクライアントがIdPにブラウザを認証してもらい、ブラウザ(ユーザー)の情報を含むIDトークンを取得することです。

Open ID Connectのシーケンス図

OpenID Connectを使ったユーザー認証のシーケンス図を示します。

おおまかな流れとしてはブラウザからログインリクエストがくると、「IdPにお前がだれか認証してな」とIdPにリダイレクトし、認証後、IdPが認可コードをブラウザにもたせ、クライアントにリダイレクトさせます。その後クライアントは認可コードを使って、IdPからIDトークンを取得するといった感じです。

このシーケンス図で振った番号と実際のコードを突き合わせながら解説していきたいと思います。

1. ディスカバリ 2. JWKセット

OIDCでは最終的にクライアントはユーザーが誰であるかを表すIDトークンをIdPから受け取ります。
そのIDトークンは署名されているので、その署名の検証に必要な情報をIdPから事前に取得しています。
その情報はJWKセットというJWKのセットを表すJSONオブジェクトとして返されるみたいです。
JWKセットはopenidクレートで定義されているClient構造体に保持されます。
openidクレートはClient構造体が認可リクエスト発行やトークンリクエスト発行のメソッドを持っていてそれを呼び出して処理を進めるというつくりになっています。

ディスカバリ
#[tokio::main]
async fn main() {
    // 環境変数からidとsecretを取得
    let client_id = std::env::var("CLIENT_ID").expect("Unspecified CLIENT_ID as env var");
    let client_secret =
        std::env::var("CLIENT_SECRET").expect("Unspecified CLIENT_SECRET as env var");
    // ディスカバリを投げるURL
    let issuer_url = std::env::var("ISSURE").unwrap_or("https://accounts.google.com".to_string());
    let issuer = reqwest::Url::parse(&issuer_url).unwrap();

    // ユーザー認証後にユーザーをリダイレクトして欲しいURL
    let redirect = Some(host("/login/oauth2/code/oidc"));

    // ディスカバリしてJWKセットを取得
    let client = Arc::new(
        DiscoveredClient::discover(client_id, client_secret, redirect, issuer)
            .await
            .unwrap(), 
    );
    //....
}
Client構造体
#[derive(Debug)]
pub struct Client<P = Discovered, C: CompactJson + Claims = StandardClaims> {
    /// OAuth provider.
    pub provider: P,

    /// Client ID.
    pub client_id: String,

    /// Client secret.
    pub client_secret: String,

    /// Redirect URI.
    pub redirect_uri: Option<String>,

    pub http_client: reqwest::Client,

    /// JWK SET 
    pub jwks: Option<JWKSet<Empty>>,
    marker: PhantomData<C>,
}

https://github.com/kilork/openid から引用

3. 認可リクエスト 4. 認可レスポンス

認可リクエストは大雑把にいうと、クライアントがユーザーに「IdPにお前がだれか証明してな」と言って、IdPにリダイレクトさせるリクエストです。
クライアントはブラウザを介してIdPに認可リクエストを投げます。
認証が成功すると認可リクエストで設定しておいたリダイレクトエンドポイントに認可コードを含んだ認可レスポンスがブラウザがリダイレクトされます。
この認可コードを使って、アクセストークンとIDトークンを取得します。
認可リクエストについては以下を参照ください。

pub async fn authorize(
    Extension(oidc_client): Extension<Arc<OpenIDClient>>, // oidc_clientはディスカバリで生成したClient構造体
) -> (StatusCode, HeaderMap) {
    // 認可リクエストを構成
    // 詳細はopenidクレートのauth_urlの実装を参照
    let origin_url = std::env::var("ORIGIN").unwrap_or(host(""));
    let auth_url = oidc_client.auth_url(&Options {
        scope: Some("openid email profile".into()),
        state: Some(origin_url),
        ..Default::default()
    });
    let url = String::from(auth_url);

    // ヘッダーの設定
    let mut headers = HeaderMap::new();
    let val = if let Ok(val) = HeaderValue::from_str(&url) {
        val
    } else {
        return (StatusCode::INTERNAL_SERVER_ERROR, headers);
    };
    headers.insert(http::header::LOCATION, val);

    (StatusCode::FOUND, headers)
}

5.トークンリクエスト 6. トークンレスポンス

クライアントは認可コードを手に入れたのでそれを使ってトークンリクエストを発行して、IdPからアクセストークンとIDトークンを手に入れることができます。

pub async fn login(
    Extension(oidc_client): Extension<Arc<OpenIDClient>>,
    login_query: Query<LoginQuery>, //クエリパラメータの認可コードを取得
) -> impl IntoResponse {
    // アクセストークンとIDトークンを取得
    let request_token = request_token(oidc_client, &login_query).await;
    match request_token {
        Ok(Some((token, user_info))) => {
            let login = user_info.preferred_username.clone();
            let email = user_info.email.clone();

            let user = User {
                id: user_info.sub.clone().unwrap_or_default(),
                login,
                last_name: user_info.family_name.clone(),
                first_name: user_info.name.clone(),
                email,
                activated: user_info.email_verified,
                image_url: user_info.picture.clone().map(|x| x.to_string()),
                lang_key: Some("en".to_string()),
                authorities: vec!["ROLE_USER".to_string()],
            };
            //....
    }
}

async fn request_token(
    oidc_client: Arc<OpenIDClient>,
    login_query: &LoginQuery,
) -> anyhow::Result<Option<(Token, Userinfo)>> {
    let mut token: Token = oidc_client.request_token(&login_query.code).await?.into(); // トークンリクエスト

    if let Some(mut id_token) = token.id_token.as_mut() {
        oidc_client.decode_token(&mut id_token)?; //トークンをデコード
        oidc_client.validate_token(&id_token, None, None)?; // トークンをバリデート
    } else {
        return Ok(None);
    }

    let userinfo = oidc_client.request_userinfo(&token).await?;

    Ok(Some((token, userinfo)))
}

所感

warp+openidのコードをAxum+openidのコードに変換するくらい楽勝やろ!」と思っていましたが、warpAxumもよく知らないということもあってスムーズには行きませんでした。
ただ実際にコードリーディングをすると、OpenID Connectの説明をただ読むよりも理解が深まった気がするので、実際のコードを見てみるというやりかたは何かを習得する方法として有効なのかなと感じました。
またopenidクレートを使うと比較的シンプルに実装できますが、クレート内ではそれなりにややこしそうなことをやっていて、OIDCクライアントの処理をスクラッチで実装するのは避けよう、、と誓いました。

GitHubで編集を提案

Discussion

ログインするとコメントできます