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#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct UnlinkAccountOutput {
11 pub status: bool,
12}
13
14pub 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 #[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 #[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}