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:
- 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 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
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
-- 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
}
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");
}
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();
// ...
}
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)
}
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>();
}
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();
}
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(())
}
and spawn it
#[tauri::command]
async fn authenticate(handle: tauri::AppHandle) {
// ...
let server_handle = tauri::async_runtime::spawn(async move { run_server(handle).await });
}
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();
}
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")
}
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>();
}
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()
}
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)
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.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!
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 😅.
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?