Skip to main content

headless_lms_models/
oauth_user_client_scopes.rs

1use crate::library::oauth::Digest;
2use crate::prelude::*;
3use chrono::{DateTime, Utc};
4use sqlx::FromRow;
5use utoipa::ToSchema;
6use uuid::Uuid;
7
8/// A user's grant to a client: the scopes they consented to.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)]
10pub struct OAuthUserClientScopes {
11    pub id: Uuid,
12    pub user_id: Uuid,
13    pub client_id: Uuid,
14    pub scopes: Vec<String>,
15    pub granted_at: DateTime<Utc>,
16}
17
18impl OAuthUserClientScopes {
19    pub async fn insert(
20        conn: &mut PgConnection,
21        user_id: Uuid,
22        client_id: Uuid,
23        scopes: &[String],
24    ) -> ModelResult<()> {
25        let mut tx = conn.begin().await?;
26        sqlx::query!(
27            r#"
28                INSERT INTO oauth_user_client_scopes
29                (user_id, client_id, scopes)
30                VALUES ($1, $2, $3)
31                                ON CONFLICT (user_id, client_id) DO UPDATE
32                  SET scopes = EXCLUDED.scopes,
33                      granted_at = NOW()
34            "#,
35            user_id,
36            client_id,
37            scopes
38        )
39        .execute(&mut *tx)
40        .await?;
41        tx.commit().await?;
42        Ok(())
43    }
44
45    /// The user's grant to the client, if any.
46    pub async fn find_grant(
47        conn: &mut PgConnection,
48        user_id: Uuid,
49        client_id: Uuid,
50    ) -> ModelResult<Option<OAuthUserClientScopes>> {
51        let grant = sqlx::query_as!(
52            OAuthUserClientScopes,
53            r#"
54SELECT id,
55  user_id,
56  client_id,
57  scopes,
58  granted_at
59FROM oauth_user_client_scopes
60WHERE user_id = $1
61  AND client_id = $2
62            "#,
63            user_id,
64            client_id
65        )
66        .fetch_optional(conn)
67        .await?;
68        Ok(grant)
69    }
70
71    /// The scopes the user has granted to the client; none without a grant.
72    pub async fn find_scopes(
73        conn: &mut PgConnection,
74        user_id: Uuid,
75        client_id: Uuid,
76    ) -> ModelResult<Vec<String>> {
77        Ok(Self::find_grant(conn, user_id, client_id)
78            .await?
79            .map(|grant| grant.scopes)
80            .unwrap_or_default())
81    }
82
83    /// One-shot revoke: remove all scopes and tokens for a (user, client) pair atomically.
84    ///
85    /// Returns the digests of the deleted access tokens so the caller can evict their
86    /// cached user mappings; without that they keep authenticating until the cache TTL.
87    pub async fn revoke_user_client_everything(
88        conn: &mut PgConnection,
89        user_id: Uuid,
90        client_id: Uuid,
91    ) -> ModelResult<Vec<Digest>> {
92        let mut tx = conn.begin().await?;
93
94        sqlx::query!(
95            r#"DELETE FROM oauth_user_client_scopes WHERE user_id = $1 AND client_id = $2"#,
96            user_id,
97            client_id
98        )
99        .execute(&mut *tx)
100        .await?;
101
102        let deleted_digests =
103            crate::oauth_refresh_tokens::OAuthRefreshTokens::revoke_grant_in_transaction(
104                &mut tx, user_id, client_id,
105            )
106            .await?;
107
108        tx.commit().await?;
109        Ok(deleted_digests)
110    }
111
112    /// The user's grants, most recently used first.
113    pub async fn get_grants_for_user(
114        conn: &mut PgConnection,
115        user_id: Uuid,
116    ) -> ModelResult<Vec<OAuthGrant>> {
117        let res = sqlx::query_as!(
118            OAuthGrant,
119            r#"
120SELECT ucs.id,
121  c.client_name,
122  c.client_uri,
123  (c.metadata_document_fetched_at IS NOT NULL) AS "is_metadata_document_client!",
124  ucs.scopes,
125  ucs.granted_at,
126  ucs.last_used_at
127FROM oauth_user_client_scopes ucs
128  JOIN oauth_clients c ON c.id = ucs.client_id
129WHERE ucs.user_id = $1
130  AND c.deleted_at IS NULL
131ORDER BY ucs.last_used_at DESC NULLS LAST,
132  ucs.granted_at DESC
133        "#,
134            user_id
135        )
136        .fetch_all(conn)
137        .await?;
138        Ok(res)
139    }
140
141    /// Records that a token of the grant was used, at most once a minute per grant. `last_used_at`
142    /// is the grant's value read with the token; while it is recent, no query is sent.
143    pub async fn mark_grant_used(
144        conn: &mut PgConnection,
145        grant_id: Uuid,
146        last_used_at: Option<DateTime<Utc>>,
147    ) -> ModelResult<()> {
148        if last_used_at.is_some_and(|used_at| Utc::now() - used_at < chrono::TimeDelta::minutes(1))
149        {
150            return Ok(());
151        }
152        sqlx::query!(
153            r#"
154UPDATE oauth_user_client_scopes
155SET last_used_at = now()
156WHERE id = $1
157  AND (
158    last_used_at IS NULL
159    OR last_used_at < now() - INTERVAL '1 minute'
160  )
161            "#,
162            grant_id
163        )
164        .execute(conn)
165        .await?;
166        Ok(())
167    }
168
169    /// Revokes one of the user's grants with all its tokens. Errors with `RecordNotFound` if the
170    /// grant is not the user's. Returns the deleted access-token digests for cache eviction.
171    pub async fn revoke_grant_by_id(
172        conn: &mut PgConnection,
173        user_id: Uuid,
174        grant_id: Uuid,
175    ) -> ModelResult<Vec<Digest>> {
176        let client_id = sqlx::query_scalar!(
177            "SELECT client_id FROM oauth_user_client_scopes WHERE id = $1 AND user_id = $2",
178            grant_id,
179            user_id
180        )
181        .fetch_one(&mut *conn)
182        .await?;
183        Self::revoke_user_client_everything(conn, user_id, client_id).await
184    }
185}
186
187/// A user's consent to one client: what the account page lists and revokes.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
189pub struct OAuthGrant {
190    pub id: Uuid,
191    pub client_name: String,
192    pub client_uri: Option<String>,
193    /// Whether the client registered through a Client ID Metadata Document, as AI agents do.
194    pub is_metadata_document_client: bool,
195    pub scopes: Vec<String>,
196    pub granted_at: DateTime<Utc>,
197    /// Minute resolution; `None` if no token from the grant was ever used.
198    pub last_used_at: Option<DateTime<Utc>>,
199}