Skip to main content

alibi_plugins/
account_management.rs

1use super::authentication_helpers::{JsonField, RequestBody, parse_body};
2use alibi_core::entity::{AuthAccount, AuthUser};
3use alibi_core::{AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult};
4use serde::{Deserialize, Serialize};
5use validator::Validate;
6
7/// Successful deletion of the authenticated caller's selected linked account.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct UnlinkAccountOutput {
10    pub status: bool,
11}
12
13/// Account management plugin for listing and unlinking user accounts.
14pub struct AccountManagementPlugin {
15    config: AccountManagementConfig,
16}
17
18#[derive(Debug, Clone, alibi_core::PluginConfig)]
19#[plugin(name = "AccountManagementPlugin")]
20pub struct AccountManagementConfig {
21    #[config(default = true)]
22    pub require_authentication: bool,
23}
24
25#[derive(Debug, Deserialize, Validate)]
26struct UnlinkAccountRequest {
27    #[serde(rename = "accountId")]
28    account_id: String,
29}
30
31impl RequestBody for UnlinkAccountRequest {
32    const FIELDS: &'static [JsonField] = &[JsonField::string("accountId", true)];
33}
34
35alibi_core::impl_auth_plugin! {
36    AccountManagementPlugin, "account-management";
37    routes {
38        get "/list-accounts" => handle_list_accounts, "list_accounts";
39        post "/unlink-account" => handle_unlink_account, "unlink_account";
40    }
41
42 extra {
43    route_openapi_metadata!(S);
44
45    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
46        vec![
47            crate::endpoint::definition("listUserAccounts", "listUserAccounts", Some("/list-accounts"), alibi_core::HttpMethod::Get),
48            crate::endpoint::definition("unlinkAccount", "unlinkAccount", Some("/unlink-account"), alibi_core::HttpMethod::Post),
49        ]
50    }
51
52    fn validate_endpoint(
53        &self,
54        call: &alibi_core::endpoint::EndpointCall,
55        _ctx: &AuthContext<S>,
56    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
57        let body = if call.operation_id() == "unlinkAccount" {
58            Some(crate::endpoint::validate_fields(call.body(), "body", UnlinkAccountRequest::FIELDS)?)
59        } else {
60            call.body().cloned()
61        };
62        Ok(alibi_core::endpoint::EndpointInput { body, query: call.query().cloned() })
63    }
64
65    async fn on_endpoint(
66        &self,
67        call: &alibi_core::endpoint::EndpointCall,
68        ctx: &AuthContext<S>,
69    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
70        if call.operation_id() == "unlinkAccount" {
71            let body: UnlinkAccountRequest = call.body_as()?;
72            let user = unlink_session(call, ctx).await?;
73            return alibi_core::endpoint::EndpointResponse::json(&unlink_account_core(&user, &body.account_id, ctx).await?);
74        }
75        let (user, session) = ctx.require_cached_session(call).await.map_err(|error| {
76            if matches!(error, AuthError::Unauthenticated | AuthError::SessionNotFound) {
77                super::organization::handlers::extension_common::org_error(401, "UNAUTHORIZED")
78            } else {
79                error
80            }
81        })?;
82        call.record_authenticated_session(match &user {
83            alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
84            alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
85        }, session);
86        alibi_core::endpoint::EndpointResponse::json(&list_accounts_core(&user, ctx).await?)
87    }
88 }
89}
90
91impl AccountManagementPlugin {
92    /// List the authenticated caller's linked accounts through logical endpoint hooks.
93    ///
94    /// The result retains configured account output fields and parsed scopes, while
95    /// omitting credentials. Supply incoming headers using `EndpointOptions`.
96    #[must_use]
97    pub const fn list_accounts_endpoint()
98    -> alibi_core::endpoint::ServerEndpoint<Vec<serde_json::Map<String, serde_json::Value>>> {
99        alibi_core::endpoint::ServerEndpoint::new("account-management", "listUserAccounts")
100    }
101
102    /// Unlink a selected account using logical incoming headers and installed endpoint hooks.
103    #[must_use]
104    pub fn unlink_account_endpoint(
105        account_id: impl Into<String>,
106    ) -> alibi_core::endpoint::ServerEndpoint<UnlinkAccountOutput> {
107        alibi_core::endpoint::ServerEndpoint::new("account-management", "unlinkAccount")
108            .with_body_value(alibi_core::utils::json::JsValue::Object(
109                [(
110                    "accountId".into(),
111                    alibi_core::utils::json::JsValue::String(account_id.into()),
112                )]
113                .into_iter()
114                .collect(),
115            ))
116    }
117
118    async fn handle_list_accounts(
119        &self,
120        req: &AuthRequest,
121        ctx: &AuthContext<impl alibi_core::AuthSchema>,
122    ) -> AuthResult<AuthResponse> {
123        let (user, _session) =
124            super::organization::handlers::extension_common::session(req, ctx).await?;
125        let filtered = list_accounts_core(&user, ctx).await?;
126        Ok(AuthResponse::json(200, &filtered)?)
127    }
128
129    async fn handle_unlink_account(
130        &self,
131        req: &AuthRequest,
132        ctx: &AuthContext<impl alibi_core::AuthSchema>,
133    ) -> AuthResult<AuthResponse> {
134        let unlink_req: UnlinkAccountRequest = match parse_body(req) {
135            Ok(v) => v,
136            Err(resp) => return Ok(resp),
137        };
138
139        let user = unlink_session(req, ctx).await?;
140        let response = unlink_account_core(&user, &unlink_req.account_id, ctx).await?;
141        Ok(AuthResponse::json(200, &response)?)
142    }
143}
144
145impl std::fmt::Debug for AccountManagementPlugin {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct("AccountManagementPlugin")
148            .finish_non_exhaustive()
149    }
150}
151
152pub(crate) async fn list_accounts_core(
153    user: &impl AuthUser,
154    ctx: &AuthContext<impl alibi_core::AuthSchema>,
155) -> AuthResult<Vec<serde_json::Map<String, serde_json::Value>>> {
156    let accounts = ctx.database.get_user_accounts_record(&user.id()).await?;
157    accounts
158        .iter()
159        .map(|account| {
160            let mut output = ctx.account_view(account)?;
161            let scopes = match output.remove("scope") {
162                None | Some(serde_json::Value::Null) => Vec::new(),
163                Some(serde_json::Value::String(scope)) => scope
164                    .split(',')
165                    .map(str::trim)
166                    .filter(|scope| !scope.is_empty())
167                    .map(str::to_owned)
168                    .collect(),
169                Some(_) => return Err(AuthError::internal("Account scope must be a string")),
170            };
171            _ = output.insert("scopes".into(), serde_json::to_value(scopes)?);
172            Ok(output)
173        })
174        .collect()
175}
176
177async fn unlink_session<S: alibi_core::AuthSchema>(
178    request: &impl alibi_core::session::SessionRequest,
179    ctx: &AuthContext<S>,
180) -> AuthResult<alibi_core::AuthenticatedUser<S>> {
181    let (user, session) = ctx.require_cached_session(request).await.map_err(|error| {
182        if matches!(
183            error,
184            AuthError::Unauthenticated | AuthError::SessionNotFound
185        ) {
186            super::organization::handlers::extension_common::org_error(401, "UNAUTHORIZED")
187        } else {
188            error
189        }
190    })?;
191    if let Some(call) = request.endpoint_call() {
192        call.record_authenticated_session(
193            match &user {
194                alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
195                alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
196            },
197            session.clone(),
198        );
199    }
200    if !ctx.session_manager().is_session_fresh(&session) {
201        return Err(AuthError::Upstream {
202            status: 403,
203            code: "SESSION_NOT_FRESH",
204            message: "Session is not fresh",
205        });
206    }
207    Ok(user)
208}
209
210pub(crate) async fn unlink_account_core(
211    user: &impl AuthUser,
212    account_id: &str,
213    ctx: &AuthContext<impl alibi_core::AuthSchema>,
214) -> AuthResult<UnlinkAccountOutput> {
215    let accounts = ctx.database.get_user_accounts(&user.id()).await?;
216    if accounts.len() == 1 && !ctx.config.account.account_linking.allow_unlinking_all {
217        return Err(AuthError::bad_request("You can't unlink your last account"));
218    }
219    let account = accounts
220        .iter()
221        .find(|account| account.id() == account_id)
222        .ok_or_else(|| AuthError::bad_request("Account not found"))?;
223    ctx.database.delete_account(&account.id()).await?;
224    Ok(UnlinkAccountOutput { status: true })
225}