Skip to main content

alibi_plugins/
bearer.rs

1//! Bearer authentication through the ordinary signed session-cookie lifecycle.
2
3use alibi_core::endpoint::{
4    BeforeEndpointAction, EndpointCall, EndpointContextPatch, EndpointHook, EndpointResponse,
5};
6use alibi_core::utils::cookie_utils::sign_cookie_value;
7use alibi_core::{
8    AuthContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute, AuthSchema,
9    BeforeRequestAction,
10};
11use async_trait::async_trait;
12use base64::{
13    Engine, alphabet,
14    engine::{GeneralPurpose, GeneralPurposeConfig},
15};
16use hmac::{Hmac, KeyInit, Mac};
17use indexmap::IndexMap;
18use sha2::Sha256;
19use std::collections::HashMap;
20
21/// Configuration of the bearer-to-cookie adapter.
22#[derive(Clone, Debug, Default)]
23pub struct BearerConfig {
24    /// Ignore unsigned bearer tokens. Invalid headers leave ordinary cookies intact.
25    pub require_signature: bool,
26}
27
28#[derive(Clone, Debug, Default)]
29pub struct BearerPlugin {
30    config: BearerConfig,
31}
32
33impl BearerPlugin {
34    #[must_use]
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    #[must_use]
40    pub const fn with_config(config: BearerConfig) -> Self {
41        Self { config }
42    }
43
44    fn authenticated_headers(
45        &self,
46        authorization: &str,
47        existing_headers: &HashMap<String, String>,
48        secret: &str,
49        cookie_name: &str,
50    ) -> Option<HashMap<String, String>> {
51        let token = self.signed_token(authorization, secret)?;
52        let existing = existing_headers
53            .iter()
54            .find_map(|(key, value)| key.eq_ignore_ascii_case("cookie").then_some(value.as_str()))
55            .unwrap_or("");
56        let cookie = replace_cookie(existing, cookie_name, &token);
57        let mut headers = existing_headers.clone();
58        headers.retain(|key, _| !key.eq_ignore_ascii_case("cookie"));
59        _ = headers.insert("cookie".to_owned(), cookie);
60        Some(headers)
61    }
62
63    fn signed_token(&self, authorization: &str, secret: &str) -> Option<String> {
64        let (scheme, token) = authorization.split_once(' ')?;
65        if !scheme.eq_ignore_ascii_case("bearer") {
66            return None;
67        }
68        let token = token
69            .trim_matches(|ch: char| (ch.is_whitespace() && ch != '\u{85}') || ch == '\u{feff}');
70        if token.is_empty() {
71            return None;
72        }
73        let decoded = if token.contains('.') {
74            try_decode(token)
75        } else {
76            if self.config.require_signature {
77                return None;
78            }
79            try_decode(&sign_cookie_value(token, secret))
80        };
81        let mut components = decoded.split('.');
82        let payload = components.next()?;
83        let signature = components.next()?;
84        // The pinned HMAC decoder accepts either alphabet, stops at padding,
85        // and ignores unused trailing bits. The downstream cookie verifier
86        // still owns the complete signed-cookie syntax and session lookup.
87        let selected = if signature.contains(['-', '_']) {
88            &alphabet::URL_SAFE
89        } else {
90            &alphabet::STANDARD
91        };
92        let engine = GeneralPurpose::new(
93            selected,
94            GeneralPurposeConfig::new()
95                .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent)
96                .with_decode_allow_trailing_bits(true),
97        );
98        let signature = engine.decode(signature.split('=').next()?).ok()?;
99        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
100        mac.update(payload.as_bytes());
101        mac.verify_slice(&signature).ok()?;
102        Some(decoded)
103    }
104}
105
106fn try_decode(value: &str) -> String {
107    // decodeURIComponent fails the entire value on any malformed escape;
108    // urlencoding alone would partially decode it before that escape.
109    let mut bytes = value.bytes();
110    while let Some(byte) = bytes.next() {
111        if byte == b'%'
112            && !(bytes.next().is_some_and(|byte| byte.is_ascii_hexdigit())
113                && bytes.next().is_some_and(|byte| byte.is_ascii_hexdigit()))
114        {
115            return value.to_owned();
116        }
117    }
118    urlencoding::decode(value).map_or_else(|_| value.to_owned(), std::borrow::Cow::into_owned)
119}
120
121fn encode_component(value: &str) -> String {
122    // encodeURIComponent preserves these characters in addition to RFC3986's
123    // unreserved set. Values are encoded once after cookie-map replacement.
124    urlencoding::encode(value)
125        .replace("%21", "!")
126        .replace("%27", "'")
127        .replace("%28", "(")
128        .replace("%29", ")")
129        .replace("%2A", "*")
130}
131
132fn replace_cookie(header: &str, name: &str, value: &str) -> String {
133    let mut cookies = IndexMap::<String, String>::new();
134    for item in header.split(';') {
135        let Some((key, value)) = item.split_once('=') else {
136            continue;
137        };
138        let key = key.trim_matches([' ', '\t']);
139        let value = value.trim_matches([' ', '\t']);
140        let value = value
141            .strip_prefix('"')
142            .and_then(|v| v.strip_suffix('"'))
143            .unwrap_or(value);
144        if key.is_empty()
145            || !key
146                .bytes()
147                .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
148            || !value
149                .bytes()
150                .all(|b| matches!(b, 0x20..=0x21 | 0x23..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e))
151        {
152            continue;
153        }
154        _ = cookies.insert(key.to_owned(), try_decode(value));
155    }
156    _ = cookies.insert(name.to_owned(), value.to_owned());
157    cookies
158        .into_iter()
159        .map(|(key, value)| format!("{key}={}", encode_component(&value)))
160        .collect::<Vec<_>>()
161        .join("; ")
162}
163
164#[async_trait]
165impl<S: AuthSchema> AuthPlugin<S> for BearerPlugin {
166    route_openapi_metadata!(S);
167
168    fn endpoint_hooks(&self) -> Vec<&dyn EndpointHook<S>> {
169        vec![self]
170    }
171    fn name(&self) -> &'static str {
172        "bearer"
173    }
174    fn routes(&self) -> Vec<AuthRoute> {
175        Vec::new()
176    }
177
178    async fn before_request(
179        &self,
180        req: &AuthRequest,
181        ctx: &AuthContext<S>,
182    ) -> AuthResult<Option<BeforeRequestAction>> {
183        let Some(authorization) = req
184            .headers
185            .iter()
186            .find_map(|(key, value)| key.eq_ignore_ascii_case("authorization").then_some(value))
187        else {
188            return Ok(None);
189        };
190        let Some(headers) = self.authenticated_headers(
191            authorization,
192            &req.headers,
193            ctx.config.current_secret(),
194            &ctx.config.session.cookie_name,
195        ) else {
196            return Ok(None);
197        };
198        Ok(Some(BeforeRequestAction::ReplaceHeaders { headers }))
199    }
200
201    async fn on_request(
202        &self,
203        _req: &AuthRequest,
204        _ctx: &AuthContext<S>,
205    ) -> AuthResult<Option<AuthResponse>> {
206        Ok(None)
207    }
208
209    async fn after_request(
210        &self,
211        _req: &AuthRequest,
212        ctx: &AuthContext<S>,
213        mut response: AuthResponse,
214    ) -> AuthResult<AuthResponse> {
215        if let Some((token, exposed)) =
216            issuance_headers(&response.headers, &ctx.config.session.cookie_name)
217        {
218            _ = response.headers.insert("set-auth-token", token);
219            _ = response
220                .headers
221                .insert("access-control-expose-headers", exposed);
222        }
223        Ok(response)
224    }
225}
226
227fn issuance_headers(headers: &alibi_core::Headers, cookie_name: &str) -> Option<(String, String)> {
228    let token = headers
229        .get_all("set-cookie")
230        .filter_map(|header| cookie::Cookie::parse(header.as_str()).ok())
231        .filter(|cookie| cookie.name() == cookie_name)
232        .last();
233    if let Some(cookie) = token.filter(|cookie| {
234        !cookie.value().is_empty() && cookie.max_age().is_none_or(|age| age.whole_seconds() != 0)
235    }) {
236        let token = try_decode(cookie.value());
237        let mut exposed = Vec::new();
238        for name in headers
239            .get("access-control-expose-headers")
240            .map_or("", String::as_str)
241            .split(',')
242            .map(str::trim)
243            .filter(|v| !v.is_empty())
244        {
245            if !exposed.contains(&name) {
246                exposed.push(name);
247            }
248        }
249        if !exposed.contains(&"set-auth-token") {
250            exposed.push("set-auth-token");
251        }
252        let exposed = exposed.join(", ");
253        return Some((token, exposed));
254    }
255    None
256}
257
258fn authorization(headers: &HashMap<String, String>) -> Option<&str> {
259    headers
260        .iter()
261        .find_map(|(name, value)| {
262            name.eq_ignore_ascii_case("authorization")
263                .then_some(value.as_str())
264        })
265        .filter(|value| !value.is_empty())
266}
267#[async_trait]
268impl<S: AuthSchema> EndpointHook<S> for BearerPlugin {
269    async fn before(
270        &self,
271        call: &EndpointCall,
272        ctx: &AuthContext<S>,
273    ) -> AuthResult<Option<BeforeEndpointAction>> {
274        let header = call
275            .request()
276            .and_then(|request| authorization(&request.headers))
277            .or_else(|| call.headers().and_then(authorization));
278        let Some(header) = header else {
279            return Ok(None);
280        };
281        let existing = call
282            .request()
283            .map(|request| &request.headers)
284            .or_else(|| call.headers());
285        let Some(existing) = existing else {
286            return Ok(None);
287        };
288        let Some(headers) = self.authenticated_headers(
289            header,
290            existing,
291            ctx.config.current_secret(),
292            &ctx.config.session.cookie_name,
293        ) else {
294            return Ok(None);
295        };
296        Ok(Some(BeforeEndpointAction::Patch(Box::new(
297            EndpointContextPatch {
298                headers: Some(headers),
299                ..Default::default()
300            },
301        ))))
302    }
303    async fn after(
304        &self,
305        _call: &EndpointCall,
306        ctx: &AuthContext<S>,
307        response: EndpointResponse,
308    ) -> AuthResult<EndpointResponse> {
309        Ok(
310            if let Some((token, exposed)) =
311                issuance_headers(response.headers(), &ctx.config.session.cookie_name)
312            {
313                response
314                    .with_header("set-auth-token", token)
315                    .with_header("access-control-expose-headers", exposed)
316            } else {
317                response
318            },
319        )
320    }
321}