Skip to main content

alibi_api/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(), |decoded| decoded.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        drop(cookies.insert(key.to_owned(), try_decode(value)));
132    }
133    drop(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    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
144        crate::metadata::plugin_metadata(
145            <Self as alibi_core::AuthPlugin<S>>::name(self),
146            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
147        )
148    }
149
150    fn openapi_metadata(
151        &self,
152        ctx: &alibi_core::AuthInitContext<S>,
153    ) -> alibi_core::PluginOpenApiMetadata {
154        crate::metadata::instance_plugin_metadata(
155            <Self as alibi_core::AuthPlugin<S>>::name(self),
156            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
157            ctx,
158        )
159    }
160
161    fn name(&self) -> &'static str {
162        "bearer"
163    }
164    fn routes(&self) -> Vec<AuthRoute> {
165        Vec::new()
166    }
167
168    async fn before_request(
169        &self,
170        req: &AuthRequest,
171        ctx: &AuthContext<S>,
172    ) -> AuthResult<Option<BeforeRequestAction>> {
173        let Some(authorization) = req
174            .headers
175            .iter()
176            .find_map(|(key, value)| key.eq_ignore_ascii_case("authorization").then_some(value))
177        else {
178            return Ok(None);
179        };
180        let Some(token) = self.signed_token(authorization, ctx.config.current_secret()) else {
181            return Ok(None);
182        };
183        let existing = req
184            .headers
185            .iter()
186            .find_map(|(key, value)| key.eq_ignore_ascii_case("cookie").then_some(value.as_str()))
187            .unwrap_or("");
188        let cookie = replace_cookie(existing, &ctx.config.session.cookie_name, &token);
189        let mut headers = req.headers.clone();
190        headers.retain(|key, _| !key.eq_ignore_ascii_case("cookie"));
191        drop(headers.insert("cookie".to_owned(), cookie));
192        Ok(Some(BeforeRequestAction::ReplaceHeaders { headers }))
193    }
194
195    async fn on_request(
196        &self,
197        _req: &AuthRequest,
198        _ctx: &AuthContext<S>,
199    ) -> AuthResult<Option<AuthResponse>> {
200        Ok(None)
201    }
202
203    async fn after_request(
204        &self,
205        _req: &AuthRequest,
206        ctx: &AuthContext<S>,
207        mut response: AuthResponse,
208    ) -> AuthResult<AuthResponse> {
209        let token = response
210            .headers
211            .get_all("set-cookie")
212            .filter_map(|header| cookie::Cookie::parse(header.as_str()).ok())
213            .filter(|cookie| cookie.name() == ctx.config.session.cookie_name)
214            .last();
215        if let Some(cookie) = token.filter(|cookie| {
216            !cookie.value().is_empty()
217                && cookie.max_age().is_none_or(|age| age.whole_seconds() != 0)
218        }) {
219            let token = try_decode(cookie.value());
220            let mut exposed = Vec::new();
221            for name in response
222                .headers
223                .get("access-control-expose-headers")
224                .map_or("", String::as_str)
225                .split(',')
226                .map(str::trim)
227                .filter(|v| !v.is_empty())
228            {
229                if !exposed.contains(&name) {
230                    exposed.push(name);
231                }
232            }
233            if !exposed.contains(&"set-auth-token") {
234                exposed.push("set-auth-token");
235            }
236            let exposed = exposed.join(", ");
237            drop(response.headers.insert("set-auth-token", token));
238            drop(
239                response
240                    .headers
241                    .insert("access-control-expose-headers", exposed),
242            );
243        }
244        Ok(response)
245    }
246}