Skip to main content

max / makenotwork

41.3 KB · 1147 lines History Blame Raw
1 //! OAuth2 authorization server endpoints for "Log in with Makenot.work"
2 //!
3 //! Implements Authorization Code + PKCE (RFC 7636) for desktop/mobile clients.
4 //!
5 //! See also: `/docs/developer/oauth`
6
7 use crate::csrf::{CsrfRouter, post_csrf_manual, post_csrf_skip};
8 use axum::{
9 Form, Json,
10 extract::{FromRequestParts, Query, State},
11 http::{StatusCode, request::Parts},
12 response::{IntoResponse, Redirect, Response},
13 routing::get,
14 };
15 use rand::Rng;
16 use serde::{Deserialize, Serialize};
17 use sha2::{Digest, Sha256};
18 use tower_governor::GovernorLayer;
19 use tower_sessions::Session;
20
21 use sqlx::PgPool;
22
23 use crate::{
24 AppState,
25 auth::{MaybeUserVerified, verify_password_async},
26 config::Config,
27 constants::{self, LOCKOUT_MINUTES},
28 csrf,
29 db::{self, CreatorTier, SyncAppId, UserId, Username},
30 error::{AppError, Result},
31 oauth_scope::{GrantedScopes, OAuthScope},
32 synckit_auth::{self, OAuthUser, SyncUser},
33 templates::OAuthAuthorizeTemplate,
34 };
35
36 /// Anti-timing dummy hash: ensures the user-not-found path takes the same time
37 /// as the wrong-password path (prevents user enumeration via response timing).
38 static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
39 crate::auth::hash_password("anti-timing-dummy").expect("dummy hash")
40 });
41
42 // ── Request/Response types ──
43
44 #[derive(Deserialize)]
45 pub struct AuthorizeQuery {
46 pub response_type: Option<String>,
47 pub client_id: Option<String>,
48 pub redirect_uri: Option<String>,
49 pub state: Option<String>,
50 pub code_challenge: Option<String>,
51 pub code_challenge_method: Option<String>,
52 /// Space-delimited requested scope. Absent => default userinfo scopes.
53 pub scope: Option<String>,
54 /// OIDC `prompt`. `prompt=none` requests silent re-auth: a code if the MNW
55 /// session is alive, else `error=login_required`, never an interactive page.
56 pub prompt: Option<String>,
57 }
58
59 #[derive(Deserialize)]
60 pub struct AuthorizeForm {
61 pub client_id: String,
62 pub redirect_uri: String,
63 pub state: String,
64 pub code_challenge: String,
65 pub code_challenge_method: String,
66 #[serde(default)]
67 pub scope: String,
68 pub login: Option<String>,
69 pub password: Option<String>,
70 #[serde(rename = "_csrf")]
71 pub csrf_token: String,
72 }
73
74 #[derive(Deserialize)]
75 pub struct TokenRequest {
76 pub grant_type: String,
77 pub client_id: String,
78 /// Developer-defined SDK key. Identifies which billing slot this session's
79 /// uploads count against. Required for the authorization_code grant; on a
80 /// refresh the stored key is carried forward.
81 #[serde(default)]
82 pub key: String,
83 // authorization_code grant
84 #[serde(default)]
85 pub code: Option<String>,
86 #[serde(default)]
87 pub redirect_uri: Option<String>,
88 #[serde(default)]
89 pub code_verifier: Option<String>,
90 // refresh_token grant
91 #[serde(default)]
92 pub refresh_token: Option<String>,
93 /// Optional downgrade-only scope on refresh.
94 #[serde(default)]
95 pub scope: Option<String>,
96 }
97
98 #[derive(Serialize)]
99 pub struct TokenResponse {
100 pub access_token: String,
101 pub token_type: String,
102 pub expires_in: i64,
103 /// Present only when the grant included `offline_access`.
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub refresh_token: Option<String>,
106 /// Space-delimited granted scope.
107 pub scope: String,
108 pub user_id: UserId,
109 pub app_id: SyncAppId,
110 }
111
112 // ── Helpers ──
113
114 fn generate_oauth_code() -> String {
115 let mut bytes = [0u8; constants::OAUTH_CODE_LENGTH];
116 rand::rng().fill_bytes(&mut bytes);
117 hex::encode(bytes)
118 }
119
120 /// Generate an opaque refresh token (returned once, then only its hash is kept).
121 fn generate_refresh_token() -> String {
122 let mut bytes = [0u8; constants::OAUTH_REFRESH_TOKEN_LENGTH];
123 rand::rng().fill_bytes(&mut bytes);
124 hex::encode(bytes)
125 }
126
127 /// SHA-256 hex of a refresh token. Tokens are stored and looked up by this hash;
128 /// the plaintext never touches the database.
129 fn hash_token(token: &str) -> String {
130 let mut hasher = Sha256::new();
131 hasher.update(token.as_bytes());
132 hex::encode(hasher.finalize())
133 }
134
135 /// Append OAuth response params to a redirect URI, preserving any existing query
136 /// AND fragment. Real URL parsing places the params in the query component, so a
137 /// registered URI carrying a `#fragment` no longer gets `?code=` naively appended
138 /// after the fragment (which corrupts the callback, ultra-fuzz Run 6 R6-UX-1).
139 /// Falls back to separator-concat only if the URI doesn't parse; it is validated
140 /// and registered before reaching here, so that path is defensive.
141 fn build_oauth_redirect(redirect_uri: &str, params: &[(&str, &str)]) -> String {
142 match url::Url::parse(redirect_uri) {
143 Ok(mut url) => {
144 url.query_pairs_mut().extend_pairs(params.iter().copied());
145 url.into()
146 }
147 Err(_) => {
148 let separator = if redirect_uri.contains('?') { "&" } else { "?" };
149 let query = params
150 .iter()
151 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
152 .collect::<Vec<_>>()
153 .join("&");
154 format!("{redirect_uri}{separator}{query}")
155 }
156 }
157 }
158
159 /// Build a `redirect_uri?error=...&state=...` response (OIDC error redirect),
160 /// used by `prompt=none` when interaction would otherwise be required.
161 fn redirect_with_error(redirect_uri: &str, state: &str, error_code: &str) -> Response {
162 let url = build_oauth_redirect(redirect_uri, &[("error", error_code), ("state", state)]);
163 Redirect::to(&url).into_response()
164 }
165
166 /// Persist an authorization code and build the success redirect back to the RP.
167 /// Shared by the interactive POST flow and `prompt=none` silent auth so both
168 /// store scope identically.
169 #[allow(clippy::too_many_arguments)]
170 async fn issue_authorization_code(
171 pool: &sqlx::PgPool,
172 app_id: SyncAppId,
173 user_id: UserId,
174 code_challenge: &str,
175 code_challenge_method: &str,
176 redirect_uri: &str,
177 scope: &GrantedScopes,
178 state_param: &str,
179 ) -> Result<Response> {
180 let code = generate_oauth_code();
181 let expires_at =
182 chrono::Utc::now() + chrono::Duration::seconds(constants::OAUTH_CODE_EXPIRY_SECS);
183
184 // Store only the hash; the plaintext code goes to the RP in the redirect and
185 // is never persisted (same at-rest contract as refresh tokens). A DB read
186 // therefore can't surface a live, redeemable code.
187 db::oauth::create_oauth_code(
188 pool,
189 &hash_token(&code),
190 app_id,
191 user_id,
192 code_challenge,
193 code_challenge_method,
194 redirect_uri,
195 &scope.to_string(),
196 expires_at,
197 )
198 .await?;
199
200 let redirect_url =
201 build_oauth_redirect(redirect_uri, &[("code", &code), ("state", state_param)]);
202 Ok(Redirect::to(&redirect_url).into_response())
203 }
204
205 /// Validate that a redirect_uri is allowed.
206 ///
207 /// Localhost callbacks are always permitted. Accepts the three loopback
208 /// forms RFC 8252 §7.3 calls out:
209 /// - `http://127.0.0.1:{port}/...` (IPv4 loopback)
210 /// - `http://[::1]:{port}/...` (IPv6 loopback, bracketed)
211 /// - `http://localhost:{port}/...` (resolver-dependent, included for parity)
212 ///
213 /// Non-localhost URIs must be registered in the app's `redirect_uris` column.
214 fn is_localhost_redirect(uri: &str) -> bool {
215 // Parse strictly rather than prefix-match: require the http scheme, a host
216 // exactly in the loopback set, an explicit non-zero port, and NO embedded
217 // credentials (Run #2 Security MINOR, the old prefix check accepted port 0
218 // and didn't reject userinfo). The host pin is the load-bearing property: a
219 // native app's loopback listener is the only thing that can receive the code.
220 let Ok(parsed) = url::Url::parse(uri) else {
221 return false;
222 };
223 if parsed.scheme() != "http" {
224 return false;
225 }
226 if !parsed.username().is_empty() || parsed.password().is_some() {
227 return false;
228 }
229 match parsed.port() {
230 Some(0) | None => return false,
231 Some(_) => {}
232 }
233 matches!(
234 parsed.host_str(),
235 Some("127.0.0.1" | "[::1]" | "::1" | "localhost")
236 )
237 }
238
239 async fn validate_redirect_uri(
240 pool: &sqlx::PgPool,
241 app_id: db::SyncAppId,
242 uri: &str,
243 ) -> Result<bool> {
244 if is_localhost_redirect(uri) {
245 return Ok(true);
246 }
247 db::oauth::is_registered_redirect_uri(pool, app_id, uri).await
248 }
249
250 /// Render the authorize page with an error message.
251 fn render_authorize_error(
252 csrf_token: Option<String>,
253 session_user: Option<crate::auth::SessionUser>,
254 app_name: &str,
255 form: &AuthorizeForm,
256 error: &str,
257 ) -> Response {
258 OAuthAuthorizeTemplate {
259 csrf_token,
260 session_user,
261 app_name: app_name.to_string(),
262 client_id: form.client_id.clone(),
263 redirect_uri: form.redirect_uri.clone(),
264 state: form.state.clone(),
265 code_challenge: form.code_challenge.clone(),
266 code_challenge_method: form.code_challenge_method.clone(),
267 scope: form.scope.clone(),
268 error_message: Some(error.to_string()),
269 }
270 .into_response()
271 }
272
273 /// Whether a session is "validated" for OAuth grants: a present, non-suspended
274 /// user (checked by `MaybeUserVerified`) that also carries a tracking ID.
275 /// Legacy sessions predating tracking must re-authenticate via password.
276 async fn has_validated_session(session: &Session) -> bool {
277 session
278 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
279 .await
280 .ok()
281 .flatten()
282 .is_some()
283 }
284
285 // ── GET /oauth/authorize ──
286
287 #[tracing::instrument(skip_all, name = "oauth::authorize_get")]
288 async fn authorize_get(
289 State(db): State<PgPool>,
290 MaybeUserVerified(session_user): MaybeUserVerified,
291 session: Session,
292 Query(params): Query<AuthorizeQuery>,
293 ) -> Result<Response> {
294 // Validate required params
295 let response_type = params.response_type.as_deref().unwrap_or("");
296 if response_type != "code" {
297 return Err(AppError::BadRequest(
298 "response_type must be 'code'".to_string(),
299 ));
300 }
301
302 let client_id = params
303 .client_id
304 .as_deref()
305 .ok_or_else(|| AppError::BadRequest("client_id is required".to_string()))?;
306 let redirect_uri = params
307 .redirect_uri
308 .as_deref()
309 .ok_or_else(|| AppError::BadRequest("redirect_uri is required".to_string()))?;
310 let state_param = params
311 .state
312 .as_deref()
313 .ok_or_else(|| AppError::BadRequest("state is required".to_string()))?;
314 // Cap state length on the GET authorize path too (the POST consent path caps
315 // at 1024). state is echoed into the auth_codes row and the redirect URL, so
316 // an unbounded value on the prompt=none branch would otherwise flow through
317 // unchecked. Mirror the POST limit.
318 if state_param.len() > 1024 {
319 return Err(AppError::BadRequest("state is too long".to_string()));
320 }
321 let code_challenge = params
322 .code_challenge
323 .as_deref()
324 .ok_or_else(|| AppError::BadRequest("code_challenge is required".to_string()))?;
325 let code_challenge_method = params.code_challenge_method.as_deref().unwrap_or("S256");
326
327 if code_challenge_method != "S256" {
328 return Err(AppError::BadRequest(
329 "code_challenge_method must be 'S256'".to_string(),
330 ));
331 }
332
333 // An S256 challenge is base64url-nopad of a SHA-256: exactly 43 chars (44
334 // if a stray `=` is included). Reject anything outside that range,
335 // including the empty string from `?code_challenge=`, so the prompt=none
336 // branch below never issues a code bound to an unsatisfiable challenge
337 // (ultra-fuzz Run #1 Security LOW). The POST consent path enforces the same.
338 if !(43..=44).contains(&code_challenge.len()) {
339 return Err(AppError::BadRequest(
340 "code_challenge has invalid length".to_string(),
341 ));
342 }
343
344 // Look up app by client_id (= sync_apps.api_key)
345 let app = db::synckit::get_sync_app_by_api_key(&db, client_id)
346 .await?
347 .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?;
348
349 if !validate_redirect_uri(&db, app.id, redirect_uri).await? {
350 return Err(AppError::BadRequest(
351 "redirect_uri is not allowed".to_string(),
352 ));
353 }
354
355 // Empty scope (no `scope` param) = a legacy sync client; it will receive a
356 // full sync token at /token. A non-empty scope opts into the userinfo flow.
357 let scope = params
358 .scope
359 .as_deref()
360 .map(GrantedScopes::parse)
361 .unwrap_or_default();
362
363 // prompt=none: silent re-auth. Issue a code if the MNW session is validated,
364 // otherwise bounce back with error=login_required, never an interactive page.
365 if params.prompt.as_deref() == Some("none") {
366 let validated_session = has_validated_session(&session).await;
367 let validated = session_user.as_ref().filter(|_| validated_session);
368 return match validated {
369 Some(user) => {
370 // Silent re-auth may only mint a code for scopes the user has
371 // ALREADY consented to for this app; anything broader requires
372 // interactive approval (ultra-fuzz Run 6 R6-Sec-L5). The sync
373 // pairing flow (explicit `scope=sync`, or the deprecated empty
374 // scope) is gated by PKCE + interactive pairing rather than the
375 // userinfo consent ledger, so it bypasses the subset check
376 // exactly as empty scope always has. The check reads consent
377 // purely from the DB, so a client-supplied userinfo scope can
378 // never silently widen a grant.
379 let granted = db::oauth::get_granted_scopes(&db, user.id, app.id).await?;
380 if !scope.is_sync_request() && !scope.subset_of(&granted) {
381 return Ok(redirect_with_error(
382 redirect_uri,
383 state_param,
384 "consent_required",
385 ));
386 }
387 issue_authorization_code(
388 &db,
389 app.id,
390 user.id,
391 code_challenge,
392 code_challenge_method,
393 redirect_uri,
394 &scope,
395 state_param,
396 )
397 .await
398 }
399 None => Ok(redirect_with_error(
400 redirect_uri,
401 state_param,
402 "login_required",
403 )),
404 };
405 }
406
407 let csrf_token = csrf::get_or_create_token(&session).await?;
408
409 Ok(OAuthAuthorizeTemplate {
410 csrf_token: Some(csrf_token),
411 session_user,
412 app_name: app.name,
413 client_id: client_id.to_string(),
414 redirect_uri: redirect_uri.to_string(),
415 state: state_param.to_string(),
416 code_challenge: code_challenge.to_string(),
417 code_challenge_method: code_challenge_method.to_string(),
418 scope: scope.to_string(),
419 error_message: None,
420 }
421 .into_response())
422 }
423
424 // ── POST /oauth/authorize ──
425
426 #[tracing::instrument(skip_all, name = "oauth::authorize_post")]
427 async fn authorize_post(
428 State(db): State<PgPool>,
429 MaybeUserVerified(session_user): MaybeUserVerified,
430 session: Session,
431 Form(form): Form<AuthorizeForm>,
432 ) -> Result<Response> {
433 // Validate CSRF via the consuming variant, returns the sealed witness
434 // type, so a future refactor that strips the validation call from this
435 // handler fails to compile rather than silently un-gating the mutation.
436 let _validated = csrf::validate_token_consuming(&session, &form.csrf_token).await?;
437
438 // Cap the size of attacker-controlled fields before they get persisted
439 // (state goes into the auth_codes row + the redirect URL; code_challenge
440 // is fixed-length base64url of a SHA-256). Unbounded `state` lets a
441 // malicious client store arbitrary blobs in the DB through the OAuth flow.
442 if form.state.len() > 1024 {
443 return Err(AppError::BadRequest(
444 "state parameter too long (max 1024 bytes)".to_string(),
445 ));
446 }
447 // S256 challenges are exactly 43 base64url chars (no padding). Allow 44
448 // for clients that include the trailing `=`. Reject anything outside that
449 // range, including the empty string, as a malformed challenge that would
450 // never verify (ultra-fuzz Run #1 Security LOW: empty was not rejected).
451 if !(43..=44).contains(&form.code_challenge.len()) {
452 return Err(AppError::BadRequest(
453 "code_challenge has invalid length".to_string(),
454 ));
455 }
456
457 if form.code_challenge_method != "S256" {
458 return Err(AppError::BadRequest(
459 "code_challenge_method must be 'S256'".to_string(),
460 ));
461 }
462
463 // Look up app
464 let app = db::synckit::get_sync_app_by_api_key(&db, &form.client_id)
465 .await?
466 .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?;
467
468 if !validate_redirect_uri(&db, app.id, &form.redirect_uri).await? {
469 return Err(AppError::BadRequest("Invalid redirect_uri".to_string()));
470 }
471
472 let csrf_token = csrf::get_or_create_token(&session).await?;
473
474 // Session revocation/suspension is checked by MaybeUserVerified at extraction.
475 // For OAuth grants specifically, also require a tracking ID, legacy
476 // sessions predating session tracking must re-authenticate via password.
477 let has_tracking = has_validated_session(&session).await;
478 let validated_session_user = session_user.as_ref().filter(|_| has_tracking);
479
480 let user_id = if let Some(user) = validated_session_user {
481 // Already logged in via validated MNW session, skip password check
482 user.id
483 } else {
484 // Must authenticate with credentials
485 let login = form.login.as_deref().unwrap_or("");
486 let password = form.password.as_deref().unwrap_or("");
487
488 if login.is_empty() || password.is_empty() {
489 return Ok(render_authorize_error(
490 Some(csrf_token),
491 session_user,
492 &app.name,
493 &form,
494 "Username/email and password are required",
495 ));
496 }
497
498 // Find user by email or username
499 let user = if login.contains('@') {
500 let email = db::Email::new(login)
501 .map_err(|_| AppError::BadRequest("Invalid email".to_string()))?;
502 db::users::get_user_by_email(&db, &email).await?
503 } else {
504 let username = Username::new(login)
505 .map_err(|_| AppError::BadRequest("Invalid username".to_string()))?;
506 db::users::get_user_by_username(&db, &username).await?
507 };
508
509 let Some(user) = user else {
510 // Perform a dummy hash verification to prevent timing-based user enumeration
511 let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
512 return Ok(render_authorize_error(
513 Some(csrf_token),
514 session_user,
515 &app.name,
516 &form,
517 "Invalid username/email or password",
518 ));
519 };
520
521 // Check lockout
522 if let Some(locked_until) = user.locked_until
523 && locked_until > chrono::Utc::now()
524 {
525 let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1;
526 return Ok(render_authorize_error(
527 Some(csrf_token),
528 session_user,
529 &app.name,
530 &form,
531 &format!("Account is locked. Try again in {remaining} minute(s)."),
532 ));
533 }
534
535 // Cap password length to prevent DoS via Argon2 on very long inputs.
536 // Same char-count metric as signup (bytes would lock out multibyte
537 // passwords that were accepted at signup).
538 if crate::validation::password_too_long(password) {
539 return Ok(render_authorize_error(
540 Some(csrf_token),
541 session_user,
542 &app.name,
543 &form,
544 "Invalid username/email or password",
545 ));
546 }
547
548 // Verify the password and account status through the shared relying-party
549 // gate. It folds wrong-password / suspended / deactivated / locked / 2FA
550 // into one accounted decision (always increment on denial, reset on
551 // success) so a correct guess against a blocked account is NOT
552 // distinguishable from a wrong one, closing the confirmed-password oracle
553 // that arose from resetting before the status gates (Run 11 Sec M1). The
554 // friendly "already locked" message above still short-circuits before
555 // Argon2; here, only a freshly-tripped lockout earns a distinct notice.
556 match crate::auth::relying_party_login_gate(&db, &user, password).await? {
557 crate::auth::LoginGate::Deny { just_locked } => {
558 let message = if just_locked {
559 format!(
560 "Too many failed attempts. Account locked for {LOCKOUT_MINUTES} minutes."
561 )
562 } else {
563 "Invalid username/email or password".to_string()
564 };
565 return Ok(render_authorize_error(
566 Some(csrf_token),
567 session_user,
568 &app.name,
569 &form,
570 &message,
571 ));
572 }
573 crate::auth::LoginGate::Allow => {}
574 }
575
576 user.id
577 };
578
579 // Empty scope = legacy sync client; non-empty opts into the userinfo flow.
580 let scope = GrantedScopes::parse(&form.scope);
581
582 // Record this interactive consent so a later prompt=none re-auth can silently
583 // reuse the approved scopes (R6-Sec-L5). Union with any prior grant.
584 db::oauth::record_granted_scopes(&db, user_id, app.id, &scope).await?;
585
586 issue_authorization_code(
587 &db,
588 app.id,
589 user_id,
590 &form.code_challenge,
591 &form.code_challenge_method,
592 &form.redirect_uri,
593 &scope,
594 &form.state,
595 )
596 .await
597 }
598
599 // ── POST /oauth/token ──
600
601 /// OAuth error response in the RFC 6749 §5.2 shape (`{"error":"..."}`), 400.
602 fn oauth_error(code: &str) -> Response {
603 (
604 StatusCode::BAD_REQUEST,
605 Json(serde_json::json!({ "error": code })),
606 )
607 .into_response()
608 }
609
610 #[tracing::instrument(skip_all, name = "oauth::token_exchange")]
611 async fn token_exchange(
612 State(db): State<PgPool>,
613 State(config): State<Config>,
614 axum::Form(req): axum::Form<TokenRequest>,
615 ) -> Result<Response> {
616 let secret = config
617 .synckit_jwt_secret
618 .as_deref()
619 .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?;
620
621 match req.grant_type.as_str() {
622 "authorization_code" => token_authorization_code(&db, secret, req).await,
623 "refresh_token" => token_refresh(&db, secret, req).await,
624 _ => Err(AppError::BadRequest(
625 "grant_type must be 'authorization_code' or 'refresh_token'".to_string(),
626 )),
627 }
628 }
629
630 /// Build the success body: a short-lived scoped access token, optionally a fresh
631 /// refresh token (when `offline_access` is granted), and the granted scope.
632 async fn build_token_response(
633 db: &PgPool,
634 secret: &str,
635 user_id: UserId,
636 app_id: SyncAppId,
637 key: &str,
638 scope: &GrantedScopes,
639 ) -> Result<TokenResponse> {
640 let access_token =
641 synckit_auth::create_oauth_access_token(secret, user_id, app_id, key, scope)?;
642
643 // Issue the first refresh token in a new chain when offline_access is granted.
644 let refresh_token = if scope.contains(OAuthScope::Offline) {
645 let plaintext = generate_refresh_token();
646 let chain_id = uuid::Uuid::new_v4();
647 let expires_at = chrono::Utc::now()
648 + chrono::Duration::seconds(constants::OAUTH_REFRESH_TOKEN_EXPIRY_SECS);
649 db::oauth::create_refresh_token(
650 db,
651 &hash_token(&plaintext),
652 app_id,
653 user_id,
654 key,
655 &scope.to_string(),
656 chain_id,
657 expires_at,
658 )
659 .await?;
660 Some(plaintext)
661 } else {
662 None
663 };
664
665 Ok(TokenResponse {
666 access_token,
667 token_type: "Bearer".to_string(),
668 expires_in: constants::OAUTH_ACCESS_TOKEN_EXPIRY_SECS,
669 refresh_token,
670 scope: scope.to_string(),
671 user_id,
672 app_id,
673 })
674 }
675
676 /// authorization_code grant: verify PKCE, then mint a scoped access token (and a
677 /// refresh token if `offline_access` was granted).
678 async fn token_authorization_code(
679 db: &PgPool,
680 secret: &str,
681 req: TokenRequest,
682 ) -> Result<Response> {
683 crate::validation::validate_synckit_key(&req.key)?;
684
685 let code = req
686 .code
687 .as_deref()
688 .ok_or_else(|| AppError::BadRequest("code is required".to_string()))?;
689 let redirect_uri = req
690 .redirect_uri
691 .as_deref()
692 .ok_or_else(|| AppError::BadRequest("redirect_uri is required".to_string()))?;
693 let code_verifier = req
694 .code_verifier
695 .as_deref()
696 .ok_or_else(|| AppError::BadRequest("code_verifier is required".to_string()))?;
697
698 // Codes are stored hashed; hash the presented plaintext once and use that for
699 // both the peek and the atomic consume below.
700 let code_hash = hash_token(code);
701
702 // Peek the code (does NOT consume it) so a failed client_id / redirect_uri
703 // / PKCE check leaves it usable for the legitimate client's retry instead of
704 // burning it (ultra-fuzz Run #1 Security LOW). The atomic consume below is
705 // what actually claims it, so concurrent redemptions stay race-safe.
706 let oauth_code =
707 db::oauth::peek_oauth_code(db, &code_hash)
708 .await?
709 .ok_or(AppError::BadRequest(
710 "Invalid or expired authorization code".to_string(),
711 ))?;
712
713 let app = db::synckit::get_sync_app_by_api_key(db, &req.client_id)
714 .await?
715 .ok_or(AppError::BadRequest("Unknown client_id".to_string()))?;
716
717 if app.id != oauth_code.app_id {
718 return Err(AppError::BadRequest("client_id does not match".to_string()));
719 }
720
721 if redirect_uri != oauth_code.redirect_uri {
722 return Err(AppError::BadRequest(
723 "redirect_uri does not match".to_string(),
724 ));
725 }
726
727 // Pin S256 (defense in depth, see authorize).
728 if oauth_code.code_challenge_method != "S256" {
729 return Err(AppError::BadRequest(
730 "Unsupported PKCE method on authorization code".to_string(),
731 ));
732 }
733
734 let mut hasher = Sha256::new();
735 hasher.update(code_verifier.as_bytes());
736 let digest = hasher.finalize();
737 let computed_challenge = base64_url_nopad_encode(&digest);
738
739 if !crate::helpers::constant_time_compare(&computed_challenge, &oauth_code.code_challenge) {
740 return Err(AppError::BadRequest("PKCE verification failed".to_string()));
741 }
742
743 // All checks passed, now atomically claim the code. A None here means a
744 // concurrent request already redeemed it (or it expired in the gap); the
745 // `used_at IS NULL` guard makes double-redemption impossible.
746 let oauth_code =
747 db::oauth::consume_oauth_code(db, &code_hash)
748 .await?
749 .ok_or(AppError::BadRequest(
750 "Invalid or expired authorization code".to_string(),
751 ))?;
752
753 // Re-check account liveness at redemption. A user suspended or deactivated
754 // between authorize and code->token must not receive a token; the refresh
755 // grant already applies this gate, but the code->token path skipped it,
756 // leaving a window (up to the code TTL) where a just-suspended user could
757 // still mint a ~1h sync token. Mirror the refresh path's liveness check.
758 match db::users::get_user_by_id(db, oauth_code.user_id).await? {
759 Some(u) if !(u.is_suspended() || u.is_deactivated()) => {}
760 _ => return Ok(oauth_error("invalid_grant")),
761 }
762
763 let scope = GrantedScopes::parse(&oauth_code.scope);
764
765 // Only an explicit `scope=sync` mints the full 7-day sync token, the sole
766 // path that issues a sync-API-capable token from /oauth/token. An omitted or
767 // unrecognized scope now falls through to the least-privilege userinfo path
768 // below rather than being escalated to sync (audit Run 17 Security).
769 if scope.is_sync_request() {
770 let token = synckit_auth::create_sync_token(
771 secret,
772 oauth_code.user_id,
773 oauth_code.app_id,
774 &req.key,
775 )?;
776 return Ok(Json(TokenResponse {
777 access_token: token,
778 token_type: "Bearer".to_string(),
779 expires_in: constants::SYNCKIT_JWT_EXPIRY_SECS,
780 refresh_token: None,
781 scope: String::new(),
782 user_id: oauth_code.user_id,
783 app_id: oauth_code.app_id,
784 })
785 .into_response());
786 }
787
788 // Scoped request = the userinfo RP flow: short-lived userinfo token, plus a
789 // refresh token when offline_access was granted.
790 let resp = build_token_response(
791 db,
792 secret,
793 oauth_code.user_id,
794 oauth_code.app_id,
795 &req.key,
796 &scope,
797 )
798 .await?;
799 Ok(Json(resp).into_response())
800 }
801
802 /// refresh_token grant: rotate the presented token (reuse-detected), re-check
803 /// revocation/liveness, enforce downgrade-only scope, and mint a fresh pair.
804 async fn token_refresh(db: &PgPool, secret: &str, req: TokenRequest) -> Result<Response> {
805 let presented = match req.refresh_token.as_deref() {
806 Some(t) if !t.is_empty() => t,
807 _ => return Ok(oauth_error("invalid_request")),
808 };
809
810 let consumed = match db::oauth::rotate_refresh_token(db, &hash_token(presented)).await? {
811 db::oauth::RefreshRotateOutcome::Valid(row) => row,
812 db::oauth::RefreshRotateOutcome::Reused { chain_id } => {
813 // Theft signal: a rotated token was presented again. Kill the chain.
814 db::oauth::revoke_refresh_chain(db, chain_id).await?;
815 return Ok(oauth_error("invalid_grant"));
816 }
817 db::oauth::RefreshRotateOutcome::Invalid => return Ok(oauth_error("invalid_grant")),
818 };
819
820 // Bind the grant to its client: the presented client_id must own this refresh
821 // lineage. The auth-code path enforces this; the refresh path did not, so a
822 // stolen refresh token was redeemable under any client_id (Run #2 Security
823 // MINOR). The token is already rotated above, so a mismatch leaves the stolen
824 // token spent and the legit client's next refresh trips reuse-detection.
825 let client_app = db::synckit::get_sync_app_by_api_key(db, &req.client_id).await?;
826 if client_app.map(|a| a.id) != Some(consumed.app_id) {
827 return Ok(oauth_error("invalid_grant"));
828 }
829
830 // Revocation + liveness via the one shared gate the token extractors use, so
831 // app-deactivate / suspend / password change AND sync-device removal all
832 // kill the refresh lineage (the M-Sec1 parity fix: this path previously
833 // skipped `sync_jwt_invalidated_at`). A liveness failure (Unauthorized)
834 // revokes the chain and denies; a real infrastructure error propagates as
835 // 5xx without touching the chain.
836 match synckit_auth::assert_token_live(
837 db,
838 consumed.app_id,
839 consumed.user_id,
840 consumed.issued_after.timestamp(),
841 )
842 .await
843 {
844 Ok(()) => {}
845 Err(AppError::Unauthorized) => {
846 db::oauth::revoke_refresh_chain(db, consumed.chain_id).await?;
847 return Ok(oauth_error("invalid_grant"));
848 }
849 Err(e) => return Err(e),
850 }
851
852 // Downgrade-only scope: a refresh may narrow but never widen.
853 let stored = GrantedScopes::parse(&consumed.scope);
854 let granted = match req.scope.as_deref() {
855 Some(s) if !s.trim().is_empty() => {
856 let requested = GrantedScopes::parse(s);
857 if !requested.subset_of(&stored) {
858 return Ok(oauth_error("invalid_scope"));
859 }
860 requested
861 }
862 _ => stored,
863 };
864
865 let access_token = synckit_auth::create_oauth_access_token(
866 secret,
867 consumed.user_id,
868 consumed.app_id,
869 &consumed.key,
870 &granted,
871 )?;
872
873 // Rotate: a new refresh token in the SAME chain, when offline_access stays.
874 let refresh_token = if granted.contains(OAuthScope::Offline) {
875 let plaintext = generate_refresh_token();
876 let expires_at = chrono::Utc::now()
877 + chrono::Duration::seconds(constants::OAUTH_REFRESH_TOKEN_EXPIRY_SECS);
878 db::oauth::create_refresh_token(
879 db,
880 &hash_token(&plaintext),
881 consumed.app_id,
882 consumed.user_id,
883 &consumed.key,
884 &granted.to_string(),
885 consumed.chain_id,
886 expires_at,
887 )
888 .await?;
889 Some(plaintext)
890 } else {
891 None
892 };
893
894 Ok(Json(TokenResponse {
895 access_token,
896 token_type: "Bearer".to_string(),
897 expires_in: constants::OAUTH_ACCESS_TOKEN_EXPIRY_SECS,
898 refresh_token,
899 scope: granted.to_string(),
900 user_id: consumed.user_id,
901 app_id: consumed.app_id,
902 })
903 .into_response())
904 }
905
906 /// URL-safe base64 encoding without padding (RFC 4648 Section 5).
907 fn base64_url_nopad_encode(data: &[u8]) -> String {
908 use base64::Engine;
909 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
910 }
911
912 // ── GET /oauth/userinfo ──
913 //
914 // Canonical "what is this user entitled to on MNW" endpoint for external
915 // implementers of "Log in with MNW". Always returns fresh state from the
916 // database, implementers cache client-side and pull-refresh on demand.
917 //
918 // The `perks` object is the extension point: new capabilities are added here
919 // (and to `CreatorTier::features`) as they ship. See `docs/oauth_integration.md`.
920
921 #[derive(Serialize)]
922 struct UserPerks {
923 /// Active Fan+ consumer subscription.
924 fan_plus: bool,
925 /// Has an active creator subscription at any tier.
926 is_creator: bool,
927 /// Structured creator tier info, present when `is_creator` is true.
928 creator_tier: Option<CreatorTierInfo>,
929 }
930
931 #[derive(Serialize)]
932 struct CreatorTierInfo {
933 tier: CreatorTier,
934 features: &'static [&'static str],
935 }
936
937 /// The principal calling `/oauth/userinfo`. Accepts the new userinfo-scoped
938 /// token (the secure path) and, for backward-compatibility during MT's
939 /// migration, the legacy full sync token, treated as holding every scope.
940 /// This is the ONLY place the legacy token remains accepted; the sync API is
941 /// unchanged and still rejects userinfo-aud tokens.
942 enum UserinfoPrincipal {
943 Oauth(OAuthUser),
944 Legacy(SyncUser),
945 }
946
947 impl FromRequestParts<AppState> for UserinfoPrincipal {
948 type Rejection = AppError;
949
950 async fn from_request_parts(
951 parts: &mut Parts,
952 state: &AppState,
953 ) -> std::result::Result<Self, Self::Rejection> {
954 if let Ok(u) = OAuthUser::from_request_parts(parts, state).await {
955 return Ok(UserinfoPrincipal::Oauth(u));
956 }
957 let sync = SyncUser::from_request_parts(parts, state).await?;
958 Ok(UserinfoPrincipal::Legacy(sync))
959 }
960 }
961
962 #[tracing::instrument(skip_all, name = "oauth::userinfo")]
963 async fn userinfo(
964 State(db): State<PgPool>,
965 principal: std::result::Result<UserinfoPrincipal, AppError>,
966 ) -> impl IntoResponse {
967 // (user_id, may read identity, may read perks). Legacy sync token => all.
968 let (user_id, profile_ok, perks_ok) = match principal {
969 Ok(UserinfoPrincipal::Oauth(u)) => (
970 u.user_id,
971 u.scopes.contains(OAuthScope::ProfileRead),
972 u.scopes.contains(OAuthScope::PerksRead),
973 ),
974 Ok(UserinfoPrincipal::Legacy(s)) => (s.user_id, true, true),
975 Err(_) => {
976 return (
977 StatusCode::UNAUTHORIZED,
978 Json(serde_json::json!({"error": "invalid_token"})),
979 )
980 .into_response();
981 }
982 };
983
984 if !profile_ok && !perks_ok {
985 return (
986 StatusCode::FORBIDDEN,
987 Json(serde_json::json!({"error": "insufficient_scope"})),
988 )
989 .into_response();
990 }
991
992 let Ok(Some(db_user)) = db::users::get_user_by_id(&db, user_id).await else {
993 return (
994 StatusCode::UNAUTHORIZED,
995 Json(serde_json::json!({"error": "user_not_found"})),
996 )
997 .into_response();
998 };
999
1000 // user_id (the subject) is always returned; identity and perks are gated.
1001 let mut body = serde_json::Map::new();
1002 body.insert("user_id".to_string(), serde_json::json!(db_user.id));
1003
1004 if profile_ok {
1005 body.insert(
1006 "username".to_string(),
1007 serde_json::json!(db_user.username.to_string()),
1008 );
1009 body.insert(
1010 "display_name".to_string(),
1011 serde_json::json!(db_user.display_name),
1012 );
1013 body.insert(
1014 "avatar_url".to_string(),
1015 serde_json::json!(db_user.avatar_url),
1016 );
1017 }
1018
1019 if perks_ok {
1020 let fan_plus = db::fan_plus::is_fan_plus_active(&db, db_user.id)
1021 .await
1022 .unwrap_or(false);
1023 let creator_tier = db_user
1024 .creator_tier
1025 .as_deref()
1026 .and_then(|s| s.parse::<CreatorTier>().ok());
1027 let perks = UserPerks {
1028 fan_plus,
1029 is_creator: creator_tier.is_some(),
1030 creator_tier: creator_tier.map(|tier| CreatorTierInfo {
1031 tier,
1032 features: tier.features(),
1033 }),
1034 };
1035 body.insert("perks".to_string(), serde_json::json!(perks));
1036 }
1037
1038 Json(serde_json::Value::Object(body)).into_response()
1039 }
1040
1041 // ── GET /.well-known/oauth-authorization-server (RFC 8414) ──
1042
1043 #[tracing::instrument(skip_all, name = "oauth::discovery")]
1044 async fn discovery_metadata(State(config): State<Config>) -> impl IntoResponse {
1045 let base = config.host_url.trim_end_matches('/');
1046 Json(serde_json::json!({
1047 "issuer": base,
1048 "authorization_endpoint": format!("{base}/oauth/authorize"),
1049 "token_endpoint": format!("{base}/oauth/token"),
1050 "userinfo_endpoint": format!("{base}/oauth/userinfo"),
1051 "scopes_supported": ["profile:read", "perks:read", "offline_access"],
1052 "response_types_supported": ["code"],
1053 "grant_types_supported": ["authorization_code", "refresh_token"],
1054 "code_challenge_methods_supported": ["S256"],
1055 "token_endpoint_auth_methods_supported": ["none"],
1056 }))
1057 }
1058
1059 // ── Router ──
1060
1061 pub fn oauth_routes() -> CsrfRouter<AppState> {
1062 let authorize_rate_limit = crate::helpers::rate_limiter_ms(
1063 constants::OAUTH_RATE_LIMIT_MS,
1064 constants::OAUTH_RATE_LIMIT_BURST,
1065 );
1066 let token_rate_limit = crate::helpers::rate_limiter_ms(
1067 constants::OAUTH_TOKEN_RATE_LIMIT_MS,
1068 constants::OAUTH_TOKEN_RATE_LIMIT_BURST,
1069 );
1070
1071 let authorize_routes = CsrfRouter::new()
1072 .route_get("/oauth/authorize", get(authorize_get))
1073 .route("/oauth/authorize", post_csrf_manual("OAuth authorize validates the consent form _csrf in-handler via validate_token_consuming", authorize_post))
1074 .route_layer(GovernorLayer::new(authorize_rate_limit));
1075
1076 let token_routes = CsrfRouter::new()
1077 .route(
1078 "/oauth/token",
1079 post_csrf_skip("pre-auth OAuth token exchange", token_exchange),
1080 )
1081 .route_layer(GovernorLayer::new(token_rate_limit));
1082
1083 // userinfo is DB-amplifying (user + creator-tier lookup) and discovery is a
1084 // public read; govern both so every public OAuth route carries a rate limit
1085 // (SEC-S3, Run #23, they were previously merged in ungoverned).
1086 let read_rate_limit = crate::helpers::rate_limiter_ms(
1087 constants::API_READ_RATE_LIMIT_MS,
1088 constants::API_READ_RATE_LIMIT_BURST,
1089 );
1090 let read_routes = CsrfRouter::new()
1091 .route_get("/oauth/userinfo", get(userinfo))
1092 .route_get(
1093 "/.well-known/oauth-authorization-server",
1094 get(discovery_metadata),
1095 )
1096 .route_layer(GovernorLayer::new(read_rate_limit));
1097
1098 authorize_routes.merge(token_routes).merge(read_routes)
1099 }
1100
1101 #[cfg(test)]
1102 mod tests {
1103 use super::build_oauth_redirect;
1104
1105 #[test]
1106 fn appends_query_to_plain_uri() {
1107 let url = build_oauth_redirect(
1108 "https://app.example/cb",
1109 &[("code", "abc"), ("state", "s1")],
1110 );
1111 assert_eq!(url, "https://app.example/cb?code=abc&state=s1");
1112 }
1113
1114 #[test]
1115 fn merges_with_existing_query() {
1116 let url = build_oauth_redirect("https://app.example/cb?foo=bar", &[("code", "abc")]);
1117 assert_eq!(url, "https://app.example/cb?foo=bar&code=abc");
1118 }
1119
1120 #[test]
1121 fn preserves_fragment_and_keeps_query_before_it() {
1122 // R6-UX-1: the naive contains('?') builder appended ?code= AFTER the
1123 // fragment, corrupting the callback. Real URL parsing keeps the query in
1124 // its own component, ahead of the fragment.
1125 let url = build_oauth_redirect(
1126 "https://app.example/cb#frag",
1127 &[("code", "abc"), ("state", "s1")],
1128 );
1129 assert_eq!(url, "https://app.example/cb?code=abc&state=s1#frag");
1130 }
1131
1132 #[test]
1133 fn percent_encodes_values() {
1134 let url = build_oauth_redirect("https://app.example/cb", &[("error", "consent required")]);
1135 assert!(url.contains("error=consent+required") || url.contains("error=consent%20required"));
1136 }
1137
1138 #[test]
1139 fn loopback_callback_gets_query() {
1140 let url = build_oauth_redirect(
1141 "http://127.0.0.1:9999/callback",
1142 &[("code", "xyz"), ("state", "s")],
1143 );
1144 assert_eq!(url, "http://127.0.0.1:9999/callback?code=xyz&state=s");
1145 }
1146 }
1147