Rullst Connect π¦
Important
This page documents the unreleased v12 source. Use a path dependency from this checkout until the planned
12.0.0-rc.1is published.
Vision preserved: message brokers, additional queue transports, remote storage, and media work are retained with explicit status and recommendations in the capability ledger.
Rullst Connect is an async OAuth2/OIDC client layer with a shared
Provider interface and normalized ConnectUser output. Provider-specific
protocols, scopes, claims, and application account-linking rules still require
explicit review.
π‘οΈ Security Engineering
Rullst Connect uses layered tests and repository security checks. CI badges report the state of those checks for the referenced commit; they are not an absolute security guarantee.
β¨ Features
- π Async HTTP: Built on Tokio-compatible request paths and
reqwest. - π§© Standardized: All providers return a unified
ConnectUserstruct. - π‘οΈ Type-Safe: Robust error handling using
thiserror(ConnectError). - π Framework adapters: Core provider APIs are framework-independent; optional extractor features cover the integrations declared in the crate.
- π Managed callback transaction: The optional Axum/tower-sessions path generates, stores, expires, validates, and consumes state + PKCE + OIDC nonce.
- π Encrypted token snapshots: Versioned AES-256-GCM envelopes bind a refresh generation to one trusted provider/account pair and an explicit key rotation ID before application-owned persistence.
- π OIDC Security: Strict discovery validation plus isolated JWKS caches with TTL, refresh on unknown
kid, and bounded stale-if-error behavior. - πͺ Typed remote revocation: Access and refresh tokens are distinct API operations. Google, GitHub, Discord, Apple, Auth0 and Cognito have bounded protocol adapters; unsupported providers fail explicitly and offline credentials remain network-free.
- π’ Explicit Corporate Proxy: First-class HTTP(S) proxy clients, including bounded Basic proxy authentication without credentials in the endpoint URL.
- πΊ Device Flow: Native RFC 8628 support for headless CLI and Smart TV auth.
- π οΈ Testing: Empty or
mock_*credentials select a deterministic offline transport, whilemock_idpsupplies a loopback-only signed OIDC fixture with one-shot codes, PKCE, nonce, EdDSA ID tokens and JWKS.
π Important Documents:
- CHANGELOG.md: See whatβs new.
- ISSUES: Any issue? Please report.
- AUDIT.md: Repository audit record; current workflow evidence remains authoritative.
π¦ Supported Providers
Official support for 11 core providers:
- GitHub
- Microsoft / Azure AD
- Apple (Sign in with Apple)
- Auth0
- AWS Cognito
- X (Twitter) (Strict PKCE requirement)
- Discord
- OIDC (OpenID Connect Custom Provider)
Remote token revocation is deliberately narrower than login support. Use
Provider::revoke_token for an access token and
Provider::revoke_refresh_token for a refresh token; Auth0/Cognito accept only
the latter through these adapters, while GitHub accepts only the former. A
successful provider response does not clear application cookies, sessions,
cached identity, or durable token recordsβthe host must commit that local
logout lifecycle. Provider revocation endpoints are intentionally idempotent in
several ecosystems, so HTTP success is protocol acceptance, not proof that the
token was valid immediately before the call.
π οΈ Installation
Inside a checkout of the unreleased v12 workspace, use its path dependency. After the RC is published, install that exact release train instead.
Published RC command:
cargo add rullst-connect@12.0.0-rc.1
cargo add secrecy
For the recommended Axum session transaction, enable axum-session and add a
tower-sessions store:
rullst-connect = { path = "../Rullst/rullst-connect", features = ["axum-session"] }
tower-sessions = "0.15"
Or manually add it to your Cargo.toml:
[dependencies]
rullst-connect = { path = "../Rullst/rullst-connect" }
secrecy = "0.10"
tokio = { version = "1.52", features = ["full"] }
π Quick Start
1. Initialize the Provider
Choose your provider and pass your credentials and callback URL:
use rullst_connect::prelude::*;
fn main() -> Result<(), ConnectError> {
let github = GithubProvider::try_new(
"YOUR_CLIENT_ID",
"YOUR_CLIENT_SECRET".to_string().into(),
"http://localhost:3000/auth/github/callback",
)?;
let _ = github;
Ok(())
}
Signed local OIDC fixture
Enable axum and mount mock_idp::mock_router_with_config only on an exact
loopback listener. MockIdpConfig::try_new also rejects non-loopback issuer and
callback URLs. The resulting fixture exercises discovery, an exact registered
client/callback, expiring one-shot authorization codes, optional nonce, S256
PKCE, EdDSA ID-token validation through JWKS and bearer-protected userinfo.
Its signing seed and credentials are deterministic public test material. It is not a production identity provider, interactive login/consent UI, refresh-token service, federation implementation or OIDC conformance suite. Follow the local OIDC testing tutorial for a complete loopback setup.
Explicit corporate proxy
Live providers can receive a first-class proxy-aware transport without relying
on ambient HTTP_PROXY state:
#![allow(unused)]
fn main() {
use rullst_connect::client::ReqwestClient;
use rullst_connect::prelude::{ConnectError, GithubProvider};
use std::sync::Arc;
fn github_through_corporate_proxy(
proxy_password: impl Into<String>,
) -> Result<GithubProvider, ConnectError> {
let proxy = ReqwestClient::try_with_proxy_basic_auth(
"https://proxy.corp.example:8443",
"proxy-user",
proxy_password,
)?;
let github = GithubProvider::try_new(
"YOUR_CLIENT_ID",
"YOUR_CLIENT_SECRET".to_string().into(),
"http://localhost:3000/auth/github/callback",
)?;
Ok(github.with_http_client(Arc::new(proxy)))
}
}
Proxy URLs are limited to an HTTP(S) scheme and authority, with no embedded credentials, path, query, or fragment. Authenticated non-loopback proxies must use HTTPS. The configured client uses only that explicit proxy; PAC/WPAD, SOCKS, proxy mTLS identity and deployment certification remain outside this bounded transport.
2. Start a Server-Bound Authorization
The recommended Axum path generates state and PKCE, stores their private
counterparts in tower-sessions for ten minutes, and returns only the redirect
URL:
#![allow(unused)]
fn main() {
use axum::response::Redirect;
use rullst_connect::prelude::{ConnectError, GithubProvider};
use rullst_connect::extractors::begin_oauth_session;
use tower_sessions::Session;
async fn start_github_authorization(
session: &Session,
github: &GithubProvider,
) -> Result<Redirect, ConnectError> {
let authorization = begin_oauth_session(session, github).await?;
Ok(Redirect::temporary(authorization.url()))
}
}
Use begin_oidc_session instead for Google, Apple, or a custom OIDC provider.
It adds and stores an OIDC nonce as well.
3. Consume the Callback and Get the User
AuthSession consumes the challenge before checking its expiry and state. It
then supplies the exact PKCE verifier and optional OIDC nonce to the provider:
#![allow(unused)]
fn main() {
use rullst_connect::extractors::AuthSession;
use rullst_connect::prelude::{
ConnectError, GithubProvider, Provider as _, UniversalProfile,
};
async fn finish_github_callback(
auth_session: AuthSession,
github: &GithubProvider,
) -> Result<UniversalProfile, ConnectError> {
let params = auth_session.exchange_params()?;
let user = github.get_user(params).await?;
Ok(user.universal_profile())
}
}
Only one managed challenge is active per browser session. Starting another login deliberately invalidates the earlier tab. The application must configure a durable production session store, Secure/HttpOnly/SameSite cookies, TLS, registered redirect URLs, account-linking policy, and post-login session rotation. See Server-Bound OAuth/OIDC Sessions.
π‘οΈ Manual State Handling
Non-Axum hosts may use the framework-neutral primitives directly. The host must atomically take the expected value from a short-lived server-side store before comparison; a reusable cookie value is not equivalent.
The following fragment is host pseudocode because the durable one-time store and callback extractor are deliberately application-provided:
use rullst_connect::pkce::generate_oauth_state;
let state = generate_oauth_state();
store_one_time_state(&state).await?; // application-provided durable operation
let url = github.redirect_url_with_state(&state);
let expected = take_one_time_state().await?; // atomically removes it
callback.verify_state(&expected)?;
π Refreshing Tokens
If the provider returned expires_in plus a refresh token, bind the result to a
statically dispatched, process-local coordinator when the callback receives it:
#![allow(unused)]
fn main() {
use rullst_connect::{AutoRefreshingSession, ConnectError, ConnectUser};
use rullst_connect::prelude::ExposeSecret as _;
async fn send_token_to_the_authorized_api(_: &str) -> Result<(), ConnectError> {
Ok(())
}
async fn call_provider_api(
github: &rullst_connect::providers::GithubProvider,
user: &ConnectUser,
token_received_at: u64,
) -> Result<(), ConnectError> {
let session = AutoRefreshingSession::from_user_at(
github,
user,
token_received_at,
)?;
let lease = session.access_token().await?;
send_token_to_the_authorized_api(lease.access_token().expose_secret()).await?;
Ok(())
}
}
The default checks 60 seconds before expiration. Refresh calls cannot overlap;
callers waiting behind a successful refresh reuse its state. A response can
replace the refresh token only after the lifetime and original provider user ID
validate. Use access_token_at in deterministic workers/tests. Seal
state_snapshot() with EncryptedTokenSnapshot before writing it to a
dedicated application store:
#![allow(unused)]
fn main() {
use rullst_connect::{
EncryptedTokenSnapshot, RefreshableTokenState, TokenSnapshotBinding,
TokenSnapshotError, TokenSnapshotKey,
};
fn seal_for_storage(
state: &RefreshableTokenState,
key_bytes: [u8; 32],
local_account_id: &str,
) -> Result<EncryptedTokenSnapshot, TokenSnapshotError> {
let binding = TokenSnapshotBinding::try_new("github", local_account_id)?;
let key = TokenSnapshotKey::try_new("oauth-primary-2026", key_bytes)?;
EncryptedTokenSnapshot::seal(state, &key, &binding)
}
}
Persist only snapshot.as_str(). On restart, validate it with
try_from_envelope, select the secret-manager key named by key_id(), and call
open with the same trusted binding. Keys must be 32 bytes from a secret
manager or CSPRNG; human passwords require an application-selected KDF. The
envelope authenticates confidentiality, ownership binding and generation but
does not provide a database transaction by itself.
Durable shared-local SQLite state
Enable rullst-connect/sqlite (or umbrella rullst/oauth-sqlite) to store the
encrypted generations in one local SQLite file:
#![allow(unused)]
fn main() {
use rullst_connect::{
RefreshableTokenState, SqliteTokenSnapshotStore, TokenSnapshotBinding,
TokenSnapshotKey, TokenStoreError,
};
async fn persist_initial_generation(
state: &RefreshableTokenState,
key: &TokenSnapshotKey,
account_id: &str,
) -> Result<(), TokenStoreError> {
let store = SqliteTokenSnapshotStore::connect(
"sqlite:///var/lib/my-app/oauth-tokens.sqlite",
50_000,
)
.await?;
let binding = TokenSnapshotBinding::try_new("github", account_id)?;
store.insert_initial(&binding, state, key).await?;
store.close().await;
Ok(())
}
}
After refreshing from generation n, call
compare_and_swap(&binding, n, &replacement, &key). BEGIN IMMEDIATE
serializes local writers and only generation n + 1 can replace the observed
row. The fixed schema persists its configured 1β1,000,000 row ceiling and
restart/key metadata; malformed rows, configuration drift, quota exhaustion
and stale replacement/deletion fail with typed redacted errors. The account
locator is a SHA-256 pseudonymous digest, not an anonymity guarantee; the
provider/account binding is also authenticated inside the ciphertext.
This local CAS does not serialize the earlier call to a remote OAuth provider. Applications must still authorize the account, lease that remote operation, reconcile a provider rotation lost to CAS, configure retry/backoff and local logout, keep keys in a secret manager, protect/backup the directory and provide multi-host replication when needed. A provider that does not support refresh continues to return a typed error.
π Manual PKCE Support
Provider adapters expose PKCE (Proof Key for Code Exchange) where supported by the provider protocol. Some providers such as X (Twitter) v2 require it; applications must preserve and validate the verifier/state for the complete authorization transaction. The generic provider boundary accepts both values explicitly:
#![allow(unused)]
fn main() {
use rullst_connect::{
ConnectError, ConnectUser,
pkce::{generate_oauth_state, generate_pkce},
provider::{ExchangeParams, Provider},
};
async fn exchange_with_pkce<P: Provider>(
provider: &P,
authorization_code: &str,
) -> Result<ConnectUser, ConnectError> {
let state = generate_oauth_state();
let (code_verifier, code_challenge) = generate_pkce();
// Persist state + verifier in a short-lived server-side record before redirecting.
let authorization_url =
provider.redirect_url_with_pkce_and_state(&code_challenge, &state);
let _ = authorization_url;
// After atomically consuming and validating that state in the callback:
provider
.get_user(ExchangeParams {
auth_code: authorization_code,
code_verifier: Some(&code_verifier),
..Default::default()
})
.await
}
}
π§βπ» Full Example with Axum
You can find a complete working server using the Axum framework in the examples directory. Just run:
cargo run --example axum_server
π¦ Releasing a New Version
Connect is released only through the repository-wide, topologically ordered release workflow. Do not publish this crate independently from a working tree. Follow the v12 release guide and require all candidate-SHA gates before creating a release tag.
π€ Contributing
Feel free to open Issues and submit Pull Requests! Want to add a new provider? Itβs easy! Just implement the Provider trait.
π License
This project is licensed under the MIT License.