Skip to main content

seamless_auth/
adapter.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::net::SocketAddr;
4use std::sync::Arc;
5
6use axum::Router;
7use axum::body::{Body, Bytes};
8use axum::extract::{ConnectInfo, Request};
9use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, SET_COOKIE};
10use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, request::Parts};
11use axum::response::Response;
12use axum::routing::any;
13use http_body_util::{BodyExt, LengthLimitError, Limited};
14use serde_json::{Value, json};
15
16use crate::client_ip::{canonical, client_user_agent};
17use crate::cookies::{CookieWrite, clear_cookie, set_cookie};
18use crate::jwks::JwksCache;
19use crate::manifest::ManifestSource;
20use crate::options::{Builder, Config};
21use crate::origin::check_origin;
22use crate::refresh::Refresher;
23use crate::service_token::ServiceTokens;
24
25const TRANSPORT_HEADER: &str = "x-seamless-auth-transport";
26pub(crate) const DELIVERY_MODE_HEADER: &str = "x-seamless-auth-delivery-mode";
27pub(crate) const MAX_BODY_BYTES: usize = 1 << 20;
28
29/// Serves the Seamless Auth routes and guards the application's own.
30///
31/// Cheap to clone: clones share the manifest, key set and refresh state.
32#[derive(Clone)]
33pub struct Adapter {
34    pub(crate) inner: Arc<Inner>,
35}
36
37impl fmt::Debug for Adapter {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.debug_struct("Adapter")
40            .field("auth_server_url", &self.inner.config.auth_server_url)
41            .finish_non_exhaustive()
42    }
43}
44
45pub(crate) struct Inner {
46    pub config: Config,
47    pub jwks: JwksCache,
48    pub manifest: ManifestSource,
49    pub tokens: ServiceTokens,
50    pub refreshes: Arc<Refresher>,
51    pub console_client: std::sync::OnceLock<Option<reqwest::Client>>,
52}
53
54impl Adapter {
55    /// Starts configuring an adapter for the auth API at `auth_server_url`.
56    pub fn builder(auth_server_url: impl Into<String>) -> Builder {
57        Builder::new(auth_server_url)
58    }
59
60    pub(crate) fn from_config(config: Config) -> Self {
61        let jwks = JwksCache::new(&config.auth_server_url, config.http_client.clone());
62        let manifest = ManifestSource::new(
63            &config.auth_server_url,
64            config.http_client.clone(),
65            config.fetch_manifest,
66        );
67        let tokens = ServiceTokens::new(config.service_secret.clone(), config.jwks_kid.clone());
68        Adapter {
69            inner: Arc::new(Inner {
70                config,
71                jwks,
72                manifest,
73                tokens,
74                refreshes: Arc::default(),
75                console_client: std::sync::OnceLock::new(),
76            }),
77        }
78    }
79
80    /// The auth routes, relative to wherever they are nested. Nest them at
81    /// `/auth`, which is where the client SDKs call:
82    /// `Router::new().nest("/auth", auth.router())`.
83    pub fn router<S>(&self) -> Router<S>
84    where
85        S: Clone + Send + Sync + 'static,
86    {
87        let inner = self.inner.clone();
88        let handler = move |req: Request| {
89            let inner = inner.clone();
90            async move { inner.serve(req).await }
91        };
92        Router::new()
93            .route("/", any(handler.clone()))
94            .route("/{*path}", any(handler))
95    }
96}
97
98/// What a route answers, applied to the response in one place.
99pub(crate) struct Reply {
100    pub status: StatusCode,
101    pub body: Option<Value>,
102    pub raw: Option<reqwest::Response>,
103    pub set: Vec<CookieWrite>,
104    pub clear: Vec<String>,
105}
106
107impl Reply {
108    pub fn new(status: StatusCode, body: Option<Value>) -> Self {
109        Reply {
110            status,
111            body,
112            raw: None,
113            set: Vec::new(),
114            clear: Vec::new(),
115        }
116    }
117
118    pub fn error(status: StatusCode, code: &str) -> Self {
119        Reply::new(status, Some(json!({ "error": code })))
120    }
121
122    pub fn with_set(mut self, set: Vec<CookieWrite>) -> Self {
123        self.set = set;
124        self
125    }
126
127    pub fn with_clear(mut self, clear: Vec<String>) -> Self {
128        self.clear = clear;
129        self
130    }
131}
132
133/// The end user's address and user agent, forwarded to the auth API.
134#[derive(Clone, Default)]
135pub(crate) struct Forwarded {
136    pub ip: Option<String>,
137    pub user_agent: Option<Vec<u8>>,
138}
139
140/// One request to the auth API.
141pub(crate) struct UpstreamCall {
142    pub method: Method,
143    pub path: String,
144    pub query: Option<String>,
145    pub body: Option<Bytes>,
146    pub authorization: Option<String>,
147    pub service: Option<String>,
148    pub external_delivery: bool,
149}
150
151impl UpstreamCall {
152    pub fn new(method: Method, path: impl Into<String>) -> Self {
153        UpstreamCall {
154            method,
155            path: path.into(),
156            query: None,
157            body: None,
158            authorization: None,
159            service: None,
160            external_delivery: false,
161        }
162    }
163}
164
165pub(crate) fn json_response(status: StatusCode, body: &Value) -> Response {
166    let mut res = Response::new(Body::from(body.to_string()));
167    *res.status_mut() = status;
168    res.headers_mut().insert(
169        CONTENT_TYPE,
170        HeaderValue::from_static("application/json; charset=utf-8"),
171    );
172    res
173}
174
175impl Inner {
176    async fn serve(self: Arc<Self>, req: Request) -> Response {
177        let (parts, body) = req.into_parts();
178
179        if let Some(blocked) = check_origin(&self.config, &parts.method, &parts.headers) {
180            return self.write(blocked, false);
181        }
182
183        let bearer = parts
184            .headers
185            .get(TRANSPORT_HEADER)
186            .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"bearer"));
187        let forwarded = self.forwarded(&parts);
188        let path = parts.uri.path();
189        // Matched on segments, as manifest routes are, so `/logout/` and `//logout`
190        // are still logout and never fall through to a route that clears less.
191        let special = path
192            .split('/')
193            .filter(|s| !s.is_empty())
194            .collect::<Vec<_>>()
195            .join("/")
196            .to_ascii_lowercase();
197
198        let reply = if parts.method == Method::POST && special == "refresh" {
199            self.refresh_route(&parts.headers, &forwarded, bearer).await
200        } else if parts.method == Method::DELETE && special == "logout" {
201            self.logout_route(&parts.headers, &forwarded, bearer, "/logout")
202                .await
203        } else if parts.method == Method::DELETE && special == "logout/all" {
204            self.logout_route(&parts.headers, &forwarded, bearer, "/logout/all")
205                .await
206        } else {
207            let manifest = self.manifest.get().await;
208            match manifest.find(parts.method.as_str(), path) {
209                Some(found) => {
210                    self.manifest_route(&parts, body, &forwarded, found, bearer)
211                        .await
212                }
213                None => Reply::error(StatusCode::NOT_FOUND, "not_found"),
214            }
215        };
216
217        self.write(reply, bearer)
218    }
219
220    fn forwarded(&self, parts: &Parts) -> Forwarded {
221        let peer = parts
222            .extensions
223            .get::<ConnectInfo<SocketAddr>>()
224            .map(|ConnectInfo(addr)| addr.ip());
225        let ip = match &self.config.client_ip {
226            Some(resolve) => resolve(&parts.headers, peer),
227            None => peer,
228        };
229        Forwarded {
230            ip: ip.map(canonical),
231            user_agent: client_user_agent(&parts.headers),
232        }
233    }
234
235    /// Applies a reply. Clears go before sets, because a reply that does both is
236    /// replacing a session rather than ending one. Bearer transport writes no
237    /// cookies at all.
238    fn write(&self, reply: Reply, bearer: bool) -> Response {
239        let mut cookies = HeaderMap::new();
240        if !bearer {
241            let mut seen = HashSet::new();
242            for name in reply.clear.iter().filter(|n| seen.insert(n.as_str())) {
243                if let Some(value) = clear_cookie(&self.config, name) {
244                    cookies.append(SET_COOKIE, value);
245                }
246            }
247            for cookie in &reply.set {
248                match set_cookie(&self.config, cookie) {
249                    Ok(value) => {
250                        cookies.append(SET_COOKIE, value);
251                    }
252                    Err(err) => {
253                        tracing::error!(
254                            "[seamless-auth] Could not issue cookie {}: {err}",
255                            cookie.name
256                        );
257                        return json_response(
258                            StatusCode::INTERNAL_SERVER_ERROR,
259                            &json!({ "error": "internal_error" }),
260                        );
261                    }
262                }
263            }
264        }
265
266        let mut res = if let Some(raw) = reply.raw {
267            let mut headers = HeaderMap::new();
268            for name in [CONTENT_TYPE, CONTENT_DISPOSITION, CACHE_CONTROL] {
269                if let Some(value) = raw.headers().get(&name) {
270                    headers.insert(name, value.clone());
271                }
272            }
273            let mut res = Response::new(Body::new(reqwest::Body::from(raw)));
274            *res.status_mut() = reply.status;
275            res.headers_mut().extend(headers);
276            res
277        } else if let Some(body) = &reply.body {
278            json_response(reply.status, body)
279        } else {
280            let mut res = Response::new(Body::empty());
281            *res.status_mut() = reply.status;
282            res
283        };
284
285        for value in cookies.get_all(SET_COOKIE) {
286            res.headers_mut().append(SET_COOKIE, value.clone());
287        }
288        res
289    }
290
291    pub(crate) async fn call(
292        &self,
293        forwarded: &Forwarded,
294        call: UpstreamCall,
295    ) -> Result<reqwest::Response, reqwest::Error> {
296        let mut url = format!("{}{}", self.config.auth_server_url, call.path);
297        if let Some(query) = call.query.as_deref().filter(|q| !q.is_empty()) {
298            url.push('?');
299            url.push_str(query);
300        }
301
302        let mut req = self
303            .config
304            .http_client
305            .request(call.method.clone(), url)
306            .header("accept", "application/json");
307        if let Some(body) = call.body.filter(|_| call.method != Method::GET) {
308            req = req.header("content-type", "application/json").body(body);
309        }
310        if let Some(authorization) = call.authorization {
311            req = req.header("authorization", authorization);
312        }
313        if let Some(service) = call.service {
314            req = req.header("x-seamless-service-token", service);
315        }
316        if let Some(ip) = &forwarded.ip {
317            req = req.header("x-seamless-client-ip", ip.as_str());
318        }
319        if let Some(ua) = forwarded
320            .user_agent
321            .as_ref()
322            .and_then(|ua| HeaderValue::from_bytes(ua).ok())
323        {
324            req = req.header("x-seamless-client-user-agent", ua);
325        }
326        if call.external_delivery {
327            req = req.header(DELIVERY_MODE_HEADER, "external");
328        }
329        req.send().await
330    }
331}
332
333/// Reads at most `max` bytes of a response body, refusing a larger one.
334pub(crate) async fn read_limited(
335    mut res: reqwest::Response,
336    max: usize,
337) -> Result<Vec<u8>, String> {
338    if res.content_length().is_some_and(|len| len > max as u64) {
339        return Err("response body too large".into());
340    }
341    let mut out = Vec::new();
342    while let Some(chunk) = res.chunk().await.map_err(|e| e.to_string())? {
343        if out.len() + chunk.len() > max {
344            return Err("response body too large".into());
345        }
346        out.extend_from_slice(&chunk);
347    }
348    Ok(out)
349}
350
351/// Reads a JSON response body. An empty body is `None`; a body that is not JSON
352/// reports false.
353pub(crate) async fn read_json(res: reqwest::Response) -> (Option<Value>, bool) {
354    let Ok(data) = read_limited(res, MAX_BODY_BYTES).await else {
355        return (None, false);
356    };
357    if data.trim_ascii().is_empty() {
358        return (None, true);
359    }
360    match serde_json::from_slice::<Value>(&data) {
361        Ok(Value::Null) => (None, true),
362        Ok(value) => (Some(value), true),
363        Err(_) => (None, false),
364    }
365}
366
367/// Passes an upstream error through. Callers read fields off the auth API's own
368/// error body, so an object goes out as-is; anything else becomes a code they can
369/// still branch on.
370pub(crate) fn failure(status: StatusCode, body: Option<Value>, is_json: bool) -> Reply {
371    match body {
372        Some(Value::Object(obj)) if is_json => Reply::new(status, Some(Value::Object(obj))),
373        _ => Reply::error(status, "upstream_error"),
374    }
375}
376
377pub(crate) enum BodyError {
378    TooLarge,
379    Unreadable,
380}
381
382pub(crate) async fn read_request_body(body: Body) -> Result<Option<Bytes>, BodyError> {
383    match Limited::new(body, MAX_BODY_BYTES).collect().await {
384        Ok(collected) => {
385            let bytes = collected.to_bytes();
386            Ok((!bytes.trim_ascii().is_empty()).then_some(bytes))
387        }
388        Err(err) if err.downcast_ref::<LengthLimitError>().is_some() => Err(BodyError::TooLarge),
389        Err(_) => Err(BodyError::Unreadable),
390    }
391}
392
393/// `application/json` or a `+json` type. A download such as
394/// `application/x-ndjson` is not: parsing it would keep only its first line.
395pub(crate) fn is_json_content_type(content_type: Option<&HeaderValue>) -> bool {
396    let Some(value) = content_type else {
397        return true;
398    };
399    let Ok(value) = value.to_str() else {
400        return false;
401    };
402    if value.trim().is_empty() {
403        return true;
404    }
405    let media_type = value
406        .split(';')
407        .next()
408        .unwrap_or("")
409        .trim()
410        .to_ascii_lowercase();
411    let Some((kind, subtype)) = media_type.split_once('/') else {
412        return false;
413    };
414    let token = |s: &str| {
415        !s.is_empty()
416            && s.bytes()
417                .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
418    };
419    if !token(kind) || !token(subtype) {
420        return false;
421    }
422    media_type == "application/json" || subtype.ends_with("+json")
423}
424
425/// The token in an `Authorization: Bearer` header.
426pub(crate) fn bearer_token(headers: &HeaderMap) -> Option<&str> {
427    let header = headers.get("authorization")?.to_str().ok()?;
428    if header.len() > 7 && header[..7].eq_ignore_ascii_case("bearer ") {
429        let token = header[7..].trim();
430        return (!token.is_empty()).then_some(token);
431    }
432    None
433}