Skip to main content

max / makenotwork

6.7 KB · 215 lines History Blame Raw
1 //! SyncKit security hardening (ultra-fuzz --deep Security A+): device removal
2 //! immediately invalidates the user's sync tokens, and security-relevant events
3 //! (auth failures, device removals) land in the `sync_security_events` audit log.
4
5 use makenotwork::db::{SyncAppId, UserId};
6 use serde_json::json;
7 use sqlx::PgPool;
8
9 use crate::harness::TestHarness;
10
11 /// Create an active first-party (internal) sync app; returns (app_id, api_key).
12 async fn create_internal_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
13 let api_key = "test-api-key-synckit-security";
14 let key_hash = crate::harness::hash_api_key(api_key);
15 let key_prefix = &api_key[..8];
16 let app_id: SyncAppId = sqlx::query_scalar(
17 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status)
18 VALUES ($1, 'SecApp', $2, $3, TRUE, 'active') RETURNING id",
19 )
20 .bind(user_id)
21 .bind(&key_hash)
22 .bind(key_prefix)
23 .fetch_one(pool)
24 .await
25 .expect("insert internal sync_app");
26 (app_id, api_key.to_string())
27 }
28
29 fn auth_as(h: &mut TestHarness, user_id: UserId, app_id: SyncAppId, key: &str) {
30 let token = makenotwork::synckit_auth::create_sync_token(
31 "test-synckit-jwt-secret",
32 user_id,
33 app_id,
34 key,
35 )
36 .expect("mint test JWT");
37 h.client.set_bearer_token(&token);
38 }
39
40 #[tokio::test]
41 async fn deleting_a_device_invalidates_the_users_sync_token_and_audits_it() {
42 let mut h = TestHarness::new().await;
43 let user_id = h
44 .signup("sec_dev", "sec_dev@example.com", "Password1!")
45 .await;
46 let (app_id, _api_key) = create_internal_app(&h.db, user_id).await;
47 auth_as(&mut h, user_id, app_id, "user-key");
48
49 // A device to remove.
50 let device_id: uuid::Uuid = sqlx::query_scalar(
51 "INSERT INTO sync_devices (app_id, user_id, device_name, platform)
52 VALUES ($1, $2, 'laptop', 'test') RETURNING id",
53 )
54 .bind(app_id)
55 .bind(user_id)
56 .fetch_one(&h.db)
57 .await
58 .unwrap();
59
60 // The token authenticates a delete of an unknown device as 404 (auth passes,
61 // device not found), proving it is valid right now.
62 let probe = h
63 .client
64 .delete(&format!("/api/sync/devices/{}", uuid::Uuid::new_v4()))
65 .await;
66 assert_eq!(
67 probe.status, 404,
68 "token should be valid before removal: {}",
69 probe.text
70 );
71
72 // Remove the real device.
73 let resp = h
74 .client
75 .delete(&format!("/api/sync/devices/{device_id}"))
76 .await;
77 assert_eq!(
78 resp.status, 204,
79 "device delete should succeed: {}",
80 resp.text
81 );
82
83 // Same token now fails: device removal bumped sync_jwt_invalidated_at, so the
84 // unknown-device probe returns 401 (auth) rather than 404.
85 let probe = h
86 .client
87 .delete(&format!("/api/sync/devices/{}", uuid::Uuid::new_v4()))
88 .await;
89 assert_eq!(
90 probe.status, 401,
91 "token must be invalid after device removal: {}",
92 probe.text
93 );
94
95 // The website session path is untouched: jwt_invalidated_at stays NULL.
96 let web_invalidated: Option<chrono::DateTime<chrono::Utc>> =
97 sqlx::query_scalar("SELECT jwt_invalidated_at FROM users WHERE id = $1")
98 .bind(user_id)
99 .fetch_one(&h.db)
100 .await
101 .unwrap();
102 assert!(
103 web_invalidated.is_none(),
104 "sync revocation must not touch web jwt_invalidated_at"
105 );
106
107 // The removal was audited.
108 let events: i64 = sqlx::query_scalar(
109 "SELECT COUNT(*) FROM sync_security_events
110 WHERE app_id = $1 AND user_id = $2 AND event_type = 'device_removed'",
111 )
112 .bind(app_id)
113 .bind(user_id)
114 .fetch_one(&h.db)
115 .await
116 .unwrap();
117 assert_eq!(
118 events, 1,
119 "device removal must be recorded in the audit log"
120 );
121 }
122
123 #[tokio::test]
124 async fn failed_sync_auth_is_recorded_in_the_audit_log() {
125 let mut h = TestHarness::new().await;
126 let user_id = h
127 .signup("sec_auth", "sec_auth@example.com", "Password1!")
128 .await;
129 let (app_id, api_key) = create_internal_app(&h.db, user_id).await;
130
131 let resp = h
132 .client
133 .post_json(
134 "/api/sync/auth",
135 &json!({
136 "api_key": api_key,
137 "email": "sec_auth@example.com",
138 "password": "WrongPassword1!",
139 "key": "user-key",
140 })
141 .to_string(),
142 )
143 .await;
144 assert_eq!(
145 resp.status, 401,
146 "wrong password must be rejected: {}",
147 resp.text
148 );
149
150 let events: i64 = sqlx::query_scalar(
151 "SELECT COUNT(*) FROM sync_security_events
152 WHERE app_id = $1 AND user_id = $2 AND event_type = 'auth_failure'",
153 )
154 .bind(app_id)
155 .bind(user_id)
156 .fetch_one(&h.db)
157 .await
158 .unwrap();
159 assert_eq!(
160 events, 1,
161 "a failed sync auth must be recorded in the audit log"
162 );
163 }
164
165 #[tokio::test]
166 async fn deleting_a_device_invalidates_oauth_userinfo_tokens_too() {
167 // M-Sec1: the OAuth userinfo extractor (and the refresh path) previously
168 // checked only `jwt_invalidated_at`, so a removed device's userinfo token
169 // survived until expiry. The shared `assert_token_live` gate now enforces
170 // `sync_jwt_invalidated_at` for every SyncKit-derived credential, closing the
171 // parity gap that migration 150 left open.
172 let mut h = TestHarness::new().await;
173 let user_id = h
174 .signup("sec_oauth", "sec_oauth@example.com", "Password1!")
175 .await;
176 let (app_id, _api_key) = create_internal_app(&h.db, user_id).await;
177
178 // Mint a userinfo-scoped OAuth access token directly and present it.
179 let scopes = makenotwork::oauth_scope::GrantedScopes::parse("profile:read");
180 let token = makenotwork::synckit_auth::create_oauth_access_token(
181 "test-synckit-jwt-secret",
182 user_id,
183 app_id,
184 "user-key",
185 &scopes,
186 )
187 .expect("mint userinfo token");
188 h.client.set_bearer_token(&token);
189
190 // Valid right now.
191 let ok = h.client.get("/oauth/userinfo").await;
192 assert_eq!(
193 ok.status, 200,
194 "userinfo token must work before device removal: {}",
195 ok.text
196 );
197
198 // Simulate device removal: bump sync_jwt_invalidated_at past the token's iat.
199 sqlx::query(
200 "UPDATE users SET sync_jwt_invalidated_at = NOW() + INTERVAL '5 seconds' WHERE id = $1",
201 )
202 .bind(user_id)
203 .execute(&h.db)
204 .await
205 .unwrap();
206
207 // The userinfo token is now rejected, the parity gap is closed.
208 let denied = h.client.get("/oauth/userinfo").await;
209 assert_eq!(
210 denied.status, 401,
211 "device removal must kill the userinfo token: {}",
212 denied.text
213 );
214 }
215