Skip to main content

matrix_sdk_ffi/
authentication.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for that specific language governing permissions and
13// limitations under the License.
14
15use std::{
16    collections::HashMap,
17    fmt::{self, Debug},
18    sync::Arc,
19};
20
21use matrix_sdk::{
22    Error,
23    authentication::oauth::{
24        ClientId, ClientRegistrationData, OAuthError as SdkOAuthError,
25        error::OAuthAuthorizationCodeError,
26        registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
27    },
28};
29use ruma::serde::Raw;
30use url::Url;
31
32use crate::client::{Client, OAuthPrompt, SlidingSyncVersion};
33
34#[derive(uniffi::Object)]
35pub struct HomeserverLoginDetails {
36    pub(crate) url: String,
37    pub(crate) sliding_sync_version: SlidingSyncVersion,
38    pub(crate) supports_oauth_login: bool,
39    pub(crate) supported_oauth_prompts: Vec<OAuthPrompt>,
40    pub(crate) supports_sso_login: bool,
41    pub(crate) supports_password_login: bool,
42}
43
44#[matrix_sdk_ffi_macros::export]
45impl HomeserverLoginDetails {
46    /// The URL of the currently configured homeserver.
47    pub fn url(&self) -> String {
48        self.url.clone()
49    }
50
51    /// The sliding sync version.
52    pub fn sliding_sync_version(&self) -> SlidingSyncVersion {
53        self.sliding_sync_version.clone()
54    }
55
56    /// Whether the current homeserver supports login using OAuth.
57    pub fn supports_oauth_login(&self) -> bool {
58        self.supports_oauth_login
59    }
60
61    /// The prompts advertised by the authentication issuer for use in the login
62    /// URL.
63    pub fn supported_oauth_prompts(&self) -> Vec<OAuthPrompt> {
64        self.supported_oauth_prompts.clone()
65    }
66
67    /// Whether the current homeserver supports login using legacy SSO.
68    pub fn supports_sso_login(&self) -> bool {
69        self.supports_sso_login
70    }
71
72    /// Whether the current homeserver supports the password login flow.
73    pub fn supports_password_login(&self) -> bool {
74        self.supports_password_login
75    }
76}
77
78/// An object encapsulating the SSO login flow
79#[derive(uniffi::Object)]
80pub struct SsoHandler {
81    /// The wrapped Client.
82    pub(crate) client: Arc<Client>,
83
84    /// The underlying URL for authentication.
85    pub(crate) url: String,
86}
87
88#[matrix_sdk_ffi_macros::export]
89impl SsoHandler {
90    /// Returns the URL for starting SSO authentication. The URL should be
91    /// opened in a web view. Once the web view succeeds, call `finish` with
92    /// the callback URL.
93    pub fn url(&self) -> String {
94        self.url.clone()
95    }
96
97    /// Completes the SSO login process.
98    pub async fn finish(&self, callback_url: String) -> Result<(), SsoError> {
99        let auth = self.client.inner.matrix_auth();
100        let url = Url::parse(&callback_url).map_err(|_| SsoError::CallbackUrlInvalid)?;
101        let builder =
102            auth.login_with_sso_callback(url.into()).map_err(|_| SsoError::CallbackUrlInvalid)?;
103        builder.await.map_err(|_| SsoError::LoginWithTokenFailed)?;
104
105        Ok(())
106    }
107}
108
109impl Debug for SsoHandler {
110    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
111        fmt.debug_struct("SsoHandler").field("url", &self.url).finish_non_exhaustive()
112    }
113}
114
115#[derive(Debug, thiserror::Error, uniffi::Error)]
116#[uniffi(flat_error)]
117pub enum SsoError {
118    #[error("The supplied callback URL used to complete SSO is invalid.")]
119    CallbackUrlInvalid,
120    #[error("Logging in with the token from the supplied callback URL failed.")]
121    LoginWithTokenFailed,
122
123    #[error("An error occurred: {message}")]
124    Generic { message: String },
125}
126
127/// The configuration to use when authenticating with OAuth.
128#[derive(uniffi::Record)]
129pub struct OAuthConfiguration {
130    /// The name of the client that will be shown during OAuth authentication.
131    pub client_name: Option<String>,
132    /// The redirect URI that will be used when OAuth authentication is
133    /// successful.
134    pub redirect_uri: String,
135    /// A URI that contains information about the client.
136    pub client_uri: String,
137    /// A URI that contains the client's logo.
138    pub logo_uri: Option<String>,
139    /// A URI that contains the client's terms of service.
140    pub tos_uri: Option<String>,
141    /// A URI that contains the client's privacy policy.
142    pub policy_uri: Option<String>,
143
144    /// Pre-configured registrations for use with homeservers that don't support
145    /// dynamic client registration.
146    ///
147    /// The keys of the map should be the URLs of the homeservers, but keys
148    /// using `issuer` URLs are also supported.
149    pub static_registrations: HashMap<String, String>,
150}
151
152impl OAuthConfiguration {
153    pub(crate) fn redirect_uri(&self) -> Result<Url, OAuthError> {
154        Url::parse(&self.redirect_uri).map_err(|_| OAuthError::CallbackUrlInvalid)
155    }
156
157    pub(crate) fn client_metadata(&self) -> Result<Raw<ClientMetadata>, OAuthError> {
158        let redirect_uri = self.redirect_uri()?;
159        let client_name = self.client_name.as_ref().map(|n| Localized::new(n.to_owned(), []));
160        let client_uri = self.client_uri.localized_url()?;
161        let logo_uri = self.logo_uri.localized_url()?;
162        let policy_uri = self.policy_uri.localized_url()?;
163        let tos_uri = self.tos_uri.localized_url()?;
164
165        let metadata = ClientMetadata {
166            // The server should display the following fields when getting the user's consent.
167            client_name,
168            logo_uri,
169            policy_uri,
170            tos_uri,
171            ..ClientMetadata::new(
172                ApplicationType::Native,
173                vec![
174                    OAuthGrantType::AuthorizationCode { redirect_uris: vec![redirect_uri] },
175                    OAuthGrantType::DeviceCode,
176                ],
177                client_uri,
178            )
179        };
180
181        Raw::new(&metadata).map_err(|_| OAuthError::MetadataInvalid)
182    }
183
184    pub(crate) fn registration_data(&self) -> Result<ClientRegistrationData, OAuthError> {
185        let client_metadata = self.client_metadata()?;
186
187        let mut registration_data = ClientRegistrationData::new(client_metadata);
188
189        if !self.static_registrations.is_empty() {
190            let static_registrations = self
191                .static_registrations
192                .iter()
193                .filter_map(|(issuer, client_id)| {
194                    let Ok(issuer) = Url::parse(issuer) else {
195                        tracing::error!("Failed to parse {issuer:?}");
196                        return None;
197                    };
198                    Some((issuer, ClientId::new(client_id.clone())))
199                })
200                .collect();
201
202            registration_data.static_registrations = Some(static_registrations);
203        }
204
205        Ok(registration_data)
206    }
207}
208
209#[derive(Debug, thiserror::Error, uniffi::Error)]
210#[uniffi(flat_error)]
211pub enum OAuthError {
212    #[error(
213        "The homeserver doesn't provide an authentication issuer in its well-known configuration."
214    )]
215    NotSupported,
216    #[error("Unable to use OAuth as the supplied client metadata is invalid.")]
217    MetadataInvalid,
218    #[error("The supplied callback URL used to complete OAuth is invalid.")]
219    CallbackUrlInvalid,
220    #[error("The OAuth login was cancelled by the user.")]
221    Cancelled,
222
223    #[error("An error occurred: {message}")]
224    Generic { message: String },
225}
226
227impl From<SdkOAuthError> for OAuthError {
228    fn from(e: SdkOAuthError) -> OAuthError {
229        match e {
230            SdkOAuthError::Discovery(error) if error.is_not_supported() => OAuthError::NotSupported,
231            SdkOAuthError::AuthorizationCode(OAuthAuthorizationCodeError::RedirectUri(_))
232            | SdkOAuthError::AuthorizationCode(OAuthAuthorizationCodeError::InvalidState) => {
233                OAuthError::CallbackUrlInvalid
234            }
235            SdkOAuthError::AuthorizationCode(OAuthAuthorizationCodeError::Cancelled) => {
236                OAuthError::Cancelled
237            }
238            _ => OAuthError::Generic { message: e.to_string() },
239        }
240    }
241}
242
243impl From<Error> for OAuthError {
244    fn from(e: Error) -> OAuthError {
245        match e {
246            Error::OAuth(e) => (*e).into(),
247            _ => OAuthError::Generic { message: e.to_string() },
248        }
249    }
250}
251
252/* Helpers */
253
254trait OptionExt {
255    /// Convenience method to convert an `Option<String>` to a URL and returns
256    /// it as a Localized URL. No localization is actually performed.
257    fn localized_url(&self) -> Result<Option<Localized<Url>>, OAuthError>;
258}
259
260impl OptionExt for Option<String> {
261    fn localized_url(&self) -> Result<Option<Localized<Url>>, OAuthError> {
262        self.as_deref().map(StrExt::localized_url).transpose()
263    }
264}
265
266trait StrExt {
267    /// Convenience method to convert a string to a URL and returns it as a
268    /// Localized URL. No localization is actually performed.
269    fn localized_url(&self) -> Result<Localized<Url>, OAuthError>;
270}
271
272impl StrExt for str {
273    fn localized_url(&self) -> Result<Localized<Url>, OAuthError> {
274        Ok(Localized::new(Url::parse(self).map_err(|_| OAuthError::MetadataInvalid)?, []))
275    }
276}