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                .providers
405                .get(&body.provider)
406                .ok_or_else(|| AuthError::not_found("Provider not found"))?;
407            return EndpointResponse::json(
408                &Box::pin(super::handlers::link_with_id_token_core(
409                    &body, token, provider, &session, ctx,
410                ))
411                .await?,
412            );
413        }
414        let flow = match Box::pin(super::handlers::link_social_core(
415            &body,
416            &session,
417            &self.config,
418            call.request(),
419            ctx,
420        ))
421        .await
422        {
423            Ok(flow) => flow,
424            Err(AuthError::Config(_)) => {
425                return Ok(EndpointResponse::error(AuthError::Api {
426                    status: 500,
427                    code: None,
428                    message: String::new(),
429                })
430                .without_error_body());
431            }
432            Err(error) => return Err(error),
433        };
434        flow_response(&flow, &ctx.config)
435    }
436    /// Initiate social sign-in or admit an ID token through installed server hooks.
437    pub fn sign_in_social_endpoint(
438        body: &super::OAuthSocialSignInRequest,
439    ) -> AuthResult<ServerEndpoint<super::OAuthSocialSignInResponse>> {
440        ServerEndpoint::new("oauth", "signInSocial").with_body(body)
441    }
442    pub(super) async fn call_social_endpoint(
443        &self,
444        call: &EndpointCall,
445        ctx: &AuthContext<impl AuthSchema>,
446    ) -> AuthResult<EndpointResponse> {
447        let body: super::OAuthSocialSignInRequest = call.body_as()?;
448        let headers = call.headers().cloned().unwrap_or_default();
449        let meta = alibi_core::RequestMeta {
450            ip_address: Some(
451                ctx.config
452                    .advanced
453                    .ip_address
454                    .resolve_ip(&headers)
455                    .unwrap_or_default(),
456            ),
457            user_agent: Some(headers.get("user-agent").cloned().unwrap_or_default()),
458        };
459        if let Some(token) = &body.id_token {
460            let provider = self
461                .config
462                .providers
463                .get(&body.provider)
464                .ok_or_else(|| AuthError::not_found("Provider not found"))?;
465            let value =
466                super::handlers::sign_in_with_id_token_core(&body, token, provider, &meta, ctx)
467                    .await?;
468            let mut response = EndpointResponse::json(&value)?;
469            if let Some(token) = &value.token {
470                response.merge_headers(crate::authentication_helpers::session_cookie_headers(
471                    &ctx.config,
472                    token,
473                    false,
474                )?);
475            }
476            return Ok(response);
477        }
478        start_social_flow_response(&body, &self.config, call.request(), ctx).await
479    }
480}
481
482pub(crate) async fn start_social_flow_response(
483    body: &super::OAuthSocialSignInRequest,
484    config: &super::OAuthConfig,
485    request: Option<&alibi_core::AuthRequest>,
486    ctx: &AuthContext<impl AuthSchema>,
487) -> AuthResult<EndpointResponse> {
488    let flow = match Box::pin(super::handlers::social_sign_in_core(
489        body, config, request, ctx,
490    ))
491    .await
492    {
493        Ok(flow) => flow,
494        Err(AuthError::Config(_)) => {
495            return Ok(EndpointResponse::error(AuthError::Api {
496                status: 500,
497                code: None,
498                message: String::new(),
499            })
500            .without_error_body());
501        }
502        Err(error) => return Err(error),
503    };
504    flow_response(&flow, &ctx.config)
505}
506
507fn flow_response(
508    flow: &super::handlers::InitiatedOAuthFlow,
509    config: &alibi_core::AuthConfig,
510) -> AuthResult<EndpointResponse> {
511    let mut response = EndpointResponse::json(&flow.response)?;
512    if flow.response.redirect
513        && let Some(url) = &flow.response.url
514    {
515        response = response.with_header("location", url);
516    }
517    let cookie = match config.account.store_state_strategy {
518        alibi_core::OAuthStateStrategy::Automatic => {
519            return Err(AuthError::config(
520                "OAuth state strategy has not been initialized",
521            ));
522        }
523        alibi_core::OAuthStateStrategy::Database => {
524            super::handlers::state_cookie_header(config, config.current_secret(), &flow.state)?
525        }
526        alibi_core::OAuthStateStrategy::Cookie => {
527            super::handlers::cookie_state_header(config, &flow.payload)?
528        }
529    };
530    Ok(response.with_header("set-cookie", cookie))
531}
532
533fn validate_callback(call: &EndpointCall) -> AuthResult<EndpointInput> {
534    let fields = [
535        "code",
536        "error",
537        "device_id",
538        "error_description",
539        "state",
540        "user",
541        "iss",
542    ]
543    .map(|name| crate::authentication_helpers::JsonField::string(name, false));
544    let parse = |value: Option<&JsValue>, location: &str| {
545        value
546            .map(|value| crate::endpoint::validate_fields(Some(value), location, &fields))
547            .transpose()
548    };
549    Ok(EndpointInput {
550        body: parse(call.body(), "body")?,
551        query: parse(call.query(), "query")?,
552    })
553}
554impl OAuthPlugin {
555    /// Redeem a provider callback through installed server hooks and genuine logical input.
556    /// Redirects remain API errors with their Location and complete cookie headers.
557    pub fn callback_oauth_endpoint(
558        provider: &str,
559        query: Option<&super::OAuthCallbackInput>,
560    ) -> AuthResult<ServerEndpoint<JsValue>> {
561        let endpoint = ServerEndpoint::new("oauth", "callbackOAuth")
562            .with_params(&serde_json::json!({"id":provider}))?;
563        match query {
564            Some(query) => endpoint.with_query(query),
565            None => Ok(endpoint),
566        }
567    }
568    pub(super) async fn call_callback_endpoint(
569        &self,
570        call: &EndpointCall,
571        ctx: &AuthContext<impl AuthSchema>,
572    ) -> AuthResult<EndpointResponse> {
573        let invalid =
574            || super::handlers::callback_failure_redirect(ctx, "invalid_callback_request");
575        let method = call.method().unwrap_or(&HttpMethod::Get);
576        let response = if !matches!(method, HttpMethod::Get | HttpMethod::Post)
577            || (method == &HttpMethod::Get && call.query().is_none())
578        {
579            invalid()
580        } else {
581            let provider = call
582                .params()
583                .and_then(|params| params.get("id"))
584                .map(JsValue::coerce_string)
585                .transpose()
586                .map_err(AuthError::internal)?
587                .ok_or_else(|| AuthError::internal("Missing OAuth provider parameters"))?;
588            let map =
589                |value: Option<&JsValue>| -> AuthResult<std::collections::HashMap<String, String>> {
590                    value
591                        .cloned()
592                        .map_or_else(
593                            || Ok(std::collections::HashMap::new()),
594                            alibi_core::utils::json::from_value,
595                        )
596                        .map_err(AuthError::from)
597                };
598            let query = map(call.query())?;
599            if method == &HttpMethod::Post {
600                let mut data = map(call.body())?;
601                data.extend(query);
602                super::handlers::callback_post_response(ctx, &provider, &data)
603            } else {
604                Box::pin(super::handlers::callback_core(
605                    &self.config,
606                    &provider,
607                    &query,
608                    call,
609                    ctx,
610                ))
611                .await?
612            }
613        };
614        let mut output = if response.status == 302 {
615            EndpointResponse::redirect(
616                response
617                    .headers
618                    .get("location")
619                    .cloned()
620                    .unwrap_or_default(),
621            )
622        } else {
623            EndpointResponse::error(AuthError::Api {
624                status: response.status,
625                code: None,
626                message: String::new(),
627            })
628            .without_error_body()
629        };
630        for (name, value) in response.headers {
631            // A redirect API error carries endpoint headers; HTTP conversion
632            // adds the content type of its absent body at the outer boundary.
633            if !name.eq_ignore_ascii_case("content-type") {
634                output = output.with_header(name, value);
635            }
636        }
637        Ok(output)
638    }
639}