1// Copyright 2023 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 the specific language governing permissions and
13// limitations under the License.
1415//! OAuth 2.0 client registration store.
16//!
17//! This module provides a way to persist OAuth 2.0 client registrations outside
18//! of the state store. This is useful when using a `Client` with an in-memory
19//! store or when different store paths are used for multi-account support
20//! within the same app, and those accounts need to share the same OAuth 2.0
21//! client registration.
2223use std::{collections::HashMap, io::ErrorKind, path::PathBuf};
2425use oauth2::ClientId;
26use ruma::serde::Raw;
27use serde::{Deserialize, Serialize};
28use tokio::fs;
29use url::Url;
3031use super::ClientMetadata;
3233/// Errors that can occur when using the [`OAuthRegistrationStore`].
34#[derive(Debug, thiserror::Error)]
35pub enum OAuthRegistrationStoreError {
36/// The supplied path is not a file path.
37#[error("supplied registrations path is not a file path")]
38NotAFilePath,
39/// An error occurred when reading from or writing to the file.
40#[error(transparent)]
41File(#[from] std::io::Error),
42/// An error occurred when serializing the registration data.
43#[error("failed to serialize registration data: {0}")]
44IntoJson(serde_json::Error),
45/// An error occurred when deserializing the registration data.
46#[error("failed to deserialize registration data: {0}")]
47FromJson(serde_json::Error),
48}
4950/// An API to store and restore OAuth 2.0 client registrations.
51///
52/// This stores dynamic client registrations in a file, and accepts "static"
53/// client registrations via
54/// [`OAuthRegistrationStore::with_static_registrations()`], for servers that
55/// don't support dynamic client registration.
56///
57/// If the client metadata passed to this API changes, the previous
58/// registrations that were stored in the file are invalidated, allowing to
59/// re-register with the new metadata.
60///
61/// The purpose of storing client IDs outside of the state store or separate
62/// from the user's session is that it allows to reuse the same client ID
63/// between user sessions on the same server.
64#[derive(Debug)]
65pub struct OAuthRegistrationStore {
66/// The path of the file where the registrations are stored.
67pub(super) file_path: PathBuf,
68/// The metadata used to register the client.
69 /// This is used to check if the client needs to be re-registered.
70pub(super) metadata: Raw<ClientMetadata>,
71/// Pre-configured registrations for use with issuers that don't support
72 /// dynamic client registration.
73static_registrations: Option<HashMap<Url, ClientId>>,
74}
7576/// The underlying data serialized into the registration file.
77#[derive(Debug, Serialize, Deserialize)]
78struct FrozenRegistrationData {
79/// The metadata used to register the client.
80metadata: Raw<ClientMetadata>,
81/// All of the registrations this client has made as a HashMap of issuer URL
82 /// to client ID.
83dynamic_registrations: HashMap<Url, ClientId>,
84}
8586impl OAuthRegistrationStore {
87/// Creates a new registration store.
88 ///
89 /// This method creates the `file`'s parent directory if it doesn't exist.
90 ///
91 /// # Arguments
92 ///
93 /// * `file` - A file path where the registrations will be stored. This
94 /// previously took a directory and stored the registrations with the path
95 /// `supplied_directory/oidc/registrations.json`.
96 ///
97 /// * `metadata` - The metadata used to register the client. If this changes
98 /// compared to the value stored in the file, any stored registrations
99 /// will be invalidated so the client can re-register with the new data.
100pub async fn new(
101 file: PathBuf,
102 metadata: Raw<ClientMetadata>,
103 ) -> Result<Self, OAuthRegistrationStoreError> {
104let parent = file.parent().ok_or(OAuthRegistrationStoreError::NotAFilePath)?;
105 fs::create_dir_all(parent).await?;
106107Ok(OAuthRegistrationStore { file_path: file, metadata, static_registrations: None })
108 }
109110/// Add static registrations to the store.
111 ///
112 /// Static registrations are used for servers that don't support dynamic
113 /// registration but provide a client ID out-of-band.
114 ///
115 /// These registrations are not stored in the file and must be provided each
116 /// time.
117pub fn with_static_registrations(
118mut self,
119 static_registrations: HashMap<Url, ClientId>,
120 ) -> Self {
121self.static_registrations = Some(static_registrations);
122self
123}
124125/// Returns the client ID registered for a particular issuer or `None` if a
126 /// registration hasn't been made.
127 ///
128 /// # Arguments
129 ///
130 /// * `issuer` - The issuer to look up.
131 ///
132 /// # Errors
133 ///
134 /// Returns an error if the file could not be read, or if the data in the
135 /// file could not be deserialized.
136pub async fn client_id(
137&self,
138 issuer: &Url,
139 ) -> Result<Option<ClientId>, OAuthRegistrationStoreError> {
140if let Some(client_id) =
141self.static_registrations.as_ref().and_then(|registrations| registrations.get(issuer))
142 {
143return Ok(Some(client_id.clone()));
144 }
145146let data = self.read_registration_data().await?;
147Ok(data.and_then(|mut data| data.dynamic_registrations.remove(issuer)))
148 }
149150/// Stores a new client ID registration for a particular issuer.
151 ///
152 /// If a client ID has already been stored for the given issuer, this will
153 /// overwrite the old value.
154 ///
155 /// # Arguments
156 ///
157 /// * `client_id` - The client ID obtained after registration.
158 ///
159 /// * `issuer` - The issuer associated with the client ID.
160 ///
161 /// # Errors
162 ///
163 /// Returns an error if the file could not be read from or written to, or if
164 /// the data in the file could not be (de)serialized.
165pub async fn set_and_write_client_id(
166&self,
167 client_id: ClientId,
168 issuer: Url,
169 ) -> Result<(), OAuthRegistrationStoreError> {
170let mut data = self.read_registration_data().await?.unwrap_or_else(|| {
171tracing::info!("Generating new OAuth 2.0 client registration data");
172 FrozenRegistrationData {
173 metadata: self.metadata.clone(),
174 dynamic_registrations: Default::default(),
175 }
176 });
177 data.dynamic_registrations.insert(issuer, client_id);
178179let contents = serde_json::to_vec(&data).map_err(OAuthRegistrationStoreError::IntoJson)?;
180 fs::write(&self.file_path, contents).await?;
181182Ok(())
183 }
184185/// The persisted registration data.
186 ///
187 /// # Errors
188 ///
189 /// Returns an error if the file could not be read, or if the data in the
190 /// file could not be deserialized.
191async fn read_registration_data(
192&self,
193 ) -> Result<Option<FrozenRegistrationData>, OAuthRegistrationStoreError> {
194let contents = match fs::read(&self.file_path).await {
195Ok(contents) => contents,
196Err(error) => {
197if error.kind() == ErrorKind::NotFound {
198// The file doesn't exist so there is no data.
199return Ok(None);
200 }
201202// Forward the error.
203return Err(error.into());
204 }
205 };
206207let registration_data: FrozenRegistrationData =
208 serde_json::from_slice(&contents).map_err(OAuthRegistrationStoreError::FromJson)?;
209210if registration_data.metadata.json().get() != self.metadata.json().get() {
211tracing::info!("Metadata mismatch, ignoring any stored registrations.");
212Ok(None)
213 } else {
214Ok(Some(registration_data))
215 }
216 }
217}
218219#[cfg(test)]
220mod tests {
221use matrix_sdk_test::async_test;
222use tempfile::tempdir;
223224use super::*;
225use crate::authentication::oauth::registration::{ApplicationType, Localized, OAuthGrantType};
226227#[async_test]
228async fn test_oauth_registration_store() {
229// Given a fresh registration store with a single static registration.
230let dir = tempdir().unwrap();
231let registrations_file = dir.path().join("oauth").join("registrations.json");
232233let static_url = Url::parse("https://example.com").unwrap();
234let static_id = ClientId::new("static_client_id".to_owned());
235let dynamic_url = Url::parse("https://example.org").unwrap();
236let dynamic_id = ClientId::new("dynamic_client_id".to_owned());
237238let mut static_registrations = HashMap::new();
239 static_registrations.insert(static_url.clone(), static_id.clone());
240241let oidc_metadata = mock_metadata("Example".to_owned());
242243let registrations = OAuthRegistrationStore::new(registrations_file, oidc_metadata)
244 .await
245.unwrap()
246 .with_static_registrations(static_registrations);
247248assert_eq!(registrations.client_id(&static_url).await.unwrap(), Some(static_id.clone()));
249assert_eq!(registrations.client_id(&dynamic_url).await.unwrap(), None);
250251// When a dynamic registration is added.
252registrations
253 .set_and_write_client_id(dynamic_id.clone(), dynamic_url.clone())
254 .await
255.unwrap();
256257// Then the dynamic registration should be stored and the static registration
258 // should be unaffected.
259assert_eq!(registrations.client_id(&static_url).await.unwrap(), Some(static_id));
260assert_eq!(registrations.client_id(&dynamic_url).await.unwrap(), Some(dynamic_id));
261 }
262263#[async_test]
264async fn test_change_of_metadata() {
265// Given a single registration with an example app name.
266let dir = tempdir().unwrap();
267let registrations_file = dir.path().join("oidc").join("registrations.json");
268269let static_url = Url::parse("https://example.com").unwrap();
270let static_id = ClientId::new("static_client_id".to_owned());
271let dynamic_url = Url::parse("https://example.org").unwrap();
272let dynamic_id = ClientId::new("dynamic_client_id".to_owned());
273274let oidc_metadata = mock_metadata("Example".to_owned());
275276let mut static_registrations = HashMap::new();
277 static_registrations.insert(static_url.clone(), static_id.clone());
278279let registrations = OAuthRegistrationStore::new(registrations_file.clone(), oidc_metadata)
280 .await
281.unwrap()
282 .with_static_registrations(static_registrations.clone());
283 registrations
284 .set_and_write_client_id(dynamic_id.clone(), dynamic_url.clone())
285 .await
286.unwrap();
287288assert_eq!(registrations.client_id(&static_url).await.unwrap(), Some(static_id.clone()));
289assert_eq!(registrations.client_id(&dynamic_url).await.unwrap(), Some(dynamic_id));
290291// When the app name changes.
292let new_oidc_metadata = mock_metadata("New App".to_owned());
293294let registrations = OAuthRegistrationStore::new(registrations_file, new_oidc_metadata)
295 .await
296.unwrap()
297 .with_static_registrations(static_registrations);
298299// Then the dynamic registrations are cleared.
300assert_eq!(registrations.client_id(&dynamic_url).await.unwrap(), None);
301assert_eq!(registrations.client_id(&static_url).await.unwrap(), Some(static_id));
302 }
303304fn mock_metadata(client_name: String) -> Raw<ClientMetadata> {
305let callback_url = Url::parse("https://example.org/login/callback").unwrap();
306let client_uri = Url::parse("https://example.org/").unwrap();
307308let mut metadata = ClientMetadata::new(
309 ApplicationType::Web,
310vec![OAuthGrantType::AuthorizationCode { redirect_uris: vec![callback_url] }],
311 Localized::new(client_uri, None),
312 );
313 metadata.client_name = Some(Localized::new(client_name, None));
314315 Raw::new(&metadata).unwrap()
316 }
317}