Skip to main content

mas_handlers/graphql/mutations/
oauth2_session.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use anyhow::Context as _;
9use async_graphql::{Context, Description, Enum, ID, InputObject, Object};
10use chrono::Duration;
11use mas_data_model::{Device, TokenType};
12use mas_storage::{
13    RepositoryAccess,
14    oauth2::{
15        OAuth2AccessTokenRepository, OAuth2ClientRepository, OAuth2RefreshTokenRepository,
16        OAuth2SessionRepository,
17    },
18    queue::{QueueJobRepositoryExt as _, SyncDevicesJob},
19    user::UserRepository,
20};
21use oauth2_types::scope::Scope;
22
23use crate::graphql::{
24    model::{NodeType, OAuth2Session},
25    state::ContextExt,
26};
27
28#[derive(Default)]
29pub struct OAuth2SessionMutations {
30    _private: (),
31}
32
33/// The input of the `createOauth2Session` mutation.
34#[derive(InputObject)]
35pub struct CreateOAuth2SessionInput {
36    /// The scope of the session
37    scope: String,
38
39    /// The ID of the user for which to create the session
40    user_id: ID,
41
42    /// Whether the session should issue a never-expiring access token
43    permanent: Option<bool>,
44}
45
46/// The payload of the `createOauth2Session` mutation.
47#[derive(Description)]
48pub struct CreateOAuth2SessionPayload {
49    access_token: String,
50    refresh_token: Option<String>,
51    session: mas_data_model::Session,
52}
53
54#[Object(use_type_description)]
55impl CreateOAuth2SessionPayload {
56    /// Access token for this session
57    pub async fn access_token(&self) -> &str {
58        &self.access_token
59    }
60
61    /// Refresh token for this session, if it is not a permanent session
62    pub async fn refresh_token(&self) -> Option<&str> {
63        self.refresh_token.as_deref()
64    }
65
66    /// The OAuth 2.0 session which was just created
67    pub async fn oauth2_session(&self) -> OAuth2Session {
68        OAuth2Session(self.session.clone())
69    }
70}
71
72/// The input of the `endOauth2Session` mutation.
73#[derive(InputObject)]
74pub struct EndOAuth2SessionInput {
75    /// The ID of the session to end.
76    oauth2_session_id: ID,
77}
78
79/// The payload of the `endOauth2Session` mutation.
80pub enum EndOAuth2SessionPayload {
81    NotFound,
82    Ended(Box<mas_data_model::Session>),
83}
84
85/// The status of the `endOauth2Session` mutation.
86#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug)]
87enum EndOAuth2SessionStatus {
88    /// The session was ended.
89    Ended,
90
91    /// The session was not found.
92    NotFound,
93}
94
95#[Object]
96impl EndOAuth2SessionPayload {
97    /// The status of the mutation.
98    async fn status(&self) -> EndOAuth2SessionStatus {
99        match self {
100            Self::Ended(_) => EndOAuth2SessionStatus::Ended,
101            Self::NotFound => EndOAuth2SessionStatus::NotFound,
102        }
103    }
104
105    /// Returns the ended session.
106    async fn oauth2_session(&self) -> Option<OAuth2Session> {
107        match self {
108            Self::Ended(session) => Some(OAuth2Session(*session.clone())),
109            Self::NotFound => None,
110        }
111    }
112}
113
114/// The input of the `setOauth2SessionName` mutation.
115#[derive(InputObject)]
116pub struct SetOAuth2SessionNameInput {
117    /// The ID of the session to set the name of.
118    oauth2_session_id: ID,
119
120    /// The new name of the session.
121    human_name: String,
122}
123
124/// The payload of the `setOauth2SessionName` mutation.
125pub enum SetOAuth2SessionNamePayload {
126    /// The session was not found.
127    NotFound,
128
129    /// The session was updated.
130    Updated(Box<mas_data_model::Session>),
131}
132
133/// The status of the `setOauth2SessionName` mutation.
134#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug)]
135enum SetOAuth2SessionNameStatus {
136    /// The session was updated.
137    Updated,
138
139    /// The session was not found.
140    NotFound,
141}
142
143#[Object]
144impl SetOAuth2SessionNamePayload {
145    /// The status of the mutation.
146    async fn status(&self) -> SetOAuth2SessionNameStatus {
147        match self {
148            Self::Updated(_) => SetOAuth2SessionNameStatus::Updated,
149            Self::NotFound => SetOAuth2SessionNameStatus::NotFound,
150        }
151    }
152
153    /// The session that was updated.
154    async fn oauth2_session(&self) -> Option<OAuth2Session> {
155        match self {
156            Self::Updated(session) => Some(OAuth2Session(*session.clone())),
157            Self::NotFound => None,
158        }
159    }
160}
161
162#[Object]
163impl OAuth2SessionMutations {
164    /// Create a new arbitrary OAuth 2.0 Session.
165    ///
166    /// Only available for administrators.
167    async fn create_oauth2_session(
168        &self,
169        ctx: &Context<'_>,
170        input: CreateOAuth2SessionInput,
171    ) -> Result<CreateOAuth2SessionPayload, async_graphql::Error> {
172        let state = ctx.state();
173        let homeserver = state.homeserver_connection();
174        let user_id = NodeType::User.extract_ulid(&input.user_id)?;
175        let scope: Scope = input
176            .scope
177            .parse()
178            .map_err(|_| async_graphql::Error::new("Invalid scope"))?;
179        let permanent = input.permanent.unwrap_or(false);
180        let requester = ctx.requester();
181
182        if !requester.is_admin() {
183            return Err(async_graphql::Error::new("Unauthorized"));
184        }
185
186        let session = requester
187            .oauth2_session()
188            .context("Requester should be a OAuth 2.0 client")?;
189
190        let mut repo = state.repository().await?;
191        let clock = state.clock();
192        let mut rng = state.rng();
193
194        let client = repo
195            .oauth2_client()
196            .lookup(session.client_id)
197            .await?
198            .context("Client not found")?;
199
200        let user = repo
201            .user()
202            .lookup(user_id)
203            .await?
204            .ok_or_else(|| async_graphql::Error::new("User not found"))?;
205
206        // Generate a new access token
207        let access_token = TokenType::AccessToken.generate(&mut rng);
208
209        // Create the OAuth 2.0 Session
210        let session = repo
211            .oauth2_session()
212            .add(&mut rng, &clock, &client, Some(&user), None, scope)
213            .await?;
214
215        // Lock the user sync to make sure we don't get into a race condition
216        repo.user().acquire_lock_for_sync(&user).await?;
217
218        // Look for devices to provision
219        for scope in &*session.scope {
220            if let Some(device) = Device::from_scope_token(scope) {
221                homeserver
222                    .upsert_device(&user.username, device.as_str(), None)
223                    .await
224                    .context("Failed to provision device")?;
225            }
226        }
227
228        let ttl = if permanent {
229            None
230        } else {
231            Some(Duration::microseconds(5 * 60 * 1000 * 1000))
232        };
233        let access_token = repo
234            .oauth2_access_token()
235            .add(&mut rng, &clock, &session, access_token, ttl)
236            .await?;
237
238        let refresh_token = if permanent {
239            None
240        } else {
241            let refresh_token = TokenType::RefreshToken.generate(&mut rng);
242
243            let refresh_token = repo
244                .oauth2_refresh_token()
245                .add(&mut rng, &clock, &session, &access_token, refresh_token)
246                .await?;
247
248            Some(refresh_token)
249        };
250
251        repo.save().await?;
252
253        Ok(CreateOAuth2SessionPayload {
254            session,
255            access_token: access_token.access_token,
256            refresh_token: refresh_token.map(|t| t.refresh_token),
257        })
258    }
259
260    async fn end_oauth2_session(
261        &self,
262        ctx: &Context<'_>,
263        input: EndOAuth2SessionInput,
264    ) -> Result<EndOAuth2SessionPayload, async_graphql::Error> {
265        let state = ctx.state();
266        let oauth2_session_id = NodeType::OAuth2Session.extract_ulid(&input.oauth2_session_id)?;
267        let requester = ctx.requester();
268
269        let mut repo = state.repository().await?;
270        let clock = state.clock();
271        let mut rng = state.rng();
272
273        let session = repo.oauth2_session().lookup(oauth2_session_id).await?;
274        let Some(session) = session else {
275            return Ok(EndOAuth2SessionPayload::NotFound);
276        };
277
278        if !requester.is_owner_or_admin(&session) {
279            return Ok(EndOAuth2SessionPayload::NotFound);
280        }
281
282        if let Some(user_id) = session.user_id {
283            let user = repo
284                .user()
285                .lookup(user_id)
286                .await?
287                .context("Could not load user")?;
288
289            // Schedule a job to sync the devices of the user with the homeserver
290            repo.queue_job()
291                .schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user))
292                .await?;
293        }
294
295        let session = repo.oauth2_session().finish(&clock, session).await?;
296
297        repo.save().await?;
298
299        Ok(EndOAuth2SessionPayload::Ended(Box::new(session)))
300    }
301
302    async fn set_oauth2_session_name(
303        &self,
304        ctx: &Context<'_>,
305        input: SetOAuth2SessionNameInput,
306    ) -> Result<SetOAuth2SessionNamePayload, async_graphql::Error> {
307        let state = ctx.state();
308        let oauth2_session_id = NodeType::OAuth2Session.extract_ulid(&input.oauth2_session_id)?;
309        let requester = ctx.requester();
310
311        let mut repo = state.repository().await?;
312        let homeserver = state.homeserver_connection();
313
314        let session = repo.oauth2_session().lookup(oauth2_session_id).await?;
315        let Some(session) = session else {
316            return Ok(SetOAuth2SessionNamePayload::NotFound);
317        };
318
319        if !requester.is_owner_or_admin(&session) {
320            return Ok(SetOAuth2SessionNamePayload::NotFound);
321        }
322
323        let user_id = session.user_id.context("Session has no user")?;
324
325        let user = repo
326            .user()
327            .lookup(user_id)
328            .await?
329            .context("User not found")?;
330
331        let session = repo
332            .oauth2_session()
333            .set_human_name(session, Some(input.human_name.clone()))
334            .await?;
335
336        // Update the device on the homeserver side
337        for scope in &*session.scope {
338            if let Some(device) = Device::from_scope_token(scope) {
339                homeserver
340                    .update_device_display_name(&user.username, device.as_str(), &input.human_name)
341                    .await
342                    .context("Failed to provision device")?;
343            }
344        }
345
346        repo.save().await?;
347
348        Ok(SetOAuth2SessionNamePayload::Updated(Box::new(session)))
349    }
350}