Skip to main content

alibi_plugins/
bearer.rs

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