Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate oauth to slices Migrate the OAuth2 provider. Care taken around the OAuth protocol `state` parameter (params.state/form.state/state_param), which is unrelated to AppState and left untouched. Handlers: - authorize_get, authorize_post, userinfo: State<PgPool> - token_exchange: State<PgPool> + State<Config> (synckit_jwt_secret) - discovery_metadata: State<Config> Token helpers (build_token_response, token_authorization_code, token_refresh) take &PgPool. The UserinfoPrincipal extractor keeps &AppState (it delegates to the OAuthUser/SyncUser extractors). Narrow the shared synckit_auth::assert_token_live liveness gate to &PgPool; its OAuthUser/SyncUser extractor callers pass &state.db. No behavior change; 23 oauth integration tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 20:32 UTC
Commit: 886154b743b14915b6d3b1ecde015edd1bb824da
Parent: e36e40c
2 files changed, +46 insertions, -43 deletions
@@ -18,8 +18,11 @@ use sha2::{Digest, Sha256};
18 18 use tower_governor::GovernorLayer;
19 19 use tower_sessions::Session;
20 20
21 + use sqlx::PgPool;
22 +
21 23 use crate::{
22 24 auth::{verify_password_async, MaybeUserVerified},
25 + config::Config,
23 26 constants::{self, LOCKOUT_MINUTES},
24 27 csrf,
25 28 db::{self, CreatorTier, SyncAppId, UserId, Username},
@@ -275,7 +278,7 @@ async fn has_validated_session(session: &Session) -> bool {
275 278
276 279 #[tracing::instrument(skip_all, name = "oauth::authorize_get")]
277 280 async fn authorize_get(
278 - State(state): State<AppState>,
281 + State(db): State<PgPool>,
279 282 MaybeUserVerified(session_user): MaybeUserVerified,
280 283 session: Session,
281 284 Query(params): Query<AuthorizeQuery>,
@@ -317,11 +320,11 @@ async fn authorize_get(
317 320 }
318 321
319 322 // Look up app by client_id (= sync_apps.api_key)
320 - let app = db::synckit::get_sync_app_by_api_key(&state.db, client_id)
323 + let app = db::synckit::get_sync_app_by_api_key(&db, client_id)
321 324 .await?
322 325 .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?;
323 326
324 - if !validate_redirect_uri(&state.db, app.id, redirect_uri).await? {
327 + if !validate_redirect_uri(&db, app.id, redirect_uri).await? {
325 328 return Err(AppError::BadRequest("redirect_uri is not allowed".to_string()));
326 329 }
327 330
@@ -350,7 +353,7 @@ async fn authorize_get(
350 353 // purely from the DB, so a client-supplied userinfo scope can
351 354 // never silently widen a grant.
352 355 let granted =
353 - db::oauth::get_granted_scopes(&state.db, user.id, app.id).await?;
356 + db::oauth::get_granted_scopes(&db, user.id, app.id).await?;
354 357 if !scope.is_sync_request() && !scope.subset_of(&granted) {
355 358 return Ok(redirect_with_error(
356 359 redirect_uri,
@@ -359,7 +362,7 @@ async fn authorize_get(
359 362 ));
360 363 }
361 364 issue_authorization_code(
362 - &state.db,
365 + &db,
363 366 app.id,
364 367 user.id,
365 368 code_challenge,
@@ -395,7 +398,7 @@ async fn authorize_get(
395 398
396 399 #[tracing::instrument(skip_all, name = "oauth::authorize_post")]
397 400 async fn authorize_post(
398 - State(state): State<AppState>,
401 + State(db): State<PgPool>,
399 402 MaybeUserVerified(session_user): MaybeUserVerified,
400 403 session: Session,
401 404 Form(form): Form<AuthorizeForm>,
@@ -425,11 +428,11 @@ async fn authorize_post(
425 428 }
426 429
427 430 // Look up app
428 - let app = db::synckit::get_sync_app_by_api_key(&state.db, &form.client_id)
431 + let app = db::synckit::get_sync_app_by_api_key(&db, &form.client_id)
429 432 .await?
430 433 .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?;
431 434
432 - if !validate_redirect_uri(&state.db, app.id, &form.redirect_uri).await? {
435 + if !validate_redirect_uri(&db, app.id, &form.redirect_uri).await? {
433 436 return Err(AppError::BadRequest("Invalid redirect_uri".to_string()));
434 437 }
435 438
@@ -463,10 +466,10 @@ async fn authorize_post(
463 466 let user = if login.contains('@') {
464 467 let email = db::Email::new(login)
465 468 .map_err(|_| AppError::BadRequest("Invalid email".to_string()))?;
466 - db::users::get_user_by_email(&state.db, &email).await?
469 + db::users::get_user_by_email(&db, &email).await?
467 470 } else {
468 471 let username = Username::new(login).map_err(|_| AppError::BadRequest("Invalid username".to_string()))?;
469 - db::users::get_user_by_username(&state.db, &username).await?
472 + db::users::get_user_by_username(&db, &username).await?
470 473 };
471 474
472 475 let user = match user {
@@ -519,7 +522,7 @@ async fn authorize_post(
519 522 // that arose from resetting before the status gates (Run 11 Sec M1). The
520 523 // friendly "already locked" message above still short-circuits before
521 524 // Argon2; here, only a freshly-tripped lockout earns a distinct notice.
522 - match crate::auth::relying_party_login_gate(&state.db, &user, password).await? {
525 + match crate::auth::relying_party_login_gate(&db, &user, password).await? {
523 526 crate::auth::LoginGate::Deny { just_locked } => {
524 527 let message = if just_locked {
525 528 format!(
@@ -548,10 +551,10 @@ async fn authorize_post(
548 551
549 552 // Record this interactive consent so a later prompt=none re-auth can silently
550 553 // reuse the approved scopes (R6-Sec-L5). Union with any prior grant.
551 - db::oauth::record_granted_scopes(&state.db, user_id, app.id, &scope).await?;
554 + db::oauth::record_granted_scopes(&db, user_id, app.id, &scope).await?;
552 555
553 556 issue_authorization_code(
554 - &state.db,
557 + &db,
555 558 app.id,
556 559 user_id,
557 560 &form.code_challenge,
@@ -576,18 +579,18 @@ fn oauth_error(code: &str) -> Response {
576 579
577 580 #[tracing::instrument(skip_all, name = "oauth::token_exchange")]
578 581 async fn token_exchange(
579 - State(state): State<AppState>,
582 + State(db): State<PgPool>,
583 + State(config): State<Config>,
580 584 axum::Form(req): axum::Form<TokenRequest>,
581 585 ) -> Result<Response> {
582 - let secret = state
583 - .config
586 + let secret = config
584 587 .synckit_jwt_secret
585 588 .as_deref()
586 589 .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?;
587 590
588 591 match req.grant_type.as_str() {
589 - "authorization_code" => token_authorization_code(&state, secret, req).await,
590 - "refresh_token" => token_refresh(&state, secret, req).await,
592 + "authorization_code" => token_authorization_code(&db, secret, req).await,
593 + "refresh_token" => token_refresh(&db, secret, req).await,
591 594 _ => Err(AppError::BadRequest(
592 595 "grant_type must be 'authorization_code' or 'refresh_token'".to_string(),
593 596 )),
@@ -597,7 +600,7 @@ async fn token_exchange(
597 600 /// Build the success body: a short-lived scoped access token, optionally a fresh
598 601 /// refresh token (when `offline_access` is granted), and the granted scope.
599 602 async fn build_token_response(
600 - state: &AppState,
603 + db: &PgPool,
601 604 secret: &str,
602 605 user_id: UserId,
603 606 app_id: SyncAppId,
@@ -614,7 +617,7 @@ async fn build_token_response(
614 617 let expires_at = chrono::Utc::now()
615 618 + chrono::Duration::seconds(constants::OAUTH_REFRESH_TOKEN_EXPIRY_SECS);
616 619 db::oauth::create_refresh_token(
617 - &state.db,
620 + db,
618 621 &hash_token(&plaintext),
619 622 app_id,
620 623 user_id,
@@ -643,7 +646,7 @@ async fn build_token_response(
643 646 /// authorization_code grant: verify PKCE, then mint a scoped access token (and a
644 647 /// refresh token if `offline_access` was granted).
645 648 async fn token_authorization_code(
646 - state: &AppState,
649 + db: &PgPool,
647 650 secret: &str,
648 651 req: TokenRequest,
649 652 ) -> Result<Response> {
@@ -670,11 +673,11 @@ async fn token_authorization_code(
670 673 // / PKCE check leaves it usable for the legitimate client's retry instead of
671 674 // burning it (ultra-fuzz Run #1 Security LOW). The atomic consume below is
672 675 // what actually claims it, so concurrent redemptions stay race-safe.
673 - let oauth_code = db::oauth::peek_oauth_code(&state.db, &code_hash)
676 + let oauth_code = db::oauth::peek_oauth_code(db, &code_hash)
674 677 .await?
675 678 .ok_or(AppError::BadRequest("Invalid or expired authorization code".to_string()))?;
676 679
677 - let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.client_id)
680 + let app = db::synckit::get_sync_app_by_api_key(db, &req.client_id)
678 681 .await?
679 682 .ok_or(AppError::BadRequest("Unknown client_id".to_string()))?;
680 683
@@ -705,7 +708,7 @@ async fn token_authorization_code(
705 708 // All checks passed — now atomically claim the code. A None here means a
706 709 // concurrent request already redeemed it (or it expired in the gap); the
707 710 // `used_at IS NULL` guard makes double-redemption impossible.
708 - let oauth_code = db::oauth::consume_oauth_code(&state.db, &code_hash)
711 + let oauth_code = db::oauth::consume_oauth_code(db, &code_hash)
709 712 .await?
710 713 .ok_or(AppError::BadRequest("Invalid or expired authorization code".to_string()))?;
711 714
@@ -714,7 +717,7 @@ async fn token_authorization_code(
714 717 // grant already applies this gate, but the code->token path skipped it,
715 718 // leaving a window (up to the code TTL) where a just-suspended user could
716 719 // still mint a ~1h sync token. Mirror the refresh path's liveness check.
717 - match db::users::get_user_by_id(&state.db, oauth_code.user_id).await? {
720 + match db::users::get_user_by_id(db, oauth_code.user_id).await? {
718 721 Some(u) if !(u.is_suspended() || u.is_deactivated()) => {}
719 722 _ => return Ok(oauth_error("invalid_grant")),
720 723 }
@@ -747,24 +750,24 @@ async fn token_authorization_code(
747 750 // Scoped request = the userinfo RP flow: short-lived userinfo token, plus a
748 751 // refresh token when offline_access was granted.
749 752 let resp =
750 - build_token_response(state, secret, oauth_code.user_id, oauth_code.app_id, &req.key, &scope)
753 + build_token_response(db, secret, oauth_code.user_id, oauth_code.app_id, &req.key, &scope)
751 754 .await?;
752 755 Ok(Json(resp).into_response())
753 756 }
754 757
755 758 /// refresh_token grant: rotate the presented token (reuse-detected), re-check
756 759 /// revocation/liveness, enforce downgrade-only scope, and mint a fresh pair.
757 - async fn token_refresh(state: &AppState, secret: &str, req: TokenRequest) -> Result<Response> {
760 + async fn token_refresh(db: &PgPool, secret: &str, req: TokenRequest) -> Result<Response> {
758 761 let presented = match req.refresh_token.as_deref() {
759 762 Some(t) if !t.is_empty() => t,
760 763 _ => return Ok(oauth_error("invalid_request")),
761 764 };
762 765
763 - let consumed = match db::oauth::rotate_refresh_token(&state.db, &hash_token(presented)).await? {
766 + let consumed = match db::oauth::rotate_refresh_token(db, &hash_token(presented)).await? {
764 767 db::oauth::RefreshRotateOutcome::Valid(row) => row,
765 768 db::oauth::RefreshRotateOutcome::Reused { chain_id } => {
766 769 // Theft signal: a rotated token was presented again. Kill the chain.
767 - db::oauth::revoke_refresh_chain(&state.db, chain_id).await?;
770 + db::oauth::revoke_refresh_chain(db, chain_id).await?;
768 771 return Ok(oauth_error("invalid_grant"));
769 772 }
770 773 db::oauth::RefreshRotateOutcome::Invalid => return Ok(oauth_error("invalid_grant")),
@@ -775,7 +778,7 @@ async fn token_refresh(state: &AppState, secret: &str, req: TokenRequest) -> Res
775 778 // stolen refresh token was redeemable under any client_id (Run #2 Security
776 779 // MINOR). The token is already rotated above, so a mismatch leaves the stolen
777 780 // token spent and the legit client's next refresh trips reuse-detection.
778 - let client_app = db::synckit::get_sync_app_by_api_key(&state.db, &req.client_id).await?;
781 + let client_app = db::synckit::get_sync_app_by_api_key(db, &req.client_id).await?;
779 782 if client_app.map(|a| a.id) != Some(consumed.app_id) {
780 783 return Ok(oauth_error("invalid_grant"));
781 784 }
@@ -787,7 +790,7 @@ async fn token_refresh(state: &AppState, secret: &str, req: TokenRequest) -> Res
787 790 // revokes the chain and denies; a real infrastructure error propagates as
788 791 // 5xx without touching the chain.
789 792 match synckit_auth::assert_token_live(
790 - state,
793 + db,
791 794 consumed.app_id,
792 795 consumed.user_id,
793 796 consumed.issued_after.timestamp(),
@@ -796,7 +799,7 @@ async fn token_refresh(state: &AppState, secret: &str, req: TokenRequest) -> Res
796 799 {
797 800 Ok(()) => {}
798 801 Err(AppError::Unauthorized) => {
799 - db::oauth::revoke_refresh_chain(&state.db, consumed.chain_id).await?;
802 + db::oauth::revoke_refresh_chain(db, consumed.chain_id).await?;
800 803 return Ok(oauth_error("invalid_grant"));
801 804 }
802 805 Err(e) => return Err(e),
@@ -829,7 +832,7 @@ async fn token_refresh(state: &AppState, secret: &str, req: TokenRequest) -> Res
829 832 let expires_at = chrono::Utc::now()
830 833 + chrono::Duration::seconds(constants::OAUTH_REFRESH_TOKEN_EXPIRY_SECS);
831 834 db::oauth::create_refresh_token(
832 - &state.db,
835 + db,
833 836 &hash_token(&plaintext),
834 837 consumed.app_id,
835 838 consumed.user_id,
@@ -914,7 +917,7 @@ impl FromRequestParts<AppState> for UserinfoPrincipal {
914 917
915 918 #[tracing::instrument(skip_all, name = "oauth::userinfo")]
916 919 async fn userinfo(
917 - State(state): State<AppState>,
920 + State(db): State<PgPool>,
918 921 principal: std::result::Result<UserinfoPrincipal, AppError>,
919 922 ) -> impl IntoResponse {
920 923 // (user_id, may read identity, may read perks). Legacy sync token => all.
@@ -942,7 +945,7 @@ async fn userinfo(
942 945 .into_response();
943 946 }
944 947
945 - let db_user = match db::users::get_user_by_id(&state.db, user_id).await {
948 + let db_user = match db::users::get_user_by_id(&db, user_id).await {
946 949 Ok(Some(u)) => u,
947 950 _ => {
948 951 return (
@@ -964,7 +967,7 @@ async fn userinfo(
964 967 }
965 968
966 969 if perks_ok {
967 - let fan_plus = db::fan_plus::is_fan_plus_active(&state.db, db_user.id)
970 + let fan_plus = db::fan_plus::is_fan_plus_active(&db, db_user.id)
968 971 .await
969 972 .unwrap_or(false);
970 973 let creator_tier = db_user
@@ -988,8 +991,8 @@ async fn userinfo(
988 991 // ── GET /.well-known/oauth-authorization-server (RFC 8414) ──
989 992
990 993 #[tracing::instrument(skip_all, name = "oauth::discovery")]
991 - async fn discovery_metadata(State(state): State<AppState>) -> impl IntoResponse {
992 - let base = state.config.host_url.trim_end_matches('/');
994 + async fn discovery_metadata(State(config): State<Config>) -> impl IntoResponse {
995 + let base = config.host_url.trim_end_matches('/');
993 996 Json(serde_json::json!({
994 997 "issuer": base,
995 998 "authorization_endpoint": format!("{base}/oauth/authorize"),
@@ -131,19 +131,19 @@ pub fn decode_sync_token(secret: &str, token: &str) -> Result<SyncClaims, AppErr
131 131 /// for a liveness failure and a propagated error for an infrastructure failure,
132 132 /// so callers can tell "revoke / deny" apart from "5xx, try again".
133 133 pub async fn assert_token_live(
134 - state: &AppState,
134 + db: &sqlx::PgPool,
135 135 app_id: SyncAppId,
136 136 user_id: UserId,
137 137 issued_at: i64,
138 138 ) -> Result<(), AppError> {
139 - let app = crate::db::synckit::get_sync_app_by_id(&state.db, app_id)
139 + let app = crate::db::synckit::get_sync_app_by_id(db, app_id)
140 140 .await?
141 141 .ok_or(AppError::Unauthorized)?;
142 142 if !app.is_active {
143 143 return Err(AppError::Unauthorized);
144 144 }
145 145
146 - let user = crate::db::users::get_user_by_id(&state.db, user_id)
146 + let user = crate::db::users::get_user_by_id(db, user_id)
147 147 .await?
148 148 .ok_or(AppError::Unauthorized)?;
149 149 if user.is_suspended() || user.is_deactivated() {
@@ -206,7 +206,7 @@ impl FromRequestParts<AppState> for SyncUser {
206 206
207 207 // App-active + user-live + revocation (password change AND device
208 208 // removal), all in one sealed gate so no check can drift out of sync.
209 - assert_token_live(state, claims.app, claims.sub, claims.iat).await?;
209 + assert_token_live(&state.db, claims.app, claims.sub, claims.iat).await?;
210 210
211 211 if claims.key.is_empty() {
212 212 return Err(AppError::Unauthorized);
@@ -327,7 +327,7 @@ impl FromRequestParts<AppState> for OAuthUser {
327 327 // enforces `sync_jwt_invalidated_at`, so removing a sync device kills the
328 328 // userinfo token too — the gate previously skipped the device-removal
329 329 // column here (and on the refresh path), the M-Sec1 parity gap.
330 - assert_token_live(state, claims.app, claims.sub, claims.iat).await?;
330 + assert_token_live(&state.db, claims.app, claims.sub, claims.iat).await?;
331 331
332 332 Ok(OAuthUser {
333 333 user_id: claims.sub,