From ae05cbc1f1625595b081f1d29e226a844c31cec3 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 6 Dec 2023 15:53:32 +0100 Subject: [PATCH] Setup the data model for the device code grant --- crates/data-model/src/lib.rs | 4 +- .../src/oauth2/device_code_grant.rs | 262 ++++++++++++++++++ crates/data-model/src/oauth2/mod.rs | 2 + 3 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 crates/data-model/src/oauth2/device_code_grant.rs diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index a765c287..0f1ea321 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -33,8 +33,8 @@ pub use self::{ CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, }, oauth2::{ - AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, - InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState, + AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant, + DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState, }, tokens::{ AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType, diff --git a/crates/data-model/src/oauth2/device_code_grant.rs b/crates/data-model/src/oauth2/device_code_grant.rs new file mode 100644 index 00000000..a22b095f --- /dev/null +++ b/crates/data-model/src/oauth2/device_code_grant.rs @@ -0,0 +1,262 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use chrono::{DateTime, Utc}; +use oauth2_types::scope::Scope; +use serde::Serialize; +use ulid::Ulid; + +use crate::{BrowserSession, InvalidTransitionError}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case", tag = "state")] +pub enum DeviceCodeGrantState { + /// The device code grant is pending. + Pending, + + /// The device code grant has been fulfilled by a user. + Fulfilled { + /// The browser session which was used to complete this device code + /// grant. + browser_session_id: Ulid, + + /// The time at which this device code grant was fulfilled. + fulfilled_at: DateTime, + }, + + /// The device code grant has been rejected by a user. + Rejected { + /// The browser session which was used to reject this device code grant. + browser_session_id: Ulid, + + /// The time at which this device code grant was rejected. + rejected_at: DateTime, + }, + + /// The device code grant was exchanged for an access token. + Exchanged { + /// The browser session which was used to exchange this device code + /// grant. + browser_session_id: Ulid, + + /// The time at which the device code grant was fulfilled. + fulfilled_at: DateTime, + + /// The time at which this device code grant was exchanged. + exchanged_at: DateTime, + + /// The OAuth 2.0 session ID which was created by this device code + /// grant. + session_id: Ulid, + }, +} + +impl DeviceCodeGrantState { + /// Mark this device code grant as fulfilled, returning a new state. + /// + /// # Errors + /// + /// Returns an error if the device code grant is not in the [`Pending`] + /// state. + /// + /// [`Pending`]: DeviceCodeGrantState::Pending + pub fn fulfill( + self, + browser_session: &BrowserSession, + fulfilled_at: DateTime, + ) -> Result { + match self { + DeviceCodeGrantState::Pending => Ok(DeviceCodeGrantState::Fulfilled { + browser_session_id: browser_session.id, + fulfilled_at, + }), + _ => Err(InvalidTransitionError), + } + } + + /// Mark this device code grant as rejected, returning a new state. + /// + /// # Errors + /// + /// Returns an error if the device code grant is not in the [`Pending`] + /// state. + /// + /// [`Pending`]: DeviceCodeGrantState::Pending + pub fn reject( + self, + browser_session: &BrowserSession, + rejected_at: DateTime, + ) -> Result { + match self { + DeviceCodeGrantState::Pending => Ok(DeviceCodeGrantState::Rejected { + browser_session_id: browser_session.id, + rejected_at, + }), + _ => Err(InvalidTransitionError), + } + } + + /// Mark this device code grant as exchanged, returning a new state. + /// + /// # Errors + /// + /// Returns an error if the device code grant is not in the [`Fulfilled`] + /// state. + /// + /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled + pub fn exchange( + self, + session_id: Ulid, + exchanged_at: DateTime, + ) -> Result { + match self { + DeviceCodeGrantState::Fulfilled { + fulfilled_at, + browser_session_id, + .. + } => Ok(DeviceCodeGrantState::Exchanged { + browser_session_id, + fulfilled_at, + exchanged_at, + session_id, + }), + _ => Err(InvalidTransitionError), + } + } + + /// Returns `true` if the device code grant state is [`Pending`]. + /// + /// [`Pending`]: DeviceCodeGrantState::Pending + #[must_use] + pub fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } + + /// Returns `true` if the device code grant state is [`Fulfilled`]. + /// + /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled + #[must_use] + pub fn is_fulfilled(&self) -> bool { + matches!(self, Self::Fulfilled { .. }) + } + + /// Returns `true` if the device code grant state is [`Rejected`]. + /// + /// [`Rejected`]: DeviceCodeGrantState::Rejected + #[must_use] + pub fn is_rejected(&self) -> bool { + matches!(self, Self::Rejected { .. }) + } + + /// Returns `true` if the device code grant state is [`Exchanged`]. + /// + /// [`Exchanged`]: DeviceCodeGrantState::Exchanged + #[must_use] + pub fn is_exchanged(&self) -> bool { + matches!(self, Self::Exchanged { .. }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeviceCodeGrant { + pub id: Ulid, + #[serde(flatten)] + pub state: DeviceCodeGrantState, + + /// The client ID which requested this device code grant. + pub client_id: Ulid, + + /// The scope which was requested by this device code grant. + pub scope: Scope, + + /// The user code which was generated for this device code grant. + /// This is the one that the user will enter into their client. + pub user_code: String, + + /// The device code which was generated for this device code grant. + /// This is the one that the client will use to poll for an access token. + pub device_code: String, + + /// The time at which this device code grant was created. + pub created_at: DateTime, + + /// The time at which this device code grant will expire. + pub expires_at: DateTime, +} + +impl std::ops::Deref for DeviceCodeGrant { + type Target = DeviceCodeGrantState; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl DeviceCodeGrant { + /// Mark this device code grant as fulfilled, returning the updated grant. + /// + /// # Errors + /// + /// Returns an error if the device code grant is not in the [`Pending`] + /// state. + /// + /// [`Pending`]: DeviceCodeGrantState::Pending + pub fn fulfill( + self, + browser_session: &BrowserSession, + fulfilled_at: DateTime, + ) -> Result { + Ok(Self { + state: self.state.fulfill(browser_session, fulfilled_at)?, + ..self + }) + } + + /// Mark this device code grant as rejected, returning the updated grant. + /// + /// # Errors + /// + /// Returns an error if the device code grant is not in the [`Pending`] + /// + /// [`Pending`]: DeviceCodeGrantState::Pending + pub fn reject( + self, + browser_session: &BrowserSession, + rejected_at: DateTime, + ) -> Result { + Ok(Self { + state: self.state.reject(browser_session, rejected_at)?, + ..self + }) + } + + /// Mark this device code grant as exchanged, returning the updated grant. + /// + /// # Errors + /// + /// Returns an error if the device code grant is not in the [`Fulfilled`] + /// state. + /// + /// [`Fulfilled`]: DeviceCodeGrantState::Fulfilled + pub fn exchange( + self, + session_id: Ulid, + exchanged_at: DateTime, + ) -> Result { + Ok(Self { + state: self.state.exchange(session_id, exchanged_at)?, + ..self + }) + } +} diff --git a/crates/data-model/src/oauth2/mod.rs b/crates/data-model/src/oauth2/mod.rs index 14c55694..46285964 100644 --- a/crates/data-model/src/oauth2/mod.rs +++ b/crates/data-model/src/oauth2/mod.rs @@ -14,10 +14,12 @@ mod authorization_grant; mod client; +mod device_code_grant; mod session; pub use self::{ authorization_grant::{AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Pkce}, client::{Client, InvalidRedirectUriError, JwksOrJwksUri}, + device_code_grant::{DeviceCodeGrant, DeviceCodeGrantState}, session::{Session, SessionState}, };