Skip to main content

max / makenotwork

37.2 KB · 1136 lines History Blame Raw
1 //! OAuth provider workflow tests: authorization code + PKCE flow.
2
3 use std::fmt::Write as _;
4
5 use crate::harness::TestHarness;
6 use makenotwork::db::{SyncAppId, UserId};
7 use serde::Deserialize;
8 use sha2::{Digest, Sha256};
9 use sqlx::PgPool;
10
11 // ── Response types ──
12
13 #[derive(Deserialize)]
14 struct TokenResponse {
15 access_token: String,
16 token_type: String,
17 expires_in: i64,
18 #[serde(default)]
19 refresh_token: Option<String>,
20 #[serde(default)]
21 scope: String,
22 user_id: UserId,
23 app_id: SyncAppId,
24 }
25
26 // ── Helpers ──
27
28 /// Insert a sync app directly via SQL and return (app_id, api_key).
29 async fn create_sync_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
30 let api_key = "test-oauth-client-id";
31 let key_hash = crate::harness::hash_api_key(api_key);
32 let key_prefix = &api_key[..8];
33 let app_id: SyncAppId = sqlx::query_scalar(
34 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'OAuth Test App', $2, $3) RETURNING id",
35 )
36 .bind(user_id)
37 .bind(&key_hash)
38 .bind(key_prefix)
39 .fetch_one(pool)
40 .await
41 .expect("Failed to create sync app");
42
43 (app_id, api_key.to_string())
44 }
45
46 /// Generate PKCE code_verifier and code_challenge (S256).
47 fn generate_pkce() -> (String, String) {
48 // Deterministic 64-char alphanumeric verifier for tests
49 let verifier: String = (0u32..64)
50 .map(|i| {
51 let idx = ((i * 7 + 3) % 26) as u8;
52 (b'A' + idx) as char
53 })
54 .collect();
55
56 let mut hasher = Sha256::new();
57 hasher.update(verifier.as_bytes());
58 let digest = hasher.finalize();
59
60 use base64::Engine;
61 let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
62
63 (verifier, challenge)
64 }
65
66 /// Extract `code` and `state` from a redirect Location header.
67 fn extract_code_from_redirect(location: &str) -> (String, String) {
68 let url = url::Url::parse(location).expect("Invalid redirect URL");
69 let mut code = String::new();
70 let mut state = String::new();
71
72 for (key, value) in url.query_pairs() {
73 match key.as_ref() {
74 "code" => code = value.to_string(),
75 "state" => state = value.to_string(),
76 _ => {}
77 }
78 }
79
80 assert!(!code.is_empty(), "No code in redirect: {location}");
81 (code, state)
82 }
83
84 /// Full OAuth authorize flow: GET authorize page, POST with credentials, return (code, state).
85 async fn authorize(
86 h: &mut TestHarness,
87 client_id: &str,
88 code_challenge: &str,
89 username: &str,
90 password: &str,
91 ) -> (String, String) {
92 let state_param = "test-state-12345";
93 let redirect_uri = "http://127.0.0.1:9999/callback";
94
95 // GET the authorize page (populates CSRF)
96 let resp = h
97 .client
98 .get(&format!(
99 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync",
100 urlencoding::encode(client_id),
101 urlencoding::encode(redirect_uri),
102 state_param,
103 code_challenge,
104 ))
105 .await;
106 assert_eq!(
107 resp.status.as_u16(),
108 200,
109 "Authorize page failed: {}",
110 resp.text
111 );
112
113 // Extract CSRF token
114 let csrf = h
115 .client
116 .csrf_token()
117 .expect("No CSRF token after loading authorize page")
118 .to_string();
119
120 // POST credentials (CSRF goes in form body as _csrf)
121 let body = format!(
122 "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync&login={}&password={}&_csrf={}",
123 urlencoding::encode(client_id),
124 urlencoding::encode(redirect_uri),
125 state_param,
126 code_challenge,
127 urlencoding::encode(username),
128 urlencoding::encode(password),
129 urlencoding::encode(&csrf),
130 );
131
132 let resp = h.client.post_form("/oauth/authorize", &body).await;
133 assert!(
134 resp.status.is_redirection(),
135 "Expected redirect after authorize POST, got {}: {}",
136 resp.status,
137 resp.text
138 );
139
140 let location = resp
141 .header("location")
142 .expect("No Location header on redirect");
143 extract_code_from_redirect(location)
144 }
145
146 // ── Tests ──
147
148 #[tokio::test]
149 async fn oauth_full_flow() {
150 let mut h = TestHarness::new().await;
151 let user_id = h
152 .signup("oauthuser", "oauthuser@test.com", "Password1!")
153 .await;
154 // Logout so we test the credential flow
155 h.client.post_form("/logout", "").await;
156
157 let (app_id, client_id) = create_sync_app(&h.db, user_id).await;
158 let (verifier, challenge) = generate_pkce();
159
160 let (code, state) = authorize(&mut h, &client_id, &challenge, "oauthuser", "Password1!").await;
161 assert_eq!(state, "test-state-12345");
162
163 // Exchange code for token (OAuth RFC requires form-encoded)
164 let resp = h
165 .client
166 .post_form(
167 "/oauth/token",
168 &format!(
169 "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}&key=test-session-key",
170 code,
171 urlencoding::encode("http://127.0.0.1:9999/callback"),
172 verifier,
173 client_id,
174 ),
175 )
176 .await;
177 assert_eq!(
178 resp.status.as_u16(),
179 200,
180 "Token exchange failed: {}",
181 resp.text
182 );
183
184 let token: TokenResponse = resp.json();
185 assert!(!token.access_token.is_empty());
186 assert_eq!(token.token_type, "Bearer");
187 assert!(token.expires_in > 0);
188 assert_eq!(token.user_id, user_id);
189 assert_eq!(token.app_id, app_id);
190
191 // `scope=sync` (sent by the authorize helper, mirroring synckit-client) mints
192 // a sync-capable token that authenticates the sync API.
193 h.client.set_bearer_token(&token.access_token);
194 let resp = h.client.get("/api/v1/sync/status").await;
195 assert_ne!(
196 resp.status.as_u16(),
197 401,
198 "scope=sync token should authenticate the sync API"
199 );
200 h.client.clear_bearer_token();
201 }
202
203 #[tokio::test]
204 async fn oauth_pkce_wrong_verifier() {
205 let mut h = TestHarness::new().await;
206 let user_id = h
207 .signup("oauthpkce", "oauthpkce@test.com", "Password1!")
208 .await;
209 h.client.post_form("/logout", "").await;
210
211 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
212 let (_verifier, challenge) = generate_pkce();
213
214 let (code, _) = authorize(&mut h, &client_id, &challenge, "oauthpkce", "Password1!").await;
215
216 // Use wrong verifier
217 let resp = h
218 .client
219 .post_form(
220 "/oauth/token",
221 &format!(
222 "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier=this-is-the-wrong-verifier-and-should-fail&client_id={}&key=test-session-key",
223 code,
224 urlencoding::encode("http://127.0.0.1:9999/callback"),
225 client_id,
226 ),
227 )
228 .await;
229 assert_eq!(
230 resp.status.as_u16(),
231 400,
232 "Wrong PKCE verifier should be rejected"
233 );
234 }
235
236 #[tokio::test]
237 async fn oauth_code_single_use() {
238 let mut h = TestHarness::new().await;
239 let user_id = h
240 .signup("oauthonce", "oauthonce@test.com", "Password1!")
241 .await;
242 h.client.post_form("/logout", "").await;
243
244 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
245 let (verifier, challenge) = generate_pkce();
246
247 let (code, _) = authorize(&mut h, &client_id, &challenge, "oauthonce", "Password1!").await;
248
249 let token_body = format!(
250 "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}&key=test-session-key",
251 code,
252 urlencoding::encode("http://127.0.0.1:9999/callback"),
253 verifier,
254 client_id,
255 );
256
257 // First exchange, should succeed
258 let resp = h.client.post_form("/oauth/token", &token_body).await;
259 assert_eq!(
260 resp.status.as_u16(),
261 200,
262 "First token exchange failed: {}",
263 resp.text
264 );
265
266 // Second exchange with same code, should fail
267 let resp = h.client.post_form("/oauth/token", &token_body).await;
268 assert_eq!(
269 resp.status.as_u16(),
270 400,
271 "Reused auth code should be rejected"
272 );
273 }
274
275 #[tokio::test]
276 async fn oauth_invalid_client_id() {
277 let mut h = TestHarness::new().await;
278 h.signup("oauthbad", "oauthbad@test.com", "Password1!")
279 .await;
280
281 let resp = h
282 .client
283 .get("/oauth/authorize?response_type=code&client_id=nonexistent-app&redirect_uri=http://127.0.0.1:9999/callback&state=x&code_challenge=abc&code_challenge_method=S256")
284 .await;
285 assert_eq!(
286 resp.status.as_u16(),
287 400,
288 "Invalid client_id should return 400"
289 );
290 }
291
292 #[tokio::test]
293 async fn oauth_invalid_credentials() {
294 let mut h = TestHarness::new().await;
295 let user_id = h
296 .signup("oauthcred", "oauthcred@test.com", "Password1!")
297 .await;
298 h.client.post_form("/logout", "").await;
299
300 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
301 let (_verifier, challenge) = generate_pkce();
302
303 let state_param = "test-state-12345";
304 let redirect_uri = "http://127.0.0.1:9999/callback";
305
306 // GET the authorize page
307 let resp = h
308 .client
309 .get(&format!(
310 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256",
311 urlencoding::encode(&client_id),
312 urlencoding::encode(redirect_uri),
313 state_param,
314 challenge,
315 ))
316 .await;
317 assert_eq!(resp.status.as_u16(), 200);
318
319 let csrf = h.client.csrf_token().expect("No CSRF token").to_string();
320
321 // POST with wrong password
322 let body = format!(
323 "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&login={}&password={}&_csrf={}",
324 urlencoding::encode(&client_id),
325 urlencoding::encode(redirect_uri),
326 state_param,
327 challenge,
328 "oauthcred",
329 "WrongPassword1%21",
330 urlencoding::encode(&csrf),
331 );
332
333 let resp = h.client.post_form("/oauth/authorize", &body).await;
334
335 // Should re-render the form with an error (200, not a redirect)
336 assert_eq!(
337 resp.status.as_u16(),
338 200,
339 "Invalid credentials should re-render form, got {}",
340 resp.status
341 );
342 assert!(
343 resp.text.contains("Invalid")
344 || resp.text.contains("invalid")
345 || resp.text.contains("password"),
346 "Should show error message: {}",
347 resp.text
348 );
349 }
350
351 /// Regression: the OAuth authorize password path must
352 /// not be a confirmed-password oracle for 2FA accounts. A CORRECT password
353 /// against a TOTP-enabled account must be indistinguishable from a wrong one,
354 /// the same generic error AND an incremented failed-login counter, not a
355 /// distinct "two-factor enabled" message with the counter reset.
356 #[tokio::test]
357 async fn oauth_2fa_account_password_not_an_oracle() {
358 let mut h = TestHarness::new().await;
359 let user_id = h
360 .signup("oauth2fa", "oauth2fa@test.com", "Password1!")
361 .await;
362 h.client.post_form("/logout", "").await;
363
364 // Enable 2FA directly, the OAuth flow rejects 2FA accounts outright.
365 sqlx::query("UPDATE users SET totp_enabled = true WHERE id = $1")
366 .bind(user_id)
367 .execute(&h.db)
368 .await
369 .expect("enable totp");
370
371 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
372 let (_verifier, challenge) = generate_pkce();
373 let state_param = "test-state-12345";
374 let redirect_uri = "http://127.0.0.1:9999/callback";
375
376 let resp = h
377 .client
378 .get(&format!(
379 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256",
380 urlencoding::encode(&client_id),
381 urlencoding::encode(redirect_uri),
382 state_param,
383 challenge,
384 ))
385 .await;
386 assert_eq!(resp.status.as_u16(), 200);
387 let csrf = h.client.csrf_token().expect("No CSRF token").to_string();
388
389 // POST with the CORRECT password.
390 let body = format!(
391 "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&login={}&password={}&_csrf={}",
392 urlencoding::encode(&client_id),
393 urlencoding::encode(redirect_uri),
394 state_param,
395 challenge,
396 "oauth2fa",
397 "Password1%21",
398 urlencoding::encode(&csrf),
399 );
400 let resp = h.client.post_form("/oauth/authorize", &body).await;
401
402 // Re-renders the form with the SAME generic error as a wrong password,
403 // never the distinct "two-factor" hint that leaked the password's validity.
404 assert_eq!(resp.status.as_u16(), 200, "should re-render, not authorize");
405 assert!(
406 !resp.text.to_lowercase().contains("two-factor")
407 && !resp.text.to_lowercase().contains("two factor"),
408 "must not reveal 2FA status on a correct password: {}",
409 resp.text
410 );
411 assert!(
412 resp.text.contains("Invalid") || resp.text.contains("invalid"),
413 "should show the generic invalid-credentials error: {}",
414 resp.text
415 );
416
417 // The denial was accounted: a correct-but-blocked guess increments the
418 // counter exactly like a wrong password (no oracle via the counter either).
419 let attempts: i32 = sqlx::query_scalar("SELECT failed_login_attempts FROM users WHERE id = $1")
420 .bind(user_id)
421 .fetch_one(&h.db)
422 .await
423 .expect("read failed_login_attempts");
424 assert_eq!(
425 attempts, 1,
426 "correct password on a 2FA account must increment"
427 );
428 }
429
430 // ── Userinfo (`/oauth/userinfo`) ──
431 //
432 // `userinfo` is the canonical entitlement endpoint for external "Log in with MNW"
433 // implementers. Tests cover the `perks` contract: shape on a fresh user, on a
434 // creator, and on a Fan+ subscriber.
435
436 /// Run the full authorize → token flow and return the Bearer access token.
437 async fn obtain_access_token(h: &mut TestHarness, username: &str, password: &str) -> String {
438 let user_id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1")
439 .bind(username)
440 .fetch_one(&h.db)
441 .await
442 .expect("user lookup");
443
444 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
445 let (verifier, challenge) = generate_pkce();
446
447 h.client.post_form("/logout", "").await;
448 let (code, _state) = authorize(h, &client_id, &challenge, username, password).await;
449
450 let resp = h
451 .client
452 .post_form(
453 "/oauth/token",
454 &format!(
455 "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}&key=test-session-key",
456 code,
457 urlencoding::encode("http://127.0.0.1:9999/callback"),
458 verifier,
459 client_id,
460 ),
461 )
462 .await;
463 assert_eq!(
464 resp.status.as_u16(),
465 200,
466 "Token exchange failed: {}",
467 resp.text
468 );
469 let token: TokenResponse = resp.json();
470 token.access_token
471 }
472
473 #[derive(Deserialize)]
474 struct UserinfoResp {
475 user_id: UserId,
476 username: String,
477 display_name: Option<String>,
478 avatar_url: Option<String>,
479 perks: PerksResp,
480 }
481
482 #[derive(Deserialize)]
483 struct PerksResp {
484 fan_plus: bool,
485 is_creator: bool,
486 creator_tier: Option<CreatorTierResp>,
487 }
488
489 #[derive(Deserialize)]
490 struct CreatorTierResp {
491 tier: String,
492 features: Vec<String>,
493 }
494
495 #[tokio::test]
496 async fn oauth_userinfo_default() {
497 let mut h = TestHarness::new().await;
498 let user_id = h
499 .signup("uinfo_def", "uinfo_def@test.com", "Password1!")
500 .await;
501
502 let token = obtain_access_token(&mut h, "uinfo_def", "Password1!").await;
503 h.client.set_bearer_token(&token);
504 let resp = h.client.get("/oauth/userinfo").await;
505 assert_eq!(resp.status.as_u16(), 200, "userinfo failed: {}", resp.text);
506
507 let info: UserinfoResp = resp.json();
508 assert_eq!(info.user_id, user_id);
509 assert_eq!(info.username, "uinfo_def");
510 assert!(info.display_name.is_none() || info.display_name.as_deref() == Some(""));
511 let _ = info.avatar_url;
512 assert!(!info.perks.fan_plus);
513 assert!(!info.perks.is_creator);
514 assert!(info.perks.creator_tier.is_none());
515 }
516
517 #[tokio::test]
518 async fn oauth_userinfo_creator_tier() {
519 let mut h = TestHarness::new().await;
520 let user_id = h
521 .signup("uinfo_creator", "uinfo_creator@test.com", "Password1!")
522 .await;
523 sqlx::query("UPDATE users SET creator_tier = 'big_files' WHERE id = $1")
524 .bind(user_id)
525 .execute(&h.db)
526 .await
527 .expect("set tier");
528
529 let token = obtain_access_token(&mut h, "uinfo_creator", "Password1!").await;
530 h.client.set_bearer_token(&token);
531 let resp = h.client.get("/oauth/userinfo").await;
532 assert_eq!(resp.status.as_u16(), 200);
533
534 let info: UserinfoResp = resp.json();
535 assert!(info.perks.is_creator);
536 assert!(!info.perks.fan_plus);
537 let tier = info.perks.creator_tier.expect("creator_tier populated");
538 assert_eq!(tier.tier, "big_files");
539 assert!(tier.features.iter().any(|f| f == "file_uploads"));
540 assert!(tier.features.iter().any(|f| f == "large_files"));
541 }
542
543 #[tokio::test]
544 async fn oauth_userinfo_fan_plus() {
545 let mut h = TestHarness::new().await;
546 let user_id = h
547 .signup("uinfo_fp", "uinfo_fp@test.com", "Password1!")
548 .await;
549 sqlx::query(
550 "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) \
551 VALUES ($1, 'sub_uinfo_fp', 'cus_uinfo_fp', 'active')",
552 )
553 .bind(user_id)
554 .execute(&h.db)
555 .await
556 .expect("seed fan_plus");
557
558 let token = obtain_access_token(&mut h, "uinfo_fp", "Password1!").await;
559 h.client.set_bearer_token(&token);
560 let resp = h.client.get("/oauth/userinfo").await;
561 assert_eq!(resp.status.as_u16(), 200);
562
563 let info: UserinfoResp = resp.json();
564 assert!(info.perks.fan_plus);
565 assert!(!info.perks.is_creator);
566 assert!(info.perks.creator_tier.is_none());
567 }
568
569 #[tokio::test]
570 async fn oauth_userinfo_unauthorized() {
571 let mut h = TestHarness::new().await;
572 // No bearer token set, extractor rejects.
573 let resp = h.client.get("/oauth/userinfo").await;
574 assert_eq!(resp.status.as_u16(), 401);
575 }
576
577 // ── Scoped flow: refresh tokens, scope enforcement, prompt=none, discovery ──
578 //
579 // These exercise the OAuth maturation that closes MT finding S13: a request
580 // that asks for `scope` opts into a short-lived userinfo-scoped access token
581 // (rejected by the sync API) plus a rotating refresh token. Requests WITHOUT
582 // scope keep getting the legacy full sync token (covered by oauth_full_flow).
583
584 /// Like `authorize`, but sends a `scope` parameter (GET + POST), opting into the
585 /// userinfo-scoped token flow.
586 async fn authorize_scoped(
587 h: &mut TestHarness,
588 client_id: &str,
589 code_challenge: &str,
590 username: &str,
591 password: &str,
592 scope: &str,
593 ) -> (String, String) {
594 let state_param = "test-state-scoped";
595 let redirect_uri = "http://127.0.0.1:9999/callback";
596
597 let resp = h
598 .client
599 .get(&format!(
600 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}",
601 urlencoding::encode(client_id),
602 urlencoding::encode(redirect_uri),
603 state_param,
604 code_challenge,
605 urlencoding::encode(scope),
606 ))
607 .await;
608 assert_eq!(
609 resp.status.as_u16(),
610 200,
611 "Authorize page failed: {}",
612 resp.text
613 );
614
615 let csrf = h.client.csrf_token().expect("No CSRF token").to_string();
616 let body = format!(
617 "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}&login={}&password={}&_csrf={}",
618 urlencoding::encode(client_id),
619 urlencoding::encode(redirect_uri),
620 state_param,
621 code_challenge,
622 urlencoding::encode(scope),
623 urlencoding::encode(username),
624 urlencoding::encode(password),
625 urlencoding::encode(&csrf),
626 );
627 let resp = h.client.post_form("/oauth/authorize", &body).await;
628 assert!(
629 resp.status.is_redirection(),
630 "authorize POST: {} {}",
631 resp.status,
632 resp.text
633 );
634 let location = resp.header("location").expect("No Location header");
635 extract_code_from_redirect(location)
636 }
637
638 /// Run a scoped authorize + code exchange, returning the parsed token response.
639 async fn obtain_scoped_token(
640 h: &mut TestHarness,
641 username: &str,
642 password: &str,
643 scope: &str,
644 ) -> TokenResponse {
645 let user_id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1")
646 .bind(username)
647 .fetch_one(&h.db)
648 .await
649 .expect("user lookup");
650 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
651 let (verifier, challenge) = generate_pkce();
652
653 h.client.post_form("/logout", "").await;
654 let (code, _state) =
655 authorize_scoped(h, &client_id, &challenge, username, password, scope).await;
656
657 let resp = h
658 .client
659 .post_form(
660 "/oauth/token",
661 &format!(
662 "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}&key=test-session-key",
663 code,
664 urlencoding::encode("http://127.0.0.1:9999/callback"),
665 verifier,
666 client_id,
667 ),
668 )
669 .await;
670 assert_eq!(
671 resp.status.as_u16(),
672 200,
673 "Token exchange failed: {}",
674 resp.text
675 );
676 resp.json()
677 }
678
679 /// POST a refresh_token grant, returning the raw response.
680 async fn refresh_token_request(
681 h: &mut TestHarness,
682 refresh_token: &str,
683 scope: Option<&str>,
684 ) -> crate::harness::client::TestResponse {
685 let mut body = format!(
686 "grant_type=refresh_token&refresh_token={}&client_id=test-oauth-client-id",
687 urlencoding::encode(refresh_token),
688 );
689 if let Some(s) = scope {
690 write!(body, "&scope={}", urlencoding::encode(s)).unwrap();
691 }
692 h.client.post_form("/oauth/token", &body).await
693 }
694
695 #[tokio::test]
696 async fn oauth_userinfo_token_rejected_by_sync_api() {
697 // THE S13 regression gate: a userinfo-scoped access token must NOT
698 // authenticate the sync API, only /oauth/userinfo.
699 let mut h = TestHarness::new().await;
700 h.signup("s13user", "s13@test.com", "Password1!").await;
701
702 let token =
703 obtain_scoped_token(&mut h, "s13user", "Password1!", "profile:read perks:read").await;
704 h.client.set_bearer_token(&token.access_token);
705
706 // Works at userinfo.
707 let resp = h.client.get("/oauth/userinfo").await;
708 assert_eq!(
709 resp.status.as_u16(),
710 200,
711 "userinfo should accept the scoped token"
712 );
713
714 // Rejected by the sync API (different audience).
715 let resp = h.client.get("/api/v1/sync/status").await;
716 assert_eq!(
717 resp.status.as_u16(),
718 401,
719 "userinfo-scoped token must be rejected by the sync API, got: {}",
720 resp.text
721 );
722 }
723
724 #[tokio::test]
725 async fn oauth_userinfo_requires_perks_scope() {
726 let mut h = TestHarness::new().await;
727 h.signup("scopeuser", "scope@test.com", "Password1!").await;
728
729 let token = obtain_scoped_token(&mut h, "scopeuser", "Password1!", "profile:read").await;
730 assert_eq!(token.scope, "profile:read");
731 h.client.set_bearer_token(&token.access_token);
732
733 let resp = h.client.get("/oauth/userinfo").await;
734 assert_eq!(resp.status.as_u16(), 200);
735 let body: serde_json::Value = resp.json();
736 assert!(
737 body.get("username").is_some(),
738 "profile:read returns identity"
739 );
740 assert!(
741 body.get("perks").is_none(),
742 "perks must be gated behind perks:read"
743 );
744 }
745
746 #[tokio::test]
747 async fn oauth_no_scope_yields_userinfo_token_not_sync() {
748 // Run 17 security flip: omitting `scope` now mints a LEAST-PRIVILEGE userinfo
749 // token, not a sync token. A relying party that merely forgot its `scope`
750 // param can no longer be silently escalated to the 7-day full-sync token; a
751 // client that wants sync must send `scope=sync` explicitly (synckit-client
752 // does). No scope also means no refresh token (no offline_access).
753 let mut h = TestHarness::new().await;
754 h.signup("legacyuser", "legacy@test.com", "Password1!")
755 .await;
756 let token = obtain_scoped_token(&mut h, "legacyuser", "Password1!", "").await;
757 assert!(
758 token.refresh_token.is_none(),
759 "no scope => no refresh token"
760 );
761
762 // The omitted-scope token must NOT authenticate the sync API, the escalation
763 // guard. (Contrast oauth_full_flow, which sends scope=sync and succeeds.)
764 h.client.set_bearer_token(&token.access_token);
765 let resp = h.client.get("/api/v1/sync/status").await;
766 assert_eq!(
767 resp.status.as_u16(),
768 401,
769 "an omitted-scope token must NOT authenticate the sync API"
770 );
771 }
772
773 #[tokio::test]
774 async fn oauth_refresh_happy_path_rotates() {
775 let mut h = TestHarness::new().await;
776 h.signup("refuser", "ref@test.com", "Password1!").await;
777
778 let token = obtain_scoped_token(
779 &mut h,
780 "refuser",
781 "Password1!",
782 "profile:read perks:read offline_access",
783 )
784 .await;
785 let rt1 = token
786 .refresh_token
787 .expect("offline_access yields a refresh token");
788 h.client.clear_bearer_token();
789
790 // Refresh -> new access token + new refresh token.
791 let resp = refresh_token_request(&mut h, &rt1, None).await;
792 assert_eq!(resp.status.as_u16(), 200, "refresh failed: {}", resp.text);
793 let refreshed: TokenResponse = resp.json();
794 let rt2 = refreshed
795 .refresh_token
796 .expect("rotation issues a new refresh token");
797 assert_ne!(rt1, rt2, "refresh token must rotate");
798 assert!(!refreshed.access_token.is_empty());
799
800 // The new access token reads userinfo.
801 h.client.set_bearer_token(&refreshed.access_token);
802 assert_eq!(h.client.get("/oauth/userinfo").await.status.as_u16(), 200);
803 h.client.clear_bearer_token();
804
805 // The OLD refresh token is now invalid.
806 let resp = refresh_token_request(&mut h, &rt1, None).await;
807 assert_eq!(
808 resp.status.as_u16(),
809 400,
810 "rotated (old) refresh token must be rejected"
811 );
812 }
813
814 #[tokio::test]
815 async fn oauth_refresh_reuse_detection_revokes_chain() {
816 let mut h = TestHarness::new().await;
817 h.signup("reuseuser", "reuse@test.com", "Password1!").await;
818 let token = obtain_scoped_token(
819 &mut h,
820 "reuseuser",
821 "Password1!",
822 "perks:read offline_access",
823 )
824 .await;
825 let rt1 = token.refresh_token.expect("refresh token");
826 h.client.clear_bearer_token();
827
828 // Legit rotation.
829 let resp = refresh_token_request(&mut h, &rt1, None).await;
830 assert_eq!(resp.status.as_u16(), 200);
831 let rt2: String = resp.json::<TokenResponse>().refresh_token.expect("rt2");
832
833 // Replay the consumed rt1 -> theft signal: whole chain revoked.
834 let resp = refresh_token_request(&mut h, &rt1, None).await;
835 assert_eq!(resp.status.as_u16(), 400, "reused token rejected");
836
837 // ...and rt2 (the live sibling) is now dead too.
838 let resp = refresh_token_request(&mut h, &rt2, None).await;
839 assert_eq!(
840 resp.status.as_u16(),
841 400,
842 "reuse detection must revoke the whole chain"
843 );
844 }
845
846 #[tokio::test]
847 async fn oauth_refresh_downgrade_only() {
848 let mut h = TestHarness::new().await;
849 h.signup("downuser", "down@test.com", "Password1!").await;
850 // Grant excludes profile:read so requesting it on refresh is a widening.
851 let token = obtain_scoped_token(
852 &mut h,
853 "downuser",
854 "Password1!",
855 "perks:read offline_access",
856 )
857 .await;
858 let rt1 = token.refresh_token.expect("refresh token");
859 h.client.clear_bearer_token();
860
861 // Widening request -> invalid_scope.
862 let resp = refresh_token_request(&mut h, &rt1, Some("profile:read perks:read")).await;
863 assert_eq!(resp.status.as_u16(), 400, "widening scope must be rejected");
864 let body: serde_json::Value = resp.json();
865 assert_eq!(
866 body.get("error").and_then(|e| e.as_str()),
867 Some("invalid_scope")
868 );
869 }
870
871 #[tokio::test]
872 async fn oauth_refresh_revoked_by_password_change() {
873 let mut h = TestHarness::new().await;
874 let user_id = h
875 .signup("revokeuser", "revoke@test.com", "Password1!")
876 .await;
877 let token = obtain_scoped_token(
878 &mut h,
879 "revokeuser",
880 "Password1!",
881 "perks:read offline_access",
882 )
883 .await;
884 let rt1 = token.refresh_token.expect("refresh token");
885 h.client.clear_bearer_token();
886
887 // Simulate a credential change: bump jwt_invalidated_at into the future of
888 // the token's issuance.
889 sqlx::query("UPDATE users SET jwt_invalidated_at = NOW() + INTERVAL '1 second' WHERE id = $1")
890 .bind(user_id)
891 .execute(&h.db)
892 .await
893 .expect("bump jwt_invalidated_at");
894
895 let resp = refresh_token_request(&mut h, &rt1, None).await;
896 assert_eq!(
897 resp.status.as_u16(),
898 400,
899 "refresh after credential change must fail"
900 );
901 }
902
903 #[tokio::test]
904 async fn oauth_no_refresh_without_offline_access() {
905 let mut h = TestHarness::new().await;
906 h.signup("noofflineuser", "nooff@test.com", "Password1!")
907 .await;
908 let token = obtain_scoped_token(
909 &mut h,
910 "noofflineuser",
911 "Password1!",
912 "profile:read perks:read",
913 )
914 .await;
915 assert!(
916 token.refresh_token.is_none(),
917 "no offline_access => no refresh token"
918 );
919 }
920
921 #[tokio::test]
922 async fn oauth_prompt_none_logged_in_issues_code() {
923 let mut h = TestHarness::new().await;
924 let user_id = h
925 .signup("promptuser", "prompt@test.com", "Password1!")
926 .await;
927 // Signup leaves a validated session (tracking id set by track_session).
928 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
929 let (_verifier, challenge) = generate_pkce();
930 let redirect_uri = "http://127.0.0.1:9999/callback";
931
932 // Silent re-auth only issues a code for scopes already consented to
933 // (R6-Sec-L5), so interactively authorize the scope first, the real RP flow
934 // (a user logs in with MNW once, then the RP refreshes silently).
935 let resp = h
936 .client
937 .get(&format!(
938 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=pn0&code_challenge={}&code_challenge_method=S256&scope={}",
939 urlencoding::encode(&client_id),
940 urlencoding::encode(redirect_uri),
941 challenge,
942 urlencoding::encode("profile:read perks:read"),
943 ))
944 .await;
945 assert_eq!(resp.status.as_u16(), 200, "authorize page: {}", resp.text);
946 let csrf = h.client.csrf_token().expect("csrf").to_string();
947 let body = format!(
948 "client_id={}&redirect_uri={}&state=pn0&code_challenge={}&code_challenge_method=S256&scope={}&_csrf={}",
949 urlencoding::encode(&client_id),
950 urlencoding::encode(redirect_uri),
951 challenge,
952 urlencoding::encode("profile:read perks:read"),
953 urlencoding::encode(&csrf),
954 );
955 let resp = h.client.post_form("/oauth/authorize", &body).await;
956 assert!(
957 resp.status.is_redirection(),
958 "consent POST: {} {}",
959 resp.status,
960 resp.text
961 );
962
963 // Now prompt=none silently issues a code for the consented scope.
964 let resp = h
965 .client
966 .get(&format!(
967 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=pn&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none",
968 urlencoding::encode(&client_id),
969 urlencoding::encode(redirect_uri),
970 challenge,
971 urlencoding::encode("profile:read perks:read"),
972 ))
973 .await;
974 assert!(
975 resp.status.is_redirection(),
976 "prompt=none logged-in should 302, got {}",
977 resp.status
978 );
979 let location = resp.header("location").expect("Location header");
980 let (code, _state) = extract_code_from_redirect(location);
981 assert!(
982 !code.is_empty(),
983 "prompt=none should return a code silently"
984 );
985 }
986
987 #[tokio::test]
988 async fn oauth_prompt_none_logged_out_returns_login_required() {
989 let mut h = TestHarness::new().await;
990 let user_id = h.signup("pnoutuser", "pnout@test.com", "Password1!").await;
991 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
992 let (_verifier, challenge) = generate_pkce();
993 h.client.post_form("/logout", "").await;
994
995 let resp = h
996 .client
997 .get(&format!(
998 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=pn2&code_challenge={}&code_challenge_method=S256&prompt=none",
999 urlencoding::encode(&client_id),
1000 urlencoding::encode("http://127.0.0.1:9999/callback"),
1001 challenge,
1002 ))
1003 .await;
1004 assert!(
1005 resp.status.is_redirection(),
1006 "prompt=none logged-out should 302"
1007 );
1008 let location = resp.header("location").expect("Location header");
1009 assert!(
1010 location.contains("error=login_required"),
1011 "expected login_required, got: {location}"
1012 );
1013 }
1014
1015 #[tokio::test]
1016 async fn oauth_discovery_metadata() {
1017 let mut h = TestHarness::new().await;
1018 let resp = h
1019 .client
1020 .get("/.well-known/oauth-authorization-server")
1021 .await;
1022 assert_eq!(resp.status.as_u16(), 200);
1023 let meta: serde_json::Value = resp.json();
1024 assert!(meta.get("authorization_endpoint").is_some());
1025 assert!(meta.get("token_endpoint").is_some());
1026 assert!(meta.get("userinfo_endpoint").is_some());
1027 let grants = meta
1028 .get("grant_types_supported")
1029 .and_then(|g| g.as_array())
1030 .expect("grants");
1031 assert!(grants.iter().any(|g| g == "refresh_token"));
1032 let scopes = meta
1033 .get("scopes_supported")
1034 .and_then(|s| s.as_array())
1035 .expect("scopes");
1036 assert!(scopes.iter().any(|s| s == "offline_access"));
1037 }
1038
1039 /// R6-Sec-L5: the prompt=none silent re-auth path may only mint a code for
1040 /// scopes already interactively consented to. A subset request is issued
1041 /// silently; a wider request returns error=consent_required.
1042 #[tokio::test]
1043 async fn oauth_prompt_none_gated_by_prior_consent() {
1044 let mut h = TestHarness::new().await;
1045 let user_id = h
1046 .signup("oauthconsent", "oc@example.com", "Password1!")
1047 .await;
1048 let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
1049
1050 // A validated site session is required for the silent path.
1051 h.client.post_form("/logout", "").await;
1052 h.login("oauthconsent", "Password1!").await;
1053
1054 let (_verifier, challenge) = generate_pkce();
1055 let redirect_uri = "http://127.0.0.1:9999/callback";
1056
1057 // Interactive consent for `profile:read perks:read`.
1058 let resp = h
1059 .client
1060 .get(&format!(
1061 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=s1&code_challenge={}&code_challenge_method=S256&scope={}",
1062 urlencoding::encode(&client_id),
1063 urlencoding::encode(redirect_uri),
1064 challenge,
1065 urlencoding::encode("profile:read perks:read"),
1066 ))
1067 .await;
1068 assert_eq!(resp.status.as_u16(), 200, "authorize page: {}", resp.text);
1069 let csrf = h.client.csrf_token().expect("csrf").to_string();
1070 let body = format!(
1071 "client_id={}&redirect_uri={}&state=s1&code_challenge={}&code_challenge_method=S256&scope={}&_csrf={}",
1072 urlencoding::encode(&client_id),
1073 urlencoding::encode(redirect_uri),
1074 challenge,
1075 urlencoding::encode("profile:read perks:read"),
1076 urlencoding::encode(&csrf),
1077 );
1078 let resp = h.client.post_form("/oauth/authorize", &body).await;
1079 assert!(
1080 resp.status.is_redirection(),
1081 "consent POST: {} {}",
1082 resp.status,
1083 resp.text
1084 );
1085
1086 // prompt=none with a SUBSET scope -> silent code issued.
1087 let resp = h
1088 .client
1089 .get(&format!(
1090 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=s2&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none",
1091 urlencoding::encode(&client_id),
1092 urlencoding::encode(redirect_uri),
1093 challenge,
1094 urlencoding::encode("profile:read"),
1095 ))
1096 .await;
1097 assert!(
1098 resp.status.is_redirection(),
1099 "silent subset should redirect: {} {}",
1100 resp.status,
1101 resp.text
1102 );
1103 let loc = resp.header("location").expect("location");
1104 assert!(
1105 loc.contains("code="),
1106 "silent subset should carry a code: {loc}"
1107 );
1108 assert!(
1109 !loc.contains("error="),
1110 "silent subset should not error: {loc}"
1111 );
1112
1113 // prompt=none with a WIDER scope (offline_access never consented) -> consent_required.
1114 let resp = h
1115 .client
1116 .get(&format!(
1117 "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=s3&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none",
1118 urlencoding::encode(&client_id),
1119 urlencoding::encode(redirect_uri),
1120 challenge,
1121 urlencoding::encode("profile:read perks:read offline_access"),
1122 ))
1123 .await;
1124 assert!(
1125 resp.status.is_redirection(),
1126 "silent wider should redirect: {} {}",
1127 resp.status,
1128 resp.text
1129 );
1130 let loc = resp.header("location").expect("location");
1131 assert!(
1132 loc.contains("error=consent_required"),
1133 "wider scope must require consent: {loc}"
1134 );
1135 }
1136