abdolence / gcloud-sdk-rs

Async Google Cloud Platform (GCP) gRPC/REST APIs client implementation based on Tonic middleware and Reqwest.
Apache License 2.0
71 stars 21 forks source link

Support for `signInWithPassword` #76

Open Zagitta opened 1 year ago

Zagitta commented 1 year ago

Greetings!

A while back I asked about signInWithPassword in firestore-rs where I was clearly confused 😉 Since then I've figured stuff out and actually managed to implement support for email/password auth in gcloud-sdk-rs however I don't think the implementation is very good or particularly in line with what you'd wish for.

I've basically added the following in credentials.rs:

#[async_trait]
impl Source for Credentials {
    async fn token(&self) -> crate::error::Result<Token> {
        match self {
            Credentials::ServiceAccount(sa) => jwt::token(sa).await,
            Credentials::User(user) => oauth2::token(user).await,
            Credentials::UserAccount(user_account) => user_account::token(user_account).await,
            Credentials::ExternalAccount(external_account) => {
                external_account::token(external_account).await
            }
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UserAccount {
    pub api_key: SecretValue,
    pub email: String,
    pub password: SecretValue,
}

mod user_account {

    use std::{convert::TryFrom, ops::Add};

    use secret_vault_value::SecretValue;

    use crate::token_source::{
        credentials::{httpc_post, UserAccount},
        Token,
    };

    #[derive(serde::Serialize)]
    struct Payload<'a> {
        key: &'a str,
        email: &'a str,
        password: &'a str,
        return_secure_token: bool,
    }

    #[derive(Debug, serde::Serialize, serde::Deserialize)]
    #[serde(rename_all = "camelCase")]
    struct Response {
        kind: String,
        id_token: SecretValue,
        expires_in: String,
    }

    impl TryFrom<Response> for Token {
        type Error = crate::error::Error;

        fn try_from(v: Response) -> Result<Self, Self::Error> {
            let expires_in = v.expires_in.parse().unwrap();
            if v.kind.is_empty() || v.id_token.as_sensitive_bytes().is_empty() || expires_in == 0 {
                Err(crate::error::ErrorKind::TokenData.into())
            } else {
                Ok(Token {
                    type_: "Bearer".into(),
                    token: v.id_token,
                    expiry: chrono::Utc::now().add(chrono::Duration::seconds(expires_in)),
                })
            }
        }
    }

    const TOKEN_URL: &str = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword";

    pub async fn token(user: &UserAccount) -> crate::error::Result<Token> {
        fetch_token(TOKEN_URL, user).await
    }

    pub(super) async fn fetch_token(url: &str, user: &UserAccount) -> crate::error::Result<Token> {
        let req = httpc_post(url).form(&Payload {
            key: &user.api_key.as_sensitive_str(),
            email: &user.email,
            password: user.password.as_sensitive_str(),
            return_secure_token: true,
        });
        let resp = req.send().await?;
        if resp.status().is_success() {
            let resp = resp.json::<Response>().await?;
            Token::try_from(resp)
        } else {
            Err(crate::error::ErrorKind::HttpStatus(resp.status()).into())
        }
    }
}

As well as some minor glue to TokenSource and GoogleEnvironment. With the above modifications it's possible to use it in firestore-rs like this:

let db = firestore::FirestoreDb::with_options_token_source(
        FirestoreDbOptions::new("app".into()),
        vec![],
        gcloud_sdk::TokenSourceType::UserAccount(gcloud_sdk::UserAccount {
            api_key: "xxxxx".into(),
            email: "yyyyy@ddddd.com".into(),
            password: "asdasdasd".into(),
        }),
    )
    .await?;

I'd be happy to submit it as a PR for it if you'd like to have a closer look at it. However, before doing that, I thought I'd create an issue and discuss if you have a better idea of how to implement it 😃

abdolence commented 1 year ago

Hey,

First of all, PRs always welcome :) So, I'll review it.

I mostly curious though why would you prefer this over service accounts? Can you describe a bit your case to understand what benefits this give you? (Again, even if they are rare I don't have anything against adding this, I just want to clarify it for myself - the needs).

Zagitta commented 1 year ago

My use case is that I'm using this app https://brewfather.app/ as part of my beer homebrewing process and while they have a public API it's very limited compared to what the app is actually capable of. So I started reverse engineering and discovered it's just using firestore combined with the above sign in method. As such it basically just boils down to me needing to use this in a client context rather than a server context since I don't have a service account to their backend 😃

I'll submit a PR, thank you 🥳

abdolence commented 1 year ago

As such it basically just boils down to me needing to use this in a client context rather than a server context since I don't have a service account to their backend

So, you can use the Firestore gRPC API using this approach on their cloud project? Have you tested it already? I find this really interesting.

Zagitta commented 1 year ago

It indeed works! Here's an example of it:


#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DefaultFermentables {
    #[serde(rename = "array_data")]
    pub array_data: Vec<DefaultFermentable>,
}

#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DefaultFermentable {
    pub origin: Option<String>,
    pub supplier: Option<String>,
    pub attenuation: Option<f64>,
    pub ibu_per_amount: Option<f64>,
    pub not_fermentable: Option<bool>,
    pub potential_percentage: Option<f64>,
    #[serde(rename = "type")]
    pub type_field: String,
    pub color: f64,
    pub potential: Option<f64>,
    pub name: String,
    #[serde(rename = "_id")]
    pub id: String,
    pub acid: Option<f64>,
    pub moisture: Option<f64>,
    pub coarse_fine_diff: Option<f64>,
    pub max_in_batch: Option<f64>,
    pub hidden: Option<bool>,
    pub protein: Option<f64>,
    pub notes: Option<String>,
    pub diastatic_power: Option<f64>,
    pub grain_category: Option<String>,
    pub lovibond: Option<f64>,
    pub p_h: Option<f64>,
    pub fgdb: Option<f64>,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let db = firestore::FirestoreDb::with_options_token_source(
        FirestoreDbOptions::new("brewfather-app".into()),
        vec![],
        gcloud_sdk::TokenSourceType::UserAccount(gcloud_sdk::UserAccount {
            api_key: "nope".into(),
            email: "nope".into(),
            password: "nope".into(),
        }),
    )
    .await?;

    let foo = db
        .fluent()
        .select()
        .by_id_in("resources/default_ingredients/default_fermentables")
        .obj::<DefaultFermentables>()
        .one("default_0001")
        .await?;

    println!("{:#?}", &foo.unwrap().array_data[..2]);
    Ok(())
}

prints:

    Finished dev [unoptimized + debuginfo] target(s) in 6.31s
     Running `target\debug\brewmaster.exe`
[
    DefaultFermentable {
        origin: Some(
            "Germany",
        ),
        supplier: Some(
            "Avangard",
        ),
        attenuation: Some(
            0.77,
        ),
        ibu_per_amount: None,
        not_fermentable: None,
        potential_percentage: Some(
            81.0,
        ),
        type_field: "Grain",
        color: 9.5,
        potential: Some(
            1.03726,
        ),
        name: "Munich Malt, Germany",
        id: "default-dea89b1",
        acid: None,
        moisture: None,
        coarse_fine_diff: None,
        max_in_batch: None,
        hidden: None,
        protein: None,
        notes: None,
        diastatic_power: None,
        grain_category: None,
        lovibond: None,
        p_h: None,
        fgdb: None,
    },
    DefaultFermentable {
        origin: Some(
            "Germany",
        ),
        supplier: Some(
            "Avangard",
        ),
        attenuation: Some(
            0.81,
        ),
        ibu_per_amount: None,
        not_fermentable: None,
        potential_percentage: Some(
            80.0,
        ),
        type_field: "Grain",
        color: 3.0,
        potential: Some(
            1.0368,
        ),
        name: "Pale Ale Malt",
        id: "default-4e2cb5c",
        acid: None,
        moisture: None,
        coarse_fine_diff: None,
        max_in_batch: None,
        hidden: None,
        protein: None,
        notes: None,
        diastatic_power: None,
        grain_category: None,
        lovibond: None,
        p_h: None,
        fgdb: None,
    },
]
abdolence commented 1 year ago

Well, that's setup I've never heard before, but if it works, then fine with me. Let's review it 👍🏻