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 tomy 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:
- Determine that user needs to authenticate
- Call the
/authorize
endpoint to receive theauth_url
with a PKCE challenge - Open the users browser allowing them to log in
- Catch callback from idp
- Check CSRF integrity
- Exchange the
code
from the callback with a token from/oauth/token
- ????
- 🎉 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
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 meansNO 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 newNative Application
. Then inside I can take theClient Id
and theDomain
. Those are not secret, I put them in my env vars, but they can live wherever you want
OAUTH2_CLIENT_ID=<my clientid>OAUTH2_AUTH_URL=https://<domain>/authorizeOAUTH2_TOKEN_URL=https://<domain>/oauth/token
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"
Because this post isn't long enough, I'm going to break down the usage:
tokio
-- Asyncaxum
-- The server frameworkoauth2
-- The oauth2 libraryreqwest
-- http request libary, playes well with oauth2open
-- 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)]structAuthState{csrf_token:CsrfToken,pkce:Arc<(PkceCodeChallenge,String)>,client:Arc<BasicClient>,socket_addr:SocketAddr}
and initiate them in themain
method.
Side note: I'm not going to include all my imports. Just follow your heart (LSP auto-import)
#[tauri::command]asyncfnauthenticate(){todo!("we'll get here soon enough")}fnmain(){let(pkce_code_challenge,pkce_code_verifier)=PkceCodeChallenge::new_random_sha256();letsocket_addr=SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127,0,0,1)),9133);// or any other portletredirect_url=format!("http://{socket_addr}/callback").to_string();letstate=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");}
Now, if you're using a more compliant provider than auth0, you can use a more robust and secure method to get thesocket_addr
like the oauth2 spec recommends
fnget_available_addr()->SocketAddr{letlistener=TcpListener::bind("127.0.0.1:0").unwrap();letaddr=listener.local_addr().unwrap();drop(listener);addr}fnmain(){// ...letsocket_addr=get_available_addr();// ...}
This will make sure you are getting an unknowable available port. Making your server harder to mess with.
You will notice that I lower thePkceCodeVerifier
to aString
. This is because it does not implementClone
orCopy
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.
thecreate_client
function is exactly what it says on the tin. My implementation looks like this:
fncreate_client(redirect_url:RedirectUrl)->BasicClient{letclient_id=ClientId::new(env!("OAUTH2_CLIENT_ID","Missing AUTH0_CLIENT_ID!").to_string());letauth_url=AuthUrl::new(env!("OAUTH2_AUTH_URL","Missing AUTH0_AUTH_URL!").to_string());lettoken_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)}
now back toauthenticate
, we need to get the state fromtauri
. It does expose someState
helper, but I found that passing around anAppHandle
around is much more convenient to work with
#[tauri::command]asyncfnauthenticate(handle:tauri::AppHandle){letauth=handle.state::<AuthState>();}
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]asyncfnauthenticate(handle:tauri::AppHandle){letauth=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();}
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 arun_server
function
asyncfnauthorize()->implIntoResponse{todo!("woo hoo!")}asyncfnrun_server(handle:tauri::AppHandle,)->Result<(),axum::Error>{letapp=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(())}
and spawn it
#[tauri::command]asyncfnauthenticate(handle:tauri::AppHandle){// ...letserver_handle=tauri::async_runtime::spawn(asyncmove{run_server(handle).await});}
Now it's time to open the browser with the auth link using theopen
crate we added or some other method.
#[tauri::command]asyncfnauthenticate(handle:tauri::AppHandle){// ...open::that(auth_url.to_string()).unwrap();}
lets get back toauthorize
to implement it.
The request we are expecting to receive is aGET
request to the/callback
endpoint withcode
andstate
(<- CSRF).
Thankfully,axum
makes my life a bit easier
#[derive(Deserialize)]structCallbackQuery{code:AuthorizationCode,state:CsrfToken,}asyncfnauthorize(query:Query<CallbackQuery>)->implIntoResponse{todo!("very cool, thanks axum")}
We will also need theAppHandle
we used earlier to access the state
asyncfnauthorize(handle:Extension<tauri::AppHandle>,query:Query<CallbackQuery>)->implIntoResponse{letauth=handle.state::<AuthState>();}
Now we just gotta check the CSRF token, and exchange ourcode
with the actual token, don't forget to attach the PKCE verifier.
asyncfnauthorize(handle:Extension<tauri::AppHandle>,query:Query<CallbackQuery>)->implIntoResponse{letauth=handle.state::<AuthState>();ifquery.state.secret()!=auth.csrf_token.secret(){println!("Suspected Man in the Middle attack!");return"authorized".to_string();// never let them know your next move}lettoken=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()}
Ok now what? You tell me. You have the token!
you can usekeyring
to store the token, you can use achannel
to broadcast back toauthenticate
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)

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 byreqwest
crate. UnderstandingRFC6749 will help with correct implementation. If I missed something correct me.

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 inrfc7636 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!

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 😅.

- LocationSwindon, United Kingdom
- Joined
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?
For further actions, you may consider blocking this person and/orreporting abuse