Skip to main content

alibi_plugins/oauth_provider/
interaction.rs

1use 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}