Skip to main content

alibi_plugins/oauth/
endpoint.rs

1//! Logical provider-token access uses the genuine session and shared account algorithm.
2use super::{OAuthAccessToken, OAuthAccountProfile, OAuthAccountSelection, OAuthPlugin};
3use alibi_core::endpoint::{
4    EndpointCall, EndpointContextPatch, EndpointDefinition, EndpointInput, EndpointResponse,
5    ServerEndpoint,
6};
7use alibi_core::utils::json::JsValue;
8use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, HttpMethod};
9
10pub(super) fn definitions() -> Vec<EndpointDefinition> {
11    vec![
12        crate::endpoint::definition(
13            "callbackOAuth",
14            "handleOAuthCallback",
15            Some("/callback/:id"),
16            HttpMethod::Get,
17        ),
18        crate::endpoint::definition(
19            "getAccessToken",
20            "getAccessToken",
21            Some("/get-access-token"),
22            HttpMethod::Post,
23        ),
24        crate::endpoint::definition(
25            "refreshToken",
26            "refreshToken",
27            Some("/refresh-token"),
28            HttpMethod::Post,
29        ),
30        crate::endpoint::definition(
31            "accountInfo",
32            "accountInfo",
33            Some("/account-info"),
34            HttpMethod::Get,
35        ),
36        crate::endpoint::definition(
37            "signInSocial",
38            "socialSignIn",
39            Some("/sign-in/social"),
40            HttpMethod::Post,
41        ),
42        crate::endpoint::definition(
43            "linkSocialAccount",
44            "linkSocialAccount",
45            Some("/link-social"),
46            HttpMethod::Post,
47        ),
48    ]
49}
50/// Public result of the `refreshToken` server operation.
51/// Provider-defined token scalars are preserved in the decoded result.
52#[derive(Debug, serde::Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct OAuthRefreshResult {
55    pub access_token: Option<serde_json::Value>,
56    pub refresh_token: Option<serde_json::Value>,
57    pub id_token: Option<serde_json::Value>,
58    pub access_token_expires_at: Option<String>,
59    pub refresh_token_expires_at: Option<String>,
60    pub scope: Option<String>,
61    pub provider_id: String,
62    pub account_id: Option<String>,
63}
64
65fn selection_input(call: &EndpointCall) -> AuthResult<serde_json::Value> {
66    Ok(alibi_core::utils::json::from_value(
67        if call.operation_id() == "accountInfo" {
68            call.query()
69        } else {
70            call.body()
71        }
72        .cloned()
73        .unwrap_or(JsValue::Null),
74    )?)
75}
76pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
77    if call.operation_id() == "handleOAuthCallback" {
78        return validate_callback(call);
79    }
80    if matches!(call.operation_id(), "socialSignIn" | "linkSocialAccount") {
81        return validate_social(call);
82    }
83    _ = OAuthAccountSelection::from_value(&selection_input(call)?).map_err(|message| {
84        crate::endpoint::validation(format!(
85            "[{}] {message}",
86            if call.operation_id() == "accountInfo" {
87                "query"
88            } else {
89                "body"
90            }
91        ))
92    })?;
93    Ok(EndpointInput {
94        body: call.body().cloned(),
95        query: call.query().cloned(),
96    })
97}
98impl OAuthPlugin {
99    /// Read or refresh a provider account's access token through installed server hooks.
100    #[must_use]
101    pub fn get_access_token_endpoint(
102        selection: OAuthAccountSelection,
103        user_id: Option<&str>,
104    ) -> ServerEndpoint<OAuthAccessToken> {
105        Self::account_endpoint("getAccessToken", selection, user_id)
106    }
107    /// Refresh an owned provider grant through installed server hooks.
108    #[must_use]
109    pub fn refresh_token_endpoint(
110        selection: OAuthAccountSelection,
111        user_id: Option<&str>,
112    ) -> ServerEndpoint<OAuthRefreshResult> {
113        Self::account_endpoint("refreshToken", selection, user_id)
114    }
115    /// Fetch the selected provider profile through installed server hooks.
116    #[must_use]
117    pub fn account_info_endpoint(
118        selection: OAuthAccountSelection,
119        user_id: Option<&str>,
120    ) -> ServerEndpoint<OAuthAccountProfile> {
121        Self::account_endpoint("accountInfo", selection, user_id)
122    }
123    fn account_endpoint<T>(
124        operation: &'static str,
125        selection: OAuthAccountSelection,
126        user_id: Option<&str>,
127    ) -> ServerEndpoint<T> {
128        let mut input = JsValue::Object(match selection {
129            OAuthAccountSelection::Id(id) => [("accountId".into(), JsValue::String(id))]
130                .into_iter()
131                .collect(),
132            OAuthAccountSelection::Cookie => [("useAccountCookie".into(), JsValue::Bool(true))]
133                .into_iter()
134                .collect(),
135        });
136        if let (Some(user), JsValue::Object(input)) = (user_id, &mut input) {
137            let _ = input.insert("userId".into(), JsValue::String(user.into()));
138        }
139        let endpoint = ServerEndpoint::new("oauth", operation);
140        if operation == "accountInfo" {
141            endpoint.with_query_value(input)
142        } else {
143            endpoint.with_body_value(input)
144        }
145    }
146    pub(super) async fn call_account_endpoint(
147        &self,
148        call: &EndpointCall,
149        ctx: &AuthContext<impl AuthSchema>,
150    ) -> AuthResult<EndpointResponse> {
151        let json = selection_input(call)?;
152        let selection =
153            OAuthAccountSelection::from_value(&json).map_err(crate::endpoint::validation)?;
154        let mut input = call.clone();
155        if ctx.config.session.has_server_session_store() {
156            EndpointContextPatch {
157                query: Some(JsValue::Object(
158                    [("disableCookieCache".into(), JsValue::Bool(true))]
159                        .into_iter()
160                        .collect(),
161                )),
162                ..Default::default()
163            }
164            .apply(&mut input);
165        }
166        let read = alibi_core::session::cookie_cache::runtime::authenticated(ctx, &input, false)
167            .await
168            .ok()
169            .flatten();
170        let user_id = if let Some(read) = read {
171            let user = ctx.user_view(&read.user);
172            call.record_authenticated_session(user.clone(), read.session);
173            user.id
174        } else {
175            if call.request().is_some() || call.headers().is_some() {
176                return Ok(EndpointResponse::error(AuthError::Api {
177                    status: 401,
178                    code: None,
179                    message: String::new(),
180                })
181                .without_error_body());
182            }
183            json.get("userId")
184                .and_then(serde_json::Value::as_str)
185                .filter(|user| !user.is_empty())
186                .ok_or(AuthError::Upstream {
187                    status: 400,
188                    code: "USER_ID_OR_SESSION_REQUIRED",
189                    message: "Either userId or session is required",
190                })?
191                .to_owned()
192        };
193        let (value, cookies) = if call.operation_id() == "accountInfo" {
194            let (value, cookies) = match Box::pin(super::account::account_info_for_selection(
195                &self.config,
196                call,
197                ctx,
198                &user_id,
199                selection,
200            ))
201            .await
202            {
203                Ok(response) => response,
204                Err(AuthError::Api {
205                    code: Some(code), ..
206                }) if code == "OAUTH_PROFILE_EXCEPTION" => {
207                    return Ok(EndpointResponse::error(AuthError::Api {
208                        status: 500,
209                        code: None,
210                        message: String::new(),
211                    })
212                    .without_error_body());
213                }
214                Err(error) => return Err(error),
215            };
216            (
217                serde_json::to_value(value)
218                    .map_err(|error| AuthError::internal(error.to_string()))?,
219                cookies,
220            )
221        } else if call.operation_id() == "refreshToken" {
222            let (tokens, account, cookies) = Box::pin(super::account::refresh_token_for_selection(
223                &self.config,
224                call,
225                ctx,
226                &user_id,
227                selection,
228            ))
229            .await?;
230            (
231                super::account::refresh_token_body(&self.config, &tokens, &account)?,
232                cookies,
233            )
234        } else {
235            let (tokens, cookies) = super::account::get_access_token_for_selection(
236                &self.config,
237                call,
238                ctx,
239                &user_id,
240                selection,
241            )
242            .await?;
243            (
244                serde_json::to_value(OAuthAccessToken::from(tokens))
245                    .map_err(|error| AuthError::internal(error.to_string()))?,
246                cookies,
247            )
248        };
249        let mut response = EndpointResponse::json(&value)?;
250        for cookie in cookies {
251            response = response.with_header("set-cookie", cookie);
252        }
253        Ok(response)
254    }
255}
256
257fn validate_social(call: &EndpointCall) -> AuthResult<EndpointInput> {
258    use crate::authentication_helpers::{JsonField, JsonFieldKind};
259    if call.body().is_some_and(|body| body.as_object().is_some())
260        && !call
261            .body()
262            .and_then(|body| body.get("provider"))
263            .is_some_and(JsValue::is_string)
264    {
265        return Err(crate::endpoint::validation("[body.provider] Invalid input"));
266    }
267    let fields = &[
268        JsonField::string("provider", true),
269        JsonField::string("callbackURL", false),
270        JsonField::string("newUserCallbackURL", false),
271        JsonField::string("errorCallbackURL", false),
272        JsonField::string("loginHint", false),
273        JsonField {
274            name: "disableRedirect",
275            kind: JsonFieldKind::Boolean,
276            required: false,
277        },
278        JsonField {
279            name: "requestSignUp",
280            kind: JsonFieldKind::Boolean,
281            required: false,
282        },
283        JsonField {
284            name: "idToken",
285            kind: JsonFieldKind::Record,
286            required: false,
287        },
288        JsonField {
289            name: "additionalData",
290            kind: JsonFieldKind::Record,
291            required: false,
292        },
293        JsonField {
294            name: "additionalParams",
295            kind: JsonFieldKind::Record,
296            required: false,
297        },
298    ];
299    let linking = call.operation_id() == "linkSocialAccount";
300    let fields = fields
301        .iter()
302        .filter(|field| !linking || field.name != "newUserCallbackURL")
303        .map(|field| JsonField {
304            name: field.name,
305            kind: field.kind,
306            required: field.required,
307        })
308        .collect::<Vec<_>>();
309    let mut body = crate::endpoint::validate_fields(call.body(), "body", &fields)?;
310    if let Some(scopes) = call.body().and_then(|body| body.get("scopes")) {
311        let values = scopes.as_array().ok_or_else(|| {
312            crate::endpoint::validation(format!(
313                "[body.scopes] Invalid input: expected array, received {}",
314                crate::authentication_helpers::json_type(Some(scopes))
315            ))
316        })?;
317        for (index, value) in values.iter().enumerate() {
318            if !value.is_string() {
319                return Err(crate::endpoint::validation(format!(
320                    "[body.scopes.{index}] Invalid input: expected string, received {}",
321                    crate::authentication_helpers::json_type(Some(value))
322                )));
323            }
324        }
325        if let JsValue::Object(body) = &mut body {
326            _ = body.insert("scopes".into(), scopes.clone());
327        }
328    }
329    if let Some(params) = body.get("additionalParams").and_then(JsValue::as_object) {
330        for (key, value) in params {
331            if !value.is_string() {
332                return Err(crate::endpoint::validation(format!(
333                    "[body.additionalParams.{key}] Invalid input: expected string, received {}",
334                    crate::authentication_helpers::json_type(Some(value))
335                )));
336            }
337        }
338    }
339    if let Some(token) = body.get("idToken") {
340        let token_fields = &[
341            JsonField::string("token", true),
342            JsonField::string("nonce", false),
343            JsonField::string("accessToken", false),
344            JsonField::string("refreshToken", false),
345            JsonField {
346                name: "user",
347                kind: JsonFieldKind::Record,
348                required: false,
349            },
350        ];
351        let token_fields = token_fields
352            .iter()
353            .filter(|field| !linking || field.name != "user")
354            .map(|field| JsonField {
355                name: field.name,
356                kind: field.kind,
357                required: field.required,
358            })
359            .collect::<Vec<_>>();
360        let parsed_token =
361            crate::endpoint::validate_fields(Some(token), "body.idToken", &token_fields)?;
362        if linking && let JsValue::Object(body) = &mut body {
363            _ = body.insert("idToken".into(), parsed_token);
364        }
365    }
366    let parsed: super::OAuthSocialSignInRequest =
367        alibi_core::utils::json::from_value(body.clone())?;
368    super::handlers::validate_authorization_params(parsed.additional_params.as_ref())?;
369    if linking && call.headers().is_none() {
370        return Err(crate::endpoint::validation("Headers is required"));
371    }
372    Ok(EndpointInput {
373        body: Some(body),
374        query: call.query().cloned(),
375    })
376}
377
378impl OAuthPlugin {
379    /// Link a provider account to the verified session owner through installed server hooks.
380    pub fn link_social_account_endpoint(
381        body: &super::OAuthLinkSocialRequest,
382    ) -> AuthResult<ServerEndpoint<super::OAuthSocialSignInResponse>> {
383        ServerEndpoint::new("oauth", "linkSocialAccount").with_body(body)
384    }
385    pub(super) async fn call_link_endpoint(
386        &self,
387        call: &EndpointCall,
388        ctx: &AuthContext<impl AuthSchema>,
389    ) -> AuthResult<EndpointResponse> {
390        let body: super::OAuthLinkSocialRequest = call.body_as()?;
391        let (user, session) =
392            ctx.require_cached_session(call)
393                .await
394                .map_err(|_| AuthError::Upstream {
395                    status: 401,
396                    code: "UNAUTHORIZED",
397                    message: "Unauthorized",
398                })?;
399        call.record_authenticated_session(ctx.user_view(&user), session.clone());
400        if let Some(token) = &body.id_token {
401            let provider = self
402                .config
403                .providers
404                .get(&body.provider)
405                .ok_or_else(|| AuthError::not_found("Provider not found"))?;
406            return EndpointResponse::json(
407                &Box::pin(super::handlers::link_with_id_token_core(
408                    &body, token, provider, &session, ctx,
409                ))
410                .await?,
411            );
412        }
413        let flow = match Box::pin(super::handlers::link_social_core(
414            &body,
415            &session,
416            &self.config,
417            call.request(),
418            ctx,
419        ))
420        .await
421        {
422            Ok(flow) => flow,
423            Err(AuthError::Config(_)) => {
424                return Ok(EndpointResponse::error(AuthError::Api {
425                    status: 500,
426                    code: None,
427                    message: String::new(),
428                })
429                .without_error_body());
430            }
431            Err(error) => return Err(error),
432        };
433        flow_response(&flow, &ctx.config)
434    }
435    /// Initiate social sign-in or admit an ID token through installed server hooks.
436    pub fn sign_in_social_endpoint(
437        body: &super::OAuthSocialSignInRequest,
438    ) -> AuthResult<ServerEndpoint<super::OAuthSocialSignInResponse>> {
439        ServerEndpoint::new("oauth", "signInSocial").with_body(body)
440    }
441    pub(super) async fn call_social_endpoint(
442        &self,
443        call: &EndpointCall,
444        ctx: &AuthContext<impl AuthSchema>,
445    ) -> AuthResult<EndpointResponse> {
446        let body: super::OAuthSocialSignInRequest = call.body_as()?;
447        let headers = call.headers().cloned().unwrap_or_default();
448        let meta = alibi_core::RequestMeta {
449            ip_address: Some(
450                ctx.config
451                    .advanced
452                    .ip_address
453                    .resolve_ip(&headers)
454                    .unwrap_or_default(),
455            ),
456            user_agent: Some(headers.get("user-agent").cloned().unwrap_or_default()),
457        };
458        if let Some(token) = &body.id_token {
459            let provider = self
460                .config
461                .providers
462                .get(&body.provider)
463                .ok_or_else(|| AuthError::not_found("Provider not found"))?;
464            let value =
465                super::handlers::sign_in_with_id_token_core(&body, token, provider, &meta, ctx)
466                    .await?;
467            let mut response = EndpointResponse::json(&value)?;
468            if let Some(token) = &value.token {
469                response.merge_headers(crate::authentication_helpers::session_cookie_headers(
470                    &ctx.config,
471                    token,
472                    false,
473                )?);
474            }
475            return Ok(response);
476        }
477        start_social_flow_response(&body, &self.config, call.request(), ctx).await
478    }
479}
480
481pub(crate) async fn start_social_flow_response(
482    body: &super::OAuthSocialSignInRequest,
483    config: &super::OAuthConfig,
484    request: Option<&alibi_core::AuthRequest>,
485    ctx: &AuthContext<impl AuthSchema>,
486) -> AuthResult<EndpointResponse> {
487    let flow = match Box::pin(super::handlers::social_sign_in_core(
488        body, config, request, ctx,
489    ))
490    .await
491    {
492        Ok(flow) => flow,
493        Err(AuthError::Config(_)) => {
494            return Ok(EndpointResponse::error(AuthError::Api {
495                status: 500,
496                code: None,
497                message: String::new(),
498            })
499            .without_error_body());
500        }
501        Err(error) => return Err(error),
502    };
503    flow_response(&flow, &ctx.config)
504}
505
506fn flow_response(
507    flow: &super::handlers::InitiatedOAuthFlow,
508    config: &alibi_core::AuthConfig,
509) -> AuthResult<EndpointResponse> {
510    let mut response = EndpointResponse::json(&flow.response)?;
511    if flow.response.redirect
512        && let Some(url) = &flow.response.url
513    {
514        response = response.with_header("location", url);
515    }
516    let cookie = match config.account.store_state_strategy {
517        alibi_core::OAuthStateStrategy::Automatic => {
518            return Err(AuthError::config(
519                "OAuth state strategy has not been initialized",
520            ));
521        }
522        alibi_core::OAuthStateStrategy::Database => {
523            super::handlers::state_cookie_header(config, config.current_secret(), &flow.state)?
524        }
525        alibi_core::OAuthStateStrategy::Cookie => {
526            super::handlers::cookie_state_header(config, &flow.payload)?
527        }
528    };
529    Ok(response.with_header("set-cookie", cookie))
530}
531
532fn validate_callback(call: &EndpointCall) -> AuthResult<EndpointInput> {
533    let fields = [
534        "code",
535        "error",
536        "device_id",
537        "error_description",
538        "state",
539        "user",
540        "iss",
541    ]
542    .map(|name| crate::authentication_helpers::JsonField::string(name, false));
543    let parse = |value: Option<&JsValue>, location: &str| {
544        value
545            .map(|value| crate::endpoint::validate_fields(Some(value), location, &fields))
546            .transpose()
547    };
548    Ok(EndpointInput {
549        body: parse(call.body(), "body")?,
550        query: parse(call.query(), "query")?,
551    })
552}
553impl OAuthPlugin {
554    /// Redeem a provider callback through installed server hooks and genuine logical input.
555    /// Redirects remain API errors with their Location and complete cookie headers.
556    pub fn callback_oauth_endpoint(
557        provider: &str,
558        query: Option<&super::OAuthCallbackInput>,
559    ) -> AuthResult<ServerEndpoint<JsValue>> {
560        let endpoint = ServerEndpoint::new("oauth", "callbackOAuth")
561            .with_params(&serde_json::json!({"id":provider}))?;
562        match query {
563            Some(query) => endpoint.with_query(query),
564            None => Ok(endpoint),
565        }
566    }
567    pub(super) async fn call_callback_endpoint(
568        &self,
569        call: &EndpointCall,
570        ctx: &AuthContext<impl AuthSchema>,
571    ) -> AuthResult<EndpointResponse> {
572        let invalid =
573            || super::handlers::callback_failure_redirect(ctx, "invalid_callback_request");
574        let method = call.method().unwrap_or(&HttpMethod::Get);
575        let response = if !matches!(method, HttpMethod::Get | HttpMethod::Post)
576            || (method == &HttpMethod::Get && call.query().is_none())
577        {
578            invalid()
579        } else {
580            let provider = call
581                .params()
582                .and_then(|params| params.get("id"))
583                .map(JsValue::coerce_string)
584                .transpose()
585                .map_err(AuthError::internal)?
586                .ok_or_else(|| AuthError::internal("Missing OAuth provider parameters"))?;
587            let map =
588                |value: Option<&JsValue>| -> AuthResult<std::collections::HashMap<String, String>> {
589                    value
590                        .cloned()
591                        .map_or_else(
592                            || Ok(std::collections::HashMap::new()),
593                            alibi_core::utils::json::from_value,
594                        )
595                        .map_err(AuthError::from)
596                };
597            let query = map(call.query())?;
598            if method == &HttpMethod::Post {
599                let mut data = map(call.body())?;
600                data.extend(query);
601                super::handlers::callback_post_response(ctx, &provider, &data)
602            } else {
603                Box::pin(super::handlers::callback_core(
604                    &self.config,
605                    &provider,
606                    &query,
607                    call,
608                    ctx,
609                ))
610                .await?
611            }
612        };
613        let mut output = if response.status == 302 {
614            EndpointResponse::redirect(
615                response
616                    .headers
617                    .get("location")
618                    .cloned()
619                    .unwrap_or_default(),
620            )
621        } else {
622            EndpointResponse::error(AuthError::Api {
623                status: response.status,
624                code: None,
625                message: String::new(),
626            })
627            .without_error_body()
628        };
629        for (name, value) in response.headers {
630            // A redirect API error carries endpoint headers; HTTP conversion
631            // adds the content type of its absent body at the outer boundary.
632            if !name.eq_ignore_ascii_case("content-type") {
633                output = output.with_header(name, value);
634            }
635        }
636        Ok(output)
637    }
638}