Skip to main content

better_auth_api/plugins/
account_management.rs

1use serde::Deserialize;
2
3use better_auth_core::entity::AuthUser;
4use better_auth_core::{AuthContext, AuthError, AuthResult};
5use better_auth_core::{AuthRequest, AuthResponse};
6
7use super::StatusResponse;
8
9/// Account management plugin for listing and unlinking user accounts.
10pub struct AccountManagementPlugin {
11    config: AccountManagementConfig,
12}
13
14#[derive(Debug, Clone, better_auth_core::PluginConfig)]
15#[plugin(name = "AccountManagementPlugin")]
16pub struct AccountManagementConfig {
17    #[config(default = true)]
18    pub require_authentication: bool,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22struct UnlinkAccountRequest {
23    #[serde(rename = "accountId")]
24    account_id: String,
25}
26
27fn unlink_account_body(
28    req: &AuthRequest,
29) -> AuthResult<better_auth_core::endpoint_input::ValidatedBody> {
30    let (typed, projection) =
31        super::json_body::string_input::<UnlinkAccountRequest>(req, &[("accountId", true)])?;
32    Ok(better_auth_core::endpoint_input::ValidatedBody::new(
33        Some(projection),
34        typed,
35    ))
36}
37
38pub(crate) type AccountResponse = better_auth_core::FieldMap;
39
40better_auth_core::impl_auth_plugin! {
41    AccountManagementPlugin, "account-management";
42    routes {
43        get "/list-accounts" => handle_list_accounts, "listUserAccounts";
44        post "/unlink-account" => handle_unlink_account, "unlinkAccount", body = unlink_account_body;
45    }
46    extra {
47        fn telemetry_plugin_id(&self) -> Option<&'static str> {
48            None
49        }
50    }
51}
52
53// ---------------------------------------------------------------------------
54// Core functions — framework-agnostic business logic
55// ---------------------------------------------------------------------------
56
57pub(crate) async fn list_accounts_core(
58    user: &impl AuthUser,
59    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
60) -> AuthResult<Vec<AccountResponse>> {
61    let accounts = ctx.database.get_user_accounts(user.id().typed()?).await?;
62
63    accounts
64        .into_iter()
65        .map(|account| {
66            let mut fields = account.internal_fields()?;
67            ctx.config
68                .account
69                .field_schema()
70                .filter_returned_fields(&mut fields);
71            let scopes = match fields.remove("scope") {
72                Some(value) if better_auth_core::user_fields::is_truthy(&value) => {
73                    let scope = value.as_str().ok_or_else(|| {
74                        AuthError::internal("account.scope.split is not a function")
75                    })?;
76                    super::helpers::parse_stored_scopes(Some(scope))
77                }
78                _ => Vec::new(),
79            };
80            // The public account endpoint removes credentials even when a replacement schema enables returned.
81            for name in [
82                "accessToken",
83                "refreshToken",
84                "idToken",
85                "accessTokenExpiresAt",
86                "refreshTokenExpiresAt",
87                "password",
88                "scope",
89            ] {
90                let _ = fields.remove(name);
91            }
92            let _ = fields.insert(
93                "scopes".into(),
94                scopes
95                    .into_iter()
96                    .map(Into::into)
97                    .collect::<Vec<better_auth_core::FieldValue>>()
98                    .into(),
99            );
100            Ok(fields)
101        })
102        .collect()
103}
104
105pub(crate) async fn unlink_account_core(
106    user: &impl AuthUser,
107    account_id: &str,
108    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
109) -> AuthResult<StatusResponse> {
110    let accounts = ctx.database.get_user_accounts(user.id().typed()?).await?;
111    if accounts.len() == 1 && !ctx.config.account.account_linking.allow_unlinking_all() {
112        return Err(AuthError::bad_request("You can't unlink your last account"));
113    }
114    let account = accounts
115        .iter()
116        .find(|account| account.id == account_id)
117        .ok_or_else(|| AuthError::bad_request("Account not found"))?;
118    ctx.database.delete_account(account.id.typed()?).await?;
119    Ok(StatusResponse { status: true })
120}
121
122// ---------------------------------------------------------------------------
123// Old handler methods — delegate to core functions
124// ---------------------------------------------------------------------------
125
126impl AccountManagementPlugin {
127    async fn handle_list_accounts(
128        &self,
129        req: &AuthRequest,
130        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
131    ) -> AuthResult<AuthResponse> {
132        let (user, _session) = ctx.require_session(req).await?;
133        let filtered = list_accounts_core(&user, ctx).await?;
134        let response = filtered
135            .iter()
136            .map(better_auth_core::FieldMap::json)
137            .collect::<AuthResult<Vec<_>>>()?;
138        Ok(AuthResponse::json(200, &response)?)
139    }
140
141    async fn handle_unlink_account(
142        &self,
143        req: &AuthRequest,
144        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
145    ) -> AuthResult<AuthResponse> {
146        let (user, session) = ctx.require_session(req).await?;
147        if !crate::plugins::helpers::session_is_fresh(&session, &ctx.config) {
148            return Err(AuthError::Upstream {
149                status: 403,
150                code: "SESSION_NOT_FRESH",
151                message: "Session is not fresh",
152            });
153        }
154
155        let unlink_req: UnlinkAccountRequest = match req.validated_body::<UnlinkAccountRequest>() {
156            Some(body) => body.clone(),
157            None => super::json_body::string_input(req, &[("accountId", true)])?.0,
158        };
159
160        let response = unlink_account_core(&user, &unlink_req.account_id, ctx).await?;
161        Ok(AuthResponse::json(200, &response)?)
162    }
163}