alibi_plugins/oauth_provider/
interaction.rs1use super::OAuthProviderPlugin;
2use alibi_core::endpoint::{BeforeEndpointAction, EndpointCall, EndpointHook, EndpointResponse};
3use alibi_core::session::SessionRequest;
4use alibi_core::types::RequestExtensions;
5use alibi_core::utils::cookie_utils::related_cookie_name;
6use alibi_core::utils::json::JsValue;
7use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema};
8use alibi_core::{AuthRequest, AuthSession, Headers, HttpMethod, SessionView, UserView};
9use async_trait::async_trait;
10use std::collections::HashMap;
11
12struct InteractionState {
13 signed_query: String,
14}
15
16struct InteractionRequest<'a, R> {
17 original: &'a R,
18 headers: HashMap<String, String>,
19 session: SessionView,
20 logical: Option<EndpointCall>,
21}
22
23impl<R: SessionRequest> SessionRequest for InteractionRequest<'_, R> {
24 fn endpoint_call(&self) -> Option<&EndpointCall> {
25 self.logical.as_ref()
26 }
27 fn physical_request(&self) -> Option<&AuthRequest> {
28 self.original.physical_request()
29 }
30 fn session_headers(&self) -> &HashMap<String, String> {
31 &self.headers
32 }
33 fn session_method(&self) -> &HttpMethod {
34 self.original.session_method()
35 }
36 fn session_query_truthy(&self, name: &str) -> bool {
37 self.original.session_query_truthy(name)
38 }
39 fn extensions(&self) -> &RequestExtensions {
40 self.original.extensions()
41 }
42 fn virtual_session<S: AuthSchema>(&self, _: &AuthContext<S>) -> Option<SessionView> {
43 Some(self.session.clone())
44 }
45 fn take_response_headers(&self) -> Headers {
46 self.original.take_response_headers()
47 }
48 fn queue_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
49 self.original.queue_response_header(name, value);
50 }
51 fn set_session_hook_snapshot(&self, user: UserView, session: SessionView) {
52 self.original.set_session_hook_snapshot(user, session);
53 }
54}
55
56fn query_text(input: &JsValue) -> AuthResult<String> {
57 let ordinary =
58 |message: &str| AuthError::CallbackFailure(Box::new(AuthError::internal(message)));
59 let mut query = url::form_urlencoded::Serializer::new(String::new());
60 match input {
61 JsValue::Object(fields) => {
62 for (name, value) in fields {
63 _ = query.append_pair(name, &value.coerce_string().map_err(ordinary)?);
64 }
65 }
66 JsValue::Array(entries) => {
67 for entry in entries {
68 let JsValue::Array(pair) = entry else {
69 return Err(ordinary(
70 "Each query pair must be an iterable [name, value] tuple",
71 ));
72 };
73 let [name, value] = pair.as_slice() else {
74 return Err(ordinary(
75 "Each query pair must be an iterable [name, value] tuple",
76 ));
77 };
78 _ = query.append_pair(
79 &name.coerce_string().map_err(ordinary)?,
80 &value.coerce_string().map_err(ordinary)?,
81 );
82 }
83 }
84 value => return value.coerce_string().map_err(ordinary),
85 }
86 Ok(query.finish())
87}
88
89pub(super) fn admit(
90 input: Option<&JsValue>,
91 secret: &str,
92 extensions: &RequestExtensions,
93) -> AuthResult<Option<EndpointResponse>> {
94 let Some(input) = input.filter(|value| value.is_truthy()) else {
95 return Ok(None);
96 };
97 let query = query_text(input)?;
98 if super::authorization::verify_authorization_query(&query, secret)? {
99 extensions.insert(InteractionState {
100 signed_query: query,
101 });
102 return Ok(None);
103 }
104 Ok(Some(
105 EndpointResponse::error(AuthError::Api {
106 status: 400,
107 code: None,
108 message: String::new(),
109 })
110 .with_error_body(alibi_core::utils::json::parse_value(
111 r#"{"error":"invalid_signature"}"#,
112 )?),
113 ))
114}
115
116#[async_trait]
117impl<S: AuthSchema> EndpointHook<S> for OAuthProviderPlugin {
118 fn matches_before(&self, call: &EndpointCall, _: &AuthContext<S>) -> AuthResult<bool> {
119 Ok(call
120 .body()
121 .and_then(|body| body.get("oauth_query"))
122 .is_some_and(JsValue::is_truthy))
123 }
124 async fn before(
125 &self,
126 call: &EndpointCall,
127 ctx: &AuthContext<S>,
128 ) -> AuthResult<Option<BeforeEndpointAction>> {
129 Ok(admit(
130 call.body().and_then(|body| body.get("oauth_query")),
131 ctx.config.current_secret(),
132 call.extensions(),
133 )?
134 .map(BeforeEndpointAction::Reject))
135 }
136 async fn after(
137 &self,
138 call: &EndpointCall,
139 ctx: &AuthContext<S>,
140 response: EndpointResponse,
141 ) -> AuthResult<EndpointResponse> {
142 let Some(mut resumed) = resume(self, call, response.headers(), ctx).await? else {
143 return Ok(response);
144 };
145 resumed.merge_headers(response.headers().clone());
146 Ok(resumed)
147 }
148}
149
150pub(super) async fn resume<S: AuthSchema>(
151 provider: &OAuthProviderPlugin,
152 req: &impl SessionRequest,
153 headers: &Headers,
154 ctx: &AuthContext<S>,
155) -> AuthResult<Option<EndpointResponse>> {
156 let Some(state) = req.extensions().get::<InteractionState>() else {
157 return Ok(None);
158 };
159 let cookie_name = related_cookie_name(&ctx.config, "session_token");
160 let token = headers
161 .get_all("set-cookie")
162 .filter_map(|cookie| cookie.split(';').next()?.split_once('='))
163 .filter(|(name, _)| *name == cookie_name)
164 .map(|(_, value)| value.split('.').next().unwrap_or(""))
165 .last()
166 .unwrap_or("");
167 if token.is_empty() {
168 return Ok(None);
169 }
170 let Some(session) = ctx.database.get_session(token).await? else {
171 return Ok(None);
172 };
173 let mut query = super::continuation::interaction_query(&state.signed_query);
174 if let Some(prompt) = query.get("prompt").and_then(serde_json::Value::as_str) {
175 let mut prompts = prompt.split(' ').collect::<Vec<_>>();
176 if let Some(index) = prompts.iter().position(|prompt| *prompt == "login") {
177 _ = prompts.remove(index);
178 if prompts.is_empty() {
179 _ = query.remove("prompt");
180 } else {
181 let value = prompts.join(" ");
182 _ = query.insert("prompt".into(), serde_json::json!(value));
183 }
184 }
185 }
186 let issued_at = url::form_urlencoded::parse(state.signed_query.as_bytes())
187 .find(|(name, _)| name == "ba_iat")
188 .and_then(|(_, value)| alibi_core::utils::javascript::string_to_number(&value))
189 .filter(|value| value.is_finite() && *value > 0.0 && *value <= 8_640_000_000_000_000.0)
190 .and_then(|value| value.trunc().to_string().parse::<i64>().ok());
191 if issued_at.is_some_and(|issued_at| session.created_at().timestamp_millis() >= issued_at) {
192 _ = query.remove("max_age");
193 }
194 let mut headers = req.session_headers().clone();
195 let navigation = req.physical_request().is_some_and(|physical| {
196 let mode = physical
197 .header("sec-fetch-mode")
198 .map(|mode| mode.to_ascii_lowercase());
199 let accept = physical
200 .header("accept")
201 .map_or_else(String::new, |accept| accept.to_ascii_lowercase());
202 mode.as_deref() == Some("navigate")
203 || mode.is_none()
204 && (accept.contains("text/html") || accept.contains("application/xhtml+xml"))
205 });
206 if !navigation {
207 _ = headers.insert("accept".into(), "application/json".into());
208 }
209 let session_view = SessionView::from(&session);
210 let logical = if let Some(original) = req.endpoint_call() {
211 let Some(user) = ctx.session_user(&session).await? else {
212 return Ok(None);
213 };
214 let mut call = original.clone();
215 call.replace_headers(headers.clone());
216 call.establish_session::<S>(
217 user.clone(),
218 ctx.user_view(&user),
219 session_view.clone(),
220 ctx,
221 );
222 Some(call)
223 } else {
224 None
225 };
226 let request = InteractionRequest {
227 original: req,
228 headers,
229 session: session_view,
230 logical,
231 };
232 Ok(Some(
233 Box::pin(provider.authorize_flow(
234 Some(alibi_core::utils::json::parse_value(
235 &serde_json::to_string(&query)?,
236 )?),
237 &request,
238 ctx,
239 super::authorization::AuthorizationSettings::default(),
240 ))
241 .await?,
242 ))
243}