Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Warning

This book is under development. Some chapters are still incomplete and the content may change without notice.

Configuration

Our application needs to be configured with settings that could change across deployments and that could depend on the particular IdP - Identity Provider in use.

#![allow(unused)]
fn main() {
// file: src/config.rs
// -------------------

pub enum IssuerType {
    Google, GitHub, Auth0, Keycloak, LinkedIn,
}

pub struct AppConfig {
    pub issuer_url: String,
    pub issuer_type: IssuerType,
    pub client_id: String,
    pub client_secret: String,
    pub scopes: Vec<String>,
    pub refresh_token_path: PathBuf,
}
}

Identity providers may differ in how they hand out refresh tokens, so our application needs to know which provider it is targeting via the OIDC_ISSUER_TYPE configuration setting.

String Representation Pattern

In general programming, providing an explicit method to convert an internal domain representation (like an enum variant or struct instance) into its string domain representation is known as the “Stringifier Pattern”.

Unlike implementing the ToString trait or standard string conversions which typically allocate a new heap string, the conventional as_str() method explicitly promises a zero-copy reference view, as it returns a string slice &str.

#![allow(unused)]
fn main() {
// file: src/config.rs
// -------------------

impl IssuerType {
    pub fn as_str(&self) -> &'static str {
        match self {
            IssuerType::Google   => "google",
            IssuerType::GitHub   => "github",
            IssuerType::Auth0    => "auth0",
            IssuerType::Keycloak => "keycloak",
            IssuerType::LinkedIn => "linkedin",
        }
    }
}
}

Once the conventional as_str() method is in place, the most obvious trait we should implement immediately after is the Display trait:

#![allow(unused)]
fn main() {
impl std::fmt::Display for IssuerType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}
}

The natural counterpart to as_str() is parsing a string back into an enum. This is usually done by implementing std::str::FromStr trait (or the TryFrom<&str> trait):

#![allow(unused)]
fn main() {
impl std::str::FromStr for IssuerType {
    type Err = anyhow::Error;

    /// Parses the issuer type case-insensitively.
    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "google"   => Ok(IssuerType::Google),
            "github"   => Ok(IssuerType::GitHub),
            "auth0"    => Ok(IssuerType::Auth0),
            "keycloak" => Ok(IssuerType::Keycloak),
            "linkedin" => Ok(IssuerType::LinkedIn),
            other => Err(anyhow!(
                "unknown OIDC_ISSUER_TYPE value '{other}'; expected one of: \
                 google, github, auth0, keycloak, linkedin"
            )),
        }
    }
}
}

Environment variables

Following the 12-Factor App recommendations, our application configuration comes from the process environment, whose variables are loaded using the dotenvy crate.

# The relevant environment variables

OIDC_ISSUER_TYPE=Google
OIDC_ISSUER_URL=https://accounts.google.com
OAUTH_CLIENT_ID=<your-client-id>
OAUTH_CLIENT_SECRET=<your-client-secret>
OAUTH_SCOPES=https://www.googleapis.com/auth/youtube

# Optional: where to persist the refresh token (absolute or relative path).
# Defaults to `.refresh-token` when unset.
OAUTH_REFRESH_TOKEN_PATH=.refresh-token

This is well different from using configuration files like app.conf or similar. Don’t get confused by the opportunity of using ../../.env file as that must be seen just as convenience for bootstrapping environment variables during local development only. The .env is not a configuration file or a deployment mechanism. It is not the source of truth, and we should never put it under version control. In production, the platform (Docker, Kubernetes, Heroku, etc.) running our application sets real environment variables, and no .env file exists!

Tip

The process environment is the single source of truth!

The ../../.env file’s only legitimate job is to eventually fill in missing values for local development convenience — never to override an explicit environment, and that the dotenv() function does precisely this.

Now we can read the Rust code, and immediately understand what it does:

#![allow(unused)]
fn main() {
// file: src/config.rs
// -------------------

use anyhow::{self, Context, Result};

// struct AppConfig ...

pub fn load() -> Result<()> {
    
    // A missing `.env` file is fine!
    // The variables are supposed to be already set in the process
    // environment (e.g. by the shell or a container). Only propagate 
    // genuine errors, such as a malformed file.
    
    if let Err(err) = dotenvy::dotenv()
        && !err.not_found()
    {
        return Err(
          anyhow::Error::new(err).context("failed to load .env file")
        );
    }

    // Read environment variable one by one ...
    
    let issuer_url = dotenvy::var("OIDC_ISSUER_URL")
        .context("OIDC_ISSUER_URL must be set as environment variable")?;

    let issuer_type = dotenvy::var("OIDC_ISSUER_TYPE")
        .context("OIDC_ISSUER_TYPE must be set as environment variable")?
        .parse::<IssuerType>()?;
    
    // ... read more here ...
    
    // Finally make the AppConfig struct value
    let loaded_config = AppConfig {
        issuer_url,
        issuer_type,
        client_id,
        client_secret,
        scopes,
        refresh_token_path
    };

    // And set it into the global cell
    // ...
}
}

Thread-Safe Lazy Singleton Pattern

Our config::load() function loads and validates our application configuration, then stashes the result in a process-wide OnceLock<AppConfig>, using the “Thread-Safe Lazy Singleton” pattern, also known as “Lazy Initialization”. In doing that, any other module can call the config::get() to obtain a read-only reference to the loaded configuration.

#![allow(unused)]
fn main() {
// file: src/config.rs
// -------------------

use std::sync::OnceLock;
use anyhow::{anyhow, Result};

// pub enum IssuerType ...
// pub struct AppConfig ...

static CONFIG: OnceLock<AppConfig> = OnceLock::new();

pub fn load() -> Result<()> {
    // Load the configuration settings from somewhere
    let loaded_config: AppConfig = ...;

    // And set it into the global cell
    CONFIG
      .set(loaded_config)
      .map_err(|_| anyhow!("AppConfig is already initialized"))
}

/// Returns the process-wide, read-only configuration.
pub fn get() -> &'static AppConfig {
    CONFIG
      .get()
      .expect("AppConfig not initialized; call load() first")
}
}

When declared as a top-level item, the static keyword creates a single memory location that lives for the entire duration of the program. Unlike const, which inlines its value wherever it is used (creating copies in code), a static item occupies a fixed, unique location in RAM. Passing a reference to a static passes an actual pointer to that exact memory address.

The initial value of a static item must be known at compile time, or lazily initialized using the OnceLock<T>. This kind of lock provides a thread-safe synchronization primitive that allows an item to be initialized exactly once upon its first access at runtime.