Skip to main content

better_auth_api/plugins/passkey/
mod.rs

1use better_auth_core::adapters::DatabaseAdapter;
2use better_auth_core::{AuthContext, AuthError, AuthResult};
3use better_auth_core::{AuthRequest, AuthResponse};
4
5use better_auth_core::utils::cookie_utils::create_session_cookie;
6
7pub(super) mod handlers;
8pub(super) mod types;
9
10#[cfg(test)]
11mod tests;
12
13use handlers::*;
14use types::*;
15
16/// Passkey / WebAuthn authentication plugin.
17///
18/// Generates WebAuthn-compatible registration and authentication options,
19/// stores challenge state via `VerificationOps`, and manages passkey CRUD.
20///
21/// **WARNING: Simplified WebAuthn mode.**
22/// This implementation does NOT perform full FIDO2 signature verification
23/// (rpId, origin, authenticatorData, signature). It trusts the client-side
24/// WebAuthn response after verifying the challenge round-trip. For production
25/// use, integrate `webauthn-rs` or another FIDO2 library for full attestation
26/// and assertion verification.
27pub struct PasskeyPlugin {
28    config: PasskeyConfig,
29}
30
31#[derive(Debug, Clone, better_auth_core::PluginConfig)]
32#[plugin(name = "PasskeyPlugin")]
33pub struct PasskeyConfig {
34    #[config(default = "localhost".to_string())]
35    pub rp_id: String,
36    #[config(default = "Better Auth".to_string())]
37    pub rp_name: String,
38    #[config(default = "http://localhost:3000".to_string())]
39    pub origin: String,
40    #[config(default = 300)]
41    pub challenge_ttl_secs: i64,
42    /// Allows simplified (non-cryptographic) response verification.
43    ///
44    /// Keep disabled in production. This exists only for local development
45    /// until full WebAuthn validation is integrated.
46    #[config(default = false)]
47    pub allow_insecure_unverified_assertion: bool,
48}
49
50// -- Plugin --
51
52impl PasskeyPlugin {
53    // -- Handlers (delegate to core functions) --
54
55    /// GET /passkey/generate-register-options
56    async fn handle_generate_register_options<DB: DatabaseAdapter>(
57        &self,
58        req: &AuthRequest,
59        ctx: &AuthContext<DB>,
60    ) -> AuthResult<AuthResponse> {
61        let (user, _session) = ctx.require_session(req).await?;
62        let authenticator_attachment = req.query.get("authenticatorAttachment").map(|s| s.as_str());
63        let result =
64            generate_register_options_core(&user, authenticator_attachment, &self.config, ctx)
65                .await?;
66        AuthResponse::json(200, &result).map_err(AuthError::from)
67    }
68
69    /// POST /passkey/verify-registration
70    async fn handle_verify_registration<DB: DatabaseAdapter>(
71        &self,
72        req: &AuthRequest,
73        ctx: &AuthContext<DB>,
74    ) -> AuthResult<AuthResponse> {
75        let (user, _session) = ctx.require_session(req).await?;
76        let body: VerifyRegistrationRequest = match better_auth_core::validate_request_body(req) {
77            Ok(v) => v,
78            Err(resp) => return Ok(resp),
79        };
80        let result = verify_registration_core(&body, &user, &self.config, ctx).await?;
81        AuthResponse::json(200, &result).map_err(AuthError::from)
82    }
83
84    /// POST /passkey/generate-authenticate-options
85    async fn handle_generate_authenticate_options<DB: DatabaseAdapter>(
86        &self,
87        req: &AuthRequest,
88        ctx: &AuthContext<DB>,
89    ) -> AuthResult<AuthResponse> {
90        let maybe_user = ctx.require_session(req).await.ok().map(|(u, _)| u);
91        let result =
92            generate_authenticate_options_core(maybe_user.as_ref(), &self.config, ctx).await?;
93        AuthResponse::json(200, &result).map_err(AuthError::from)
94    }
95
96    /// POST /passkey/verify-authentication
97    async fn handle_verify_authentication<DB: DatabaseAdapter>(
98        &self,
99        req: &AuthRequest,
100        ctx: &AuthContext<DB>,
101    ) -> AuthResult<AuthResponse> {
102        let body: VerifyAuthenticationRequest = match better_auth_core::validate_request_body(req) {
103            Ok(v) => v,
104            Err(resp) => return Ok(resp),
105        };
106        let ip_address = req.headers.get("x-forwarded-for").cloned();
107        let user_agent = req.headers.get("user-agent").cloned();
108        let (response, token) =
109            verify_authentication_core(&body, &self.config, ip_address, user_agent, ctx).await?;
110        let cookie_header = create_session_cookie(&token, &ctx.config);
111        Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
112    }
113
114    /// GET /passkey/list-user-passkeys
115    async fn handle_list_user_passkeys<DB: DatabaseAdapter>(
116        &self,
117        req: &AuthRequest,
118        ctx: &AuthContext<DB>,
119    ) -> AuthResult<AuthResponse> {
120        let (user, _session) = ctx.require_session(req).await?;
121        let result = list_user_passkeys_core(&user, ctx).await?;
122        AuthResponse::json(200, &result).map_err(AuthError::from)
123    }
124
125    /// POST /passkey/delete-passkey
126    async fn handle_delete_passkey<DB: DatabaseAdapter>(
127        &self,
128        req: &AuthRequest,
129        ctx: &AuthContext<DB>,
130    ) -> AuthResult<AuthResponse> {
131        let (user, _session) = ctx.require_session(req).await?;
132        let body: DeletePasskeyRequest = match better_auth_core::validate_request_body(req) {
133            Ok(v) => v,
134            Err(resp) => return Ok(resp),
135        };
136        let result = delete_passkey_core(&body, &user, ctx).await?;
137        AuthResponse::json(200, &result).map_err(AuthError::from)
138    }
139
140    /// POST /passkey/update-passkey
141    async fn handle_update_passkey<DB: DatabaseAdapter>(
142        &self,
143        req: &AuthRequest,
144        ctx: &AuthContext<DB>,
145    ) -> AuthResult<AuthResponse> {
146        let (user, _session) = ctx.require_session(req).await?;
147        let body: UpdatePasskeyRequest = match better_auth_core::validate_request_body(req) {
148            Ok(v) => v,
149            Err(resp) => return Ok(resp),
150        };
151        let result = update_passkey_core(&body, &user, ctx).await?;
152        AuthResponse::json(200, &result).map_err(AuthError::from)
153    }
154}
155
156better_auth_core::impl_auth_plugin! {
157    PasskeyPlugin, "passkey";
158    routes {
159        get  "/passkey/generate-register-options"      => handle_generate_register_options,      "passkey_generate_register_options";
160        post "/passkey/verify-registration"            => handle_verify_registration,            "passkey_verify_registration";
161        post "/passkey/generate-authenticate-options"  => handle_generate_authenticate_options,  "passkey_generate_authenticate_options";
162        post "/passkey/verify-authentication"          => handle_verify_authentication,          "passkey_verify_authentication";
163        get  "/passkey/list-user-passkeys"             => handle_list_user_passkeys,             "passkey_list_user_passkeys";
164        post "/passkey/delete-passkey"                 => handle_delete_passkey,                 "passkey_delete_passkey";
165        post "/passkey/update-passkey"                 => handle_update_passkey,                 "passkey_update_passkey";
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Axum integration
171// ---------------------------------------------------------------------------
172
173#[cfg(feature = "axum")]
174mod axum_impl {
175    use super::*;
176    use std::sync::Arc;
177
178    use axum::Json;
179    use axum::extract::{Extension, Query, State};
180    use axum::http::HeaderMap;
181    use axum::http::header;
182    use axum::response::IntoResponse;
183    use better_auth_core::error::AuthError;
184    use better_auth_core::extractors::{CurrentSession, OptionalSession, ValidatedJson};
185    use better_auth_core::plugin::AuthState;
186
187    #[derive(Clone)]
188    struct PluginState {
189        config: PasskeyConfig,
190    }
191
192    async fn handle_generate_register_options<DB: DatabaseAdapter>(
193        State(state): State<AuthState<DB>>,
194        Extension(ps): Extension<Arc<PluginState>>,
195        CurrentSession { user, .. }: CurrentSession<DB>,
196        Query(params): Query<RegisterOptionsQuery>,
197    ) -> Result<Json<serde_json::Value>, AuthError> {
198        let ctx = state.to_context();
199        let result = generate_register_options_core(
200            &user,
201            params.authenticator_attachment.as_deref(),
202            &ps.config,
203            &ctx,
204        )
205        .await?;
206        Ok(Json(result))
207    }
208
209    async fn handle_verify_registration<DB: DatabaseAdapter>(
210        State(state): State<AuthState<DB>>,
211        Extension(ps): Extension<Arc<PluginState>>,
212        CurrentSession { user, .. }: CurrentSession<DB>,
213        ValidatedJson(body): ValidatedJson<VerifyRegistrationRequest>,
214    ) -> Result<Json<PasskeyView>, AuthError> {
215        let ctx = state.to_context();
216        let result = verify_registration_core(&body, &user, &ps.config, &ctx).await?;
217        Ok(Json(result))
218    }
219
220    async fn handle_generate_authenticate_options<DB: DatabaseAdapter>(
221        State(state): State<AuthState<DB>>,
222        Extension(ps): Extension<Arc<PluginState>>,
223        OptionalSession(maybe): OptionalSession<DB>,
224    ) -> Result<Json<serde_json::Value>, AuthError> {
225        let ctx = state.to_context();
226        let maybe_user = maybe.as_ref().map(|s| &s.user);
227        let result = generate_authenticate_options_core(maybe_user, &ps.config, &ctx).await?;
228        Ok(Json(result))
229    }
230
231    async fn handle_verify_authentication<DB: DatabaseAdapter>(
232        State(state): State<AuthState<DB>>,
233        Extension(ps): Extension<Arc<PluginState>>,
234        headers: HeaderMap,
235        ValidatedJson(body): ValidatedJson<VerifyAuthenticationRequest>,
236    ) -> Result<impl IntoResponse, AuthError> {
237        let ip = headers
238            .get("x-forwarded-for")
239            .and_then(|v| v.to_str().ok())
240            .map(String::from);
241        let ua = headers
242            .get("user-agent")
243            .and_then(|v| v.to_str().ok())
244            .map(String::from);
245        let ctx = state.to_context();
246        let (response, token) = verify_authentication_core(&body, &ps.config, ip, ua, &ctx).await?;
247        let cookie = state.session_cookie(&token);
248        Ok(([(header::SET_COOKIE, cookie)], Json(response)))
249    }
250
251    async fn handle_list_user_passkeys<DB: DatabaseAdapter>(
252        State(state): State<AuthState<DB>>,
253        CurrentSession { user, .. }: CurrentSession<DB>,
254    ) -> Result<Json<Vec<PasskeyView>>, AuthError> {
255        let ctx = state.to_context();
256        let result = list_user_passkeys_core(&user, &ctx).await?;
257        Ok(Json(result))
258    }
259
260    async fn handle_delete_passkey<DB: DatabaseAdapter>(
261        State(state): State<AuthState<DB>>,
262        CurrentSession { user, .. }: CurrentSession<DB>,
263        ValidatedJson(body): ValidatedJson<DeletePasskeyRequest>,
264    ) -> Result<Json<crate::plugins::StatusResponse>, AuthError> {
265        let ctx = state.to_context();
266        let result = delete_passkey_core(&body, &user, &ctx).await?;
267        Ok(Json(result))
268    }
269
270    async fn handle_update_passkey<DB: DatabaseAdapter>(
271        State(state): State<AuthState<DB>>,
272        CurrentSession { user, .. }: CurrentSession<DB>,
273        ValidatedJson(body): ValidatedJson<UpdatePasskeyRequest>,
274    ) -> Result<Json<PasskeyResponse>, AuthError> {
275        let ctx = state.to_context();
276        let result = update_passkey_core(&body, &user, &ctx).await?;
277        Ok(Json(result))
278    }
279
280    impl<DB: DatabaseAdapter> better_auth_core::AxumPlugin<DB> for PasskeyPlugin {
281        fn name(&self) -> &'static str {
282            "passkey"
283        }
284
285        fn router(&self) -> axum::Router<AuthState<DB>> {
286            use axum::routing::{get, post};
287
288            let plugin_state = Arc::new(PluginState {
289                config: self.config.clone(),
290            });
291            axum::Router::new()
292                .route(
293                    "/passkey/generate-register-options",
294                    get(handle_generate_register_options::<DB>),
295                )
296                .route(
297                    "/passkey/verify-registration",
298                    post(handle_verify_registration::<DB>),
299                )
300                .route(
301                    "/passkey/generate-authenticate-options",
302                    post(handle_generate_authenticate_options::<DB>),
303                )
304                .route(
305                    "/passkey/verify-authentication",
306                    post(handle_verify_authentication::<DB>),
307                )
308                .route(
309                    "/passkey/list-user-passkeys",
310                    get(handle_list_user_passkeys::<DB>),
311                )
312                .route("/passkey/delete-passkey", post(handle_delete_passkey::<DB>))
313                .route("/passkey/update-passkey", post(handle_update_passkey::<DB>))
314                .layer(Extension(plugin_state))
315        }
316    }
317}