Skip to main content

better_auth_api/plugins/
account_management.rs

1use serde::{Deserialize, Serialize};
2use validator::Validate;
3
4use better_auth_core::entity::{AuthAccount, AuthUser};
5use better_auth_core::{AuthContext, AuthError, AuthResult};
6use better_auth_core::{AuthRequest, AuthResponse};
7
8use crate::plugins::helpers::user_has_password;
9
10use super::StatusResponse;
11
12/// Account management plugin for listing and unlinking user accounts.
13pub struct AccountManagementPlugin {
14    config: AccountManagementConfig,
15}
16
17#[derive(Debug, Clone, better_auth_core::PluginConfig)]
18#[plugin(name = "AccountManagementPlugin")]
19pub struct AccountManagementConfig {
20    #[config(default = true)]
21    pub require_authentication: bool,
22}
23
24#[derive(Debug, Deserialize, Validate)]
25struct UnlinkAccountRequest {
26    #[serde(rename = "providerId")]
27    #[validate(length(min = 1, message = "Provider ID is required"))]
28    provider_id: String,
29    #[serde(rename = "accountId")]
30    account_id: Option<String>,
31}
32
33#[derive(Debug, Serialize)]
34pub(crate) struct AccountResponse {
35    id: String,
36    #[serde(rename = "accountId")]
37    account_id: String,
38    #[serde(rename = "providerId")]
39    provider_id: String,
40    #[serde(rename = "userId")]
41    user_id: String,
42    #[serde(rename = "createdAt")]
43    created_at: String,
44    #[serde(rename = "updatedAt")]
45    updated_at: String,
46    scopes: Vec<String>,
47}
48
49better_auth_core::impl_auth_plugin! {
50    AccountManagementPlugin, "account-management";
51    routes {
52        get "/list-accounts" => handle_list_accounts, "list_accounts";
53        post "/unlink-account" => handle_unlink_account, "unlink_account";
54    }
55}
56
57// ---------------------------------------------------------------------------
58// Core functions — framework-agnostic business logic
59// ---------------------------------------------------------------------------
60
61pub(crate) async fn list_accounts_core(
62    user: &impl AuthUser,
63    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
64) -> AuthResult<Vec<AccountResponse>> {
65    let accounts = ctx.database.get_user_accounts(&user.id()).await?;
66
67    let filtered: Vec<AccountResponse> = accounts
68        .iter()
69        .map(|acc| AccountResponse {
70            id: acc.id().to_string(),
71            account_id: acc.account_id().to_string(),
72            provider_id: acc.provider_id().to_string(),
73            user_id: acc.user_id().to_string(),
74            created_at: acc.created_at().to_rfc3339(),
75            updated_at: acc.updated_at().to_rfc3339(),
76            scopes: acc
77                .scope()
78                .map(|s| {
79                    s.split([' ', ','])
80                        .filter(|s| !s.is_empty())
81                        .map(|s| s.to_string())
82                        .collect()
83                })
84                .unwrap_or_default(),
85        })
86        .collect::<Vec<_>>();
87
88    let mut filtered = filtered;
89    filtered.sort_by(|left, right| left.created_at.cmp(&right.created_at));
90
91    Ok(filtered)
92}
93
94pub(crate) async fn unlink_account_core(
95    user: &impl AuthUser,
96    provider_id: &str,
97    account_id: Option<&str>,
98    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
99) -> AuthResult<StatusResponse> {
100    let accounts = ctx.database.get_user_accounts(&user.id()).await?;
101
102    let allow_unlinking_all = ctx.config.account.account_linking.allow_unlinking_all;
103
104    // Check if user has a password (credential provider)
105    let has_password = user_has_password(ctx, user).await?;
106
107    // Count remaining credentials after unlinking
108    let remaining_accounts = accounts
109        .iter()
110        .filter(|acc| {
111            if acc.provider_id() != provider_id {
112                return true;
113            }
114            match account_id {
115                Some(account_id) => acc.account_id() != account_id,
116                None => false,
117            }
118        })
119        .count();
120
121    // Prevent unlinking the last credential (unless allow_unlinking_all is true)
122    if !allow_unlinking_all && !has_password && remaining_accounts == 0 {
123        return Err(AuthError::bad_request(
124            "Cannot unlink the last account. You must have at least one authentication method.",
125        ));
126    }
127
128    // Find and delete the account
129    let account_to_remove = accounts
130        .iter()
131        .find(|acc| {
132            if acc.provider_id() != provider_id {
133                return false;
134            }
135            match account_id {
136                Some(account_id) => acc.account_id() == account_id,
137                None => true,
138            }
139        })
140        .ok_or_else(|| AuthError::not_found("No account found with this provider"))?;
141
142    ctx.database.delete_account(&account_to_remove.id()).await?;
143
144    Ok(StatusResponse { status: true })
145}
146
147// ---------------------------------------------------------------------------
148// Old handler methods — delegate to core functions
149// ---------------------------------------------------------------------------
150
151impl AccountManagementPlugin {
152    async fn handle_list_accounts(
153        &self,
154        req: &AuthRequest,
155        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
156    ) -> AuthResult<AuthResponse> {
157        let (user, _session) = ctx.require_session(req).await?;
158        let filtered = list_accounts_core(&user, ctx).await?;
159        Ok(AuthResponse::json(200, &filtered)?)
160    }
161
162    async fn handle_unlink_account(
163        &self,
164        req: &AuthRequest,
165        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
166    ) -> AuthResult<AuthResponse> {
167        let (user, _session) = ctx.require_session(req).await?;
168
169        let unlink_req: UnlinkAccountRequest = match better_auth_core::validate_request_body(req) {
170            Ok(v) => v,
171            Err(resp) => return Ok(resp),
172        };
173
174        let response = unlink_account_core(
175            &user,
176            &unlink_req.provider_id,
177            unlink_req.account_id.as_deref(),
178            ctx,
179        )
180        .await?;
181        Ok(AuthResponse::json(200, &response)?)
182    }
183}