this repo has no description
1use crate::api::SuccessResponse;
2use crate::api::error::ApiError;
3use axum::{
4 Json,
5 extract::State,
6 response::{IntoResponse, Response},
7};
8use chrono::{DateTime, Duration, Utc};
9use serde::{Deserialize, Serialize};
10use sqlx::PgPool;
11use tracing::{error, info};
12
13use crate::auth::BearerAuth;
14use crate::state::AppState;
15
16const TRUST_DURATION_DAYS: i64 = 30;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum DeviceTrustState {
21 Untrusted,
22 Trusted,
23 Expired,
24}
25
26impl DeviceTrustState {
27 pub fn from_timestamps(
28 trusted_at: Option<DateTime<Utc>>,
29 trusted_until: Option<DateTime<Utc>>,
30 ) -> Self {
31 match (trusted_at, trusted_until) {
32 (Some(_), Some(until)) if until > Utc::now() => Self::Trusted,
33 (Some(_), Some(_)) => Self::Expired,
34 _ => Self::Untrusted,
35 }
36 }
37
38 pub fn is_trusted(&self) -> bool {
39 matches!(self, Self::Trusted)
40 }
41
42 pub fn is_expired(&self) -> bool {
43 matches!(self, Self::Expired)
44 }
45
46 pub fn as_str(&self) -> &'static str {
47 match self {
48 Self::Untrusted => "untrusted",
49 Self::Trusted => "trusted",
50 Self::Expired => "expired",
51 }
52 }
53}
54
55#[derive(Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct TrustedDevice {
58 pub id: String,
59 pub user_agent: Option<String>,
60 pub friendly_name: Option<String>,
61 pub trusted_at: Option<DateTime<Utc>>,
62 pub trusted_until: Option<DateTime<Utc>>,
63 pub last_seen_at: DateTime<Utc>,
64 pub trust_state: DeviceTrustState,
65}
66
67#[derive(Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct ListTrustedDevicesResponse {
70 pub devices: Vec<TrustedDevice>,
71}
72
73pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAuth) -> Response {
74 let devices = sqlx::query!(
75 r#"SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at
76 FROM oauth_device od
77 JOIN oauth_account_device oad ON od.id = oad.device_id
78 WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()
79 ORDER BY od.last_seen_at DESC"#,
80 &auth.0.did
81 )
82 .fetch_all(&state.db)
83 .await;
84
85 match devices {
86 Ok(rows) => {
87 let devices = rows
88 .into_iter()
89 .map(|row| {
90 let trust_state =
91 DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until);
92 TrustedDevice {
93 id: row.id,
94 user_agent: row.user_agent,
95 friendly_name: row.friendly_name,
96 trusted_at: row.trusted_at,
97 trusted_until: row.trusted_until,
98 last_seen_at: row.last_seen_at,
99 trust_state,
100 }
101 })
102 .collect();
103 Json(ListTrustedDevicesResponse { devices }).into_response()
104 }
105 Err(e) => {
106 error!("DB error: {:?}", e);
107 ApiError::InternalError(None).into_response()
108 }
109 }
110}
111
112#[derive(Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct RevokeTrustedDeviceInput {
115 pub device_id: String,
116}
117
118pub async fn revoke_trusted_device(
119 State(state): State<AppState>,
120 auth: BearerAuth,
121 Json(input): Json<RevokeTrustedDeviceInput>,
122) -> Response {
123 let device_exists = sqlx::query_scalar!(
124 r#"SELECT 1 as one FROM oauth_device od
125 JOIN oauth_account_device oad ON od.id = oad.device_id
126 WHERE oad.did = $1 AND od.id = $2"#,
127 &auth.0.did,
128 input.device_id
129 )
130 .fetch_optional(&state.db)
131 .await;
132
133 match device_exists {
134 Ok(Some(_)) => {}
135 Ok(None) => {
136 return ApiError::DeviceNotFound.into_response();
137 }
138 Err(e) => {
139 error!("DB error: {:?}", e);
140 return ApiError::InternalError(None).into_response();
141 }
142 }
143
144 let result = sqlx::query!(
145 "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
146 input.device_id
147 )
148 .execute(&state.db)
149 .await;
150
151 match result {
152 Ok(_) => {
153 info!(did = %&auth.0.did, device_id = %input.device_id, "Trusted device revoked");
154 SuccessResponse::ok().into_response()
155 }
156 Err(e) => {
157 error!("DB error: {:?}", e);
158 ApiError::InternalError(None).into_response()
159 }
160 }
161}
162
163#[derive(Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct UpdateTrustedDeviceInput {
166 pub device_id: String,
167 pub friendly_name: Option<String>,
168}
169
170pub async fn update_trusted_device(
171 State(state): State<AppState>,
172 auth: BearerAuth,
173 Json(input): Json<UpdateTrustedDeviceInput>,
174) -> Response {
175 let device_exists = sqlx::query_scalar!(
176 r#"SELECT 1 as one FROM oauth_device od
177 JOIN oauth_account_device oad ON od.id = oad.device_id
178 WHERE oad.did = $1 AND od.id = $2"#,
179 &auth.0.did,
180 input.device_id
181 )
182 .fetch_optional(&state.db)
183 .await;
184
185 match device_exists {
186 Ok(Some(_)) => {}
187 Ok(None) => {
188 return ApiError::DeviceNotFound.into_response();
189 }
190 Err(e) => {
191 error!("DB error: {:?}", e);
192 return ApiError::InternalError(None).into_response();
193 }
194 }
195
196 let result = sqlx::query!(
197 "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2",
198 input.friendly_name,
199 input.device_id
200 )
201 .execute(&state.db)
202 .await;
203
204 match result {
205 Ok(_) => {
206 info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated");
207 SuccessResponse::ok().into_response()
208 }
209 Err(e) => {
210 error!("DB error: {:?}", e);
211 ApiError::InternalError(None).into_response()
212 }
213 }
214}
215
216pub async fn get_device_trust_state(db: &PgPool, device_id: &str, did: &str) -> DeviceTrustState {
217 let result = sqlx::query!(
218 r#"SELECT trusted_at, trusted_until FROM oauth_device od
219 JOIN oauth_account_device oad ON od.id = oad.device_id
220 WHERE od.id = $1 AND oad.did = $2"#,
221 device_id,
222 did
223 )
224 .fetch_optional(db)
225 .await;
226
227 match result {
228 Ok(Some(row)) => DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until),
229 _ => DeviceTrustState::Untrusted,
230 }
231}
232
233pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
234 get_device_trust_state(db, device_id, did)
235 .await
236 .is_trusted()
237}
238
239pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
240 let now = Utc::now();
241 let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
242
243 sqlx::query!(
244 "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3",
245 now,
246 trusted_until,
247 device_id
248 )
249 .execute(db)
250 .await?;
251
252 Ok(())
253}
254
255pub async fn extend_device_trust(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
256 let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS);
257
258 sqlx::query!(
259 "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL",
260 trusted_until,
261 device_id
262 )
263 .execute(db)
264 .await?;
265
266 Ok(())
267}