| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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"),
|