DEV Community

Wahyu Rudiyan Saputra
Wahyu Rudiyan Saputra

Posted on

Build Auth Service using Axum and PASETO

Overview

Auth service is a most crucial service in a Web Application. Auth consist of two roles, Authentication and Authorization. Authentication act as user identity verificatory, and Authorization to grant user to access certain resources according to the Access Levels.

PASETO

The Auth Creds Data will signed into Token using JavaScript Object Signing Encryption (JOSE), and the new and more secure than JSON Web Token signing algorithm is PASETO. PASETO stands for Platform Agnostic Security Token. If you do not familiar with this algorithm, I really recommend you to visit this page and read the references: https://paseto.io/.

PASETO provides two type of signing encryption, for Local and Public usage. If you want to use token for internal microservice communication, use Local signing. Or use Public to share asymmetric key to share with public service communication such as 3rd Party Services.

Service Architecture

The architecture Claiming Local Token like the following image.

Local Token

Implementation to Claim Public Token need ED25519 Key Pairs, so prepare it first. The architecture may like this.

Public Token

PASETORS Crate

This library recommend by official website PASETO (https://paseto.io). Before we start to implement PASETO in Rust, we need to know constraints about this library (crate).

PASETORS use compact ED25519 algorithm crate that generate 32-bytes PEM file to generate key pairs. Then, this file set as reference to generate secret-key and public-key. Those keys will use as Asymmetric Key.

To implement local token, you can use 32-bytes (32 characters) as secret key.

Initialization and Routes

In main.rs file in src/, put all configuration code and initialize HTTP server.

// main.rs

use api::router::Routes;
use axum::Router;
use dotenv::dotenv;
use log::info;
use service::{auth_interface::AuthService, paseto_service::PasetoService};
use std::{env, fs, sync::Arc};

mod api;
mod models;
mod service;

#[tokio::main]
async fn main() {
    // Init some dependencies
    dotenv().ok();
    env_logger::init();

    let mut app_port = env::var("APP_PORT").unwrap();
    app_port = if app_port == "" {
        "3000".to_string()
    } else {
        app_port
    };

    // Just for example, not recommend for production
    let cert_path = env::var("CERTIFICATE_PATH").expect("Certificate path cannot empty");
    let pem = fs::read_to_string(cert_path).expect("Unable to read ed25519_key.pem");
    let local_secret = env::var("PASETO_LOCAL_SECRET").expect("Envar: local secret cannot empty");
    let hmac_secret = env::var("PASETO_HMAC_SECRET").expect("Envar: hmac secret cannot empty");

    // Init auth service as dependency for controller that passing over layer
    let paseto_service = PasetoService::new(pem, local_secret, hmac_secret);

    // Init REST Service
    let app_host = format!("0.0.0.0:{app_port}");
    let routes = Routes::new(paseto_service);
    let app = Router::new().nest("/api", routes.router());
    let listener = tokio::net::TcpListener::bind(app_host.clone())
        .await
        .unwrap();

    log::info!("🚀 Server running at host: {app_host}");
    if let Err(e) = axum::serve(listener, app.into_make_service()).await {
        panic!("Unable to run server, error occur: {}", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, see at the beginning of main() function, #[tokio::main] macro is set to make main() running with asynchronous runtime.

For API routes, all of them written as module and placed in src/api/router.rs.

// src/api/router.rs

use super::controller::auth_controller::{
    claim_local_token, claim_public_token, verify_local_token, verify_public_token,
};
use crate::service::auth_interface::AuthService;
use axum::{routing::post, Extension, Router};

pub struct Routes<T: AuthService + Clone> {
    auth_service: T,
}

impl <T: AuthService + Clone> Routes<T> {
    pub fn new(paseto_service: T) -> Self {
        Routes {
            auth_service: paseto_service,
        }
    }

    pub fn router(&self) -> Router {
        Router::new().nest(
            "/auth/token",
            Router::new()
                .route("/local/claim", post(claim_local_token::<T>))
                .route("/local/verify", post(verify_local_token::<T>))
                .route("/public/claim", post(claim_public_token::<T>))
                .route("/public/verify", post(verify_public_token::<T>))
                .layer(Extension(self.auth_service.clone())),
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Controller and Service Layer

Controller will handle any request and act as 'bridge' between HTTP Server and Service Layer. Controller will perform JSON Request Body Parsing and provide Extension for Dependency Injection. In term of Dependency Injection, this means the Service Layer will inject into Controller, it make Controller able to access the AuthService trait. The Controller and Service Layer look like this.

// The Controller
// src/api/controller/auth_controller.rs

use crate::{
    models::{
        dto::auth::{
            PasetoClaimRequest, PasetoClaimResponse, PasetoVerifyRequest, PasetoVerifyResponse,
        },
        entities::auth::{PasetoToken, UserData},
    },
    service::auth_interface::AuthService,
};
use axum::{http::StatusCode, response::IntoResponse, Extension, Json};
use std::sync::Arc;

pub async fn claim_local_token<T:AuthService>(
    Extension(state): Extension<T>,
    Json(body): Json<PasetoClaimRequest>,
) -> impl IntoResponse {
    let user_data = UserData { user: body.user };
    let claim_svc = state.claim_local_token(user_data);
    match claim_svc {
        Ok(result) => {
            log::info!("Local token claimed...");
            let response = PasetoClaimResponse::success(result);
            (StatusCode::OK, Json(response))
        }

        Err(e) => {
            log::error!("Error occur - claim local token: {}", e.to_string());
            let response = PasetoClaimResponse::failure("unable to claim token".to_string());
            (StatusCode::INTERNAL_SERVER_ERROR, Json(response))
        }
    }
}

pub async fn verify_local_token<T:AuthService>(
    Extension(state): Extension<T>,
    Json(body): Json<PasetoVerifyRequest>,
) -> impl IntoResponse {
    let token = PasetoToken {
        token: body.token,
        expired: body.expired,
    };
    let verified = state.verify_local_token(token);
    match verified {
        Ok(data) => {
            log::info!("Local token verified...");
            let response = PasetoVerifyResponse::success(data);
            (StatusCode::OK, Json(response))
        }

        Err(e) => {
            log::error!("Error occur - verify local token: {}", e.to_string());
            let response =
                PasetoVerifyResponse::failure("invalid local token, user unauthorized".to_string());
            (StatusCode::UNAUTHORIZED, Json(response))
        }
    }
}

pub async fn claim_public_token<T:AuthService>(
    Extension(state): Extension<T>,
    Json(body): Json<PasetoClaimRequest>,
) -> impl IntoResponse {
    let user_data = UserData { user: body.user };
    let claim_svc = state.claim_public_token(user_data);
    match claim_svc {
        Ok(result) => {
            log::info!("Public token claimed...");
            let response = PasetoClaimResponse::success(result);
            (StatusCode::OK, Json(response))
        }

        Err(e) => {
            log::error!("Error occur - claim public token: {}", e.to_string());
            let response = PasetoClaimResponse::failure("unable to claim public token".to_string());
            (StatusCode::INTERNAL_SERVER_ERROR, Json(response))
        }
    }
}

pub async fn verify_public_token<T:AuthService>(
    Extension(state): Extension<T>,
    Json(body): Json<PasetoVerifyRequest>,
) -> impl IntoResponse {
    let token = PasetoToken {
        token: body.token,
        expired: body.expired,
    };
    let verified = state.verify_public_token(token);
    match verified {
        Ok(data) => {
            log::info!("Public token verified...");
            let response = PasetoVerifyResponse::success(data);
            (StatusCode::OK, Json(response))
        }

        Err(e) => {
            log::error!("Error occur - verify public token: {}", e.to_string());
            let response = PasetoVerifyResponse::failure(
                "invalid public token, user unauthorized".to_string(),
            );
            (StatusCode::UNAUTHORIZED, Json(response))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, the following code is trait of AuthService. Trait act as interface for modularity.

// src/service/auth_interface.rs

use crate::models::{
    dto::auth::PasetoVerifiedData,
    entities::auth::{PasetoToken, UserData},
};
use async_trait::async_trait;
use axum::Error;

#[async_trait]
pub trait AuthService: Send + Sync + 'static {
    fn claim_local_token(&self, user: UserData) -> Result<PasetoToken, Error>;
    fn verify_local_token(&self, token: PasetoToken) -> Result<PasetoVerifiedData, Error>;
    fn claim_public_token(&self, user: UserData) -> Result<PasetoToken, Error>;
    fn verify_public_token(&self, body: PasetoToken) -> Result<PasetoVerifiedData, Error>;
}
Enter fullscreen mode Exit fullscreen mode

Trait just for abstraction of concrete function. The implementation of trait linked by a struct. I use new() function as constructor to do that. The concrete function is core logic for PASETO Auth Service. So, the code should be like this.

// src/service/paseto_service.rs

use crate::models::{
    dto::auth::PasetoVerifiedData,
    entities::auth::{PasetoToken, PayloadClaim, UserData},
};
use async_trait::async_trait;
use axum::Error;
use ed25519_compact::SecretKey;
use pasetors::{
    claims::{Claims, ClaimsValidationRules},
    keys::{AsymmetricPublicKey, AsymmetricSecretKey, SymmetricKey},
    local, public,
    token::UntrustedToken,
    version4::V4,
    Local, Public,
};

use super::auth_interface::AuthService;

#[derive(Clone)]
pub struct PasetoService {
    secret_key: Vec<u8>,
    public_key: Vec<u8>,
    local_secret: String,
    hmac_secret: String,
}

impl PasetoService {
    pub fn new(pem: String, local_secret: String, hmac_secret: String) -> Self {
        let sk = SecretKey::from_pem(pem.as_str()).unwrap();
        let pk = sk.public_key();
        PasetoService {
            secret_key: sk.as_ref().to_vec(),
            public_key: pk.as_ref().to_vec(),
            local_secret: local_secret,
            hmac_secret: hmac_secret,
        }
    }
}

#[async_trait]
impl AuthService for PasetoService {
    fn claim_local_token(&self, user: UserData) -> Result<PasetoToken, Error> {
        let now = chrono::Utc::now();
        let expired_at = now + chrono::Duration::hours(12);

        // claim data
        let mut claims = Claims::new().unwrap();
        claims.expiration(&expired_at.to_rfc3339()).unwrap();
        claims
            .add_additional("user_data", serde_json::json!(user))
            .unwrap();

        if self.local_secret.len() != 32 {
            return Err(Error::new("invalid secret key length, must 32 bytes"));
        }

        let symmetric_key = SymmetricKey::<V4>::from(self.local_secret.as_bytes()).unwrap();
        let encrypted = local::encrypt(
            &symmetric_key,
            &claims,
            None,
            Some(self.hmac_secret.as_bytes()),
        );
        match encrypted {
            Ok(token) => {
                let data = PasetoToken {
                    token: token,
                    expired: expired_at.to_rfc3339(),
                };

                return Ok(data);
            }
            Err(err) => {
                return Err(Error::new(format!(
                    "error occur when encrypting token: {}",
                    err.to_string()
                )));
            }
        }
    }

    fn verify_local_token(&self, body: PasetoToken) -> Result<PasetoVerifiedData, Error> {
        let is_expired = body.is_expired();
        if is_expired {
            return Err(Error::new("expired token cannot claimed"));
        }

        if self.local_secret.len() != 32 {
            return Err(Error::new("invalid secret key length, must 32 bytes"));
        }

        let validation_rules = ClaimsValidationRules::new();
        let untrusted_token = UntrustedToken::<Local, V4>::try_from(&body.token).unwrap();

        let symetric_key = SymmetricKey::<V4>::from(self.local_secret.as_bytes()).unwrap();
        let trusted_token = local::decrypt(
            &symetric_key,
            &untrusted_token,
            &validation_rules,
            None,
            Some(self.hmac_secret.as_bytes()),
        )
        .unwrap();

        let payload = trusted_token.payload_claims().unwrap().to_string().unwrap();
        let p = serde_json::from_str::<PayloadClaim>(&payload).unwrap();
        let verified_data = PasetoVerifiedData::new(p.user_data.user, true);

        Ok(verified_data)
    }

    fn claim_public_token(&self, user: UserData) -> Result<PasetoToken, Error> {
        let now = chrono::Utc::now();
        let expired_at = now + chrono::Duration::hours(12);

        // claim data
        let mut claims = Claims::new().unwrap();
        claims.expiration(&expired_at.to_rfc3339()).unwrap();
        claims
            .add_additional("user_data", serde_json::json!(user))
            .unwrap();

        let pvt_key = AsymmetricSecretKey::<V4>::from(&self.secret_key).unwrap();
        let encrypted = public::sign(&pvt_key, &claims, None, Some(self.hmac_secret.as_bytes()));
        match encrypted {
            Ok(token) => {
                let data = PasetoToken {
                    token: token,
                    expired: expired_at.to_rfc3339(),
                };

                return Ok(data);
            }
            Err(err) => {
                return Err(Error::new(format!(
                    "error occur when encrypting token: {}",
                    err.to_string()
                )));
            }
        }
    }

    fn verify_public_token(&self, body: PasetoToken) -> Result<PasetoVerifiedData, Error> {
        let is_expired = body.is_expired();
        if is_expired {
            return Err(Error::new("expired token cannot claimed"));
        }

        let validation_rules = ClaimsValidationRules::new();
        let untrusted_token = UntrustedToken::<Public, V4>::try_from(&body.token).unwrap();

        let pub_key = AsymmetricPublicKey::<V4>::from(&self.public_key).unwrap();
        let trusted_token = public::verify(
            &pub_key,
            &untrusted_token,
            &validation_rules,
            None,
            Some(self.hmac_secret.as_bytes()),
        )
        .unwrap();

        let payload = trusted_token.payload_claims().unwrap().to_string().unwrap();
        let p = serde_json::from_str::<PayloadClaim>(&payload).unwrap();
        let verified_data = PasetoVerifiedData::new(p.user_data.user, true);

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

Containerization and Running the App

In modern web application deployment, containerization is a must. We need Docker to make an image for this application. I also build this application using Multi-Stage strategy.

FROM rust:1.84 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release

FROM debian:latest
WORKDIR /app
COPY --from=builder /app/target/release/axum-auth /app
RUN --mount=type=secret,id=paseto_private_key,target=/run/secrets/private_key.pem
RUN chmod +x axum-auth
CMD ["./axum-auth"]
Enter fullscreen mode Exit fullscreen mode

Please, pay attention with the base image that I use for application image. I choose debian:latest to solve GLIBC incompatibility issue.

You don't need to run to make Docker Image separately with running application using Docker Compose. Define build and creating image within docker-compose.yaml.

services:
  axum:
    container_name: axum-auth-service
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    secrets:
      - private_key
    environment:
      - RUST_LOG=info
      - APP_PORT=3000
      - CERTIFICATE_PATH=/run/secrets/private_key
      - PASETO_LOCAL_SECRET=#S3cretK3Y!ThisK3yMu5tBe32Bytes!
      - PASETO_HMAC_SECRET=f3f8a71c69bdf338c447029f0ff51887fc7e57c49b77c8b5cab001c986393dcc
    volumes:
      - ./axum-auth:/app/data

secrets:
  private_key:
    file: ./ed25519_key.pem # this file generate by me in my local machine
Enter fullscreen mode Exit fullscreen mode

Build the container image with run this command:

docker compose up --build   
Enter fullscreen mode Exit fullscreen mode

Closing

This implementation is not for production purpose. I intend to write this code just for my portfolio sample to proof my Rust skill. There are notes if you want to implement this code on your production level:

  1. Take care with error handling, I use too many unwrap() to get the return value directly. If an error occur, it will panic.

  2. The secrets and config file. You have to set all secret with docker secrets (or Kubernetes secrets or Vault).

  3. Use Dynamic Dispatch for Thread Safety when you implement Dependency Injection. I am using Static Dispatch just for example.

For more details of this code, please visit my repository. Support me with like in this post and start on my repo.

Github Repo: https://github.com/wahyurudiyan/axum-auth/

Top comments (0)