headless_lms_models/
oauth_user_client_scopes.rs

1use crate::prelude::*;
2use chrono::{DateTime, Utc};
3use sqlx::FromRow;
4use uuid::Uuid;
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)]
7pub struct OAuthUserClientScopes {
8    pub user_id: Uuid,
9    pub client_id: Uuid,
10    pub scopes: Vec<String>,
11    pub granted_at: DateTime<Utc>,
12}
13
14#[cfg_attr(feature = "ts_rs", derive(TS))]
15#[derive(Debug, Clone, PartialEq, FromRow, Serialize, Deserialize)]
16pub struct AuthorizedClientInfo {
17    pub client_id: Uuid,     // oauth_clients.id
18    pub client_name: String, // oauth_clients.client_id (display/name)
19    pub scopes: Vec<String>,
20}
21
22impl OAuthUserClientScopes {
23    pub async fn insert(
24        conn: &mut PgConnection,
25        user_id: Uuid,
26        client_id: Uuid,
27        scopes: &[String],
28    ) -> ModelResult<()> {
29        let mut tx = conn.begin().await?;
30        sqlx::query!(
31            r#"
32                INSERT INTO oauth_user_client_scopes
33                (user_id, client_id, scopes)
34                VALUES ($1, $2, $3)
35                                ON CONFLICT (user_id, client_id) DO UPDATE
36                  SET scopes = EXCLUDED.scopes,
37                      granted_at = NOW()
38            "#,
39            user_id,
40            client_id,
41            scopes
42        )
43        .execute(&mut *tx)
44        .await?;
45        tx.commit().await?;
46        Ok(())
47    }
48
49    pub async fn find_scopes(
50        conn: &mut PgConnection,
51        user_id: Uuid,
52        client_id: Uuid,
53    ) -> ModelResult<Vec<String>> {
54        let mut tx = conn.begin().await?;
55        let rows = sqlx::query!(
56            r#"
57            SELECT scopes
58            FROM oauth_user_client_scopes
59            WHERE user_id = $1 AND client_id = $2
60        "#,
61            user_id,
62            client_id
63        )
64        .fetch_all(&mut *tx)
65        .await?;
66        tx.commit().await?;
67        Ok(rows.into_iter().flat_map(|r| r.scopes).collect())
68    }
69
70    pub async fn find_distinct_clients(
71        conn: &mut PgConnection,
72        user_id: Uuid,
73    ) -> ModelResult<Vec<Uuid>> {
74        let mut tx = conn.begin().await?;
75        let rows = sqlx::query!(
76            r#"
77            SELECT DISTINCT client_id
78            FROM oauth_user_client_scopes
79            WHERE user_id = $1
80            "#,
81            user_id
82        )
83        .fetch_all(&mut *tx)
84        .await?;
85        tx.commit().await?;
86        Ok(rows.into_iter().map(|r| r.client_id).collect())
87    }
88
89    pub async fn delete_all_for_user_client(
90        conn: &mut PgConnection,
91        user_id: Uuid,
92        client_id: Uuid,
93    ) -> ModelResult<()> {
94        let mut tx = conn.begin().await?;
95        sqlx::query!(
96            r#"DELETE FROM oauth_user_client_scopes WHERE user_id = $1 AND client_id = $2"#,
97            user_id,
98            client_id
99        )
100        .execute(&mut *tx)
101        .await?;
102        tx.commit().await?;
103        Ok(())
104    }
105
106    pub async fn list_authorized_clients_for_user(
107        conn: &mut PgConnection,
108        user_id: Uuid,
109    ) -> ModelResult<Vec<AuthorizedClientInfo>> {
110        let mut tx = conn.begin().await?;
111        // Aggregate scopes and join to clients to fetch the human-readable name (client.client_id)
112
113        let rows = sqlx::query_as!(
114            AuthorizedClientInfo,
115            r#"
116            SELECT
117              c.id        AS client_id,
118              c.client_id AS client_name,
119              COALESCE(
120                array_agg(DISTINCT s.scope ORDER BY s.scope) FILTER (WHERE s.scope IS NOT NULL),
121                '{}'::text[]
122              ) AS "scopes!: Vec<String>"
123            FROM oauth_user_client_scopes ucs
124            JOIN oauth_clients c ON c.id = ucs.client_id
125            LEFT JOIN LATERAL unnest(ucs.scopes) AS s(scope) ON TRUE
126            WHERE ucs.user_id = $1
127            GROUP BY c.id, c.client_id
128            ORDER BY c.client_id
129            "#,
130            user_id
131        )
132        .fetch_all(&mut *tx)
133        .await?;
134
135        tx.commit().await?;
136        Ok(rows)
137    }
138
139    /// One-shot revoke: remove all scopes and tokens for a (user, client) pair atomically.
140    pub async fn revoke_user_client_everything(
141        conn: &mut PgConnection,
142        user_id: Uuid,
143        client_id: Uuid,
144    ) -> ModelResult<()> {
145        let mut tx = conn.begin().await?;
146
147        sqlx::query!(
148            r#"DELETE FROM oauth_user_client_scopes WHERE user_id = $1 AND client_id = $2"#,
149            user_id,
150            client_id
151        )
152        .execute(&mut *tx)
153        .await?;
154
155        sqlx::query!(
156            r#"DELETE FROM oauth_access_tokens WHERE user_id = $1 AND client_id = $2"#,
157            user_id,
158            client_id
159        )
160        .execute(&mut *tx)
161        .await?;
162
163        sqlx::query!(
164            r#"UPDATE oauth_refresh_tokens SET revoked = true WHERE user_id = $1 AND client_id = $2"#,
165            user_id,
166            client_id
167        )
168        .execute(&mut *tx)
169        .await?;
170
171        sqlx::query!(
172            r#"DELETE FROM oauth_auth_codes WHERE user_id = $1 AND client_id = $2"#,
173            user_id,
174            client_id
175        )
176        .execute(&mut *tx)
177        .await?;
178
179        tx.commit().await?;
180        Ok(())
181    }
182}