1use 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#[derive(Clone, Debug, Default)]
23pub struct BearerConfig {
24 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 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 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 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}