Authentication

How OIDC authentication works with FerrisKey in the DX SaaS Template

Overview

The template uses FerrisKey as the identity provider, integrated via OIDC (authorization code flow with PKCE) and a custom login UI that drives FerrisKey's REST API directly. Authentication is session-based using tower-sessions with Redis as the session store.

Auth Flow

The login page supports three credentials, auto-detected from the user's account state:

1

User enters email

The login page POSTs to /auth/session/start. The server looks up the user in FerrisKey by email and inspects their credentials.

2

Branch on credentials

  • Has passkey → server bootstraps a FerrisKey auth-flow session and returns WebAuthn request options. The browser invokes navigator.credentials.get() and POSTs the assertion back to /auth/session/passkey/verify.
    • Has password → page shows a password input that POSTs to /auth/session/password/verify.
    • No credentials / new user → CAPTCHA-gated email-OTP flow: a 6-digit code is sent via SMTP and verified at /auth/session/otp/verify.
3

Token exchange

On Success, the server exchanges the OIDC code for tokens at FerrisKey's /protocol/openid-connect/token endpoint using the PKCE verifier. The id_token is validated against FerrisKey's JWKS (cached, refreshed on key rotation).

4

Session cookie is set

The server records the user via AuthUserStore::lookup_or_create_user and stores the session in Redis. The signed cookie is HTTP-only and (in production) secure + SameSite=Lax.

5

Client receives auth state

The login page bumps UserDataRefreshTrigger, which re-runs /api/me. UserAuthState transitions to Authenticated and the user is navigated to the redirect URL — no full page reload.

Key Components

Server Side

The auth crate defines two traits that the main app implements:

rust
// AuthUserStore — find or create users, run TOS / post-login redirect logic
#[async_trait]
pub trait AuthUserStore: Send + Sync {
    async fn get_user_by_sub(&self, sub: &str) -> AuthResult<Option<AuthUser>>;
    async fn get_user_by_email(&self, email: &str) -> AuthResult<Option<AuthUser>>;
    async fn create_user(&self, user: NewAuthUser) -> AuthResult<AuthUser>;
    async fn update_user_sub(&self, user_id: &str, new_sub: &str) -> AuthResult<()>;
    // ... and a few more
}

// AuthEmailSender — deliver OTP codes via SMTP
#[async_trait]
pub trait AuthEmailSender: Send + Sync {
    async fn send_verification_code(&self, to_email: &str, code: &str, expires_in_minutes: u32)
        -> AuthResult<()>;
}

UserSession Extractor

Server functions can require authentication by adding a session parameter:

rust
#[post("/api/me", session: auth::UserSession)]
async fn get_login_data() -> Result<Option<LoggedInData>, ServerFnError> {
    Ok(session.data().ok().map(LoggedInData::from))
}

The UserSession extractor reads the session from the cookie. If the session is missing or invalid, the server function returns an error.

Client Side

The App component provides auth state via context:

rust
#[derive(Clone, Debug, PartialEq)]
pub enum UserAuthState {
    Loading,
    Authenticated(LoggedInData),
    NotAuthenticated,
}

A use_server_future fetches /api/me on load. The UserDataRefreshTrigger signal allows any component to trigger a re-fetch (e.g., after login or profile update).

Protected Routes

The DashboardShell layout checks UserAuthState and redirects unauthenticated users to /login:

rust
#[component]
pub fn DashboardShell() -> Element {
    let user_auth = use_context::<Signal<UserAuthState>>();
    let nav = use_navigator();

    use_effect(move || {
        if let UserAuthState::NotAuthenticated = &*user_auth.read() {
            nav.push(Route::LoginPage {
                redirect_url: "/dashboard".to_string(),
            });
        }
    });

    // ... render dashboard layout
}

Configuration

Set these environment variables for FerrisKey:

Variable Description
FERRISKEY_URL FerrisKey base API URL (e.g. http://localhost:3333 or https://ferriskey.example.com/api)
FERRISKEY_ISSUER_URL (Optional) Public OIDC issuer base URL. Falls back to FERRISKEY_URL (with /api stripped) if unset.
FERRISKEY_REALM Realm name configured in FerrisKey (e.g. myapp)
FERRISKEY_CLIENT_ID OIDC client ID registered in the realm
FERRISKEY_CLIENT_SECRET Client secret. Required for the authorization-code exchange and the client_credentials grant used for service-account user lookup/create.
TRUST_PROXY_HEADERS Set to true when running behind a reverse proxy so auth rate limiting trusts X-Forwarded-For.

Security middleware

All routes mounted by auth_router are wrapped with:

  • Rate limiting — 20 requests/minute per client IP via governor.
  • CSRF Origin check — POST requests must carry Origin (or Referer) matching BASE_URL.

Both are tunable in crates/auth/src/router.rs and rate_limit.rs.

Navigation