Skip to main content

bext_plugin_api/
auth.rs

1//! Auth capability trait. See `plan/ecosystem/02-capabilities.md` (Auth section).
2//!
3//! An `AuthPlugin` resolves a request to an authenticated principal and
4//! (optionally) drives a login/logout flow. The shape is deliberately
5//! vendor-neutral: no JWT-specific fields (`iss`, `aud`, `alg`), no OAuth
6//! specifics (`redirect_uri`, `scopes`, `nonce`). Provider-specific knobs
7//! live in the plugin's own config section, not on the trait.
8//!
9//! # Design notes (E1, not yet stabilised)
10//!
11//! **Sync, not async.** Every other plugin trait in this crate is sync
12//! (`middleware.rs`, `cache.rs`, `lifecycle.rs`, `transform.rs`). The plan
13//! sketch in `02-capabilities.md` uses `async fn`, but matching the existing
14//! convention here keeps the WASM/QuickJS/nsjail ABI consistent (plugins
15//! compiled to WASM cannot expose native async at the host boundary) and
16//! avoids pulling `async-trait` into a dependency-minimal leaf crate.
17//! Backends that need async I/O (OAuth callbacks, JWKS fetches) can use
18//! `tokio::runtime::Handle::current().block_on(..)` the same way
19//! `bext-server`'s JWKS fetcher already does.
20//!
21//! **Errors as `String`.** Matches every other trait in this crate. An
22//! associated error type would be the first in the API surface and make
23//! cross-capability composition (e.g. an auth plugin calling a session
24//! plugin) awkward.
25//!
26//! **Session issuance is delegated.** The `Session` capability owns session
27//! storage. An `AuthPlugin` declares `requires_capabilities = [Session]`
28//! in its manifest; at runtime the host wires a `SessionId` through
29//! `resolve` and `logout` rather than making this trait carry session
30//! state. This keeps the two capabilities independent as required by
31//! `00-architecture.md` principle 6.
32//!
33//! **Login-flow methods are optional.** Validation-only backends (JWT,
34//! opaque bearer) implement `resolve` and leave `begin_login`,
35//! `complete_login`, `logout` as default-no-ops. Full OAuth / magic-link /
36//! passkey backends override all four. This lets the existing
37//! `bext-server::middleware::auth` JWT middleware port cleanly without
38//! inventing stub flows it cannot service.
39
40use std::collections::HashMap;
41
42/// What kind of auth flow a provider implements. Runtime uses this to
43/// decide which HTTP routes to mount (e.g. only OAuth needs a
44/// `/auth/callback` route) and to surface provider shape in admin UI.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum AuthProviderKind {
48    /// Stateless token validation (JWT, opaque bearer). No login flow.
49    Token,
50    /// Username/password against a backing store.
51    Password,
52    /// Redirect-based OAuth 2 / OIDC.
53    OAuth,
54    /// Send a one-time link to an address (email, SMS).
55    MagicLink,
56    /// WebAuthn / passkeys.
57    Passkey,
58}
59
60/// Per-request context passed to every `AuthPlugin` method.
61///
62/// Pure data, no framework types — matches `middleware::RequestContext`
63/// conventions so plugins can live behind the same sandbox boundary.
64/// Mirrors the JWT middleware's token-extraction sources (Authorization
65/// header, cookies) without hardcoding either.
66#[derive(Debug, Clone)]
67pub struct AuthRequestContext {
68    pub method: String,
69    pub path: String,
70    pub hostname: String,
71    pub headers: Vec<(String, String)>,
72    pub query_string: String,
73    pub peer_ip: Option<String>,
74    pub tenant_id: Option<String>,
75    pub site_id: Option<String>,
76    /// Opaque session id previously issued via the `Session` capability,
77    /// if any. `None` for first-time/anonymous requests.
78    pub session_id: Option<String>,
79}
80
81/// An authenticated principal. Vendor-neutral: every concept here
82/// (subject, tenancy, role, permissions, free-form attributes) maps
83/// cleanly to JWT claims, OAuth profiles, LDAP records, and
84/// custom auth stores.
85///
86/// `attributes` is the escape hatch for provider-specific data
87/// (OAuth ID-token claims, group memberships, etc.) so the trait
88/// itself never grows vendor-specific fields.
89#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
90pub struct AuthUser {
91    /// Stable unique identifier for the user within this provider.
92    pub subject: String,
93    /// Tenant scope, if the deployment is multi-tenant.
94    #[serde(default)]
95    pub tenant_id: Option<String>,
96    /// Primary role (e.g. `"admin"`, `"editor"`). Empty string = no role.
97    #[serde(default)]
98    pub role: String,
99    /// Fine-grained permissions (e.g. `["read:posts", "write:posts"]`).
100    #[serde(default)]
101    pub permissions: Vec<String>,
102    /// Provider-opaque extra claims. Deliberately `String` values so the
103    /// shape round-trips through any serialisation format.
104    #[serde(default)]
105    pub attributes: HashMap<String, String>,
106}
107
108/// Parameters a caller passes when initiating a login flow.
109///
110/// Intentionally minimal. Providers that need extra inputs (username,
111/// OAuth provider hint, magic-link address) read them from `inputs`
112/// as a flat `HashMap<String, String>` — same escape-hatch convention
113/// used by `AuthUser::attributes`.
114#[derive(Debug, Clone, Default)]
115pub struct LoginParams {
116    /// Where to send the user after a successful login. The runtime
117    /// treats this as an opaque string; providers that validate it
118    /// (e.g. OAuth redirect URI allowlists) do so themselves.
119    pub return_to: Option<String>,
120    /// Free-form inputs (username, provider hint, email for magic-link).
121    pub inputs: HashMap<String, String>,
122}
123
124/// Result of `begin_login` — what the runtime should do to continue
125/// the login flow.
126#[derive(Debug, Clone)]
127pub enum LoginAction {
128    /// Return a 3xx redirect. Used by OAuth flows.
129    Redirect { url: String },
130    /// Ask the caller to submit a form (or equivalent). Used by
131    /// password flows where the plugin wants to render its own form,
132    /// and by magic-link flows confirming the message was sent.
133    Prompt {
134        /// Short human-readable message (e.g. "Check your email").
135        message: String,
136    },
137    /// Login already complete — no further action needed. Used by
138    /// passkey flows that finish in a single call.
139    Complete { user: AuthUser },
140}
141
142/// Data a caller hands to `complete_login` — whatever the upstream
143/// provider sends back in its callback (OAuth code+state, magic-link
144/// token, passkey assertion, etc.).
145#[derive(Debug, Clone, Default)]
146pub struct CallbackData {
147    /// Query-string parameters from the callback URL.
148    pub query: HashMap<String, String>,
149    /// Body fields if the callback is a POST.
150    pub form: HashMap<String, String>,
151    /// Raw body (e.g. WebAuthn assertion bytes) — providers that need
152    /// structured binary data decode this themselves.
153    pub body: Vec<u8>,
154}
155
156/// An auth provider plugin.
157///
158/// Lifecycle (validation-only flow):
159/// 1. Every incoming request: host calls `resolve(ctx)` → `Some(user)` or `None`.
160/// 2. On logout: host calls `logout(ctx)` (default no-op for stateless providers).
161///
162/// Lifecycle (interactive flow):
163/// 1. User hits a protected route, not yet authed: runtime calls
164///    `begin_login(ctx, params)` and applies the returned `LoginAction`.
165/// 2. User returns via the callback route: runtime calls
166///    `complete_login(ctx, callback)` and — on success — asks the
167///    active `Session` provider to issue a session keyed to `user.subject`.
168/// 3. Subsequent requests: `resolve(ctx)` reads `ctx.session_id`, looks
169///    the session up via the `Session` capability, and returns the user.
170/// 4. On logout: `logout(ctx)` and the `Session` capability deletes.
171///
172/// The plugin never issues sessions directly — that's the `Session`
173/// capability's job (see `session.rs`).
174pub trait AuthPlugin: Send + Sync {
175    /// Unique identifier (e.g. `"auth-jwt"`, `"auth-oauth2-github"`).
176    fn name(&self) -> &str;
177
178    /// What flow shape this provider implements. The runtime uses this
179    /// to decide which HTTP routes to mount and to gate calls to the
180    /// optional login-flow methods below.
181    fn provider_kind(&self) -> AuthProviderKind;
182
183    /// Resolve the current request to an authenticated user.
184    ///
185    /// Returns `Ok(None)` for "not logged in" (not an error).
186    /// Returns `Err(..)` only for malformed credentials or backend
187    /// failures the caller should surface as 401/500.
188    ///
189    /// This is the only *required* method — validation-only backends
190    /// (JWT, opaque bearer) implement just this one.
191    fn resolve(&self, ctx: &AuthRequestContext) -> Result<Option<AuthUser>, String>;
192
193    /// Initiate an interactive login flow.
194    ///
195    /// Default: `Err("login flow not supported by this provider")`.
196    /// Token-validation backends keep the default.
197    fn begin_login(
198        &self,
199        _ctx: &AuthRequestContext,
200        _params: LoginParams,
201    ) -> Result<LoginAction, String> {
202        Err("login flow not supported by this provider".into())
203    }
204
205    /// Complete an interactive login at the callback route.
206    ///
207    /// On success, returns the authenticated `AuthUser`. The runtime
208    /// then hands that user to the active `Session` provider to issue
209    /// a session; this plugin does not persist session state itself.
210    ///
211    /// Default: `Err("login flow not supported by this provider")`.
212    fn complete_login(
213        &self,
214        _ctx: &AuthRequestContext,
215        _callback: CallbackData,
216    ) -> Result<AuthUser, String> {
217        Err("login flow not supported by this provider".into())
218    }
219
220    /// Invalidate auth state for the current request.
221    ///
222    /// For stateless providers this is a no-op (the token expires on
223    /// its own). For interactive providers this hooks any
224    /// provider-side revocation (OAuth token revoke endpoint, magic-link
225    /// one-time-use marking). The runtime still calls the `Session`
226    /// capability to delete the session row — this method only handles
227    /// provider-side cleanup.
228    ///
229    /// Default: `Ok(())`.
230    fn logout(&self, _ctx: &AuthRequestContext) -> Result<(), String> {
231        Ok(())
232    }
233}