Skip to main content

max / makenotwork

4.0 KB · 154 lines History Blame Raw
1 //! Passkey / WebAuthn credential queries.
2
3 use sqlx::PgPool;
4
5 use super::{PasskeyId, UserId};
6 use crate::error::Result;
7
8 /// A stored passkey row for listing in the dashboard.
9 #[derive(sqlx::FromRow)]
10 pub struct DbPasskey {
11 pub id: PasskeyId,
12 pub name: String,
13 pub created_at: chrono::DateTime<chrono::Utc>,
14 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
15 }
16
17 /// Store a new passkey credential.
18 #[tracing::instrument(skip_all)]
19 pub async fn create_passkey(
20 pool: &PgPool,
21 user_id: UserId,
22 name: &str,
23 credential_json: &serde_json::Value,
24 credential_id: &[u8],
25 ) -> Result<PasskeyId> {
26 let id: PasskeyId = sqlx::query_scalar(
27 r#"
28 INSERT INTO user_passkeys (user_id, name, credential_json, credential_id)
29 VALUES ($1, $2, $3, $4)
30 RETURNING id
31 "#,
32 )
33 .bind(user_id)
34 .bind(name)
35 .bind(credential_json)
36 .bind(credential_id)
37 .fetch_one(pool)
38 .await?;
39
40 Ok(id)
41 }
42
43 /// List passkeys for a user (dashboard display).
44 #[tracing::instrument(skip_all)]
45 pub async fn list_passkeys(pool: &PgPool, user_id: UserId) -> Result<Vec<DbPasskey>> {
46 let rows: Vec<DbPasskey> = sqlx::query_as(
47 "SELECT id, name, created_at, last_used_at FROM user_passkeys WHERE user_id = $1 ORDER BY created_at LIMIT 100",
48 )
49 .bind(user_id)
50 .fetch_all(pool)
51 .await?;
52
53 Ok(rows)
54 }
55
56 /// Get all credential JSONs for a user (used as exclusion list during registration).
57 #[tracing::instrument(skip_all)]
58 pub async fn get_passkey_credentials(
59 pool: &PgPool,
60 user_id: UserId,
61 ) -> Result<Vec<serde_json::Value>> {
62 let rows: Vec<serde_json::Value> =
63 sqlx::query_scalar("SELECT credential_json FROM user_passkeys WHERE user_id = $1")
64 .bind(user_id)
65 .fetch_all(pool)
66 .await?;
67
68 Ok(rows)
69 }
70
71 /// Find which user owns a credential ID (for discoverable login).
72 #[tracing::instrument(skip_all)]
73 pub async fn find_user_by_credential_id(
74 pool: &PgPool,
75 credential_id: &[u8],
76 ) -> Result<Option<(UserId, serde_json::Value)>> {
77 let row: Option<(UserId, serde_json::Value)> = sqlx::query_as(
78 "SELECT user_id, credential_json FROM user_passkeys WHERE credential_id = $1",
79 )
80 .bind(credential_id)
81 .fetch_optional(pool)
82 .await?;
83
84 Ok(row)
85 }
86
87 /// Update a passkey credential after successful authentication (bump counter + last_used_at).
88 #[tracing::instrument(skip_all)]
89 pub async fn update_passkey_after_auth(
90 pool: &PgPool,
91 credential_id: &[u8],
92 updated_json: &serde_json::Value,
93 ) -> Result<()> {
94 sqlx::query(
95 "UPDATE user_passkeys SET credential_json = $2, last_used_at = NOW() WHERE credential_id = $1",
96 )
97 .bind(credential_id)
98 .bind(updated_json)
99 .execute(pool)
100 .await?;
101
102 Ok(())
103 }
104
105 /// Rename a passkey (returns false if not found or not owned by user).
106 #[tracing::instrument(skip_all)]
107 pub async fn rename_passkey(
108 pool: &PgPool,
109 passkey_id: PasskeyId,
110 user_id: UserId,
111 name: &str,
112 ) -> Result<bool> {
113 let result = sqlx::query(
114 "UPDATE user_passkeys SET name = $3 WHERE id = $1 AND user_id = $2",
115 )
116 .bind(passkey_id)
117 .bind(user_id)
118 .bind(name)
119 .execute(pool)
120 .await?;
121
122 Ok(result.rows_affected() > 0)
123 }
124
125 /// Delete a passkey (returns false if not found or not owned by user).
126 #[tracing::instrument(skip_all)]
127 pub async fn delete_passkey(
128 pool: &PgPool,
129 passkey_id: PasskeyId,
130 user_id: UserId,
131 ) -> Result<bool> {
132 let result = sqlx::query(
133 "DELETE FROM user_passkeys WHERE id = $1 AND user_id = $2",
134 )
135 .bind(passkey_id)
136 .bind(user_id)
137 .execute(pool)
138 .await?;
139
140 Ok(result.rows_affected() > 0)
141 }
142
143 /// Count passkeys for a user.
144 #[tracing::instrument(skip_all)]
145 pub async fn count_passkeys(pool: &PgPool, user_id: UserId) -> Result<i64> {
146 let count: i64 =
147 sqlx::query_scalar("SELECT COUNT(*) FROM user_passkeys WHERE user_id = $1")
148 .bind(user_id)
149 .fetch_one(pool)
150 .await?;
151
152 Ok(count)
153 }
154