this repo has no description
1use crate::api::error::ApiError;
2use crate::api::SuccessResponse;
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 = DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until);
91 TrustedDevice {
92 id: row.id,
93 user_agent: row.user_agent,
94 friendly_name: row.friendly_name,
95 trusted_at: row.trusted_at,
96 trusted_until: row.trusted_until,
97 last_seen_at: row.last_seen_at,
98 trust_state,
99 }
100 })
101 .collect();
102 Json(ListTrustedDevicesResponse { devices }).into_response()
103 }
104 Err(e) => {
105 error!("DB error: {:?}", e);
106 ApiError::InternalError(None).into_response()
107 }
108 }
109}
110
111#[derive(Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct RevokeTrustedDeviceInput {
114 pub device_id: String,
115}
116
117pub async fn revoke_trusted_device(
118 State(state): State<AppState>,
119 auth: BearerAuth,
120 Json(input): Json<RevokeTrustedDeviceInput>,
121) -> Response {
122 let device_exists = sqlx::query_scalar!(
123 r#"SELECT 1 as one FROM oauth_device od
124 JOIN oauth_account_device oad ON od.id = oad.device_id
125 WHERE oad.did = $1 AND od.id = $2"#,
126 &auth.0.did,
127 input.device_id
128 )
129 .fetch_optional(&state.db)
130 .await;
131
132 match device_exists {
133 Ok(Some(_)) => {}
134 Ok(None) => {
135 return ApiError::DeviceNotFound.into_response();
136 }
137 Err(e) => {
138 error!("DB error: {:?}", e);
139 return ApiError::InternalError(None).into_response();
140 }
141 }
142
143 let result = sqlx::query!(
144 "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
145 input.device_id
146 )
147 .execute(&state.db)
148 .await;
149
150 match result {
151 Ok(_) => {
152 info!(did = %&auth.0.did, device_id = %input.device_id, "Trusted device revoked");
153 SuccessResponse::ok().into_response()
154 }
155 Err(e) => {
156 error!("DB error: {:?}", e);
157 ApiError::InternalError(None).into_response()
158 }
159 }
160}
161
162#[derive(Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct UpdateTrustedDeviceInput {
165 pub device_id: String,
166 pub friendly_name: Option<String>,
167}
168
169pub async fn update_trusted_device(
170 State(state): State<AppState>,
171 auth: BearerAuth,
172 Json(input): Json<UpdateTrustedDeviceInput>,
173) -> Response {
174 let device_exists = sqlx::query_scalar!(
175 r#"SELECT 1 as one FROM oauth_device od
176 JOIN oauth_account_device oad ON od.id = oad.device_id
177 WHERE oad.did = $1 AND od.id = $2"#,
178 &auth.0.did,
179 input.device_id
180 )
181 .fetch_optional(&state.db)
182 .await;
183
184 match device_exists {
185 Ok(Some(_)) => {}
186 Ok(None) => {
187 return ApiError::DeviceNotFound.into_response();
188 }
189 Err(e) => {
190 error!("DB error: {:?}", e);
191 return ApiError::InternalError(None).into_response();
192 }
193 }
194
195 let result = sqlx::query!(
196 "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2",
197 input.friendly_name,
198 input.device_id
199 )
200 .execute(&state.db)
201 .await;
202
203 match result {
204 Ok(_) => {
205 info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated");
206 SuccessResponse::ok().into_response()
207 }
208 Err(e) => {
209 error!("DB error: {:?}", e);
210 ApiError::InternalError(None).into_response()
211 }
212 }
213}
214
215pub async fn get_device_trust_state(db: &PgPool, device_id: &str, did: &str) -> DeviceTrustState {
216 let result = sqlx::query!(
217 r#"SELECT trusted_at, trusted_until FROM oauth_device od
218 JOIN oauth_account_device oad ON od.id = oad.device_id
219 WHERE od.id = $1 AND oad.did = $2"#,
220 device_id,
221 did
222 )
223 .fetch_optional(db)
224 .await;
225
226 match result {
227 Ok(Some(row)) => DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until),
228 _ => DeviceTrustState::Untrusted,
229 }
230}
231
232pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
233 get_device_trust_state(db, device_id, did).await.is_trusted()
234}
235
236pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
237 let now = Utc::now();
238 let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
239
240 sqlx::query!(
241 "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3",
242 now,
243 trusted_until,
244 device_id
245 )
246 .execute(db)
247 .await?;
248
249 Ok(())
250}
251
252pub async fn extend_device_trust(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
253 let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS);
254
255 sqlx::query!(
256 "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL",
257 trusted_until,
258 device_id
259 )
260 .execute(db)
261 .await?;
262
263 Ok(())
264}