[Rust]Google OpenIDConnectライブラリの作成日記
概要
この記事は,作成したGoogleのOpenIDConnect(OIDC)認証のライブラリ紹介と学びや問題点を紹介させて頂く内容になります.
Crates(pacakge)
言語はRustを使用しており,OSSとしてgithubとcrates.ioに公開しています.
作成の動機とコンセプト
きっかけは,自身のアプリケーション開発における認証の実装になります.
当初、自分自身で認証を実装していましたが,昨今、「認証の実装は自身で行わないほうがよい」「そもそもパスワードを必要とさせないほうが良い」という意見もあり,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
が必要になります.
しかしCode
はUncheckedCodeResponse
にプライベートでラップされており,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