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