From 65dbe14795b38332ec6996ba14223146bbf1fb9d Mon Sep 17 00:00:00 2001 From: Daniel Kilimnik Date: Sat, 11 Jan 2025 20:41:46 +0100 Subject: [PATCH 1/4] refactor(oidc): move oidc_client to own module --- .../src/authentication/common_oidc/mod.rs | 88 +++++++++++++++++++ .../{qrcode => common_oidc}/oidc_client.rs | 9 +- crates/matrix-sdk/src/authentication/mod.rs | 3 + .../src/authentication/qrcode/login.rs | 12 +-- .../src/authentication/qrcode/mod.rs | 64 +------------- 5 files changed, 103 insertions(+), 73 deletions(-) create mode 100644 crates/matrix-sdk/src/authentication/common_oidc/mod.rs rename crates/matrix-sdk/src/authentication/{qrcode => common_oidc}/oidc_client.rs (97%) diff --git a/crates/matrix-sdk/src/authentication/common_oidc/mod.rs b/crates/matrix-sdk/src/authentication/common_oidc/mod.rs new file mode 100644 index 00000000000..4a5fba4ea13 --- /dev/null +++ b/crates/matrix-sdk/src/authentication/common_oidc/mod.rs @@ -0,0 +1,88 @@ +// Copyright 2025 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. + +//! Common modules useful when using OIDC as the auththentication mechanism. + +pub(crate) mod oidc_client; + +use as_variant::as_variant; +use openidconnect::core::CoreErrorResponseType; +pub use openidconnect::{ + ConfigurationError, DeviceCodeErrorResponseType, DiscoveryError, HttpClientError, + RequestTokenError, StandardErrorResponse, +}; +use thiserror::Error; + +use crate::{oidc, HttpError}; + +/// Error type describing failures in the interaction between the device +/// attempting to log in and the OIDC provider. +#[derive(Debug, Error)] +pub enum DeviceAuhorizationOidcError { + /// A generic OIDC error happened while we were attempting to register the + /// device with the OIDC provider. + #[error(transparent)] + Oidc(#[from] oidc::OidcError), + + /// The issuer URL failed to be parsed. + #[error(transparent)] + InvalidIssuerUrl(#[from] url::ParseError), + + /// There was an error with our device configuration right before attempting + /// to wait for the access token to be issued by the OIDC provider. + #[error(transparent)] + Configuration(#[from] ConfigurationError), + + /// An error happened while we attempted to discover the authentication + /// issuer URL. + #[error(transparent)] + AuthenticationIssuer(HttpError), + + /// An error happened while we attempted to request a device authorization + /// from the OIDC provider. + #[error(transparent)] + DeviceAuthorization( + #[from] + RequestTokenError< + HttpClientError, + StandardErrorResponse, + >, + ), + + /// An error happened while waiting for the access token to be issued and + /// sent to us by the OIDC provider. + #[error(transparent)] + RequestToken( + #[from] + RequestTokenError< + HttpClientError, + StandardErrorResponse, + >, + ), + + /// An error happened during the discovery of the OIDC provider metadata. + #[error(transparent)] + Discovery(#[from] DiscoveryError>), +} + +impl DeviceAuhorizationOidcError { + /// If the [`DeviceAuhorizationOidcError`] is of the + /// [`DeviceCodeErrorResponseType`] error variant, return it. + pub fn as_request_token_error(&self) -> Option<&DeviceCodeErrorResponseType> { + let error = as_variant!(self, DeviceAuhorizationOidcError::RequestToken)?; + let request_token_error = as_variant!(error, RequestTokenError::ServerResponse)?; + + Some(request_token_error.error()) + } +} diff --git a/crates/matrix-sdk/src/authentication/qrcode/oidc_client.rs b/crates/matrix-sdk/src/authentication/common_oidc/oidc_client.rs similarity index 97% rename from crates/matrix-sdk/src/authentication/qrcode/oidc_client.rs rename to crates/matrix-sdk/src/authentication/common_oidc/oidc_client.rs index 3cdc3a0a272..e269e819272 100644 --- a/crates/matrix-sdk/src/authentication/qrcode/oidc_client.rs +++ b/crates/matrix-sdk/src/authentication/common_oidc/oidc_client.rs @@ -87,13 +87,14 @@ pub type OidcClientInner< /// An OIDC specific HTTP client. /// /// This is used to communicate with the OIDC provider exclusively. -pub(super) struct OidcClient { +#[derive(Debug)] +pub(crate) struct OidcClient { inner: OidcClientInner, http_client: HttpClient, } impl OidcClient { - pub(super) async fn new( + pub(crate) async fn new( client_id: String, issuer_url: String, http_client: HttpClient, @@ -120,7 +121,7 @@ impl OidcClient { Ok(OidcClient { inner: oidc_client, http_client }) } - pub(super) async fn request_device_authorization( + pub(crate) async fn request_device_authorization( &self, device_id: Curve25519PublicKey, ) -> Result { @@ -145,7 +146,7 @@ impl OidcClient { Ok(details) } - pub(super) async fn wait_for_tokens( + pub(crate) async fn wait_for_tokens( &self, details: &CoreDeviceAuthorizationResponse, ) -> Result { diff --git a/crates/matrix-sdk/src/authentication/mod.rs b/crates/matrix-sdk/src/authentication/mod.rs index 408de86805e..54172722dee 100644 --- a/crates/matrix-sdk/src/authentication/mod.rs +++ b/crates/matrix-sdk/src/authentication/mod.rs @@ -30,6 +30,9 @@ use crate::{ Client, RefreshTokenError, SessionChange, }; +#[cfg(feature = "experimental-oidc")] +pub mod common_oidc; + #[cfg(all(feature = "experimental-oidc", feature = "e2e-encryption", not(target_arch = "wasm32")))] pub mod qrcode; diff --git a/crates/matrix-sdk/src/authentication/qrcode/login.rs b/crates/matrix-sdk/src/authentication/qrcode/login.rs index 4422d6b0757..ab4ce09ec4b 100644 --- a/crates/matrix-sdk/src/authentication/qrcode/login.rs +++ b/crates/matrix-sdk/src/authentication/qrcode/login.rs @@ -29,15 +29,15 @@ use ruma::OwnedDeviceId; use tracing::trace; use vodozemac::ecies::CheckCode; -use super::{ - messages::LoginFailureReason, oidc_client::OidcClient, DeviceAuhorizationOidcError, - SecureChannelError, -}; +use super::{messages::LoginFailureReason, DeviceAuhorizationOidcError, SecureChannelError}; #[cfg(doc)] use crate::oidc::Oidc; use crate::{ - authentication::qrcode::{ - messages::QrAuthMessage, secure_channel::EstablishedSecureChannel, QRCodeLoginError, + authentication::{ + common_oidc::oidc_client::OidcClient, + qrcode::{ + messages::QrAuthMessage, secure_channel::EstablishedSecureChannel, QRCodeLoginError, + }, }, Client, }; diff --git a/crates/matrix-sdk/src/authentication/qrcode/mod.rs b/crates/matrix-sdk/src/authentication/qrcode/mod.rs index 752f21e0c8f..bee566a6510 100644 --- a/crates/matrix-sdk/src/authentication/qrcode/mod.rs +++ b/crates/matrix-sdk/src/authentication/qrcode/mod.rs @@ -22,7 +22,6 @@ //! QR code. To log in using a QR code, please take a look at the //! [`Oidc::login_with_qr_code()`] method -use as_variant::as_variant; use matrix_sdk_base::crypto::SecretImportError; pub use openidconnect::{ core::CoreErrorResponseType, ConfigurationError, DeviceCodeErrorResponseType, DiscoveryError, @@ -38,7 +37,6 @@ use crate::{oidc::CrossProcessRefreshLockError, HttpError}; mod login; mod messages; -mod oidc_client; mod rendezvous_channel; mod secure_channel; @@ -50,6 +48,7 @@ pub use self::{ login::{LoginProgress, LoginWithQrCode}, messages::{LoginFailureReason, LoginProtocolType, QrAuthMessage}, }; +use crate::authentication::common_oidc::DeviceAuhorizationOidcError; /// The error type for failures while trying to log in a new device using a QR /// code. @@ -106,67 +105,6 @@ pub enum QRCodeLoginError { SecretImport(#[from] SecretImportError), } -/// Error type describing failures in the interaction between the device -/// attempting to log in and the OIDC provider. -#[derive(Debug, Error)] -pub enum DeviceAuhorizationOidcError { - /// A generic OIDC error happened while we were attempting to register the - /// device with the OIDC provider. - #[error(transparent)] - Oidc(#[from] crate::oidc::OidcError), - - /// The issuer URL failed to be parsed. - #[error(transparent)] - InvalidIssuerUrl(#[from] url::ParseError), - - /// There was an error with our device configuration right before attempting - /// to wait for the access token to be issued by the OIDC provider. - #[error(transparent)] - Configuration(#[from] ConfigurationError), - - /// An error happened while we attempted to discover the authentication - /// issuer URL. - #[error(transparent)] - AuthenticationIssuer(HttpError), - - /// An error happened while we attempted to request a device authorization - /// from the OIDC provider. - #[error(transparent)] - DeviceAuthorization( - #[from] - RequestTokenError< - HttpClientError, - StandardErrorResponse, - >, - ), - - /// An error happened while waiting for the access token to be issued and - /// sent to us by the OIDC provider. - #[error(transparent)] - RequestToken( - #[from] - RequestTokenError< - HttpClientError, - StandardErrorResponse, - >, - ), - - /// An error happened during the discovery of the OIDC provider metadata. - #[error(transparent)] - Discovery(#[from] DiscoveryError>), -} - -impl DeviceAuhorizationOidcError { - /// If the [`DeviceAuhorizationOidcError`] is of the - /// [`DeviceCodeErrorResponseType`] error variant, return it. - pub fn as_request_token_error(&self) -> Option<&DeviceCodeErrorResponseType> { - let error = as_variant!(self, DeviceAuhorizationOidcError::RequestToken)?; - let request_token_error = as_variant!(error, RequestTokenError::ServerResponse)?; - - Some(request_token_error.error()) - } -} - /// Error type for failures in when receiving or sending messages over the /// secure channel. #[derive(Debug, Error)] From b5e3a2faefa1926c539a86709f11af355e682a66 Mon Sep 17 00:00:00 2001 From: Daniel Kilimnik Date: Sat, 11 Jan 2025 20:48:59 +0100 Subject: [PATCH 2/4] feat(oidc_client): add custom_scopes option to request_device_authorization --- .../authentication/common_oidc/oidc_client.rs | 20 ++++++++++--------- .../src/authentication/qrcode/login.rs | 3 ++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/matrix-sdk/src/authentication/common_oidc/oidc_client.rs b/crates/matrix-sdk/src/authentication/common_oidc/oidc_client.rs index e269e819272..0efeea528e7 100644 --- a/crates/matrix-sdk/src/authentication/common_oidc/oidc_client.rs +++ b/crates/matrix-sdk/src/authentication/common_oidc/oidc_client.rs @@ -124,17 +124,19 @@ impl OidcClient { pub(crate) async fn request_device_authorization( &self, device_id: Curve25519PublicKey, + custom_scopes: Option>, ) -> Result { - let scopes = [ - ScopeToken::Openid, - ScopeToken::MatrixApi(MatrixApiScopeToken::Full), - ScopeToken::try_with_matrix_device(device_id.to_base64()).expect( - "We should be able to create a scope token from a \ + let scopes = custom_scopes + .unwrap_or(vec![ + ScopeToken::Openid, + ScopeToken::MatrixApi(MatrixApiScopeToken::Full), + ScopeToken::try_with_matrix_device(device_id.to_base64()).expect( + "We should be able to create a scope token from a \ Curve25519 public key encoded as base64", - ), - ] - .into_iter() - .map(|scope| Scope::new(scope.to_string())); + ), + ]) + .into_iter() + .map(|scope| Scope::new(scope.to_string())); let details: CoreDeviceAuthorizationResponse = self .inner diff --git a/crates/matrix-sdk/src/authentication/qrcode/login.rs b/crates/matrix-sdk/src/authentication/qrcode/login.rs index ab4ce09ec4b..187020dab10 100644 --- a/crates/matrix-sdk/src/authentication/qrcode/login.rs +++ b/crates/matrix-sdk/src/authentication/qrcode/login.rs @@ -128,7 +128,8 @@ impl<'a> IntoFuture for LoginWithQrCode<'a> { // Let's tell the OIDC provider that we want to log in using the device // authorization grant described in [RFC8628](https://datatracker.ietf.org/doc/html/rfc8628). trace!("Requesting device authorization."); - let auth_grant_response = oidc_client.request_device_authorization(device_id).await?; + let auth_grant_response = + oidc_client.request_device_authorization(device_id, None).await?; // Now we need to inform the other device of the login protocols we picked and // the URL they should use to log us in. From b2660886634934355ef23e20e2a4be0fc7b5d620 Mon Sep 17 00:00:00 2001 From: Daniel Kilimnik Date: Sat, 11 Jan 2025 20:58:15 +0100 Subject: [PATCH 3/4] feat(oidc): add device code oidc authentication method --- .../src/authentication/device_code/login.rs | 567 ++++++++++++++++++ .../src/authentication/device_code/mod.rs | 90 +++ crates/matrix-sdk/src/authentication/mod.rs | 3 + crates/matrix-sdk/src/oidc/mod.rs | 21 +- crates/matrix-sdk/src/oidc/registrations.rs | 2 +- 5 files changed, 680 insertions(+), 3 deletions(-) create mode 100644 crates/matrix-sdk/src/authentication/device_code/login.rs create mode 100644 crates/matrix-sdk/src/authentication/device_code/mod.rs diff --git a/crates/matrix-sdk/src/authentication/device_code/login.rs b/crates/matrix-sdk/src/authentication/device_code/login.rs new file mode 100644 index 00000000000..b857fac6552 --- /dev/null +++ b/crates/matrix-sdk/src/authentication/device_code/login.rs @@ -0,0 +1,567 @@ +// Copyright 2025 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 mas_oidc_client::types::{registration::VerifiedClientMetadata, scope::ScopeToken}; +use matrix_sdk_base::SessionMeta; +use openidconnect::core::CoreDeviceAuthorizationResponse; +use ruma::OwnedDeviceId; +use tracing::trace; + +use super::{DeviceCodeFinishLoginError, DeviceCodeLoginError}; +#[cfg(doc)] +use crate::oidc::Oidc; +use crate::{ + authentication::common_oidc::{oidc_client::OidcClient, DeviceAuhorizationOidcError}, + oidc::registrations::OidcRegistrations, + Client, +}; + +#[derive(Debug)] +struct LoginData { + account: String, + pickle_key: Box<[u8; 32]>, + oidc_client: OidcClient, + auth_grant_response: CoreDeviceAuthorizationResponse, +} + +/// State for the [`Oidc::login_with_device_code()`] method. +#[derive(Debug)] +pub struct LoginWithDeviceCode<'a> { + client: &'a Client, + client_metadata: VerifiedClientMetadata, + registrations: OidcRegistrations, + login_data: Option, +} + +impl<'a> LoginWithDeviceCode<'a> { + pub(crate) fn new( + client: &'a Client, + client_metadata: VerifiedClientMetadata, + registrations: OidcRegistrations, + ) -> LoginWithDeviceCode<'a> { + LoginWithDeviceCode { client, client_metadata, registrations, login_data: None } + } + + async fn register_client(&self) -> Result { + let oidc = self.client.oidc(); + + // Let's figure out the OIDC issuer, this fetches the info from the homeserver. + let issuer = self + .client + .oidc() + .fetch_authentication_issuer() + .await + .map_err(DeviceAuhorizationOidcError::AuthenticationIssuer)?; + + // Configure and register the client + oidc.configure(issuer.clone(), self.client_metadata.clone(), self.registrations.clone()) + .await?; + + // client_credentials has to be Some because we just configured the client + let credentials = oidc.client_credentials().unwrap(); + + // configure sets the secret to None using + let client_secret = None; + + OidcClient::new( + credentials.client_id().to_owned(), + issuer, + self.client.inner.http_client.clone(), + client_secret, + ) + .await + } + + fn pickle_key(&self) -> Result, rand::Error> { + let mut rng = rand::thread_rng(); + + let mut key = Box::new([0u8; 32]); + rand::Fill::try_fill(key.as_mut_slice(), &mut rng)?; + + Ok(key) + } + + /// Register client and return device code aswell as the url to use for + /// authentication + pub async fn device_code_for_login( + &mut self, + custom_scopes: Option>, + ) -> Result { + // Register the client with the OIDC provider. + trace!("Registering the client with the OIDC provider."); + let oidc_client = self.register_client().await?; + + // We want to use the Curve25519 public key for the device ID, so let's generate + // a new vodozemac `Account` now. + let account = vodozemac::olm::Account::new(); + let public_key = account.identity_keys().curve25519; + let device_id = public_key; + + // Let's tell the OIDC provider that we want to log in using the device + // authorization grant described in [RFC8628](https://datatracker.ietf.org/doc/html/rfc8628). + trace!("Requesting device authorization."); + let auth_grant_response = + oidc_client.request_device_authorization(device_id, custom_scopes).await?; + + let pickle_key = self.pickle_key()?; + + self.login_data = Some(LoginData { + account: account.to_libolm_pickle(pickle_key.as_ref())?, + pickle_key, + oidc_client, + auth_grant_response: auth_grant_response.clone(), + }); + + Ok(auth_grant_response) + } + + /// Wait for authentication and finish login by setting the session token on + /// success + pub async fn wait_finish_login(self) -> Result<(), DeviceCodeFinishLoginError> { + let login_data = self.login_data.ok_or(DeviceCodeFinishLoginError::LoginFailure {})?; + let account = vodozemac::olm::Account::from_libolm_pickle( + &login_data.account, + login_data.pickle_key.as_ref(), + )?; + let public_key = account.identity_keys().curve25519; + let device_id = public_key; + + // Let's now wait for the access token to be provided to use by the OIDC + // provider. + trace!("Waiting for the OIDC provider to give us the access token."); + let session_tokens = + login_data.oidc_client.wait_for_tokens(&login_data.auth_grant_response).await?; + + self.client.oidc().set_session_tokens(session_tokens); + + // We only received an access token from the OIDC provider, we have no clue who + // we are, so we need to figure out our user ID now. + // TODO: This snippet is almost the same as the Oidc::finish_login_method(), why + // is that method even a public method and not called as part of the set session + // tokens method. + trace!("Discovering our own user id."); + let whoami_response = + self.client.whoami().await.map_err(DeviceCodeFinishLoginError::UserIdDiscovery)?; + self.client + .set_session_meta( + SessionMeta { + user_id: whoami_response.user_id, + device_id: OwnedDeviceId::from(device_id.to_base64()), + }, + Some(account), + ) + .await + .map_err(DeviceCodeFinishLoginError::SessionTokens)?; + + self.client.oidc().enable_cross_process_lock().await?; + + #[cfg(feature = "e2e-encryption")] + self.client.encryption().spawn_initialization_task(None); + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use assert_matches2::assert_let; + use mas_oidc_client::types::{ + iana::oauth::OAuthClientAuthenticationMethod, + oidc::ApplicationType, + registration::{ClientMetadata, Localized}, + requests::GrantType, + }; + use matrix_sdk_test::{async_test, test_json}; + use openidconnect::DeviceCodeErrorResponseType; + use serde_json::{json, Value}; + use tempfile::tempdir; + use url::Url; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + use crate::config::RequestConfig; + + fn client_metadata() -> VerifiedClientMetadata { + let client_uri = Url::parse("https://github.com/matrix-org/matrix-rust-sdk") + .expect("Couldn't parse client URI"); + + ClientMetadata { + application_type: Some(ApplicationType::Native), + redirect_uris: None, + grant_types: Some(vec![GrantType::DeviceCode]), + token_endpoint_auth_method: Some(OAuthClientAuthenticationMethod::None), + client_name: Some(Localized::new("test-matrix-rust-sdk-qrlogin".to_owned(), [])), + contacts: Some(vec!["root@127.0.0.1".to_owned()]), + client_uri: Some(Localized::new(client_uri.clone(), [])), + policy_uri: Some(Localized::new(client_uri.clone(), [])), + tos_uri: Some(Localized::new(client_uri, [])), + ..Default::default() + } + .validate() + .unwrap() + } + + fn open_id_configuration(server: &MockServer) -> Value { + let issuer_url = + Url::parse(&server.uri()).expect("We should be able to parse the example homeserver"); + let account_management_uri = issuer_url.join("account").unwrap(); + let authorization_endpoint = issuer_url.join("authorize").unwrap(); + let device_authorization_endpoint = issuer_url.join("oauth2/device").unwrap(); + let jwks_url = issuer_url.join("oauth2/keys.json").unwrap(); + let registration_endpoint = issuer_url.join("oauth2/registration").unwrap(); + let token_endpoint = issuer_url.join("oauth2/token").unwrap(); + + json!({ + "account_management_actions_supported": [ + "org.matrix.profile", + "org.matrix.sessions_list", + "org.matrix.session_view", + "org.matrix.session_end", + "org.matrix.cross_signing_reset" + ], + "account_management_uri": account_management_uri, + "authorization_endpoint": authorization_endpoint, + "claim_types_supported": [ + "normal" + ], + "claims_parameter_supported": false, + "claims_supported": [ + "iss", + "sub", + "aud", + "iat", + "exp", + "nonce", + "auth_time", + "at_hash", + "c_hash" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "device_authorization_endpoint": device_authorization_endpoint, + "display_values_supported": [ + "page" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "client_credentials", + "urn:ietf:params:oauth:grant-type:device_code" + ], + "id_token_signing_alg_values_supported": [ + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "PS256", + "PS384", + "PS512", + "ES256K" + ], + "issuer": issuer_url.to_string().trim_end_matches("/"), + "jwks_uri": jwks_url, + "prompt_values_supported": [ + "none", + "login", + "create" + ], + "registration_endpoint": registration_endpoint, + "request_parameter_supported": false, + "request_uri_parameter_supported": false, + "response_modes_supported": [ + "form_post", + "query", + "fragment" + ], + "response_types_supported": [ + "code", + "id_token", + "code id_token" + ], + "scopes_supported": [ + "openid", + "email" + ], + "subject_types_supported": [ + "public" + ], + "token_endpoint": token_endpoint, + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + }) + } + + fn keys_json() -> Value { + json!({ + "keys": [ + { + "e": "AQAB", + "kid": "hxdHWoF9mn", + "kty": "RSA", + "n": "u4op7tDV41j-f_-DqsqjjCObiySB0q2CGS1JVjJXbV5jctHP6Wp_oMb2aIImMdHDcnTvxaID\ + WwuKA8o-0SBfkHFifMHHRvePz_l7NxxUMyGX8Bfu_EVkECe50BXpFydcEEl1eIIsPW-F0WJKFYR\ + 5cscmBgRX3zv_w7WFbaOLh711S9DNu21epdSvFSrKRe9oG_FbeOFfDl-YU7BLGFvEozg9Z3hKF\ + SomOlz-t3ABvRUweGuLCpHFKsI6yhGCoqPyS7o5gpfenizdfHLqq-l7kgyr7lSbW_mTSyYutby\ + DpQ_HM98Lt-4a9zwlGfiqPS3svkH6KSd1mBcayCI0Cm9FuQ", + "use": "sig" + }, + { + "crv": "P-256", + "kid": "IRbxoGCBjs", + "kty": "EC", + "use": "sig", + "x": "1AYfsklcgvscvJiNZ1Og7vQePzIBf-flJKlANWJ7D4g", + "y": "L4b-jMZVZlnLhXCpV0EOc6zdEz1e6ONgKQZVE3jOBhY" + }, + { + "crv": "P-384", + "kid": "FjEZp4JjqW", + "kty": "EC", + "use": "sig", + "x": "bZP2bPUEQGeGaDICINswZSTCHdoVmDD3LIJE1Szxw27ruCJBW-sy_lY3dhA2FjWm", + "y": "3HMgAu___-4JG9IXZFXwzr5nU_GUPvmWJHqgS7vzK1S91s0v1GXiqQMHwYA0keYG" + }, + { + "crv": "secp256k1", + "kid": "7ohCuHzgqB", + "kty": "EC", + "use": "sig", + "x": "80KXhBY8JBy8qO9-wMBaGtgOgtagowHJ4dDGfVr4eVw", + "y": "0ALeT-J40AjdIS4S1YDgMrPkyE_rnw9wVm7Dvz_9Np4" + } + ] + }) + } + + fn device_code(server: &MockServer) -> Value { + let issuer_url = + Url::parse(&server.uri()).expect("We should be able to parse the example homeserver"); + let verification_uri = issuer_url.join("link").unwrap(); + let mut verification_uri_complete = issuer_url.join("link").unwrap(); + verification_uri_complete.set_query(Some("code=N32YVC")); + + json!({ + "device_code": "N8NAYD9fOhMulpm37mSthx0xSw2p7vdR", + "expires_in": 1200, + "interval": 5, + "user_code": "N32YVC", + "verification_uri": verification_uri, + "verification_uri_complete": verification_uri_complete, + }) + } + + fn token() -> Value { + json!({ + "access_token": "mat_z65RpDAbvR5aTr7MzD0aPw40xFbwch_09xTgn", + "expires_in": 300, + "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJhdWQiOiIwMUhZRlpEQ1\ + BTV1dCREVWWkQyRlRBUVlFViIsInN1YiI6IjAxSFYxNzNTSjQxUDBGMFgxQ0FRU1lBVENQIiwiaWF0IjoxN\ + zE2Mzc1NzIwLCJpc3MiOiJodHRwczovL2F1dGgtb2lkYy5sYWIuZWxlbWVudC5kZXYvIiwiZXhwIjoxNzE2\ + Mzc5MzIwLCJhdF9oYXNoIjoieGZIS21qQW83cEVCRmUwTkM5ODJEQSJ9.HQs7Si5gU_5tm2hYaCa3jg0kPO\ + MXGNdpV88MWzG6N9x3yXK0ZGgn58i38HiQTbiyPuhw8OH6baMSjbcVP-KXSDpsSPZbkmp7Ozb50dC0eIebD\ + aVK0EyZ35KQRVc5BFPQBPbq0r_TrcUgjoLRKpoexvdmjfEb2dE-kKse25jfs-bTHKP6jeAyFgR9Emn0RfVx\ + 32He32-bRP1NfkBnPNnJse32tF1o8gs7zG-cm7kSUx1wiQbvfSGfETx_mJ-aFGABbVGKQlTrCe32HUTvNbp\ + tT2WXa1t7d3eDuEV_6hZS9LFRdIXhgEcGIZMz_ss3WQsSOKN8Yq2NC8_bNxRAQ-1J3A", + "refresh_token": "mar_CHFh124AMHsdishuHgLSx1svdKMVQA_080gj2", + "scope": "openid \ + urn:matrix:org.matrix.msc2967.client:api:* \ + urn:matrix:org.matrix.msc2967.client:device:\ + lKa+6As0PSFtqOMKALottO6hlt3gCpZtaVfHanSUnEE", + "token_type": "Bearer" + }) + } + + async fn mock_oidc_provider(server: &MockServer, token_response: ResponseTemplate) { + Mock::given(method("GET")) + .and(path("/_matrix/client/unstable/org.matrix.msc2965/auth_issuer")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "issuer": server.uri(), + + }))) + .expect(1) + .mount(server) + .await; + + Mock::given(method("GET")) + .and(path("/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(open_id_configuration(server))) + .expect(1..) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(path("/oauth2/registration")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "client_id": "01HYFZDCPSWWBDEVZD2FTAQYEV", + "client_id_issued_at": 1716375696 + }))) + .expect(1) + .mount(server) + .await; + + Mock::given(method("GET")) + .and(path("/oauth2/keys.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(keys_json())) + .expect(1) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(path("/oauth2/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(device_code(server))) + .expect(1) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(path("/oauth2/token")) + .respond_with(token_response) + .mount(server) + .await; + } + + #[async_test] + async fn test_device_code_login() { + let server = MockServer::start().await; + + mock_oidc_provider(&server, ResponseTemplate::new(200).set_body_json(token())).await; + + Mock::given(method("GET")) + .and(path("/_matrix/client/r0/account/whoami")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::WHOAMI)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/_matrix/client/versions")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::VERSIONS)) + .mount(&server) + .await; + + let client = Client::builder() + .server_name_or_homeserver_url(server.uri()) + .request_config(RequestConfig::new().disable_retry()) + .build() + .await + .expect("We should be able to build the Client object from the server uri"); + + let dir = tempdir().unwrap(); + let registrations_file = dir.path().join("oidc").join("registrations.json"); + + let static_registrations = HashMap::new(); + + let registrations = + OidcRegistrations::new(®istrations_file, client_metadata(), static_registrations) + .unwrap(); + + let oidc = client.oidc(); + let mut login_device_code = oidc.login_with_device_code(client_metadata(), registrations); + + login_device_code + .device_code_for_login(None) + .await + .expect("Client should be able to register and get a device code"); + + login_device_code.wait_finish_login().await.expect("Client should finish the login"); + } + + async fn test_failure( + token_response: ResponseTemplate, + ) -> Result<(), DeviceCodeFinishLoginError> { + let server = MockServer::start().await; + + mock_oidc_provider(&server, token_response).await; + + Mock::given(method("GET")) + .and(path("/_matrix/client/r0/account/whoami")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::WHOAMI)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/_matrix/client/versions")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::VERSIONS)) + .mount(&server) + .await; + + let client = Client::builder() + .server_name_or_homeserver_url(server.uri()) + .request_config(RequestConfig::new().disable_retry()) + .build() + .await + .expect("We should be able to build the Client object from the server uri"); + + let dir = tempdir().unwrap(); + let registrations_file = dir.path().join("oidc").join("registrations.json"); + + let static_registrations = HashMap::new(); + + let registrations = + OidcRegistrations::new(®istrations_file, client_metadata(), static_registrations) + .unwrap(); + + let oidc = client.oidc(); + let mut login_device_code = oidc.login_with_device_code(client_metadata(), registrations); + + login_device_code + .device_code_for_login(None) + .await + .expect("Client should be able to register and get a device code"); + + login_device_code.wait_finish_login().await + } + + #[async_test] + async fn test_device_code_login_refused_access_token() { + let result = test_failure(ResponseTemplate::new(400).set_body_json(json!({ + "error": "access_denied", + }))) + .await; + + assert_let!(Err(DeviceCodeFinishLoginError::Oidc(e)) = result); + assert_eq!( + e.as_request_token_error(), + Some(&DeviceCodeErrorResponseType::AccessDenied), + "The server should have told us that access has been denied." + ); + } + + #[async_test] + async fn test_device_code_login_expired_token() { + let result = test_failure(ResponseTemplate::new(400).set_body_json(json!({ + "error": "expired_token", + }))) + .await; + + assert_let!(Err(DeviceCodeFinishLoginError::Oidc(e)) = result); + assert_eq!( + e.as_request_token_error(), + Some(&DeviceCodeErrorResponseType::ExpiredToken), + "The server should have told us that access has been denied." + ); + } +} diff --git a/crates/matrix-sdk/src/authentication/device_code/mod.rs b/crates/matrix-sdk/src/authentication/device_code/mod.rs new file mode 100644 index 00000000000..bf4b32484d4 --- /dev/null +++ b/crates/matrix-sdk/src/authentication/device_code/mod.rs @@ -0,0 +1,90 @@ +// Copyright 2025 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. + +//! Device code authentication for ligging in this device on another device +//! without using QR codes +//! +//! Please note, device code logins are only supported when using OIDC as the +//! auththentication mechanism, native Matrix authentication does not support +//! it. + +pub use openidconnect::{ + core::CoreErrorResponseType, ConfigurationError, DeviceCodeErrorResponseType, DiscoveryError, + HttpClientError, RequestTokenError, StandardErrorResponse, +}; +use thiserror::Error; +pub use vodozemac::ecies::{Error as EciesError, MessageDecodeError}; + +#[cfg(doc)] +use crate::oidc::Oidc; +use crate::{oidc::CrossProcessRefreshLockError, HttpError}; + +mod login; + +pub use matrix_sdk_base::crypto::types::qr_login::{ + LoginQrCodeDecodeError, QrCodeData, QrCodeMode, QrCodeModeData, +}; + +pub use self::login::LoginWithDeviceCode; +use crate::authentication::common_oidc::DeviceAuhorizationOidcError; + +/// The error type for failures while trying to log in a new device using a +/// Device code. +#[derive(Debug, Error)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error), uniffi(flat_error))] +pub enum DeviceCodeLoginError { + /// An error happened while we were communicating with the OIDC provider. + #[error(transparent)] + Oidc(#[from] DeviceAuhorizationOidcError), + + /// Failed to generate random value for pickling the account data + #[error(transparent)] + RandGeneratingError(#[from] rand::Error), + + /// Failed to pickle the account data + #[error(transparent)] + AccountPickleError(#[from] vodozemac::LibolmPickleError), +} + +/// The error type for failures while trying to finish log in a new device using +/// a Device code. +#[derive(Debug, Error)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error), uniffi(flat_error))] +pub enum DeviceCodeFinishLoginError { + /// An error happened while we were communicating with the OIDC provider. + #[error(transparent)] + Oidc(#[from] DeviceAuhorizationOidcError), + + /// The login failed so we can't finish it here. + #[error("Can't finish failed login")] + LoginFailure {}, + + /// An error happened while we were trying to discover our user and device + /// ID, after we have acquired an access token from the OIDC provider. + #[error(transparent)] + UserIdDiscovery(HttpError), + + /// We failed to set the session tokens after we figured out our device and + /// user IDs. + #[error(transparent)] + SessionTokens(crate::Error), + + /// The cross-process refresh lock failed to be initialized. + #[error(transparent)] + CrossProcessRefreshLock(#[from] CrossProcessRefreshLockError), + + /// Failed to unpickle the account data + #[error(transparent)] + AccountPickleError(#[from] vodozemac::LibolmPickleError), +} diff --git a/crates/matrix-sdk/src/authentication/mod.rs b/crates/matrix-sdk/src/authentication/mod.rs index 54172722dee..3dfe4ac530e 100644 --- a/crates/matrix-sdk/src/authentication/mod.rs +++ b/crates/matrix-sdk/src/authentication/mod.rs @@ -36,6 +36,9 @@ pub mod common_oidc; #[cfg(all(feature = "experimental-oidc", feature = "e2e-encryption", not(target_arch = "wasm32")))] pub mod qrcode; +#[cfg(all(feature = "experimental-oidc", feature = "e2e-encryption", not(target_arch = "wasm32")))] +pub mod device_code; + /// Session tokens, for any kind of authentication. #[allow(missing_debug_implementations, clippy::large_enum_variant)] pub enum SessionTokens { diff --git a/crates/matrix-sdk/src/oidc/mod.rs b/crates/matrix-sdk/src/oidc/mod.rs index 5fb525d75a1..dd8dd6ba4d2 100644 --- a/crates/matrix-sdk/src/oidc/mod.rs +++ b/crates/matrix-sdk/src/oidc/mod.rs @@ -219,7 +219,7 @@ use self::{ cross_process::{CrossProcessRefreshLockGuard, CrossProcessRefreshManager}, }; use crate::{ - authentication::{qrcode::LoginWithQrCode, AuthData}, + authentication::{device_code::LoginWithDeviceCode, qrcode::LoginWithQrCode, AuthData}, client::SessionChange, oidc::registrations::{ClientId, OidcRegistrations}, Client, HttpError, RefreshTokenError, Result, @@ -436,6 +436,23 @@ impl Oidc { LoginWithQrCode::new(&self.client, client_metadata, data) } + /// Log in using a device code. + /// + /// This method allows you to generate a device code so another device can + /// log this one in + #[cfg(all( + feature = "experimental-oidc", + feature = "e2e-encryption", + not(target_arch = "wasm32") + ))] + pub fn login_with_device_code( + &self, + client_metadata: VerifiedClientMetadata, + registrations: OidcRegistrations, + ) -> LoginWithDeviceCode<'_> { + LoginWithDeviceCode::new(&self.client, client_metadata, registrations) + } + /// A higher level wrapper around the configuration and login methods that /// will take some client metadata, register the client if needed and begin /// the login process, returning the authorization data required to show a @@ -513,7 +530,7 @@ impl Oidc { /// Higher level wrapper that restores the OIDC client with automatic /// static/dynamic client registration. - async fn configure( + pub(crate) async fn configure( &self, issuer: String, client_metadata: VerifiedClientMetadata, diff --git a/crates/matrix-sdk/src/oidc/registrations.rs b/crates/matrix-sdk/src/oidc/registrations.rs index 67dd4779e3d..f1847001c46 100644 --- a/crates/matrix-sdk/src/oidc/registrations.rs +++ b/crates/matrix-sdk/src/oidc/registrations.rs @@ -50,7 +50,7 @@ pub enum OidcRegistrationsError { pub struct ClientId(pub String); /// The data needed to restore an OpenID Connect session. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct OidcRegistrations { /// The path of the file where the registrations are stored. file_path: PathBuf, From 5b53fc75e7773d3dca7ccd8bc4783b650cf736c7 Mon Sep 17 00:00:00 2001 From: Daniel Kilimnik Date: Sat, 11 Jan 2025 20:58:40 +0100 Subject: [PATCH 4/4] Add an example for device code oidc login --- Cargo.lock | 13 ++++ examples/device-code/Cargo.toml | 27 +++++++ examples/device-code/src/main.rs | 120 +++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 examples/device-code/Cargo.toml create mode 100644 examples/device-code/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 36fb56dec1a..1a9354b12e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,6 +1528,19 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "example-device-code-login" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dirs", + "matrix-sdk", + "tokio", + "tracing-subscriber", + "url", +] + [[package]] name = "example-emoji-verification" version = "0.1.0" diff --git a/examples/device-code/Cargo.toml b/examples/device-code/Cargo.toml new file mode 100644 index 00000000000..c7571918c99 --- /dev/null +++ b/examples/device-code/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "example-device-code-login" +version = "0.1.0" +edition = "2021" +publish = false +license = "Apache-2.0" + +[[bin]] +name = "example-device-code-login" +test = false + +[dependencies] +anyhow = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +clap = { version = "4.0.15", features = ["derive"] } +tracing-subscriber = { workspace = true } +url = "2.3.1" +dirs = "5.0.1" + +[dependencies.matrix-sdk] +# when copy-pasting this, please use a git dependency or make sure that you +# have copied the example as it was at the time of the release you use. +path = "../../crates/matrix-sdk" +features = ["experimental-oidc"] + +[package.metadata.release] +release = false diff --git a/examples/device-code/src/main.rs b/examples/device-code/src/main.rs new file mode 100644 index 00000000000..769fd70f7d3 --- /dev/null +++ b/examples/device-code/src/main.rs @@ -0,0 +1,120 @@ +use std::collections::HashMap; + +use anyhow::Result; +use clap::Parser; +use matrix_sdk::{ + oidc::{ + registrations::OidcRegistrations, + types::{ + iana::oauth::OAuthClientAuthenticationMethod, + oidc::ApplicationType, + registration::{ClientMetadata, Localized, VerifiedClientMetadata}, + requests::GrantType, + scope::ScopeToken, + }, + }, + Client, +}; +use url::Url; + +/// A command line example showcasing how to login using a device code. +/// +/// Another device, will verify the device code. +#[derive(Parser, Debug)] +struct Cli { + /// Set the homeserver that should be used for authentication. + #[clap(long, required = true)] + homeserver: Url, + + /// Add extra scopes to the request. + #[clap(long)] + custom_scopes: Option>, + + /// Enable verbose logging output. + #[clap(short, long, action)] + verbose: bool, +} + +/// Generate the OIDC client metadata. +/// +/// For simplicity, we use most of the default values here, but usually this +/// should be adapted to the provider metadata to make interactions as secure as +/// possible, for example by using the most secure signing algorithms supported +/// by the provider. +fn client_metadata() -> VerifiedClientMetadata { + let client_uri = Url::parse("https://github.com/matrix-org/matrix-rust-sdk") + .expect("Couldn't parse client URI"); + + ClientMetadata { + // This is a native application (in contrast to a web application, that runs in a browser). + application_type: Some(ApplicationType::Native), + // Native clients should be able to register the loopback interface and then point to any + // port when needing a redirect URI. An alternative is to use a custom URI scheme registered + // with the OS. + redirect_uris: None, + // We are going to use the Authorization Code flow, and of course we want to be able to + // refresh our access token. + grant_types: Some(vec![GrantType::RefreshToken, GrantType::DeviceCode]), + // A native client shouldn't use authentication as the credentials could be intercepted. + // Other protections are in place for the different requests. + token_endpoint_auth_method: Some(OAuthClientAuthenticationMethod::None), + // The following fields should be displayed in the OIDC provider interface as part of the + // process to get the user's consent. It means that these should contain real data so the + // user can make sure that they allow the proper application. + // We are cheating here because this is an example. + client_name: Some(Localized::new("matrix-rust-sdk-device-code-login".to_owned(), [])), + contacts: Some(vec!["root@127.0.0.1".to_owned()]), + client_uri: Some(Localized::new(client_uri.clone(), [])), + policy_uri: Some(Localized::new(client_uri.clone(), [])), + tos_uri: Some(Localized::new(client_uri, [])), + ..Default::default() + } + .validate() + .unwrap() +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + if cli.verbose { + tracing_subscriber::fmt::init(); + } + + let server_name = cli.homeserver; + let client = Client::builder().server_name_or_homeserver_url(server_name).build().await?; + + let metadata = client_metadata(); + + let data_dir = dirs::data_dir().expect("no data_dir directory found"); + let registrations_file = data_dir.join("matrix_sdk/oidc").join("registrations.json"); + + let static_registrations = HashMap::new(); + + let registrations = + OidcRegistrations::new(®istrations_file, client_metadata(), static_registrations)?; + + let oidc = client.oidc(); + + let mut login_device_code = oidc.login_with_device_code(metadata, registrations); + + let auth_grant_response = login_device_code.device_code_for_login(cli.custom_scopes).await?; + + println!( + "Log in using this {}", + auth_grant_response.verification_uri_complete().unwrap().clone().into_secret() + ); + println!( + "You can also go to {} and type in the code {}", + auth_grant_response.verification_uri(), + auth_grant_response.user_code().clone().into_secret() + ); + + login_device_code.wait_finish_login().await?; + + let user_id = client.user_id().unwrap(); + + println!("Successfully logged in as {user_id} using the device code"); + + Ok(()) +}