Guide
8 Min.
Read

Adding passkeys to your Rust application

If you're looking to add a passkey login to your Rust app, you're in the right place! In this tutorial, we'll walk you through the step-by-step process and showing you how to use the Hanko Passkey API to make it happen. So, let's dive in and start upgrading your app's login experience.

We'll assume that you already have an authentication system in place, so we'll focus specifically on adding a passkey functionality to complement your current setup. To bring passkey functionality to your app, you'll typically need two things:

  1. A backend to handle the authentication flow. We'll use Rust for this.
  2. A couple of functions on your frontend to display and handle the "Sign in with a passkey" and "Register a passkey" dialogs. In this tutorial, we'll be using React.js for our frontend, but the process will be pretty similar for any other frontend framework you might be using.

Let's get started and give your users a seamless and secure login experience. To make things even simpler, we have created a sample app that you can use as a reference throughout the tutorial.

Passkey registration

Rust backend:

First, you need to retrieve the PASSKEY_TENANT_ID and PASSKEY_API_KEY. These variables are used to authenticate and communicate with the Hanko Cloud API.

Next, we create two controller functions.

1. start_registration_handler

  • This function is responsible for initiating the passkey registration process.
  • It first checks if the user is logged in by verifying the presence of the user_id in the session.
  • If the user is logged in, it retrieves the user_id and user_email from the session.
  • It then sends a POST request to the Hanko Cloud API's /registration/initialize endpoint with the user_id and username in the payload.
  • The response from the API contains the necessary creation options, which are returned as JSON to the frontend.
// src/controller.rs

pub async fn start_registration_handler(Json(user): Json) -> Result, StatusCode> {
    let client = reqwest::Client::new();
    let url = format!("{}/{}/registration/initialize", BASE_URL, TENANT_ID);
    let payload = json!({ "user_id": user.id, "username": user.email });

    let mut headers = HeaderMap::new();
    headers.insert("apikey", HeaderValue::from_static(API_KEY));
    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));

    let response = client.post(url)
        .headers(headers)
        .json(&payload)
        .send()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let creation_options: serde_json::Value = response
        .json()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(creation_options))
}

2. finalize_registration_handler:

  • This function is responsible for finalizing the passkey registration process.
  • It expects the registration data to be sent in the request body as JSON.
  • It sends a POST request to the Hanko Cloud API's /registration/finalize endpoint with the received data.
  • If the registration is successful, it returns a success message as JSON.
pub async fn finalize_registration_handler(Json(data): Json) -> Result, StatusCode> {
    let client = reqwest::Client::new();
    let url = format!("{}/{}/registration/finalize", BASE_URL, TENANT_ID);

    let mut headers = HeaderMap::new();
    headers.insert("apikey", HeaderValue::from_static(API_KEY));
    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));

    let response = client.post(url)
        .headers(headers)
        .json(&data)
        .send()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let result: serde_json::Value = response
        .json()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(result))
}

Now, create two routes and use the APIs on your frontend.


//src/main.rs

.route("/passkeys/start-login", post(start_login_handler))
.route("/passkeys/finalize-login", post(finalize_login_handler))

React frontend:

Make sure to install the @github/webauthn-json library in your frontend.

npm install @github/webauthn-json

Next, create a function called registerPasskey that will be called when the user clicks a button to register a new passkey.

import { create, type CredentialCreationOptionsJSON } from "@github/webauthn-json";

async function registerPasskey() {
  // Step 1: Start the passkey registration process
  const createOptionsResponse = await fetch("http://localhost:8080/passkeys/start-registration", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    credentials: 'include',
  });
  const createOptions = await createOptionsResponse.json();
  console.log("createOptions", createOptions);

  // Step 2: Create the passkey credential using the WebAuthn API
  const credential = await create(
    createOptions as CredentialCreationOptionsJSON,
  );
  console.log(credential);

  // Step 3: Finalize the passkey registration
  const response = await fetch("http://localhost:8080/passkeys/finalize-registration", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    credentials: "include",
    body: JSON.stringify(credential),
  });
  console.log(response);

  if (response.ok) {
    toast.success("Registered passkey successfully!");
    return;
  }
}

Let's break down the registerPasskey function step by step:

  1. Start the passkey registration process:
    • We make a POST request to the /passkey/start-registration endpoint of our Rust backend.
    • The request includes the necessary headers and credentials to maintain the user's session.
    • The response from the backend contains the creation options required for passkey registration.
  2. Create the passkey credential using the WebAuthn API:
    • We use the create function from the @github/webauthn-json library to create the passkey credential.
    • The create function takes the creation options received from the backend and returns a promise that resolves to the created credential.
  3. Finalize the passkey registration:
    • We make a POST request to the /passkey/finalize-registration endpoint of our Rust backend.
    • The request includes the created credential as the request body, along with the necessary headers and credentials.
    • If the response from the backend is successful (indicated by response.ok), we display a success message to the user using a toast notification.

With this code in place, when the user clicks the button to register a new passkey, the registerPasskey function will be called. It will start the registration process, create the passkey credential using the WebAuthn API, and finalize the registration with the backend.

Get credentials from Hanko Cloud

Now, to make it work we're missing one crucial step: getting the Tenant ID and API key secret from Hanko Cloud. For that, navigate over to Hanko, create an account, and set up your organization. Navigate to 'Create new project', select 'Passkey Infrastructure', and provide your project name and URL.

Note: It's recommended to create separate projects for your development and production environments. This way, you can use your frontend localhost URL for the development project and your production URL for the live project, ensuring a smooth transition between environments without the need to modify the 'App URL' later.

Obtain your Tenant ID and create an API key, then add the Tenant ID and API key secret to your backend's '.env' file.

Now that you have your secrets added to your backend's .env file, you should be all set to register a passkey. Go ahead and give it a try!

Passkey login

Backend:

To enable passkey login in your Rust backend, you'll need to add two more functions:

  1. start_passkey_login_handler:
    • This function initiates the passkey login process.
    • It sends a POST request to the Hanko Cloud API's /login/initialize endpoint.
    • The response from the API contains the necessary login options, which are returned as JSON to the frontend.
  2. finalize_passkey_login_handler:
    • This one finalizes the passkey login process.
    • It expects the client data to be sent in the request body as JSON.
    • It sends a POST request to the Hanko Cloud API's /login/finalize endpoint with the received client data.
    • The response from the API contains a token, which is decoded to extract the user ID.
    • If a user with the extracted user ID exists in your user database, the user is logged in.
    • If the login is successful, it returns a success message.
// src/controller.rs

pub async fn start_passkey_login_handler() -> Result, StatusCode> {
    let client = reqwest::Client::new();
    let url = format!("{}/login/initialize", BASE_URL);
    let headers = [("Content-Type", "application/json")]; // Add more headers as needed

    let res = client.post(&url)
        .headers(headers.into_iter().map(|(k, v)| (k, HeaderValue::from_static(v))).collect())
        .send()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let body = res.text().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let login_options: LoginOptions = serde_json::from_str(&body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(login_options))
}

pub async fn finalize_passkey_login_handler(Json(client_data): Json) -> Result {
    let client = reqwest::Client::new();
    let url = format!("{}/login/finalize", BASE_URL);
    let headers = [("Content-Type", "application/json")]; // Add more headers as needed

    let res = client.post(&url)
    .headers(headers.into_iter().map(|(k, v)| (http::header::HeaderName::from_static(k), HeaderValue::from_static(v))).collect())
        .json(&client_data)
        .send()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let body = res.text().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let result: serde_json::Value = serde_json::from_str(&body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let token = result.get("token").and_then(|t| t.as_str()).ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
    let decoded = decode::(&token, &DecodingKey::from_secret("secret".as_ref()), &Validation::default())
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let claims = decoded.claims;
    let user_id = claims.sub;

    let new_token = encode(&Header::default(), &claims, &EncodingKey::from_secret("secret".as_ref()))
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let mut headers = HeaderMap::new();
    headers.insert(SET_COOKIE, HeaderValue::from_str(&format!("token={}; HttpOnly; Path=/; Max-Age=86400", new_token)).unwrap());

    Ok(Response::builder()
        .status(StatusCode::OK)
        .headers(headers)
        .body(Json(json!({"message": "Login successful"})).into())
        .unwrap())
}

Frontend:

Now we create a signInWithPasskey function. This function will interact with the Rust backend APIs to initiate and finalize the passkey login process.

Here's the code for the signInWithPasskey function:

async function signInWithPasskey() {
  // Step 1: Start the passkey login process
  const createOptionsResponse = await fetch("http://localhost:8080/passkeys/start-login", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    credentials: 'include',
    body: JSON.stringify({ start: true, finish: false, credential: null }),
  });
  const loginOptions = await createOptionsResponse.json();

  // Step 2: Open the "sign in with a passkey" dialog and get the credential
  const options = await get(
    loginOptions as any,
  );

  // Step 3: Finalize the passkey login
  const response = await fetch("http://localhost:8080/passkeys/finalize-login", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    credentials: 'include',
    body: JSON.stringify(options),
  });

  if (response.ok) {
    navigate("/dashboard");
    return;
  }
}

Let's break down the signInWithPasskey function:

  1. Start the passkey login process:
    • We make a POST request to the /passkeys/start-login endpoint.
    • The request includes the necessary headers, credentials, and a JSON payload indicating the start of the login process.
    • The response from the backend contains the login options required for passkey authentication.
  2. Open the "sign in with a passkey" dialog and get the credential:
    • We use the get function from the @github/webauthn-json library to open the "sign in with a passkey" dialog.
    • The get function takes the login options received from the backend and returns a promise that resolves to the selected credential.
  3. Finalize the passkey login:
    • We make a POST request to the /passkeys/finalize-login endpoint of our Rust backend.
    • The request includes the selected credential as the request body, along with the necessary headers and credentials.
    • If the response from the backend is successful (indicated by response.ok), we log a message indicating a successful passkey login.
    • After a successful login, we navigate the user to the "/dashboard" route using the navigate function (assuming you have a routing system in place).

That's it! You've successfully integrated passkeys into your Rust app. Feel free to reach out to us on Discord if you face any issues.

GitHub Repo: https://github.com/teamhanko/passkeys-rust

arrow
Back to overview

More blog posts

Don't miss out on latest blog posts, new releases and features of Hanko's products, and more.

Your submission has been received!
Something went wrong.