DEV Community

Datner
Datner

Posted on

Tauri + oauth2

So you want to do native application authentication.

Before we begin, I recommend you don't just skim the code snippets, many decisions are made due to my own restrictions, security concerns, and lack of skill with rust.

I am not going to explain the mechanisms used in depth, as it would put both you and I to sleep.

Auth flow

A quick read through the fascinating and very stimulating oauth0 specification that I am sure you read in full will tell you how native app auth is recommended. Machine to Machine auth is problematic, and some flows are more secure than others.
I chose to authenticate through the browser, secured using CSRF and PKCE.

Meaning the flow is as follows:

  1. Determine that user needs to authenticate
  2. Call the /authorize endpoint to receive the auth_url with a PKCE challenge
  3. Open the users browser allowing them to log in
  4. Catch callback from idp
  5. Check CSRF integrity
  6. Exchange the code from the callback with a token from /oauth/token
  7. ????
  8. 🎉 Profit! 🎉

Setup

I will be using Auth0 as my authentication provider. But should work just the same with any provider.

First you should have a project.

$ pnpm create tauri-app 
Enter fullscreen mode Exit fullscreen mode

You can pick whatever, we're going to touch only the rust side for added security 😉

Some thing to remember, unlike web apps, we can't hide any secrets from a malicious (or curious, not gonna judge) actor. We also do not work in an isolated environment, so we should assume that all outward communication is entirely public. This means NO CLIENT SECRET my dudes, I mean it.

We need a client id, authorization url, and a token url. You get those from your auth provider.
For example, in auth0 I need to create a new Native Application. Then inside I can take the Client Id and the Domain. Those are not secret, I put them in my env vars, but they can live wherever you want

OAUTH2_CLIENT_ID=<my client id>
OAUTH2_AUTH_URL=https://<domain>/authorize
OAUTH2_TOKEN_URL=https://<domain>/oauth/token
Enter fullscreen mode Exit fullscreen mode

note that these endpoints are the standard, but they might be different for you.

now to make my life easier, I'm going to use a few crates. If you're a 🦀 and know better, go ahead, I'm not your dad.

[dependencies]
tauri = { version = "1.2", features = ["window-close", "window-hide", "window-maximize", "window-minimize", "window-show", "window-start-dragging", "window-unmaximize"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
axum = { version = "0.6.12", features = ["headers"] }
oauth2 = "4.3"
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"] }
open = "4.0.2"
Enter fullscreen mode Exit fullscreen mode

Because this post isn't long enough, I'm going to break down the usage:

  • tokio -- Async
  • axum -- The server framework
  • oauth2 -- The oauth2 library
  • reqwest -- http request libary, playes well with oauth2
  • open -- to open the browser without hassle

Implement auth flow

Now that everything is in place, first lets create a struct to describe our auth dependencies

#[derive(Clone)]
struct AuthState {
    csrf_token: CsrfToken,
    pkce: Arc<(PkceCodeChallenge, String)>,
    client: Arc<BasicClient>,
    socket_addr: SocketAddr
}
Enter fullscreen mode Exit fullscreen mode

and initiate them in the main method.

Side note: I'm not going to include all my imports. Just follow your heart (LSP auto-import)


#[tauri::command]
async fn authenticate() {
    todo!("we'll get here soon enough")
}

fn main() {
    let (pkce_code_challenge,pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
    let socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 9133); // or any other port
    let redirect_url = format!("http://{socket_addr}/callback").to_string();

    let state = AuthState {
        csrf_token: CsrfToken::new_random(),
        pkce: Arc::new((pkce_code_challenge, PkceCodeVerifier::secret(&pkce_code_verifier).to_string())),
        client: Arc::new(create_client(RedirectUrl::new(redirect_url).unwrap())),
        socket_addr
    };

    tauri::Builder::default()
        .manage(state)
        .invoke_handler(tauri::generate_handler![authenticate])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
Enter fullscreen mode Exit fullscreen mode

Now, if you're using a more compliant provider than auth0, you can use a more robust and secure method to get the socket_addr like the oauth2 spec recommends

fn get_available_addr() -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let addr = listener.local_addr().unwrap();
    drop(listener);

    addr
}

fn main() {
    // ...
    let socket_addr = get_available_addr();
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This will make sure you are getting an unknowable available port. Making your server harder to mess with.

You will notice that I lower the PkceCodeVerifier to a String. This is because it does not implement Clone or Copy out of security concerns and it makes it really hard to pass around. For me this is safe enough, but you're free to do this your way.

the create_client function is exactly what it says on the tin. My implementation looks like this:

fn create_client(redirect_url: RedirectUrl) -> BasicClient {
    let client_id = ClientId::new(env!("OAUTH2_CLIENT_ID", "Missing AUTH0_CLIENT_ID!").to_string());

    let auth_url = AuthUrl::new(env!("OAUTH2_AUTH_URL", "Missing AUTH0_AUTH_URL!").to_string());

    let token_url = TokenUrl::new(env!("OAUTH2_TOKEN_URL", "Missing AUTH0_TOKEN_URL!").to_string());

    BasicClient::new(client_id, None, auth_url.unwrap(), token_url.ok())
        .set_redirect_uri(redirect_url)
}
Enter fullscreen mode Exit fullscreen mode

now back to authenticate, we need to get the state from tauri. It does expose some State helper, but I found that passing around an AppHandle around is much more convenient to work with

#[tauri::command]
async fn authenticate(handle: tauri::AppHandle) {
    let auth = handle.state::<AuthState>();
}
Enter fullscreen mode Exit fullscreen mode

Now with the state, we can create our auth url. Don't forget to add all the data you need like scopes and such.

#[tauri::command]
async fn authenticate(handle: tauri::AppHandle) {
    let auth = handle.state::<AuthState>();

    // The 2nd element is the csrf token.
    // We already have it so we don't care about it.
    let (auth_url, _) = auth
        .client
        .authorize_url(|| auth.csrf_token.clone())
        // .add_scope(...)
        .set_pkce_challenge(auth.pkce.0.clone())
        .url();
}
Enter fullscreen mode Exit fullscreen mode

Before we open the browser with our newly-created url, we should spawn a server to actually listen to the callback. For that I defined a run_server function

async fn authorize() -> impl IntoResponse {
  todo!("woo hoo!")
}

async fn run_server(
    handle: tauri::AppHandle,
) -> Result<(), axum::Error> {
    let app = Router::new()
        .route("/callback", get(authorize))
        .layer(Extension(handle.clone()));

    let _ = axum::Server::bind(&handle.state::<AuthState>().socket_addr.clone())
        .serve(app.into_make_service())
        .await;

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

and spawn it

#[tauri::command]
async fn authenticate(handle: tauri::AppHandle) {
    // ...
    let server_handle = tauri::async_runtime::spawn(async move { run_server(handle).await });
}
Enter fullscreen mode Exit fullscreen mode

Now it's time to open the browser with the auth link using the open crate we added or some other method.

#[tauri::command]
async fn authenticate(handle: tauri::AppHandle) {
    // ...
    open::that(auth_url.to_string()).unwrap();
}
Enter fullscreen mode Exit fullscreen mode

lets get back to authorize to implement it.
The request we are expecting to receive is a GET request to the /callback endpoint with code and state (<- CSRF).

Thankfully, axum makes my life a bit easier

#[derive(Deserialize)]
struct CallbackQuery {
    code: AuthorizationCode,
    state: CsrfToken,
}

async fn authorize(query: Query<CallbackQuery>) -> impl IntoResponse {
    todo!("very cool, thanks axum")
}
Enter fullscreen mode Exit fullscreen mode

We will also need the AppHandle we used earlier to access the state

async fn authorize(
    handle: Extension<tauri::AppHandle>,
    query: Query<CallbackQuery>
) -> impl IntoResponse {
    let auth = handle.state::<AuthState>();
}
Enter fullscreen mode Exit fullscreen mode

Now we just gotta check the CSRF token, and exchange our code with the actual token, don't forget to attach the PKCE verifier.

async fn authorize(
    handle: Extension<tauri::AppHandle>,
    query: Query<CallbackQuery>
) -> impl IntoResponse {
    let auth = handle.state::<AuthState>();

    if query.state.secret() != auth.csrf_token.secret() {
        println!("Suspected Man in the Middle attack!");
        return "authorized".to_string(); // never let them know your next move
    }

    let token = auth
        .client
        .exchange_code(query.code.clone())
        .set_pkce_verifier(PkceCodeVerifier::new(auth.pkce.1.clone()))
        .request_async(async_http_client)
        .await
        .unwrap();

    "authorized".to_string()
}
Enter fullscreen mode Exit fullscreen mode

Ok now what? You tell me. You have the token!
you can use keyring to store the token, you can use a channel to broadcast back to authenticate that you got a token so it can close the server (which is what I did). The world is your authenticated oyster :)

Closing words

Despite the length of this post, this solution is not complete! You, the reader, will have to implement token refresh and usage, you will have to implement server control, you will have to configure your oauth2 provider with the correct parameters. But for the sake of brevity I kept it to the absolute bare essentials.

If there is a missing piece that you think is absolutely essential or an optimization feel free to drop a comment.

Top comments (4)

Collapse
 
adimac93 profile image
Adimac93

It should be mentioned that authorisation code obtained by this program shouldn't be exposed on the client side thus I would highly recommend processing code grant on a separate axum server and communicating with it by reqwest crate. Understanding RFC6749 will help with correct implementation. If I missed something correct me.

Collapse
 
datner profile image
Datner • Edited

it is quite impossible to avoid getting the code to the client for the simple reason that it's the whole point of the flow 😅
I've actually implemented the code using the document you linked and this later extension in rfc7636 for working with public oauth clients like native desktop clients (IE tauri)

you're right to be worried about code hijacking, but that's exactly what pkce is for. It makes sure that it is only possible for the requester of the code to be the exchanger of the code.
There's no need for any other servers besides the callback server to catch the code and csrf state. I hope that clears things up!

Collapse
 
adimac93 profile image
Adimac93

I think you are right! I am just surprised how axum server in tauri app is used just to process the OAuth2 callback, it's interesting. I thought that auth code would be processed on the centralised server to which all desktop clients would be connected with an API. Maybe I've got confused with different types of OAuth2 specs 😅.

Collapse
 
alexmikhalev profile image
AlexMikhalev

Great write up, it would be good to have a link to full repo - I can spin axum server, but all requests return index.html from devPath. How did you manage to achieve your goals? any specific recommendations in tauri.conf?