Skip to main content

backbone_integrations/presentation/http/
oauth_handler.rs

1//! The verb-shaped OAuth HTTP surface (hand-authored, user-owned).
2//!
3//! Five verbs, no generic CRUD anywhere on it:
4//!
5//! - `POST /oauth/authorize` — initiation (`write:integrations`): returns the
6//!   provider consent URL bound to a freshly minted account row.
7//! - `GET /oauth/callback` — the provider redirect target. PUBLIC (the
8//!   provider cannot carry the caller's auth), and side-effect-free by
9//!   construction: it verifies the signed state and serves a page whose only
10//!   act is auto-submitting an invisible POST form — a safe method never
11//!   writes.
12//! - `POST /oauth/complete` — the form target (`write:integrations`):
13//!   code exchange + identity gauntlet + store + transition. Accepts the
14//!   RFC-8058-style form body the page submits AND a plain JSON body.
15//! - `POST /oauth/:id/disconnect` — revoke credential + terminal account
16//!   status (`delete:integrations`).
17//! - `GET /oauth/:id/status` — metadata-only account view (any authenticated
18//!   principal).
19//!
20//! Authorization fails closed: every route except the callback sits behind
21//! [`require_principal`] (401 without a validated principal extension — the
22//! composing host's auth layer inserts it) and enforces its own permission
23//! (403 without it). No god flag grants every verb.
24//!
25//! Tenancy (ADR-0029): the module's tables carry no company column — the
26//! composing service's tenancy decorator owns org scoping, so isolation here
27//! is the host's fence plus permission checks, not a company predicate. The
28//! principal's `company_id` is forwarded only on the verbs that reach the
29//! credential STORE (complete / disconnect) — it is the store's scope key,
30//! the documented legacy twin; an unknown value fails closed at the store.
31//!
32//! No response ever carries token material — the account row holds none, and
33//! the store is reachable only through the service's port calls.
34
35use std::sync::Arc;
36
37use axum::extract::{Path, Query, State};
38use axum::http::{header, HeaderMap, StatusCode};
39use axum::middleware::{self, Next};
40use axum::response::{Html, IntoResponse, Response};
41use axum::routing::{get, post};
42use axum::{Json, Router};
43use serde::Deserialize;
44use uuid::Uuid;
45
46use crate::application::service::integrations_oauth::{
47    AuthorizeRequest, AuthorizeResponse, CompleteOutcome, CompleteRequest, IntegrationsOauthService,
48    OauthError,
49};
50
51/// The validated principal the composing host's auth layer inserts into the
52/// request extensions. Present ⇒ authenticated; the permission list carries
53/// the module-scope grants (`write:integrations`, `delete:integrations` …).
54///
55/// `company_id` is the legacy tenancy twin (ADR-0029): the module's own
56/// tables are unfenced, so it never predicates a SQL statement here — it is
57/// forwarded only to the credential STORE (which is still company-scoped) as
58/// the store's scope key on the complete / disconnect paths.
59#[derive(Debug, Clone)]
60pub struct OAuthPrincipal {
61    pub company_id: Uuid,
62    pub user_id: Option<Uuid>,
63    pub permissions: Vec<String>,
64}
65
66impl OAuthPrincipal {
67    fn has(&self, permission: &str) -> bool {
68        self.permissions.iter().any(|p| p == permission)
69    }
70}
71
72/// Auth-required layer: 401 unless a validated principal extension is on the
73/// request (fail closed — absence of auth information is never access).
74pub async fn require_principal(req: axum::extract::Request, next: Next) -> Response {
75    if req.extensions().get::<OAuthPrincipal>().is_none() {
76        return error_response(
77            StatusCode::UNAUTHORIZED,
78            "OAUTH_UNAUTHENTICATED",
79            "authentication required: no validated principal on the request",
80        );
81    }
82    next.run(req).await
83}
84
85// ─────────────────────────────────────────────────────────────────────────────
86// Error → HTTP
87// ─────────────────────────────────────────────────────────────────────────────
88
89fn error_response(status: StatusCode, code: &str, message: impl std::fmt::Display) -> Response {
90    (
91        status,
92        Json(serde_json::json!({
93            "success": false,
94            "error": code,
95            "message": message.to_string(),
96        })),
97    )
98        .into_response()
99}
100
101impl IntoResponse for OauthError {
102    fn into_response(self) -> Response {
103        let (status, code) = match &self {
104            OauthError::Invalid(_) => (StatusCode::BAD_REQUEST, "OAUTH_INVALID_INPUT"),
105            OauthError::State(_) => (StatusCode::BAD_REQUEST, "OAUTH_STATE_REJECTED"),
106            OauthError::Identity(_) => (StatusCode::BAD_REQUEST, "OAUTH_IDENTITY_REJECTED"),
107            OauthError::Unstoreable(_) => (StatusCode::BAD_GATEWAY, "OAUTH_UNSTOREABLE_TOKEN"),
108            OauthError::NotFound => (StatusCode::NOT_FOUND, "OAUTH_ACCOUNT_NOT_FOUND"),
109            OauthError::ProviderUnconfigured(_) => {
110                (StatusCode::SERVICE_UNAVAILABLE, "OAUTH_PROVIDER_UNCONFIGURED")
111            }
112            OauthError::Transport(_) => (StatusCode::BAD_GATEWAY, "OAUTH_PROVIDER_TRANSPORT"),
113            OauthError::Store(_) => (StatusCode::SERVICE_UNAVAILABLE, "OAUTH_CREDENTIAL_STORE"),
114            OauthError::Db(_) => (StatusCode::INTERNAL_SERVER_ERROR, "OAUTH_DATABASE"),
115        };
116        error_response(status, code, self)
117    }
118}
119
120fn forbidden(permission: &str) -> Response {
121    error_response(
122        StatusCode::FORBIDDEN,
123        "OAUTH_FORBIDDEN",
124        format!("this route requires the {permission:?} permission"),
125    )
126}
127
128// ─────────────────────────────────────────────────────────────────────────────
129// Route handlers
130// ─────────────────────────────────────────────────────────────────────────────
131
132/// `POST /oauth/authorize` — start one authorization.
133async fn authorize(
134    State(service): State<Arc<IntegrationsOauthService>>,
135    axum::Extension(principal): axum::Extension<OAuthPrincipal>,
136    Json(req): Json<AuthorizeRequest>,
137) -> Response {
138    if !principal.has("write:integrations") {
139        return forbidden("write:integrations");
140    }
141    // No company parameter: initiation touches no credential store, and the
142    // module's rows are unfenced (ADR-0029) — the composing service's
143    // decorator scopes the write.
144    match service.authorize(req).await {
145        Ok(out) => (StatusCode::OK, Json(serde_json::json!({ "success": true, "data": out }))).into_response(),
146        Err(e) => e.into_response(),
147    }
148}
149
150/// The provider's redirect query: the code + signed state, or the provider's
151/// own refusal (`error=access_denied` when the user declines consent).
152#[derive(Debug, Deserialize)]
153struct CallbackQuery {
154    code: Option<String>,
155    state: Option<String>,
156    error: Option<String>,
157}
158
159/// `GET /oauth/callback` — public, side-effect-free. Serves the auto-POST
160/// page; every failure is an error page, never a redirect to any URL the
161/// query carried.
162async fn callback(
163    State(service): State<Arc<IntegrationsOauthService>>,
164    Query(q): Query<CallbackQuery>,
165) -> Response {
166    if let Some(err) = q.error.as_deref().filter(|e| !e.trim().is_empty()) {
167        return error_page(StatusCode::BAD_REQUEST, "the provider refused the authorization", err);
168    }
169    let (Some(code), Some(state)) = (q.code.as_deref(), q.state.as_deref()) else {
170        return error_page(
171            StatusCode::BAD_REQUEST,
172            "incomplete callback",
173            "the callback is missing its code or signed state",
174        );
175    };
176    match service.callback_page(code, state) {
177        Ok(page) => Html(page).into_response(),
178        Err(e) => error_page(StatusCode::BAD_REQUEST, "the connection could not continue", &e.to_string()),
179    }
180}
181
182/// `POST /oauth/complete` — exchange + gauntlet + store + transition. The
183/// callback page posts a urlencoded form; an API caller may post JSON. The
184/// body shape is dispatched on the content type — never parsed twice.
185async fn complete(
186    State(service): State<Arc<IntegrationsOauthService>>,
187    axum::Extension(principal): axum::Extension<OAuthPrincipal>,
188    headers: HeaderMap,
189    body: axum::body::Bytes,
190) -> Response {
191    if !principal.has("write:integrations") {
192        return forbidden("write:integrations");
193    }
194    let content_type = headers
195        .get(header::CONTENT_TYPE)
196        .and_then(|v| v.to_str().ok())
197        .unwrap_or("")
198        .to_ascii_lowercase();
199    let req = if content_type.starts_with("application/json") {
200        match serde_json::from_slice::<CompleteRequest>(&body) {
201            Ok(r) => r,
202            Err(e) => {
203                return error_response(StatusCode::BAD_REQUEST, "OAUTH_INVALID_INPUT", format!("malformed JSON body: {e}"))
204            }
205        }
206    } else {
207        match parse_form_complete(&body) {
208            Some(r) => r,
209            None => {
210                return error_response(
211                    StatusCode::BAD_REQUEST,
212                    "OAUTH_INVALID_INPUT",
213                    "body must be the callback form (code + state) or JSON with those fields",
214                )
215            }
216        }
217    };
218    match service.complete(principal.company_id, req).await {
219        Ok(out) => (StatusCode::OK, Json(serde_json::json!({ "success": true, "data": out }))).into_response(),
220        Err(e) => e.into_response(),
221    }
222}
223
224/// `POST /oauth/:id/disconnect` — revoke + terminal status.
225async fn disconnect(
226    State(service): State<Arc<IntegrationsOauthService>>,
227    axum::Extension(principal): axum::Extension<OAuthPrincipal>,
228    Path(account_id): Path<Uuid>,
229) -> Response {
230    if !principal.has("delete:integrations") {
231        return forbidden("delete:integrations");
232    }
233    match service.disconnect(principal.company_id, account_id).await {
234        Ok(()) => StatusCode::NO_CONTENT.into_response(),
235        Err(e) => e.into_response(),
236    }
237}
238
239/// `GET /oauth/:id/status` — metadata only. The principal is extracted (and
240/// already enforced by the auth layer) but not used on this path: the read is
241/// ID-only and the module's rows are unfenced (ADR-0029).
242async fn status(
243    State(service): State<Arc<IntegrationsOauthService>>,
244    axum::Extension(_principal): axum::Extension<OAuthPrincipal>,
245    Path(account_id): Path<Uuid>,
246) -> Response {
247    match service.status(account_id).await {
248        Ok(out) => (StatusCode::OK, Json(serde_json::json!({ "success": true, "data": out }))).into_response(),
249        Err(e) => e.into_response(),
250    }
251}
252
253// ─────────────────────────────────────────────────────────────────────────────
254// The router
255// ─────────────────────────────────────────────────────────────────────────────
256
257/// Build the OAuth verb routes. The callback is PUBLIC (the provider cannot
258/// carry the caller's credentials); every other route requires a validated
259/// principal and enforces its own permission.
260pub fn create_oauth_routes(service: Arc<IntegrationsOauthService>) -> Router {
261    let public = Router::new().route("/oauth/callback", get(callback)).with_state(service.clone());
262    let authed = Router::new()
263        .route("/oauth/authorize", post(authorize))
264        .route("/oauth/complete", post(complete))
265        .route("/oauth/:id/disconnect", post(disconnect))
266        .route("/oauth/:id/status", get(status))
267        .layer(middleware::from_fn(require_principal))
268        .with_state(service);
269    public.merge(authed)
270}
271
272// ─────────────────────────────────────────────────────────────────────────────
273// Form body parsing (the callback page's urlencoded POST)
274// ─────────────────────────────────────────────────────────────────────────────
275
276/// Parse the auto-POST form body (`code=...&state=...`). Returns `None`
277/// unless BOTH fields are present and non-empty. Values are
278/// percent-decoded (`+` as space, `%XX` escapes) — the page's own encoding,
279/// applied to strings the provider influenced.
280fn parse_form_complete(body: &[u8]) -> Option<CompleteRequest> {
281    let text = std::str::from_utf8(body).ok()?;
282    let mut code: Option<String> = None;
283    let mut state: Option<String> = None;
284    for pair in text.split('&') {
285        let (key, value) = pair.split_once('=')?;
286        let decoded = percent_decode(value);
287        match key {
288            "code" => code = Some(decoded),
289            "state" => state = Some(decoded),
290            _ => {}
291        }
292    }
293    let code = code.filter(|c| !c.is_empty())?;
294    let state = state.filter(|s| !s.is_empty())?;
295    Some(CompleteRequest { code, state })
296}
297
298fn percent_decode(input: &str) -> String {
299    let bytes = input.as_bytes();
300    let mut out = Vec::with_capacity(bytes.len());
301    let mut i = 0;
302    while i < bytes.len() {
303        match bytes[i] {
304            b'+' => {
305                out.push(b' ');
306                i += 1;
307            }
308            b'%' => {
309                if let Some(byte) = bytes.get(i + 1..i + 3).and_then(decode_hex_pair) {
310                    out.push(byte);
311                    i += 3;
312                } else {
313                    // Truncated or non-hex escape: keep the byte as-is.
314                    out.push(b'%');
315                    i += 1;
316                }
317            }
318            b => {
319                out.push(b);
320                i += 1;
321            }
322        }
323    }
324    String::from_utf8_lossy(&out).into_owned()
325}
326
327fn decode_hex_pair(pair: &[u8]) -> Option<u8> {
328    let hi = (pair.first()?).to_ascii_uppercase();
329    let lo = (pair.get(1)?).to_ascii_uppercase();
330    let digit = |b: u8| -> Option<u8> {
331        match b {
332            b'0'..=b'9' => Some(b - b'0'),
333            b'A'..=b'F' => Some(b - b'A' + 10),
334            _ => None,
335        }
336    };
337    Some(digit(hi)? * 16 + digit(lo)?)
338}
339
340/// A terminal page for a failed callback — plain text (nothing in it is
341/// machine-consumed), status 400, no redirect anywhere.
342fn error_page(status: StatusCode, title: &str, detail: &str) -> Response {
343    let escaped = detail
344        .replace('&', "&amp;")
345        .replace('<', "&lt;")
346        .replace('>', "&gt;");
347    (
348        status,
349        Html(format!(
350            "<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"><title>Connection not completed</title></head>\n<body>\n  <h1>{title}</h1>\n  <p>{escaped}</p>\n  <p>Close this window and start the connection again.</p>\n</body>\n</html>\n"
351        )),
352    )
353        .into_response()
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn form_body_parses_code_and_state() {
362        let req = parse_form_complete(b"code=4%2F0Ax4PKb&state=abc.def").unwrap();
363        assert_eq!(req.code, "4/0Ax4PKb");
364        assert_eq!(req.state, "abc.def");
365        // Plus is space, missing field is refused, empty values are refused.
366        assert_eq!(percent_decode("a+b"), "a b");
367        assert!(parse_form_complete(b"code=only").is_none());
368        assert!(parse_form_complete(b"code=&state=x").is_none());
369        assert!(parse_form_complete(b"state=x").is_none());
370    }
371
372    #[test]
373    fn percent_decode_handles_odd_input_without_panicking() {
374        assert_eq!(percent_decode("100%"), "100%");
375        assert_eq!(percent_decode("%2z"), "%2z");
376        assert_eq!(percent_decode("%41%42"), "AB");
377        assert_eq!(percent_decode("caf%C3%A9"), "café");
378    }
379}