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