//! OAuth 2.0 authorization code storage and retrieval. use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; use super::models::{DbOAuthCode, DbOAuthRefreshToken}; use super::{SyncAppId, UserId}; use crate::error::Result; /// Store a new OAuth authorization code, carrying the granted scope. /// /// `code_hash` is the SHA-256 hex of the opaque code, never the plaintext, /// the `code` column holds the hash, looked up by hash in [`peek_oauth_code`] / /// [`consume_oauth_code`], so a DB read never exposes a live, redeemable code. /// Same at-rest contract as `oauth_refresh_tokens.token_hash`. #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all)] pub(crate) async fn create_oauth_code( pool: &PgPool, code_hash: &str, app_id: SyncAppId, user_id: UserId, code_challenge: &str, code_challenge_method: &str, redirect_uri: &str, scope: &str, expires_at: DateTime, ) -> Result { let row = sqlx::query_as::<_, DbOAuthCode>( r" INSERT INTO oauth_authorization_codes (code, app_id, user_id, code_challenge, code_challenge_method, redirect_uri, scope, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * ", ) .bind(code_hash) .bind(app_id) .bind(user_id) .bind(code_challenge) .bind(code_challenge_method) .bind(redirect_uri) .bind(scope) .bind(expires_at) .fetch_one(pool) .await?; Ok(row) } /// Outcome of presenting a refresh token for rotation. pub(crate) enum RefreshRotateOutcome { /// The token was valid and has just been consumed (marked used). The caller /// must now mint a replacement in the same `chain_id`. Valid(Box), /// The token exists but was already used (rotated). This is a reuse/theft /// signal: the caller must revoke the whole `chain_id`. Reused { chain_id: Uuid }, /// Unknown, expired, or already-revoked token, reject without side effects. Invalid, } /// Store a new refresh token (hashed). Used both on the authorization-code /// grant (first issuance) and on every rotation. #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all)] pub(crate) async fn create_refresh_token( pool: &PgPool, token_hash: &str, app_id: SyncAppId, user_id: UserId, key: &str, scope: &str, chain_id: Uuid, expires_at: DateTime, ) -> Result { let row = sqlx::query_as::<_, DbOAuthRefreshToken>( r" INSERT INTO oauth_refresh_tokens (token_hash, app_id, user_id, key, scope, chain_id, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * ", ) .bind(token_hash) .bind(app_id) .bind(user_id) .bind(key) .bind(scope) .bind(chain_id) .bind(expires_at) .fetch_one(pool) .await?; Ok(row) } /// Atomically consume a refresh token by its hash, classifying the outcome. /// /// The single `UPDATE ... WHERE used_at IS NULL ...` makes consumption /// race-free (mirrors [`consume_oauth_code`]). A miss is then disambiguated: /// an already-used row is a reuse/theft signal (caller revokes the chain), /// anything else is invalid. #[tracing::instrument(skip_all)] pub(crate) async fn rotate_refresh_token( pool: &PgPool, token_hash: &str, ) -> Result { let consumed = sqlx::query_as::<_, DbOAuthRefreshToken>( r" UPDATE oauth_refresh_tokens SET used_at = NOW() WHERE token_hash = $1 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW() RETURNING * ", ) .bind(token_hash) .fetch_optional(pool) .await?; if let Some(row) = consumed { return Ok(RefreshRotateOutcome::Valid(Box::new(row))); } // No row consumed, was it a replay of an already-rotated token? let existing: Option<(Uuid, Option>)> = sqlx::query_as("SELECT chain_id, used_at FROM oauth_refresh_tokens WHERE token_hash = $1") .bind(token_hash) .fetch_optional(pool) .await?; match existing { Some((chain_id, Some(_used))) => Ok(RefreshRotateOutcome::Reused { chain_id }), _ => Ok(RefreshRotateOutcome::Invalid), } } /// Revoke every refresh token in a chain, the response to a reuse/theft signal. #[tracing::instrument(skip_all)] pub(crate) async fn revoke_refresh_chain(pool: &PgPool, chain_id: Uuid) -> Result<()> { sqlx::query( "UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE chain_id = $1 AND revoked_at IS NULL", ) .bind(chain_id) .execute(pool) .await?; Ok(()) } /// Delete expired or long-used refresh tokens. Called opportunistically from /// the health monitor loop alongside [`cleanup_expired_oauth_codes`]. #[tracing::instrument(skip_all)] pub(crate) async fn cleanup_expired_refresh_tokens(pool: &PgPool) -> Result { let result = sqlx::query( "DELETE FROM oauth_refresh_tokens WHERE expires_at < NOW() OR (used_at IS NOT NULL AND used_at < NOW() - INTERVAL '1 day') OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '1 day')", ) .execute(pool) .await?; Ok(result.rows_affected()) } /// Fetch a still-valid authorization code WITHOUT consuming it. /// /// `code_hash` is the SHA-256 hex of the presented code (the column stores the /// hash, not the plaintext). Lets the token handler validate client_id / /// redirect_uri / PKCE against the code's stored values before burning it, so a /// failed validation leaves the code usable for the legitimate client's retry /// (ultra-fuzz Run #1 Security LOW: the code was previously marked used before /// any of those checks). The atomic `consume_oauth_code` below is still what /// actually claims the code, so concurrent redemptions remain race-safe, this /// is only a pre-flight read. #[tracing::instrument(skip_all)] pub(crate) async fn peek_oauth_code(pool: &PgPool, code_hash: &str) -> Result> { let row = sqlx::query_as::<_, DbOAuthCode>( r" SELECT * FROM oauth_authorization_codes WHERE code = $1 AND used_at IS NULL AND expires_at > NOW() ", ) .bind(code_hash) .fetch_optional(pool) .await?; Ok(row) } /// Atomically consume an authorization code: mark it used and return it in one step. /// /// `code_hash` is the SHA-256 hex of the presented code. Returns `Some(code)` if /// it was valid and successfully consumed, or `None` if already used, expired, or /// nonexistent. Because this is a single UPDATE with `used_at IS NULL` in the /// WHERE clause, concurrent requests for the same code will never both succeed. #[tracing::instrument(skip_all)] pub(crate) async fn consume_oauth_code( pool: &PgPool, code_hash: &str, ) -> Result> { let row = sqlx::query_as::<_, DbOAuthCode>( r" UPDATE oauth_authorization_codes SET used_at = NOW() WHERE code = $1 AND used_at IS NULL AND expires_at > NOW() RETURNING * ", ) .bind(code_hash) .fetch_optional(pool) .await?; Ok(row) } /// Delete expired or used authorization codes older than 1 hour. /// Called opportunistically from the health monitor loop. #[tracing::instrument(skip_all)] pub(crate) async fn cleanup_expired_oauth_codes(pool: &PgPool) -> Result { let result = sqlx::query( "DELETE FROM oauth_authorization_codes WHERE expires_at < NOW() - INTERVAL '1 hour' OR (used_at IS NOT NULL AND used_at < NOW() - INTERVAL '1 hour')", ) .execute(pool) .await?; Ok(result.rows_affected()) } /// Check if a redirect URI is registered for a given sync app. /// /// Returns `Ok(false)` when the app row doesn't exist or is inactive, never /// surfaces a "no rows" error to the caller. Matching is **exact-string** on /// the registered `redirect_uris` array; trailing slashes are significant /// (`https://x/cb` and `https://x/cb/` are distinct registrations), so apps /// must register every variant they intend to redirect to. #[tracing::instrument(skip_all)] pub(crate) async fn is_registered_redirect_uri( pool: &PgPool, app_id: SyncAppId, uri: &str, ) -> Result { let row: Option<(bool,)> = sqlx::query_as( "SELECT $2 = ANY(redirect_uris) FROM sync_apps WHERE id = $1 AND is_active = true", ) .bind(app_id) .bind(uri) .fetch_optional(pool) .await?; Ok(row.is_some_and(|r| r.0)) } /// Scopes the user has already interactively consented to for this app. Empty /// when the pair has no row (so a first prompt=none with any non-empty scope is /// not a subset and must fall back to interactive consent). Run 6 R6-Sec-L5. #[tracing::instrument(skip_all)] pub(crate) async fn get_granted_scopes( pool: &PgPool, user_id: UserId, app_id: SyncAppId, ) -> Result { let row = sqlx::query_scalar!( "SELECT scopes FROM oauth_granted_scopes WHERE user_id = $1 AND app_id = $2", user_id as UserId, app_id as SyncAppId, ) .fetch_optional(pool) .await?; Ok(row .map(|s| crate::oauth_scope::GrantedScopes::parse(&s)) .unwrap_or_default()) } /// Record an interactive consent: union the freshly-approved scope set into the /// user's standing grant for this app, so a later prompt=none re-auth can /// silently reuse what was approved (Run 6 R6-Sec-L5). #[tracing::instrument(skip_all)] pub(crate) async fn record_granted_scopes( pool: &PgPool, user_id: UserId, app_id: SyncAppId, scope: &crate::oauth_scope::GrantedScopes, ) -> Result<()> { let mut tx = pool.begin().await?; // Serialize concurrent consent recordings for this (user, app) so the // read-modify-write union below can't lose a just-granted scope to a lost // update (Sec-M1). Keyed on the pair, so it only contends with this user's // own concurrent consents for this app and auto-releases at commit. Mirrors // the per-reporter lock in db::reports::create_report_within_daily_limit. sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':' || $2::text, 0))") .bind(user_id) .bind(app_id) .execute(&mut *tx) .await?; // Read the standing grant INSIDE the lock + transaction. let existing: Option = sqlx::query_scalar( "SELECT scopes FROM oauth_granted_scopes WHERE user_id = $1 AND app_id = $2", ) .bind(user_id) .bind(app_id) .fetch_optional(&mut *tx) .await?; let mut merged = existing .map(|s| crate::oauth_scope::GrantedScopes::parse(&s)) .unwrap_or_default(); merged.union_with(scope); let scopes = merged.to_string(); sqlx::query!( r#" INSERT INTO oauth_granted_scopes (user_id, app_id, scopes) VALUES ($1, $2, $3) ON CONFLICT (user_id, app_id) DO UPDATE SET scopes = EXCLUDED.scopes, updated_at = NOW() "#, user_id as UserId, app_id as SyncAppId, scopes, ) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) }