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}