bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! Auth capability trait. See `plan/ecosystem/02-capabilities.md` (Auth section).
//!
//! An `AuthPlugin` resolves a request to an authenticated principal and
//! (optionally) drives a login/logout flow. The shape is deliberately
//! vendor-neutral: no JWT-specific fields (`iss`, `aud`, `alg`), no OAuth
//! specifics (`redirect_uri`, `scopes`, `nonce`). Provider-specific knobs
//! live in the plugin's own config section, not on the trait.
//!
//! # Design notes (E1, not yet stabilised)
//!
//! **Sync, not async.** Every other plugin trait in this crate is sync
//! (`middleware.rs`, `cache.rs`, `lifecycle.rs`, `transform.rs`). The plan
//! sketch in `02-capabilities.md` uses `async fn`, but matching the existing
//! convention here keeps the WASM/QuickJS/nsjail ABI consistent (plugins
//! compiled to WASM cannot expose native async at the host boundary) and
//! avoids pulling `async-trait` into a dependency-minimal leaf crate.
//! Backends that need async I/O (OAuth callbacks, JWKS fetches) can use
//! `tokio::runtime::Handle::current().block_on(..)` the same way
//! `bext-server`'s JWKS fetcher already does.
//!
//! **Errors as `String`.** Matches every other trait in this crate. An
//! associated error type would be the first in the API surface and make
//! cross-capability composition (e.g. an auth plugin calling a session
//! plugin) awkward.
//!
//! **Session issuance is delegated.** The `Session` capability owns session
//! storage. An `AuthPlugin` declares `requires_capabilities = [Session]`
//! in its manifest; at runtime the host wires a `SessionId` through
//! `resolve` and `logout` rather than making this trait carry session
//! state. This keeps the two capabilities independent as required by
//! `00-architecture.md` principle 6.
//!
//! **Login-flow methods are optional.** Validation-only backends (JWT,
//! opaque bearer) implement `resolve` and leave `begin_login`,
//! `complete_login`, `logout` as default-no-ops. Full OAuth / magic-link /
//! passkey backends override all four. This lets the existing
//! `bext-server::middleware::auth` JWT middleware port cleanly without
//! inventing stub flows it cannot service.

use std::collections::HashMap;

/// What kind of auth flow a provider implements. Runtime uses this to
/// decide which HTTP routes to mount (e.g. only OAuth needs a
/// `/auth/callback` route) and to surface provider shape in admin UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthProviderKind {
    /// Stateless token validation (JWT, opaque bearer). No login flow.
    Token,
    /// Username/password against a backing store.
    Password,
    /// Redirect-based OAuth 2 / OIDC.
    OAuth,
    /// Send a one-time link to an address (email, SMS).
    MagicLink,
    /// WebAuthn / passkeys.
    Passkey,
}

/// Per-request context passed to every `AuthPlugin` method.
///
/// Pure data, no framework types — matches `middleware::RequestContext`
/// conventions so plugins can live behind the same sandbox boundary.
/// Mirrors the JWT middleware's token-extraction sources (Authorization
/// header, cookies) without hardcoding either.
#[derive(Debug, Clone)]
pub struct AuthRequestContext {
    pub method: String,
    pub path: String,
    pub hostname: String,
    pub headers: Vec<(String, String)>,
    pub query_string: String,
    pub peer_ip: Option<String>,
    pub tenant_id: Option<String>,
    pub site_id: Option<String>,
    /// Opaque session id previously issued via the `Session` capability,
    /// if any. `None` for first-time/anonymous requests.
    pub session_id: Option<String>,
}

/// An authenticated principal. Vendor-neutral: every concept here
/// (subject, tenancy, role, permissions, free-form attributes) maps
/// cleanly to JWT claims, OAuth profiles, LDAP records, and
/// custom auth stores.
///
/// `attributes` is the escape hatch for provider-specific data
/// (OAuth ID-token claims, group memberships, etc.) so the trait
/// itself never grows vendor-specific fields.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AuthUser {
    /// Stable unique identifier for the user within this provider.
    pub subject: String,
    /// Tenant scope, if the deployment is multi-tenant.
    #[serde(default)]
    pub tenant_id: Option<String>,
    /// Primary role (e.g. `"admin"`, `"editor"`). Empty string = no role.
    #[serde(default)]
    pub role: String,
    /// Fine-grained permissions (e.g. `["read:posts", "write:posts"]`).
    #[serde(default)]
    pub permissions: Vec<String>,
    /// Provider-opaque extra claims. Deliberately `String` values so the
    /// shape round-trips through any serialisation format.
    #[serde(default)]
    pub attributes: HashMap<String, String>,
}

/// Parameters a caller passes when initiating a login flow.
///
/// Intentionally minimal. Providers that need extra inputs (username,
/// OAuth provider hint, magic-link address) read them from `inputs`
/// as a flat `HashMap<String, String>` — same escape-hatch convention
/// used by `AuthUser::attributes`.
#[derive(Debug, Clone, Default)]
pub struct LoginParams {
    /// Where to send the user after a successful login. The runtime
    /// treats this as an opaque string; providers that validate it
    /// (e.g. OAuth redirect URI allowlists) do so themselves.
    pub return_to: Option<String>,
    /// Free-form inputs (username, provider hint, email for magic-link).
    pub inputs: HashMap<String, String>,
}

/// Result of `begin_login` — what the runtime should do to continue
/// the login flow.
#[derive(Debug, Clone)]
pub enum LoginAction {
    /// Return a 3xx redirect. Used by OAuth flows.
    Redirect { url: String },
    /// Ask the caller to submit a form (or equivalent). Used by
    /// password flows where the plugin wants to render its own form,
    /// and by magic-link flows confirming the message was sent.
    Prompt {
        /// Short human-readable message (e.g. "Check your email").
        message: String,
    },
    /// Login already complete — no further action needed. Used by
    /// passkey flows that finish in a single call.
    Complete { user: AuthUser },
}

/// Data a caller hands to `complete_login` — whatever the upstream
/// provider sends back in its callback (OAuth code+state, magic-link
/// token, passkey assertion, etc.).
#[derive(Debug, Clone, Default)]
pub struct CallbackData {
    /// Query-string parameters from the callback URL.
    pub query: HashMap<String, String>,
    /// Body fields if the callback is a POST.
    pub form: HashMap<String, String>,
    /// Raw body (e.g. WebAuthn assertion bytes) — providers that need
    /// structured binary data decode this themselves.
    pub body: Vec<u8>,
}

/// An auth provider plugin.
///
/// Lifecycle (validation-only flow):
/// 1. Every incoming request: host calls `resolve(ctx)` → `Some(user)` or `None`.
/// 2. On logout: host calls `logout(ctx)` (default no-op for stateless providers).
///
/// Lifecycle (interactive flow):
/// 1. User hits a protected route, not yet authed: runtime calls
///    `begin_login(ctx, params)` and applies the returned `LoginAction`.
/// 2. User returns via the callback route: runtime calls
///    `complete_login(ctx, callback)` and — on success — asks the
///    active `Session` provider to issue a session keyed to `user.subject`.
/// 3. Subsequent requests: `resolve(ctx)` reads `ctx.session_id`, looks
///    the session up via the `Session` capability, and returns the user.
/// 4. On logout: `logout(ctx)` and the `Session` capability deletes.
///
/// The plugin never issues sessions directly — that's the `Session`
/// capability's job (see `session.rs`).
pub trait AuthPlugin: Send + Sync {
    /// Unique identifier (e.g. `"auth-jwt"`, `"auth-oauth2-github"`).
    fn name(&self) -> &str;

    /// What flow shape this provider implements. The runtime uses this
    /// to decide which HTTP routes to mount and to gate calls to the
    /// optional login-flow methods below.
    fn provider_kind(&self) -> AuthProviderKind;

    /// Resolve the current request to an authenticated user.
    ///
    /// Returns `Ok(None)` for "not logged in" (not an error).
    /// Returns `Err(..)` only for malformed credentials or backend
    /// failures the caller should surface as 401/500.
    ///
    /// This is the only *required* method — validation-only backends
    /// (JWT, opaque bearer) implement just this one.
    fn resolve(&self, ctx: &AuthRequestContext) -> Result<Option<AuthUser>, String>;

    /// Initiate an interactive login flow.
    ///
    /// Default: `Err("login flow not supported by this provider")`.
    /// Token-validation backends keep the default.
    fn begin_login(
        &self,
        _ctx: &AuthRequestContext,
        _params: LoginParams,
    ) -> Result<LoginAction, String> {
        Err("login flow not supported by this provider".into())
    }

    /// Complete an interactive login at the callback route.
    ///
    /// On success, returns the authenticated `AuthUser`. The runtime
    /// then hands that user to the active `Session` provider to issue
    /// a session; this plugin does not persist session state itself.
    ///
    /// Default: `Err("login flow not supported by this provider")`.
    fn complete_login(
        &self,
        _ctx: &AuthRequestContext,
        _callback: CallbackData,
    ) -> Result<AuthUser, String> {
        Err("login flow not supported by this provider".into())
    }

    /// Invalidate auth state for the current request.
    ///
    /// For stateless providers this is a no-op (the token expires on
    /// its own). For interactive providers this hooks any
    /// provider-side revocation (OAuth token revoke endpoint, magic-link
    /// one-time-use marking). The runtime still calls the `Session`
    /// capability to delete the session row — this method only handles
    /// provider-side cleanup.
    ///
    /// Default: `Ok(())`.
    fn logout(&self, _ctx: &AuthRequestContext) -> Result<(), String> {
        Ok(())
    }
}