DEV Community

Javier Viola
Javier Viola

Posted on • Originally published at javierviola.com

Basic crud with rust using tide - Adding OAuth

Hi there! welcome back, in the last post of this serie we made a final refactor and completed the process of ci/cd but I found a couple of bonus to add that could be useful. In this first bonus post I will be add OAuth to our app to allow users to login and manage their dinos.


Until now we are not tracking the user that create a dino, so everyone can edit or delete dinos despite of if they created or not. Now we can introduce the user concept and those dinos created by an user can only be updated/deleted by that user, we will still supporting create dinos without been logged and those will have the same behavior as they have now.

So, our roadmap for this note will be :

  • Implement OAuth, so the visitors can authenticate with a provider ( e.g google, github, etc )

  • Track the user that create the dino, if we have one.

  • Add an authorization mechanism for the update and delete operations.

Implement OAuth

We will be using google as OAuth provider, so before start we need to get your credentials ( here is the Setting up OAuth 2.0 documentation ).

Great, now that we had the credentials we can continue with the implementation. We will using this two examples as inspiration for our implementation tide-tera-example and tide-google-oauth-example.

Also, @jmn was the one to give me the idea of adding this OAuth bonus to this serie, thanks 🙌 🙌!

Let's started, we need to add the deps we will be using, first we will add oauth2 but we will try the last alpha version and with reqwest to use with async/await.

oauth2 = { version = "4.0.0-alpha.3", features = ["reqwest"], default-features = false  }
Enter fullscreen mode Exit fullscreen mode

Move on and create the basic scaffolding, first we need to create the auth.rs file inside the controllers directory and put the basic skeleton

// controllers/auth.rs
use super::*;

use tide::{Request, Result};

pub async fn auth_google(req: Request<State>) -> Result {
    unimplemented!();
}

pub async fn auth_google_authorized(req: Request<State>) -> Result {
    unimplemented!();
}

pub async fn logout(req: Request<State>) -> Result {
    unimplemented!();
}
Enter fullscreen mode Exit fullscreen mode

So, we will be handle three routes:

  • /auth/google will start the process and redirect the user to authorize our app.

  • /auth/google/authorized will handle the callback from the provider ( google in this case ), get the user info and create the session.

  • /logout will destroy the session.

Wait! session you say... Yes, we will use cookie based sessions in this example, so we need to add the Session middleware to our server.

// main.rs

async fn server(db_pool: PgPool) -> Server<State> {
 (...)
    app.with(tide::sessions::SessionMiddleware::new(
        tide::sessions::MemoryStore::new(),
        std::env::var("TIDE_SECRET")
            .expect("Please provide a TIDE_SECRET value of at least 32 bytes")
            .as_bytes(),
    ));
Enter fullscreen mode Exit fullscreen mode

To keep things simple we will use the builtin MemoryStore but it's recommended to use one of the others stores in production.

Great! let's add also the routes in our main.rs


    // auth
    app.at("/auth/google")
        .get(auth::login)
        .at( "/authorized").get(auth::login_authorized);

    app.at("/logout").get(auth::logout);
Enter fullscreen mode Exit fullscreen mode

Nice! so, now we had a bunch of env vars to add, remember to add those to our .env file.

We had all the structure in place, now we need to actually implement the OAuth client, let's add a helper function to create the basic client

fn make_oauth_google_client() -> tide::Result<BasicClient> {
    let client  =  BasicClient::new(
    ClientId::new(std::env::var("OAUTH_GOOGLE_CLIENT_ID").expect("missing env var OAUTH_GOOGLE_CLIENT_ID")),
Some(ClientSecret::new(std::env::var("OAUTH_GOOGLE_CLIENT_SECRET").expect("missing env var OAUTH_GOOGLE_CLIENT_SECRET"))),
    AuthUrl::new(AUTH_URL.to_string())?,Some(TokenUrl::new(TOKEN_URL.to_string())?),
    )
    .set_redirect_url(RedirectUrl::new(std::env::var("OAUTH_GOOGLE_REDIRECT_URL").expect("missing env var OAUTH_GOOGLE_REDIRECT_URL"))?);

    Ok(client)
}
Enter fullscreen mode Exit fullscreen mode

and store in our State

#[derive(Clone, Debug)]
pub struct State {
    db_pool: PgPool,
    tera: Tera,
    oauth_google_client: BasicClient
}

(...)

    let oauth_google_client = make_oauth_google_client().unwrap();

    let state = State { db_pool, tera, oauth_google_client };

    let mut app = tide::with_state(state);
Enter fullscreen mode Exit fullscreen mode

Nice! we can move on and work in the auth logic.

First, we need to handle the get request that start the OAuth flow in our auth_google fn. We are getting the client from the state and adding the scopes we want to use. With the goal of keep the things simple we will only request the profile since we will use the firstName and the id only.

The rest of the code have the email commented in case you also want to use.

pub async fn auth_google(req: Request<State>) -> Result {
    let client  = &req.state().oauth_google_client;
    let (auth_url, _csrf_token) = client
    .authorize_url(CsrfToken::new_random)
    // Set the desired scopes.
    // .add_scope(Scope::new(AUTH_GOOGLE_SCOPE_EMAIL.to_string()))
    .add_scope(Scope::new(AUTH_GOOGLE_SCOPE_PROFILE.to_string()))
    .url();

    Ok(Redirect::see_other(auth_url).into())
}
Enter fullscreen mode Exit fullscreen mode

The last line redirect the user to the google page to ask the user if grant the request permissions/data to our app.

image

After accept the user will be redirected to our callback fn, so now let's complete the flow to get the user info.

#[derive(Debug, Deserialize)]
struct AuthRequestQuery {
    code: String,
    state: String,
    scope: String,
}

#[derive(Debug, Deserialize)]
struct UserInfoResponse {
    // email: String,
    id: String,
    given_name: String
}
(...)

pub async fn auth_google_authorized(mut req: Request<State>) -> Result {
    let client  = &req.state().oauth_google_client;
    let query: AuthRequestQuery = req.query()?;
    let token_result = client
        .exchange_code(AuthorizationCode::new(query.code))
        .request_async(async_http_client)
        .await;

    let token_result = match token_result {
        Ok(token) => token,
        Err(_) => return Err(tide::Error::from_str(401, "error"))
    };

    let userinfo: UserInfoResponse = surf::get("https://www.googleapis.com/oauth2/v2/userinfo")
    .header(
        http::headers::AUTHORIZATION,
        format!("Bearer {}", token_result.access_token().secret()),
    )
    .recv_json()
    .await?;

    let session = req.session_mut();
    session.insert("user_name", userinfo.given_name)?;
    session.insert("user_id", userinfo.id)?;

    Ok(Redirect::new("/").into())
Enter fullscreen mode Exit fullscreen mode

Again, we are first getting the client from the store to exchange the auth code that we extract from the query string to the AuthRequestQuery struct.
Then, with the code we request the userInfo and deserialize the response into the UserInfoResponse struct. In the last lines we are storing the user_id and user_name into the session and redirect the logged in user to the home again.

So, let's add the link to log in and test this flow...

image

Whoops... the flow works but the browser is not sending the right cookie.

After checking this issue, I found this so answer that point me to check the SameSite policy. And set the policy to Lax make the flow works :-)

    app.with(tide::sessions::SessionMiddleware::new(
        tide::sessions::MemoryStore::new(),
        std::env::var("TIDE_SECRET")
            .expect("Please provide a TIDE_SECRET value of at least 32 bytes")
            .as_bytes(),
        ).with_same_site_policy(SameSite::Lax)
    );
Enter fullscreen mode Exit fullscreen mode

image

Awesome!!! We just need to implement the logout and we will finish the first item of our goals.

pub async fn logout(mut req: Request<State>) -> Result {
    let session = req.session_mut();
    session.destroy();

    Ok(Redirect::new("/").into())
}
Enter fullscreen mode Exit fullscreen mode

Nice, this will handle the logout and redirect the user to home again.

Save the user, if there is one

The next item in our list is to track the user in the database, for this task we need another field in our db that could be null or an string that represente the user_id.

After adding the field in our dinos table, we need to update our Dino struct.

[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Dino {
    id: Uuid,
    name: String,
    weight: i32,
    diet: String,
    user_id: Option<String>
}
Enter fullscreen mode Exit fullscreen mode

so, user_id is an Option that can hold an String. Now we need to add an extra logic to add the user_id in our controller

pub async fn create(mut req: Request<State>) -> tide::Result {
    let mut dino: Dino = req.body_json().await?;
    let db_pool = req.state().db_pool.clone();

    let session = req.session();
    match session.get("user_id") {
        Some(id) => dino.user_id = Some(id),
        None => dino.user_id = None
    };

    let row = handlers::dino::create(dino, &db_pool).await?;

    let mut res = Response::new(201);
    res.set_body(Body::from_json(&row)?);
    Ok(res)
}
Enter fullscreen mode Exit fullscreen mode

And update the handlerfns to use the new field

pub async fn create(dino: Dino, db_pool: &PgPool) -> tide::Result<Dino> {
    let row: Dino = query_as!(
        Dino,
        r#"
        INSERT INTO dinos (id, name, weight, diet, user_id) VALUES
        ($1, $2, $3, $4, $5) returning id as "id!", name, weight, diet, user_id
        "#,
        dino.id,
        dino.name,
        dino.weight,
        dino.diet,
        dino.user_id
    )
    .fetch_one(db_pool)
    .await
    .map_err(|e| Error::new(409, e))?;

    Ok(row)
}

(...)
Enter fullscreen mode Exit fullscreen mode

Now you can login, create a new dino and see that the new row has the user_id set. Also, if you create a new dino without been logged in this column should be null.

Nice! second goal done, we are tracking the id of the users when they create a new dino.

Authorize update and delete operations

The last item in our list is restrict the update and delete operations. If a dino was created by a logged in user only this user is authorized to delete or edit this dino. We will add this auth rule in two different places.

First in the ui, the edit and delete options should only be available for public dinos or the ones that the user created.

We can archive this with some logic in the tera template

                {% if dino.user_id %}
                    {%if user_id != "" and dino.user_id == user_id %}
                        <td><a href="/dinos/{{dino.id}}/edit"> Edit </a></td>
                        <td><a class="delete" data-id="{{dino.id}}"href="#"> Delete </a></td>
                    {% else %}
                        <td></td>
                        <td></td>
                    {% endif %}
                {% else %}
                    <td><a href="/dinos/{{dino.id}}/edit"> Edit </a></td>
                    <td><a class="delete" data-id="{{dino.id}}"href="#"> Delete </a></td>
                {% endif %}
Enter fullscreen mode Exit fullscreen mode

image

Nice!! we are half way, we just restrict the ui but we also need to check this restriction in the controller.

//controllers/dino.rs

(...)
    // auth operation
    let session = req.session();
    let user_id: String = session.get("user_id").unwrap_or("".to_string());
    let row = handlers::dino::get(id, &db_pool).await?;
    if let Some(dino) = row {
        if dino.user_id.is_some() && dino.user_id.unwrap() != user_id {
            // 401
            return Ok(Response::new(401));
        }
    }
(...)
Enter fullscreen mode Exit fullscreen mode

We are checking that the user_id ( if is set ) is the same of the session, and if not we just return a
401 Unauthorized
.

Nice! we have to add some tests now to check if this works as expected

    #[async_std::test]
    async fn updatet_dino_create_by_another_user_should_reject_with_401() -> tide::Result<()> {
        dotenv::dotenv().ok();

        let mut dino = Dino {
            id: Uuid::new_v4(),
            name: String::from("test_update"),
            weight: 500,
            diet: String::from("carnivorous"),
            user_id: Some(String::from("123"))
        };

        let db_pool = make_db_pool(&DB_URL).await;

        // create the dino for update
        query!(
            r#"
            INSERT INTO dinos (id, name, weight, diet, user_id) VALUES
            ($1, $2, $3, $4, $5) returning id
            "#,
            dino.id,
            dino.name,
            dino.weight,
            dino.diet,
            dino.user_id
        )
        .fetch_one(&db_pool)
        .await?;

        // change the dino
        dino.name = String::from("updated from test");

        // start the server
        let app = server(db_pool).await;

        let res = surf::Client::with_http_client(app)
            .put(format!("https://example.com/dinos/{}", &dino.id))
            .body(serde_json::to_string(&dino)?)
            .await?;

        assert_eq!(401, res.status());

        Ok(())
    }

    #[async_std::test]
    async fn delete_dino_create_by_another_user_should_reject_with_401() -> tide::Result<()> {
        dotenv::dotenv().ok();

        let dino = Dino {
            id: Uuid::new_v4(),
            name: String::from("test_delete"),
            weight: 500,
            diet: String::from("carnivorous"),
            user_id: Some(String::from("123"))
        };

        let db_pool = make_db_pool(&DB_URL).await;

        // create the dino for delete
        query!(
            r#"
            INSERT INTO dinos (id, name, weight, diet, user_id) VALUES
            ($1, $2, $3, $4, $5) returning id
            "#,
            dino.id,
            dino.name,
            dino.weight,
            dino.diet,
            dino.user_id
        )
        .fetch_one(&db_pool)
        .await?;

        // start the server
        let app = server(db_pool).await;

        let res = surf::Client::with_http_client(app)
            .delete(format!("https://example.com/dinos/{}", &dino.id))
            .await?;

        assert_eq!(401, res.status());

        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

Nice, now we can run those test :)

cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.31s
     Running target/debug/deps/tide_basic_crud-b16dbe7e8764d4d3

running 12 tests
test tests::clear ... ok
test tests::create_dino_with_existing_key ... ok
test tests::delete_dino_create_by_another_user_should_reject_with_401 ... ok
test tests::delete_dino ... ok
test tests::create_dino ... ok
test tests::get_dino_non_existing_key ... ok
test tests::get_dino ... ok
test tests::delete_dino_non_existing_key ... ok
test tests::list_dinos ... ok
test tests::updatet_dino_create_by_another_user_should_reject_with_401 ... ok
test tests::update_dino ... ok
test tests::updatet_dino_non_existing_key ... ok

test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Enter fullscreen mode Exit fullscreen mode

Awesome!! we just finished the third item in our list.


That's all for today... first bonus of this serie, we just implemented OAuth ( with google as provider ) and now users can track his dinos :-), you can check the complete code in the PR.
I think there are still room to improve ( like adding a mechanism to authorize api calls ) but we can leave for the next bonus post.

As always, I write this as a learning journal and there could be another more elegant and correct way to do it and any feedback is welcome.

Thanks!

Discussion (0)