🔐

Rust で Google のサービスアカウント認証を実装してみた

2023/06/06に公開

こんにちは,なっふぃです.

Google API の認証にはいくつか種類がありますが,そのうちの一つに

  1. サービスアカウントを作成する
  2. サービスアカウントに紐づく JWT (JSON Web Token) を生成する
  3. JWT を使って OAuth 2.0 のアクセストークンを取得する

というものがあります.(参考:Preparing to make an authorized API call

これを,暗号ライブラリの練習がてら Rust で実装してみたという記事です.

なお,

Recommendation: Although your application can complete these tasks by directly interacting with the OAuth 2.0 system using HTTP, the mechanics of server-to-server authentication interactions require applications to create and cryptographically sign JSON Web Tokens (JWTs), and it's easy to make serious errors that can have a severe impact on the security of your application.

For this reason, we strongly encourage you to use libraries, such as the Google APIs client libraries, that abstract the cryptography away from your application code.

とあるように,あくまで練習だと考えてください.

Cargo.toml

以下で用いる依存ライブラリは

[dependencies]
rsa = { version = "0.9", features = ["sha2", "pem"] }

base64 = "0.21"

serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }

# to send HTTP request to fetch access tokens
reqwest = "0.11"

になります.

JWT とは

JWT (JSON Web Token) は "jot" と発音します.

これは,サービスアカウントの情報を表した JSON を base64 エンコードし,それに署名を加えた文字列です.

HEADER.CLAIM.SIGNATURE

という形になっていて,HEADER は JWT のアルゴリズムやフォーマットを,CLAIM はサービスアカウントの情報を,SIGNATUREHEADER.CLAIM の署名を,それぞれ表します.

JWT header を作る

JWT header は,現在

{"alg":"RS256","typ":"JWT"}

のみしか許されません.RS256 は RSA-SHA256 で署名することを意味します.

ここは単に serde-json で to_vec() するだけで良いですね.

#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "UPPERCASE")]
enum Algorithm {
    Rs256,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "UPPERCASE")]
enum HeaderFmt {
    Jwt,
}

#[derive(Debug, Clone, Copy, Serialize)]
struct Header {
    alg: Algorithm,
    typ: HeaderFmt,
}

fn make_header_string() -> Vec<u8> {
    let header = Header {
        alg: Algorithm::Rs256,
        typ: HeaderFmt::Jwt,
    };
    serde_json::to_vec(&header).unwrap()
}

最後の to_vec()

のどれを用いても構いません.

JWT claim を作る

JWT claim は

#[derive(Debug, Serialize)]
struct Claim<'a> {
    iss: &'a str,
    scope: String,
    aud: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    sub: Option<String>,
    exp: u64,
    iat: u64,
}

という構造体を JSON でエンコードしたものになります.&'a str になっているのは,下の Credentials という構造体から借用したいためです.

それぞれのフィールドの意味は

  • iss:サービスアカウントの client_email
  • scope:今回使用するスコープ(スペース区切り),
  • audtoken_uri
  • sub (optional):委任されたユーザ(このユーザの代わりにサービスアカウントが実行する,cf) https://developers.google.com/identity/protocols/oauth2/service-account?hl=en#delegatingauthority ),
  • exp:アクセストークンがいつまで有効か(Unix epoch からの秒数),
  • iat:現在時刻(Unix epoch からの秒数),

であり,サービスアカウントの鍵ファイル <project id>-<private key id>.json から作成します.

鍵ファイルを

#[derive(Debug, Deserialize)]
struct Credentials {
    private_key: String,
    client_email: String,
    token_uri: String,
}

impl Credentials {
    fn from_file(path: impl AsRef<Path>) -> Self {
        let json = std::fs::read(path).unwrap();
        serde_json::from_slice(&json).unwrap()
    }
}

のように読み込んで,claim を作成します:

const GMAIL_SEND_SCODE: &str = "https://www.googleapis.com/auth/gmail.send";

// as_user = "foo@example.com".to_string();
fn make_claim_string(cred: &Credentials, as_user: Option<String>) -> Vec<u8> {
    use std::time::{SystemTime, UNIX_EPOCH};
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap();

    let claim = Claim {
        iss: &cred.client_email,
        scope: GMAIL_SEND_SCODE.to_string(),
        aud: &cred.token_uri,
        sub: as_user,
        exp: now.as_secs() + 3600,
        iat: now.as_secs(),
    };

    serde_json::to_vec(&claim).unwrap()
}

署名する

上で作成した JWT header と claim を Base64 URL エンコードして,

HEADER.CLAIM

という文字列を作成します.Base64 エンコードで用いるエンジンは BASE64_URL_SAFE または BASE64_URL_SAFE_NO_PAD です.(どちらでも可.)

fn string_to_sign(header: Vec<u8>, claim: Vec<u8>) -> String {
    use base64::engine::Engine as Base64Engine;
    use base64::prelude::BASE64_URL_SAFE_NO_PAD;

    let header = <_ as Base64Engine>::encode(&BASE64_URL_SAFE_NO_PAD, header);
    let claim = <_ as Base64Engine>::encode(&BASE64_URL_SAFE_NO_PAD, claim);

    let mut s = String::with_capacity(header.len() + claim.len() + 1);
    s.push_str(&header);
    s.push('.');
    s.push_str(&claim);

    s
}

この文字列を署名します.署名スキームには RSASSA-PKCS1-v1_5 を用い,ハッシュ関数は SHA-256 です.RSA の秘密鍵は鍵ファイルの private_key に,PEM フォーマットで格納されています.

fn private_key(cred: &Credentials) -> rsa::RsaPrivateKey {
    use rsa::pkcs8::DecodePrivateKey;

    <_ as DecodePrivateKey>::from_pkcs8_pem(&cred.private_key).unwrap()
}

署名部分は

fn sign(private_key: rsa::RsaPrivateKey, plain_text: &[u8]) -> impl AsRef<[u8]> {
    use rsa::pkcs1v15::SigningKey;
    use rsa::sha2::Sha256;
    use rsa::signature::{SignatureEncoding, Signer};

    let signing_key = SigningKey::<Sha256>::new(private_key);
    let signature = <_ as Signer<_>>::sign(&signing_key, plain_text);

    <_ as SignatureEncoding>::to_bytes(&signature)
}

となり,これを Base64 URL エンコードします.

fn make_sign_string(cred: &Credentials, plain_text: &[u8]) -> String {
    use base64::engine::Engine as Base64Engine;
    use base64::prelude::BASE64_URL_SAFE_NO_PAD;

    let private_key = private_key(cred);
    let signature = sign(private_key, plain_text);
    <_ as Base64Engine>::encode(&BASE64_URL_SAFE_NO_PAD, signature)
}

もし署名の安全性を高めたいのであれば,Signer の代わりに RandomizedSigner を使って,自分で乱数生成器を指定することもできます.

JWT を完成させる

以上の header と claim,署名を '.' で繋ぎ合わせたものが JWT になります.

fn make_jwt(cred: &Credentials, as_user: Option<String>) -> String {
    let header = make_header_string();
    let claim = make_claim_string(cred, as_user);

    let mut jwt = string_to_sign(header, claim);

    let signature = make_sign_string(cred, jwt.as_bytes());
    jwt.push('.');
    jwt.push_str(&signature);

    jwt
}

アクセストークンを取得する

JWT を使って token_uri へ POST します.

成功したときのレスポンスは

#[derive(Debug, Deserialize)]
struct Token {
  access_token: String,
  scope: String,
  token_type: String,
  expires_in: i64,
}

であり,失敗したときのレスポンスは

#[derive(Debug, Deserialize)]
struct TokenError {
  error: String,
  error_description: String,
}

です.エラーの意味はこちらを確認してください.

成功時のレスポンスは,

&token.scope == &claim.scope && &token.token_type == "Bearer"

となるべきです.また,token.expires_inclaim.exp - claim.iat に近い値のはずで,アクセストークンの期限が切れるまでの秒数を表します.
このアクセストークンを使って,

req.set_header("authorization", format!("{} {}", &token.token_type, &token.access_token))

のように Google API を叩くことができます.

実際にアクセストークンを取得するには

async fn fetch_access_token(cred: &Credentials, as_user: Option<String>) -> Result<Token, TokenError> {
    use reqwest::header::CONTENT_TYPE;

    let jwt = make_jwt(cred, as_user);
    let body = format!(
        "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion={jwt}",
    );

    let client = reqwest::Client::new();

    let res = client
        .post(&cred.token_uri)
        .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .unwrap();
    let status = res.status();
    let body = res.bytes().await.unwrap();

    if status.is_success() {
        Ok(serde_json::from_slice(&body).unwrap())
    } else {
        Err(serde_json::from_slice(&body).unwrap())
    }
}

とします.grant_type の値は現在

urn:ietf:params:oauth:grant-type:jwt-bearer

のみ許されています.

おわり

暗号周りは知らなかった話もあり,勉強になりました.
自分で実装したものが動くと感動ですね.

Discussion