pr_01m47d15m3e54sn21z27rpy5n9/services/identity/src/lib.rs

585 lines20,608 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

API and MCP server, Rust identity service, registration, site redesign1//! The identity service: accounts, sessions, SSH keys and access tokens.
2//!
3//! Reached only through service bindings; see `g1t_contracts::identity` for
4//! the methods and their arguments.
5
6mod crypto;
Device sign-in replaces registering and minting tokens over the API7mod device;
Email verification, password reset, and Git for AI scale positioning8mod email;
API and MCP server, Rust identity service, registration, site redesign9
10use g1t_contracts::identity::*;
RFC 3339 timestamps in identity and repos11use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
API and MCP server, Rust identity service, registration, site redesign12use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_namespace, new_id};
13use g1t_kit::{args, now_ms, reply, rpc_method};
14use serde::Deserialize;
15use worker::wasm_bindgen::JsValue;
16use worker::{Context, D1Database, Env, Request, Response, Result, event};
17
RFC 3339 timestamps in identity and repos18const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
19const VERIFY_TTL_SECONDS: u64 = 24 * 60 * 60;
20const RESET_TTL_SECONDS: u64 = 60 * 60;
API and MCP server, Rust identity service, registration, site redesign21const TOKEN_PREFIX: &str = "g1t_";
22const MIN_PASSWORD_LENGTH: usize = 10;
Email verification, password reset, and Git for AI scale positioning23const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
24
25/// A user as selected from the database; `verified` arrives as 0 or 1.
26#[derive(Deserialize)]
27struct Account {
28 id: String,
29 username: String,
30 verified: u8,
31}
32
33impl From<Account> for User {
34 fn from(row: Account) -> Self {
35 User {
36 id: row.id,
37 username: row.username,
38 verified: row.verified != 0,
39 }
40 }
41}
API and MCP server, Rust identity service, registration, site redesign42
43#[derive(Deserialize)]
44struct UserRow {
45 id: String,
46 username: String,
47 password_hash: String,
Email verification, password reset, and Git for AI scale positioning48 verified: u8,
API and MCP server, Rust identity service, registration, site redesign49}
50
Email verification, password reset, and Git for AI scale positioning51/// The owner of an emailed token.
API and MCP server, Rust identity service, registration, site redesign52#[derive(Deserialize)]
Email verification, password reset, and Git for AI scale positioning53struct TokenOwner {
54 id: String,
55 username: String,
56 email: Option<String>,
57}
58
59#[derive(Deserialize)]
API and MCP server, Rust identity service, registration, site redesign60struct KeyRow {
61 id: String,
62 title: String,
63 fingerprint: String,
RFC 3339 timestamps in identity and repos64 created_at: String,
API and MCP server, Rust identity service, registration, site redesign65}
66
67impl From<KeyRow> for SshKey {
68 fn from(row: KeyRow) -> Self {
69 SshKey {
70 id: row.id,
71 title: row.title,
72 fingerprint: row.fingerprint,
RFC 3339 timestamps in identity and repos73 created_at: row.created_at,
API and MCP server, Rust identity service, registration, site redesign74 }
75 }
76}
77
78#[derive(Deserialize)]
79struct TokenRow {
80 id: String,
81 name: String,
RFC 3339 timestamps in identity and repos82 created_at: String,
API and MCP server, Rust identity service, registration, site redesign83}
84
85impl From<TokenRow> for AccessToken {
86 fn from(row: TokenRow) -> Self {
87 AccessToken {
88 id: row.id,
89 name: row.name,
RFC 3339 timestamps in identity and repos90 created_at: row.created_at,
API and MCP server, Rust identity service, registration, site redesign91 }
92 }
93}
94
95struct Identity {
96 db: D1Database,
Email verification, password reset, and Git for AI scale positioning97 env: Env,
API and MCP server, Rust identity service, registration, site redesign98}
99
100impl Identity {
101 /// Runs a query that returns at most one user.
102 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
Email verification, password reset, and Git for AI scale positioning103 Ok(self
104 .db
API and MCP server, Rust identity service, registration, site redesign105 .prepare(sql)
106 .bind(&[JsValue::from(param)])?
Email verification, password reset, and Git for AI scale positioning107 .first::<Account>(None)
108 .await?
109 .map(User::from))
110 }
111
112 /// Stores a one-time token of `kind` for the user and returns it.
RFC 3339 timestamps in identity and repos113 async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u64) -> Result<String> {
Email verification, password reset, and Git for AI scale positioning114 let token = crypto::random_hex(32);
115 self.db
RFC 3339 timestamps in identity and repos116 .prepare(format!(
Email verification, password reset, and Git for AI scale positioning117 "INSERT INTO email_tokens (id, user_id, kind, expires_at)
RFC 3339 timestamps in identity and repos118 VALUES (?, ?, ?, {})",
119 sql_after(ttl)
120 ))
Email verification, password reset, and Git for AI scale positioning121 .bind(&[
122 crypto::sha256_hex(&token).into(),
123 user_id.into(),
124 kind.into(),
125 ])?
126 .run()
127 .await?;
128 Ok(token)
API and MCP server, Rust identity service, registration, site redesign129 }
130
Email verification, password reset, and Git for AI scale positioning131 /// Consumes a token of `kind`, returning its owner if it was valid.
132 async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
133 let id = crypto::sha256_hex(token);
134 let owner = self
135 .db
RFC 3339 timestamps in identity and repos136 .prepare(format!(
Email verification, password reset, and Git for AI scale positioning137 "SELECT users.id, users.username, users.email FROM email_tokens
138 JOIN users ON users.id = email_tokens.user_id
139 WHERE email_tokens.id = ? AND email_tokens.kind = ?
RFC 3339 timestamps in identity and repos140 AND email_tokens.expires_at > {SQL_NOW}"
141 ))
Email verification, password reset, and Git for AI scale positioning142 .bind(&[id.as_str().into(), kind.into()])?
143 .first::<TokenOwner>(None)
144 .await?;
145 if let Some(owner) = &owner {
146 // Every outstanding token of this kind dies with the one used.
147 self.db
148 .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = ?")
149 .bind(&[owner.id.as_str().into(), kind.into()])?
150 .run()
151 .await?;
152 }
153 Ok(owner)
154 }
155
156 async fn send_verification(&self, user: &User, email: &str) -> Result<()> {
157 let token = self
158 .issue_email_token(&user.id, "verify", VERIFY_TTL_SECONDS)
159 .await?;
160 email::send_verification(&self.env, email, &user.username, &token).await
161 }
162
163 async fn resend_verification(&self, a: UserArgs) -> Result<Outcome<bool>> {
164 let row = self
165 .db
166 .prepare(
167 "SELECT id, username, email FROM users WHERE id = ? AND email_verified_at IS NULL",
168 )
169 .bind(&[a.user.id.as_str().into()])?
170 .first::<TokenOwner>(None)
171 .await?;
172 let Some(TokenOwner {
173 email: Some(email), ..
174 }) = row
175 else {
176 return Ok(Outcome::fail(
177 FailureCode::Conflict,
178 "This account's email is already confirmed.",
179 ));
180 };
181 self.send_verification(&a.user, &email).await?;
182 Ok(Outcome::Ok(true))
183 }
184
185 async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<User>> {
186 let Some(owner) = self.redeem_email_token(&a.token, "verify").await? else {
187 return Ok(Outcome::fail(
188 FailureCode::Invalid,
189 "This confirmation link is not valid or has expired.",
190 ));
191 };
192 self.db
RFC 3339 timestamps in identity and repos193 .prepare(format!(
194 "UPDATE users SET email_verified_at = {SQL_NOW} WHERE id = ?"
195 ))
Email verification, password reset, and Git for AI scale positioning196 .bind(&[owner.id.as_str().into()])?
197 .run()
198 .await?;
199 Ok(Outcome::Ok(User {
200 id: owner.id,
201 username: owner.username,
202 verified: true,
203 }))
204 }
205
206 async fn request_password_reset(&self, a: EmailArgs) -> Result<bool> {
207 let row = self
208 .db
209 .prepare("SELECT id, username, email FROM users WHERE email = ?")
210 .bind(&[a.email.trim().to_lowercase().into()])?
211 .first::<TokenOwner>(None)
212 .await?;
213 if let Some(TokenOwner {
214 id,
215 username,
216 email: Some(email),
217 }) = row
218 {
219 let token = self
220 .issue_email_token(&id, "reset", RESET_TTL_SECONDS)
221 .await?;
222 email::send_password_reset(&self.env, &email, &username, &token).await?;
223 }
224 // The same answer either way, so addresses cannot be probed.
225 Ok(true)
226 }
227
228 async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
229 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
230 return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
231 }
232 let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
233 return Ok(Outcome::fail(
234 FailureCode::Invalid,
235 "This reset link is not valid or has expired.",
236 ));
237 };
238 // Following an emailed link also proves the address.
239 self.db
RFC 3339 timestamps in identity and repos240 .prepare(format!(
Email verification, password reset, and Git for AI scale positioning241 "UPDATE users SET password_hash = ?,
RFC 3339 timestamps in identity and repos242 email_verified_at = COALESCE(email_verified_at, {SQL_NOW})
243 WHERE id = ?"
244 ))
Email verification, password reset, and Git for AI scale positioning245 .bind(&[
246 crypto::hash_password(&a.password).into(),
247 owner.id.as_str().into(),
248 ])?
249 .run()
250 .await?;
251 // Anyone signed in with the old password is signed out.
252 self.db
253 .prepare("DELETE FROM sessions WHERE user_id = ?")
254 .bind(&[owner.id.as_str().into()])?
255 .run()
256 .await?;
257 Ok(Outcome::Ok(User {
258 id: owner.id,
259 username: owner.username,
260 verified: true,
261 }))
262 }
263
API and MCP server, Rust identity service, registration, site redesign264 async fn user_for_password(&self, username: &str, password: &str) -> Result<Viewer> {
265 let row = self
266 .db
Email verification, password reset, and Git for AI scale positioning267 .prepare("SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?")
API and MCP server, Rust identity service, registration, site redesign268 .bind(&[JsValue::from(username.to_lowercase())])?
269 .first::<UserRow>(None)
270 .await?;
271 Ok(row
272 .filter(|row| crypto::verify_password(password, &row.password_hash))
273 .map(|row| User {
274 id: row.id,
275 username: row.username,
Email verification, password reset, and Git for AI scale positioning276 verified: row.verified != 0,
API and MCP server, Rust identity service, registration, site redesign277 }))
278 }
279
280 async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
281 let username = a.username.trim().to_lowercase();
282 let email = a.email.trim().to_lowercase();
283 let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
284 if !is_valid_namespace(&username) {
285 return invalid(
286 "Usernames use lowercase letters, digits and single hyphens, up to 39 characters.",
287 );
288 }
289 let well_formed_email = email
290 .split_once('@')
291 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
292 && !email.contains(char::is_whitespace);
293 if !well_formed_email {
294 return invalid("Enter a valid email address.");
295 }
296 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
Email verification, password reset, and Git for AI scale positioning297 return invalid(PASSWORD_TOO_SHORT);
API and MCP server, Rust identity service, registration, site redesign298 }
299 let taken = self
300 .db
301 .prepare("SELECT username FROM users WHERE username = ? OR email = ?")
302 .bind(&[username.as_str().into(), email.as_str().into()])?
303 .first::<serde_json::Value>(None)
304 .await?;
305 if taken.is_some() {
306 return Ok(Outcome::fail(
307 FailureCode::Conflict,
308 "That username or email is already registered.",
309 ));
310 }
311 let user = User {
312 id: new_id("usr", now_ms()),
313 username,
Email verification, password reset, and Git for AI scale positioning314 verified: false,
API and MCP server, Rust identity service, registration, site redesign315 };
316 self.db
317 .prepare("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)")
318 .bind(&[
319 user.id.as_str().into(),
320 user.username.as_str().into(),
Email verification, password reset, and Git for AI scale positioning321 email.as_str().into(),
API and MCP server, Rust identity service, registration, site redesign322 crypto::hash_password(&a.password).into(),
323 ])?
324 .run()
325 .await?;
Email verification, password reset, and Git for AI scale positioning326 // The account exists either way; the email can be sent again later.
327 if let Err(error) = self.send_verification(&user, &email).await {
328 worker::console_error!("verification email failed: {error}");
329 }
API and MCP server, Rust identity service, registration, site redesign330 self.start_session(user).await
331 }
332
333 async fn sign_in(&self, a: SignInArgs) -> Result<Outcome<SignedIn>> {
334 let Some(user) = self.user_for_password(&a.username, &a.password).await? else {
335 return Ok(Outcome::fail(
336 FailureCode::Unauthenticated,
337 "Incorrect username or password.",
338 ));
339 };
340 self.start_session(user).await
341 }
342
343 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
344 let session_token = crypto::random_hex(32);
345 self.db
RFC 3339 timestamps in identity and repos346 .prepare(format!(
347 "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, {})",
348 sql_after(SESSION_TTL_SECONDS)
349 ))
API and MCP server, Rust identity service, registration, site redesign350 .bind(&[
351 crypto::sha256_hex(&session_token).into(),
352 user.id.as_str().into(),
353 ])?
354 .run()
355 .await?;
356 Ok(Outcome::Ok(SignedIn {
357 user,
358 session_token,
359 }))
360 }
361
362 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
363 self.db
364 .prepare("DELETE FROM sessions WHERE id = ?")
365 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
366 .run()
367 .await?;
368 Ok(())
369 }
370
371 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
372 self.find_user(
RFC 3339 timestamps in identity and repos373 &format!(
374 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified
375 FROM sessions JOIN users ON users.id = sessions.user_id
376 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}"
377 ),
API and MCP server, Rust identity service, registration, site redesign378 &crypto::sha256_hex(&a.session_token),
379 )
380 .await
381 }
382
383 async fn user_for_access_token(&self, token: &str) -> Result<Viewer> {
384 if !token.starts_with(TOKEN_PREFIX) {
385 return Ok(None);
386 }
387 self.find_user(
RFC 3339 timestamps in identity and repos388 &format!(
389 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified
390 FROM access_tokens JOIN users ON users.id = access_tokens.user_id
391 WHERE token_hash = ?
392 AND (access_tokens.expires_at IS NULL OR access_tokens.expires_at > {SQL_NOW})"
393 ),
API and MCP server, Rust identity service, registration, site redesign394 &crypto::sha256_hex(token),
395 )
396 .await
397 }
398
399 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
400 // Like GitHub, a token alone identifies its user.
401 if a.secret.starts_with(TOKEN_PREFIX) {
402 self.user_for_access_token(&a.secret).await
403 } else {
404 self.user_for_password(&a.username, &a.secret).await
405 }
406 }
407
408 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
409 self.find_user(
Email verification, password reset, and Git for AI scale positioning410 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
API and MCP server, Rust identity service, registration, site redesign411 JOIN users ON users.id = ssh_keys.user_id
412 WHERE fingerprint = ?",
413 &a.fingerprint,
414 )
415 .await
416 }
417
418 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
419 self.find_user(
Email verification, password reset, and Git for AI scale positioning420 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?",
API and MCP server, Rust identity service, registration, site redesign421 &a.username.to_lowercase(),
422 )
423 .await
424 }
425
426 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
427 let rows = self
428 .db
429 .prepare("SELECT id, title, fingerprint, created_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
430 .bind(&[a.user.id.into()])?
431 .all()
432 .await?
433 .results::<KeyRow>()?;
434 Ok(rows.into_iter().map(SshKey::from).collect())
435 }
436
437 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
438 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
439 return Ok(Outcome::fail(
440 FailureCode::Invalid,
441 "That is not a valid OpenSSH public key.",
442 ));
443 };
444 let taken = self
445 .db
446 .prepare("SELECT id FROM ssh_keys WHERE fingerprint = ?")
447 .bind(&[key.fingerprint.as_str().into()])?
448 .first::<serde_json::Value>(None)
449 .await?;
450 if taken.is_some() {
451 return Ok(Outcome::fail(
452 FailureCode::Conflict,
453 "That key is already registered.",
454 ));
455 }
456 let now = now_ms();
457 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
458 .into_iter()
459 .find(|candidate| !candidate.is_empty())
460 .unwrap_or_default()
461 .to_owned();
462 let row = KeyRow {
463 id: new_id("key", now),
464 title,
465 fingerprint: key.fingerprint,
RFC 3339 timestamps in identity and repos466 created_at: rfc3339(now),
API and MCP server, Rust identity service, registration, site redesign467 };
468 self.db
469 .prepare(
470 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
471 VALUES (?, ?, ?, ?, ?, ?)",
472 )
473 .bind(&[
474 row.id.as_str().into(),
475 a.user.id.into(),
476 row.title.as_str().into(),
477 key.public_key.into(),
478 row.fingerprint.as_str().into(),
RFC 3339 timestamps in identity and repos479 row.created_at.as_str().into(),
API and MCP server, Rust identity service, registration, site redesign480 ])?
481 .run()
482 .await?;
483 Ok(Outcome::Ok(row.into()))
484 }
485
486 async fn list_access_tokens(&self, a: UserArgs) -> Result<Vec<AccessToken>> {
487 let rows = self
488 .db
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)489 // Expiring tokens belong to hosted attempts, not to the user's list.
490 .prepare(
491 "SELECT id, name, created_at FROM access_tokens
492 WHERE user_id = ? AND expires_at IS NULL ORDER BY id",
493 )
API and MCP server, Rust identity service, registration, site redesign494 .bind(&[a.user.id.into()])?
495 .all()
496 .await?
497 .results::<TokenRow>()?;
498 Ok(rows.into_iter().map(AccessToken::from).collect())
499 }
500
501 async fn create_access_token(&self, a: CreateAccessTokenArgs) -> Result<CreatedAccessToken> {
502 let token = format!("{TOKEN_PREFIX}{}", crypto::random_hex(20));
503 let now = now_ms();
504 let name = match a.name.trim() {
505 "" => "Access token",
506 name => name,
507 };
508 let row = TokenRow {
509 id: new_id("tok", now),
510 name: name.to_owned(),
RFC 3339 timestamps in identity and repos511 created_at: rfc3339(now),
API and MCP server, Rust identity service, registration, site redesign512 };
513 self.db
514 .prepare(
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)515 "INSERT INTO access_tokens (id, user_id, name, token_hash, created_at, expires_at)
516 VALUES (?, ?, ?, ?, ?, ?)",
API and MCP server, Rust identity service, registration, site redesign517 )
518 .bind(&[
519 row.id.as_str().into(),
520 a.user.id.into(),
521 row.name.as_str().into(),
522 crypto::sha256_hex(&token).into(),
RFC 3339 timestamps in identity and repos523 row.created_at.as_str().into(),
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)524 a.ttl_seconds
RFC 3339 timestamps in identity and repos525 .map_or(JsValue::NULL, |ttl| rfc3339(now + ttl * 1000).into()),
API and MCP server, Rust identity service, registration, site redesign526 ])?
527 .run()
528 .await?;
529 Ok(CreatedAccessToken {
530 token,
531 info: row.into(),
532 })
533 }
534
535 /// Deletes a row the user owns from `table`.
536 async fn remove(&self, table: &str, a: RemoveArgs) -> Result<()> {
537 self.db
538 .prepare(format!("DELETE FROM {table} WHERE id = ? AND user_id = ?"))
539 .bind(&[a.id.into(), a.user.id.into()])?
540 .run()
541 .await?;
542 Ok(())
543 }
544}
545
546#[event(fetch)]
547async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
548 let Some(method) = rpc_method(&request) else {
549 return Response::error("Not found", 404);
550 };
551 let body: serde_json::Value = request.json().await?;
Email verification, password reset, and Git for AI scale positioning552 let identity = Identity {
553 db: env.d1("DB")?,
554 env,
555 };
API and MCP server, Rust identity service, registration, site redesign556
557 match method.as_str() {
558 "register" => reply(&identity.register(args(body)?).await?),
559 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
Device sign-in replaces registering and minting tokens over the API560 "device_start" => reply(&identity.device_start(args(body)?).await?),
561 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
562 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
563 "device_claim" => reply(&identity.device_claim(args(body)?).await?),
Email verification, password reset, and Git for AI scale positioning564 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
565 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
566 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
567 "reset_password" => reply(&identity.reset_password(args(body)?).await?),
API and MCP server, Rust identity service, registration, site redesign568 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
569 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
570 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
571 "user_for_access_token" => {
572 let a: TokenArgs = args(body)?;
573 reply(&identity.user_for_access_token(&a.token).await?)
574 }
575 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
576 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
577 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
578 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
579 "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?),
580 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
581 "create_access_token" => reply(&identity.create_access_token(args(body)?).await?),
582 "remove_access_token" => reply(&identity.remove("access_tokens", args(body)?).await?),
583 _ => Response::error("Unknown method", 404),
584 }
585}