alibi_api/plugins/
bearer.rs1use 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#[derive(Clone, Debug, Default)]
19pub struct BearerConfig {
20 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 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 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 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}