pr_01m47d15m3e54sn21z27rpy5n9/services/identity/src/device.rs

179 lines6,118 bytesCodeBlame
1//! Device sign-in (RFC 8628): how an agent or command-line tool gets an
2//! access token without ever seeing a password.
3//!
4//! The tool starts a request and shows the person a short code and a URL.
5//! The person signs in on the website, or registers there, and approves the
6//! code. The tool polls until that happens and receives a token. Account
7//! creation and passwords stay in the browser, where they can be protected.
8
9use g1t_contracts::identity::*;
10use g1t_contracts::time::{SQL_NOW, sql_after};
11use g1t_contracts::{FailureCode, Outcome, User};
12use serde::Deserialize;
13use worker::Result;
14
15use crate::{Identity, crypto};
16
17const EXPIRES_IN_SECONDS: u64 = 15 * 60;
18const POLL_INTERVAL_SECONDS: u32 = 5;
19/// No vowels, so a code never spells a word, and nothing easily confused.
20const USER_CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
21
22#[derive(Deserialize)]
23struct DeviceRow {
24 user_code: String,
25 client_name: String,
26 status: String,
27 user_id: Option<String>,
28}
29
30/// Eight letters as `XXXX-XXXX`.
31fn new_user_code() -> String {
32 let mut random = [0u8; 8];
33 getrandom::getrandom(&mut random).expect("no source of randomness");
34 let letters: String = random
35 .iter()
36 .map(|byte| USER_CODE_ALPHABET[*byte as usize % USER_CODE_ALPHABET.len()] as char)
37 .collect();
38 format!("{}-{}", &letters[..4], &letters[4..])
39}
40
41/// A user code as stored, however it was typed.
42fn normalize(user_code: &str) -> String {
43 let letters: String = user_code
44 .chars()
45 .filter(char::is_ascii_alphabetic)
46 .map(|c| c.to_ascii_uppercase())
47 .collect();
48 match letters.len() {
49 8 => format!("{}-{}", &letters[..4], &letters[4..]),
50 _ => letters,
51 }
52}
53
54impl Identity {
55 pub async fn device_start(&self, a: DeviceStartArgs) -> Result<DeviceStart> {
56 let device_code = crypto::random_hex(32);
57 let user_code = new_user_code();
58 let client_name: String = match a.client_name.trim() {
59 "" => "An application".to_owned(),
60 name => name.chars().take(60).collect(),
61 };
62 self.db
63 .prepare(format!(
64 "INSERT INTO device_codes (id, user_code, client_name, expires_at)
65 VALUES (?, ?, ?, {})",
66 sql_after(EXPIRES_IN_SECONDS)
67 ))
68 .bind(&[
69 crypto::sha256_hex(&device_code).into(),
70 user_code.as_str().into(),
71 client_name.into(),
72 ])?
73 .run()
74 .await?;
75 Ok(DeviceStart {
76 device_code,
77 user_code,
78 expires_in: EXPIRES_IN_SECONDS as u32,
79 interval: POLL_INTERVAL_SECONDS,
80 })
81 }
82
83 /// The pending, unexpired request with this user code.
84 async fn pending_device(&self, user_code: &str) -> Result<Option<DeviceRow>> {
85 self.db
86 .prepare(format!(
87 "SELECT user_code, client_name, status, user_id FROM device_codes
88 WHERE user_code = ? AND status = 'pending' AND expires_at > {SQL_NOW}"
89 ))
90 .bind(&[normalize(user_code).into()])?
91 .first::<DeviceRow>(None)
92 .await
93 }
94
95 pub async fn device_lookup(&self, a: DeviceLookupArgs) -> Result<Option<DeviceRequest>> {
96 Ok(self
97 .pending_device(&a.user_code)
98 .await?
99 .map(|row| DeviceRequest {
100 user_code: row.user_code,
101 client_name: row.client_name,
102 }))
103 }
104
105 pub async fn device_resolve(&self, a: DeviceResolveArgs) -> Result<Outcome<bool>> {
106 let Some(row) = self.pending_device(&a.user_code).await? else {
107 return Ok(Outcome::fail(
108 FailureCode::NotFound,
109 "That code is not valid or has expired. Start again from the application.",
110 ));
111 };
112 self.db
113 .prepare("UPDATE device_codes SET status = ?, user_id = ? WHERE user_code = ?")
114 .bind(&[
115 if a.approve { "approved" } else { "denied" }.into(),
116 a.user.id.into(),
117 row.user_code.into(),
118 ])?
119 .run()
120 .await?;
121 Ok(Outcome::Ok(a.approve))
122 }
123
124 pub async fn device_claim(&self, a: DeviceClaimArgs) -> Result<DeviceClaim> {
125 let id = crypto::sha256_hex(&a.device_code);
126 let row = self
127 .db
128 .prepare(format!(
129 "SELECT user_code, client_name, status, user_id FROM device_codes
130 WHERE id = ? AND expires_at > {SQL_NOW}"
131 ))
132 .bind(&[id.as_str().into()])?
133 .first::<DeviceRow>(None)
134 .await?;
135 let Some(row) = row else {
136 return Ok(DeviceClaim::Expired);
137 };
138 let user_id = match (row.status.as_str(), row.user_id) {
139 ("pending", _) => return Ok(DeviceClaim::Pending),
140 ("approved", Some(user_id)) => user_id,
141 _ => {
142 self.forget_device(&id).await?;
143 return Ok(DeviceClaim::Denied);
144 }
145 };
146 // A device code yields exactly one token.
147 self.forget_device(&id).await?;
148 let Some(user) = self
149 .find_user(
150 "SELECT id, username, email_verified_at IS NOT NULL AS verified
151 FROM users WHERE id = ?",
152 &user_id,
153 )
154 .await?
155 else {
156 return Ok(DeviceClaim::Expired);
157 };
158 let created = self
159 .create_access_token(CreateAccessTokenArgs {
160 user: User { ..user.clone() },
161 name: row.client_name,
162 ttl_seconds: None,
163 })
164 .await?;
165 Ok(DeviceClaim::Approved {
166 token: created.token,
167 user,
168 })
169 }
170
171 async fn forget_device(&self, id: &str) -> Result<()> {
172 self.db
173 .prepare("DELETE FROM device_codes WHERE id = ?")
174 .bind(&[id.into()])?
175 .run()
176 .await?;
177 Ok(())
178 }
179}