📖

AxumでOpenID Connectを使いたい

2022/01/11に公開

やりたいこと

AxumでOAuthを使うサンプルはあります。
しかし残念ながらOpenID Connectではないのです。
せっかくやるならOpenID Connectを利用し、StateやNonce、PKCEも使って正しく検証を行いたいと思います。

今回はopenidconnectクレートを使って実装しました。

方針

上で書いた事がほとんどではありますが・・・

  • OpenID Connectを使う
  • StateやNonce、PKCEでの検証を行う
  • 簡単なユースケースであれば再利用しやすいような形にする

といったことを考えました。

ソースコード

メイン部分

OpenIdConnectUtilというのを作って、AddExtensionLayerで登録しておきます。
関数signin_redirectsample_finishでOpenIdConnectUtilのメソッドを呼び出す形です。

main.rs

#[tokio::main]
async fn main() -> Result<(), Error> {
    dotenv().ok();

    // OpenIDConnectの設定
    let oidc_util = OpenIdConnectUtil::new(
        env::var("OIDC_ISSUER_URL").unwrap(),
        env::var("OIDC_CLIENT_ID").unwrap(),
        env::var("OIDC_CLIENT_SECRET").unwrap(),
        env::var("OIDC_SCOPE").unwrap(),
        env::var("OIDC_REDIRECT_URL").unwrap(),
        None,
        None,
    )
    .await?;

    // /googleの設定
    let google_auth = Router::new()
        .route("/signin", get(signin_redirect))
        .route("/signin_finish", get(sample_finish))
        .layer(AddExtensionLayer::new(oidc_util));

    // build our application with a route
    let app = Router::new()
        // `GET /` goes to `root`
        .nest("/google", google_auth)
        .layer(CookieManagerLayer::new());

    // Run app on local server
    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));

    let config = RustlsConfig::from_pem_file(r"./localhost.crt", r"./localhost.pem").await?;

    axum_server::bind_rustls(addr, config)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

呼び出される関数

URLに紐づけられて呼び出される関数です。
こちらで、OpenID Connectでのリダイレクト先のURLを作成リダイレクトしたり、認証が終わった際のデータ取得や検証を行います。
実際の処理のほとんどはOpenIdConnectUtilに実装されています。

signin_redirect関数については、実際にWebアプリケーションを作ったとしてもそのまま再利用可能だと思います。
しかし、sample_finish関数については実際のWebアプリケーションの開発に際して、ユーザー情報をストレージに保存したり、セッション維持の仕組みを入れたりなどが必要になってくるかと思います。

main.rs
pub async fn signin_redirect(
    Extension(oidc_util): Extension<OpenIdConnectUtil>,
    cookies: Cookies,
) -> Result<Redirect, WebError> {
    let r = oidc_util.signin_redirect(&cookies).await?;
    Ok(Redirect::temporary(r.parse::<Uri>()?))
}

pub async fn sample_finish(
    Extension(oidc_util): Extension<OpenIdConnectUtil>,
    cookies: Cookies,
    Query(params): Query<HashMap<String, String>>,
) -> Result<String, WebError> {
    // 検証して問題なければトークンを取得、問題があればエラーとなるので ? で返す
    let (token, claims) = oidc_util.verify(&cookies, &params).await?;

    // アクセストークン、リフレッシュトークンを取得
    let access_token = token.access_token().secret();
    let refresh_token = token.refresh_token().map(|i| i.secret());

    println!("{:?}", claims);
    println!("{:?}", access_token);
    println!("{:?}", refresh_token);

    // とりあえずログインできたよメッセージを返す
    Ok("ログインできました".to_string())
}

OpenIdConnectUtilの実装

https://docs.rs/openidconnect/2.2.0/openidconnect/#getting-started-authorization-code-grant-w-pkce

こちらにStateやNonce、PKCEなどしっかり検証を行っているサンプルがありますので、基本的にはこちらに書いてある処理で問題ありません。
ですがこちらのサンプルではWebアプリ上に実装しているものではなく、このままでは使用しづらいのです。

今回Axumで使えるようにするため、下記のようなものを作っています。
StateやNonce,PKCEの情報はブラウザのCookieにmagic_cryptを使って暗号化して保存しています。

エラーは適当にすべてanyhow::Errorにしてしまってるので、これでいいのかな?
とか思ったりもするのですが、Rust初心者ということで大目に見ていただければ・・・。

oidc_util.rs
use anyhow::anyhow;
use anyhow::Result;
use magic_crypt::{new_magic_crypt, MagicCryptTrait};
use oauth2::{
    basic::{BasicErrorResponseType, BasicTokenType},
    EmptyExtraTokenFields, RevocationErrorResponseType, StandardErrorResponse,
    StandardRevocableToken, StandardTokenIntrospectionResponse, TokenResponse,
};
use openidconnect::core::{
    CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreGenderClaim,
    CoreJsonWebKey, CoreJsonWebKeyType, CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm,
    CoreJwsSigningAlgorithm, CoreProviderMetadata,
};
use openidconnect::reqwest::async_http_client;
use openidconnect::{
    AccessTokenHash, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken,
    EmptyAdditionalClaims, IdTokenClaims, IdTokenFields, IssuerUrl, Nonce, PkceCodeChallenge,
    PkceCodeVerifier, RedirectUrl, Scope, StandardTokenResponse,
    TokenResponse as TokenResponseOIDC,
};
use std::collections::HashMap;
use tower_cookies::{Cookie, Cookies};
use uuid::Uuid;


#[derive(Clone, Debug)]
pub struct OpenIdConnectUtil {
    scope: Vec<String>,
    cookie_name: String,
    encrypt_key: String,
    client: Client<
        EmptyAdditionalClaims,
        CoreAuthDisplay,
        CoreGenderClaim,
        CoreJweContentEncryptionAlgorithm,
        CoreJwsSigningAlgorithm,
        CoreJsonWebKeyType,
        CoreJsonWebKeyUse,
        CoreJsonWebKey,
        CoreAuthPrompt,
        StandardErrorResponse<BasicErrorResponseType>,
        StandardTokenResponse<
            IdTokenFields<
                EmptyAdditionalClaims,
                EmptyExtraTokenFields,
                CoreGenderClaim,
                CoreJweContentEncryptionAlgorithm,
                CoreJwsSigningAlgorithm,
                CoreJsonWebKeyType,
            >,
            BasicTokenType,
        >,
        BasicTokenType,
        StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>,
        StandardRevocableToken,
        StandardErrorResponse<RevocationErrorResponseType>,
    >,
}

impl OpenIdConnectUtil {
    pub async fn new(
        issuer_url: String,
        client_id: String,
        client_secret: String,
        scope: String,
        redirect_url: String,
        cookie_name: Option<String>,
        encrypt_key: Option<String>,
    ) -> Result<Self> {
        // cookie名の設定
        let cookie_name = cookie_name.unwrap_or(Uuid::new_v4().to_string());

        // 暗号化のキーを設定
        let encrypt_key = encrypt_key.unwrap_or(Uuid::new_v4().to_string());

        let scope: Vec<String> = scope
            .split(' ')
            .map(|s| s.trim().to_string())
            .filter(|s| s != "")
            .collect();

        let provider_metadata =
            CoreProviderMetadata::discover_async(IssuerUrl::new(issuer_url)?, async_http_client)
                .await?;

        let client = CoreClient::from_provider_metadata(
            provider_metadata,
            ClientId::new(client_id),
            Some(ClientSecret::new(client_secret)),
        )
        // Set the URL the user will be redirected to after the authorization process.
        .set_redirect_uri(RedirectUrl::new(redirect_url)?);

        Ok(Self {
            scope: scope,
            cookie_name,
            encrypt_key,
            client,
        })
    }

    pub async fn signin_redirect(&self, cookies: &Cookies) -> Result<String> {
        // Generate a PKCE challenge.
        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();

        // Generate the full authorization URL.
        let mut c = self.client.authorize_url(
            CoreAuthenticationFlow::AuthorizationCode,
            CsrfToken::new_random,
            Nonce::new_random,
        );
        for s in &self.scope {
            c = c.add_scope(Scope::new(s.clone()))
        }
        let (auth_url, csrf_token, nonce) = c.set_pkce_challenge(pkce_challenge).url();

        // Googleの場合、RefreshTokenが必要な場合下記をコメントアウトする
        // {
        //     let mut x = auth_url.query_pairs_mut();
        //     x.append_pair("access_type", "offline");
        // }

        // Cookie設定
        let mc = new_magic_crypt!(&self.encrypt_key, 256);
        let cookie_values = serde_json::to_string(&(nonce, csrf_token, pkce_verifier))?;
        let cookie_values = mc.encrypt_str_to_base64(cookie_values);
        cookies.add(Cookie::new(self.cookie_name.clone(), cookie_values));

        Ok(auth_url.to_string())
    }

    pub async fn verify(
        &self,
        cookies: &Cookies,
        query_params: &HashMap<String, String>,
    ) -> Result<(
        StandardTokenResponse<
            IdTokenFields<
                EmptyAdditionalClaims,
                EmptyExtraTokenFields,
                CoreGenderClaim,
                CoreJweContentEncryptionAlgorithm,
                CoreJwsSigningAlgorithm,
                CoreJsonWebKeyType,
            >,
            BasicTokenType,
        >,
        IdTokenClaims<EmptyAdditionalClaims, CoreGenderClaim>,
    )> {
        let cookie_value = cookies
            .get(&self.cookie_name)
            .ok_or(anyhow!("cookieが正しく設定されていません"))?;
        cookies.remove(Cookie::new(self.cookie_name.clone(), "")); // 取得されたクッキーは不要なので削除

        let mc = new_magic_crypt!(&self.encrypt_key, 256);
        let cookie_value = mc.decrypt_base64_to_string(cookie_value.value())?;
        let (c_nonce, c_state, c_pkce_verifier): (Nonce, String, PkceCodeVerifier) =
            serde_json::from_str(&cookie_value)?;

        if Some(&c_state) != query_params.get("state") {
            return Err(anyhow!("stateエラー"));
        }

        let code = query_params.get("code").ok_or(anyhow!("パラメータ不正"))?;

        let token_response = self
            .client
            .exchange_code(AuthorizationCode::new(code.to_string()))
            // Set the PKCE code verifier.
            .set_pkce_verifier(c_pkce_verifier)
            .request_async(async_http_client)
            .await?;

        let t = token_response.clone();
        let id_token = t
            .id_token()
            .ok_or_else(|| anyhow!("Server did not return an ID token"))?;
        let claims = id_token.claims(&self.client.id_token_verifier(), &c_nonce)?;

        if let Some(expected_access_token_hash) = claims.access_token_hash() {
            let actual_access_token_hash = AccessTokenHash::from_token(
                token_response.access_token(),
                &id_token.signing_alg()?,
            )?;
            if actual_access_token_hash != *expected_access_token_hash {
                return Err(anyhow!("Invalid access token"));
            }
        }

        Ok((token_response, claims.clone()))
    }
}

思った事など

Actix Webも軽く触った事ありますが、こちらの方が個人的にはとっつきやすいです。
悩む量が少なくて済んだというか・・・。
エコシステム的に周りのライブラリがそろってないので、その辺は辛いかなと思ったりしています。

非同期のライブラリがTokioなので、非同期関係で困ることはあまりなさそうに思います。
Actix Webの場合その辺が解決できなくて困ったりしたことがそれなりにありました。

個人的には今後も使っていきたいと思うWebフレームワークだと思っています。

なお、ソースコードはこちらにあります。

https://github.com/saitoooo/sample_axum_oidc

Discussion