🔑

[Rust]Google OpenIDConnectライブラリの作成日記

に公開

概要

この記事は,作成したGoogleのOpenIDConnect(OIDC)認証のライブラリ紹介と学びや問題点を紹介させて頂く内容になります.

Crates(pacakge)

言語はRustを使用しており,OSSとしてgithubとcrates.ioに公開しています.
https://github.com/nakaryo716/tiny_google_oidc

作成の動機とコンセプト

きっかけは,自身のアプリケーション開発における認証の実装になります.
当初、自分自身で認証を実装していましたが,昨今、「認証の実装は自身で行わないほうがよい」「そもそもパスワードを必要とさせないほうが良い」という意見もあり,OIDCの優位性からcrates.ioで検索をかけました.
人気でよくメンテナンスされているクレートがありましたが,すこしヘビーな気がしました.
そこで、GoogleのOIDCに限定して,かつ最小限のクレートを作ろうと思い,制作に至りました.

Example

簡単にどのように使用するのかご紹介します.
exampleコードの抜粋になります. (Axumを使用しています)

Googleでログインのようなボタンを押された際に叩かれるハンドラになります.

static COOKIE_KEY: &str = "csrf_token";

async fn start_auth(
    State(app_state): State<Arc<AppState>>,
    jar: CookieJar,
) -> Result<impl IntoResponse, StatusCode> {
    // CSRFTokenの作成
    let state = CSRFToken::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // CSRFTokenを保存するためのCookieの作成
    // Cookie_Key -- CSRF_Token_Key
    //               CSRF_Token_Key -- CSRF_Token_Value(in memory or redis)
    let csrf_key = Uuid::new_v4().to_string();
    let cookie = Cookie::new(COOKIE_KEY, csrf_key.clone());
    // CSRFTokenをサーバー側で保存
    // 今回はインメモリですが、実際にはRedisなどの使用が推定されています
    {
        app_state
            .token
            .lock()
            .unwrap()
            .insert(csrf_key, state.clone());
    }

    // Nonceの生成
    let nonce = Nonce::new();
    // scopeの設定
    let scope = Some([AdditionalScope::Email, AdditionalScope::Profile]);

    // codeを取得するためのRequestを作成
    let req = CodeRequest::new(AccessType::Offline, &app_state.config, scope, &state, &nonce);
    // CodeRequestをURLに変換
    let url = req
        .into_url()
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    // CSRFTokenのkeyを保存したCokkieのSETとリダイレクトを行う
    Ok((jar.add(cookie), Redirect::to(&url)))
}

今度はユーザーが認証を終えた後のcallbackの実装になります.
ここではIDTokenの取得を行います.
これによりGoogle側がユーザーを一意に割り当てるSUBやユーザー名などが取得できます.

async fn call_back(
    State(app_state): State<Arc<AppState>>,
    jar: CookieJar,
    req: Request,
) -> Result<impl IntoResponse, StatusCode> {
    // Googleからのurlを解析する
    let host = req
        .headers()
        .get(HOST)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("localhost");
    let path = req
        .uri()
        .path_and_query()
        .map(|pq| pq.as_str())
        .unwrap_or("/");
    let scheme = "http";
    let full_url = format!("{}://{}{}", scheme, host, path);

    // URLからUncheckedCodeResponseを作る
    let code_res = UnCheckedCodeResponse::from_url(&full_url.as_str()).map_err(|e| {
        error!("Failed to parse url: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    // CookieからCSRFTokenのkeyを取得する
    let csrf_token: CSRFToken;
    // Get cookie
    let cookie = jar.get(COOKIE_KEY).ok_or_else(|| StatusCode::BAD_REQUEST)?;
    let csrf_key = cookie.value();
    // サーバー側に保存されたCSRFTokenを取得(実際にはRedis)
    {
        // This block for early unlock
        let lock = app_state.token.lock().unwrap();
        csrf_token = lock
            .get(csrf_key)
            .ok_or_else(|| StatusCode::BAD_REQUEST)?
            .to_owned();
    }
    // CSRFTokenが一致するか検証し、一致したらcodeが取得できる
    let code = code_res
        .exchange_with_code(csrf_token.value())
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // IDTokenを取得するためのIDTokenRequestを先程のCodeを使用してつくる
    let id_token_req = IDTokenRequest::new(&app_state.config, code);

    // HTTPリクエストを投げる
    // IDTokenExeによってIDTokneResponseが渡される
    // (res: IDTokenResponse)
    let res = IDTokenExe
        .execute(&id_token_req)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    // AccessTokenを取得することができる
    let refresh_token = res.access_token();
    println!("{:?}", refresh_token);

    // IDTokenを取得できます
    // このままではJWT形式でEncodeされているためDecodeする必要がある
    let id_token_row = res.id_token();
    // IDTokenRowをデコードしてIDTokenの作成
    let id_token = IDToken::decode_from_row(&id_token_row).unwrap();
    // IDTokenをJSON形式で表示
    Ok((StatusCode::OK, Json(id_token)))
}

工夫したところ

CSRFTokenについて

現代ではCookieの取り扱いに関して重要視されており,基本的にはsecure, http_only SameSite::LaxのようになっているためCSRF攻撃を意識する必要はないという記事も見かけましたが,それでもGoogleのドキュメントではCSRFTokenの検証を行うことが推奨されていました.

このライブラリでは型システムを利用して,CSRFTokenの検証が義務化しています.

IDTokenを取得するためのIDTokenRequestを作るためにはGoogleから送られてくるCodeが必要になります.
しかしCodeUncheckedCodeResponseにプライベートでラップされており,exchange_with_codeメソッドで消費し、CSRFTokenを検証しない限り、取り出すことができなくなっています.
実装は以下の通りになります.

#[derive(Debug, Clone)]
pub struct UnCheckedCodeResponse {
    state: UnCheckedCSRFToken,
    code: Code,
}

impl UnCheckedCodeResponse {
    pub fn from_url(response_url: &str) -> Result<Self, Error> {
        let url = url::Url::try_from(response_url).map_err(|e| {
            error!("Failed to parse url from google: {}", e);
            Error::URL
        })?;
        let params: HashMap<_, _> = url.query_pairs().map(|v| (v.0, v.1)).collect();
        Ok(Self {
            state: params.get("state").ok_or(Error::URL)?.to_string().into(),
            code: params.get("code").ok_or(Error::URL)?.to_string().into(),
        })
    }

    /// Must be validated using a CSRF token before use.
    pub fn exchange_with_code(self, csrf_token_val: &str) -> Result<Code, Error> {
        if self.state.0 == csrf_token_val {
            Ok(self.code)
        } else {
            Err(Error::CSRFNotMatch)
        }
    }
}

exmapleは以下の通りです

    let code_res = UnCheckedCodeResponse::from_url(&full_url.as_str()).map_err(|e| {
        error!("Failed to parse url: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    // dbからCSRFTokenを取得(架空コード)
    let saved_csrf_token = db.get("CSRF_key");

    // CSRFTokenが一致するか検証し、一致したらcodeが取得できる
    let code = code_res
        .exchange_with_code(saved_csrf_token.value())
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // IDTokenを取得するためのIDTokenRequestを先程のCodeを使用してつくる
    let id_token_req = IDTokenRequest::new(&app_state.config, code);

問題点

現在の問題は以下のようになっています

  • テストが不十分
  • テストの自動化が出来ていない(GitHub)
  • ドキュメントが不十分
  • examplesが不十分
  • example codeが汚い
  • ソースコードの構成や命名規則がイマイチ
  • OSSとしてのコントリビュートの体制が整っていない

色々問題があり,致命的になっています.とくにテスト周りとコントリビュートの体制については,早めに対処していきたいと考えています.

学び

OSSとしてライブラリを公開するにあたって,コードを書くだけでなく,ドキュメントの書き方や英語,CI/CD,OSSについて学ぶ必要があることを理解しました.
また、私のRustに対する理解はまだまだだということも実感しました.

使用しているユーザーはほぼいませんが(),コントリビュートできる体制を整え,勉強の一貫としてメンテナンスしていきたいと考えています.

Discussion