refactor(oidc): Use oauth2 for authorization code grant

Signed-off-by: Kévin Commaille <zecakeh@tedomum.fr>
This commit is contained in:
Kévin Commaille
2025-03-01 14:24:47 +01:00
committed by Damir Jelić
parent 52f98582f1
commit e15897b3f1
9 changed files with 292 additions and 188 deletions
@@ -6,6 +6,7 @@ use std::{
use matrix_sdk::{
authentication::oidc::{
error::OauthAuthorizationCodeError,
registrations::OidcRegistrationsError,
types::{
iana::oauth::OAuthClientAuthenticationMethod,
@@ -198,9 +199,13 @@ impl From<SdkOidcError> for OidcError {
match e {
SdkOidcError::Discovery(error) if error.is_not_supported() => OidcError::NotSupported,
SdkOidcError::MissingRedirectUri => OidcError::MetadataInvalid,
SdkOidcError::InvalidCallbackUrl => OidcError::CallbackUrlInvalid,
SdkOidcError::InvalidState => OidcError::CallbackUrlInvalid,
SdkOidcError::CancelledAuthorization => OidcError::Cancelled,
SdkOidcError::AuthorizationCode(OauthAuthorizationCodeError::RedirectUri(_))
| SdkOidcError::AuthorizationCode(OauthAuthorizationCodeError::InvalidState) => {
OidcError::CallbackUrlInvalid
}
SdkOidcError::AuthorizationCode(OauthAuthorizationCodeError::Cancelled) => {
OidcError::Cancelled
}
_ => OidcError::Generic { message: e.to_string() },
}
}
+9 -34
View File
@@ -20,6 +20,7 @@ use matrix_sdk::{
},
ruma::{
api::client::{
discovery::get_authorization_server_metadata::msc2965::Prompt as RumaOidcPrompt,
push::{EmailPusherData, PusherIds, PusherInit, PusherKind as RumaPusherKind},
room::{create_room, Visibility},
session::get_login_types,
@@ -399,7 +400,7 @@ impl Client {
pub async fn url_for_oidc(
&self,
oidc_configuration: &OidcConfiguration,
prompt: OidcPrompt,
prompt: Option<OidcPrompt>,
) -> Result<Arc<OidcAuthorizationData>, OidcError> {
let oidc_metadata: VerifiedClientMetadata = oidc_configuration.try_into()?;
let registrations_file = Path::new(&oidc_configuration.dynamic_registrations_file);
@@ -420,8 +421,11 @@ impl Client {
static_registrations,
)?;
let data =
self.inner.oidc().url_for_oidc(oidc_metadata, registrations, prompt.into()).await?;
let data = self
.inner
.oidc()
.url_for_oidc(oidc_metadata, registrations, prompt.map(Into::into))
.await?;
Ok(Arc::new(data))
}
@@ -1813,26 +1817,6 @@ impl TryFrom<SlidingSyncVersion> for SdkSlidingSyncVersion {
#[derive(Clone, uniffi::Enum)]
pub enum OidcPrompt {
/// The Authorization Server must not display any authentication or consent
/// user interface pages.
None,
/// The Authorization Server should prompt the End-User for
/// reauthentication.
Login,
/// The Authorization Server should prompt the End-User for consent before
/// returning information to the Client.
Consent,
/// The Authorization Server should prompt the End-User to select a user
/// account.
///
/// This enables an End-User who has multiple accounts at the Authorization
/// Server to select amongst the multiple accounts that they might have
/// current sessions for.
SelectAccount,
/// The Authorization Server should prompt the End-User to create a user
/// account.
///
@@ -1846,26 +1830,17 @@ pub enum OidcPrompt {
impl From<&SdkOidcPrompt> for OidcPrompt {
fn from(value: &SdkOidcPrompt) -> Self {
match value {
SdkOidcPrompt::None => Self::None,
SdkOidcPrompt::Login => Self::Login,
SdkOidcPrompt::Consent => Self::Consent,
SdkOidcPrompt::SelectAccount => Self::SelectAccount,
SdkOidcPrompt::Create => Self::Create,
SdkOidcPrompt::Unknown(value) => Self::Unknown { value: value.to_owned() },
_ => Self::Unknown { value: value.to_string() },
}
}
}
impl From<OidcPrompt> for SdkOidcPrompt {
impl From<OidcPrompt> for RumaOidcPrompt {
fn from(value: OidcPrompt) -> Self {
match value {
OidcPrompt::None => Self::None,
OidcPrompt::Login => Self::Login,
OidcPrompt::Consent => Self::Consent,
OidcPrompt::SelectAccount => Self::SelectAccount,
OidcPrompt::Create => Self::Create,
OidcPrompt::Unknown { value } => Self::Unknown(value),
OidcPrompt::Unknown { value } => value.into(),
}
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ matrix-sdk-sqlite = { workspace = true, optional = true }
matrix-sdk-test = { workspace = true, optional = true }
mime = { workspace = true }
mime2ext = "0.1.53"
oauth2 = { version = "5.0.0", default-features = false, features = ["reqwest"], optional = true }
oauth2 = { version = "5.0.0", default-features = false, features = ["reqwest", "timing-resistant-secret-traits"], optional = true }
once_cell = { workspace = true }
percent-encoding = "2.3.1"
pin-project-lite = { workspace = true }
@@ -12,16 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use mas_oidc_client::{
requests::authorization_code::{build_authorization_url, AuthorizationRequestData},
types::{requests::Prompt, scope::Scope},
use std::borrow::Cow;
use oauth2::{
basic::BasicClient as OauthClient, AuthUrl, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope,
};
use ruma::UserId;
use ruma::{api::client::discovery::get_authorization_server_metadata::msc2965::Prompt, UserId};
use tracing::{info, instrument};
use url::Url;
use super::{Oidc, OidcError};
use crate::Result;
use crate::{authentication::oidc::AuthorizationValidationData, Result};
/// Builder type used to configure optional settings for authorization with an
/// OpenID Connect Provider via the Authorization Code flow.
@@ -30,15 +31,15 @@ use crate::Result;
#[allow(missing_debug_implementations)]
pub struct OidcAuthCodeUrlBuilder {
oidc: Oidc,
scope: Scope,
scopes: Vec<Scope>,
redirect_uri: Url,
prompt: Option<Vec<Prompt>>,
login_hint: Option<String>,
}
impl OidcAuthCodeUrlBuilder {
pub(super) fn new(oidc: Oidc, scope: Scope, redirect_uri: Url) -> Self {
Self { oidc, scope, redirect_uri, prompt: None, login_hint: None }
pub(super) fn new(oidc: Oidc, scopes: Vec<Scope>, redirect_uri: Url) -> Self {
Self { oidc, scopes, redirect_uri, prompt: None, login_hint: None }
}
/// Set the [`Prompt`] of the authorization URL.
@@ -73,34 +74,44 @@ impl OidcAuthCodeUrlBuilder {
/// request fails.
#[instrument(target = "matrix_sdk::client", skip_all)]
pub async fn build(self) -> Result<OidcAuthorizationData, OidcError> {
let Self { oidc, scope, redirect_uri, prompt, login_hint } = self;
let Self { oidc, scopes, redirect_uri, prompt, login_hint } = self;
let data = oidc.data().ok_or(OidcError::NotAuthenticated)?;
info!(
issuer = data.issuer,
%scope, "Authorizing scope via the OpenID Connect Authorization Code flow"
?scopes,
"Authorizing scope via the OpenID Connect Authorization Code flow"
);
let provider_metadata = oidc.provider_metadata().await?;
let auth_url = AuthUrl::from_url(provider_metadata.authorization_endpoint().clone());
let mut authorization_data =
AuthorizationRequestData::new(data.client_id.as_str().to_owned(), scope, redirect_uri);
authorization_data.code_challenge_methods_supported =
provider_metadata.code_challenge_methods_supported.clone();
authorization_data.prompt = prompt;
authorization_data.login_hint = login_hint;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let redirect_uri = RedirectUrl::from_url(redirect_uri);
let authorization_endpoint = provider_metadata.authorization_endpoint();
let client = OauthClient::new(data.client_id.clone()).set_auth_uri(auth_url);
let mut request = client
.authorize_url(CsrfToken::new_random)
.add_scopes(scopes)
.set_pkce_challenge(pkce_challenge)
.set_redirect_uri(Cow::Borrowed(&redirect_uri));
let (url, validation_data) = build_authorization_url(
authorization_endpoint.clone(),
authorization_data,
&mut super::rng()?,
)?;
if let Some(prompt) = prompt {
// This should be a list of space separated values.
let prompt_str = prompt.iter().map(Prompt::as_str).collect::<Vec<_>>().join(" ");
request = request.add_extra_param("prompt", prompt_str);
}
let state = validation_data.state.clone();
if let Some(login_hint) = login_hint {
request = request.add_extra_param("login_hint", login_hint);
}
data.authorization_data.lock().await.insert(state.clone(), validation_data);
let (url, state) = request.url();
data.authorization_data
.lock()
.await
.insert(state.clone(), AuthorizationValidationData { redirect_uri, pkce_verifier });
Ok(OidcAuthorizationData { url, state })
}
@@ -114,7 +125,7 @@ pub struct OidcAuthorizationData {
pub url: Url,
/// A unique identifier for the request, used to ensure the response
/// originated from the authentication issuer.
pub state: String,
pub state: CsrfToken,
}
#[cfg(feature = "uniffi")]
@@ -15,10 +15,13 @@
//! Error types used in the [`Oidc`](super::Oidc) API.
pub use mas_oidc_client::error::*;
use matrix_sdk_base::deserialized_responses::PrivOwnedStr;
use oauth2::ErrorResponseType;
pub use oauth2::{
basic::{BasicErrorResponse, BasicErrorResponseType, BasicRequestTokenError},
HttpClientError, RequestTokenError, StandardErrorResponse,
};
use ruma::serde::{PartialEqAsRefStr, StringEnum};
pub use super::cross_process::CrossProcessRefreshLockError;
@@ -69,26 +72,9 @@ pub enum OidcError {
#[error("client not authenticated")]
NotAuthenticated,
/// The state used to complete authorization doesn't match an original
/// value.
#[error("the supplied state is unexpected")]
InvalidState,
/// The user cancelled authorization in the web view.
#[error("authorization cancelled")]
CancelledAuthorization,
/// The login was completed with an invalid callback.
#[error("the supplied callback URL is invalid")]
InvalidCallbackUrl,
/// An error occurred during authorization.
#[error("authorization failed")]
Authorization(super::AuthorizationError),
/// The device ID is invalid.
#[error("invalid device ID")]
InvalidDeviceId,
/// An error occurred using the OAuth 2.0 authorization code grant.
#[error("authorization code grant failed: {0}")]
AuthorizationCode(#[from] OauthAuthorizationCodeError),
/// An error occurred interacting with the OAuth 2.0 authorization server
/// while refreshing the access token.
@@ -154,3 +140,87 @@ impl OauthDiscoveryError {
matches!(self, Self::NotSupported)
}
}
/// All errors that can occur when using the Authorization Code grant with the
/// OAuth 2.0 API.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum OauthAuthorizationCodeError {
/// The query of the redirect URI doesn't have the expected format.
#[error(transparent)]
RedirectUri(#[from] RedirectUriQueryParseError),
/// The user cancelled the authorization in the web UI.
#[error("authorization cancelled by the user")]
Cancelled,
/// An error occurred when getting the authorization from the user in the
/// web UI.
#[error("authorization failed: {0}")]
Authorization(StandardErrorResponse<AuthorizationCodeErrorResponseType>),
/// The state used to complete authorization doesn't match any of the
/// ongoing authorizations.
#[error("authorization state value is unexpected")]
InvalidState,
/// An error occurred interacting with the OAuth 2.0 authorization server
/// while exchanging the authorization code for an access token.
#[error("failed to request token: {0}")]
RequestToken(BasicRequestTokenError<HttpClientError<reqwest::Error>>),
}
impl From<StandardErrorResponse<AuthorizationCodeErrorResponseType>>
for OauthAuthorizationCodeError
{
fn from(value: StandardErrorResponse<AuthorizationCodeErrorResponseType>) -> Self {
if *value.error() == AuthorizationCodeErrorResponseType::AccessDenied {
// The user cancelled the login in the web view.
Self::Cancelled
} else {
Self::Authorization(value)
}
}
}
/// Error response returned by server after requesting an authorization code.
///
/// The fields in this structure are defined in [Section 4.1.2.1 of RFC 6749].
///
/// [Section 4.1.2.1 of RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
#[derive(Clone, StringEnum, PartialEqAsRefStr, Eq)]
#[ruma_enum(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AuthorizationCodeErrorResponseType {
/// The request is invalid.
///
/// It is missing a required parameter, includes an invalid parameter value,
/// includes a parameter more than once, or is otherwise malformed.
InvalidRequest,
/// The client is not authorized to request an authorization code using this
/// method.
UnauthorizedClient,
/// The resource owner or authorization server denied the request.
AccessDenied,
/// The authorization server does not support obtaining an authorization
/// code using this method.
UnsupportedResponseType,
/// The requested scope is invalid, unknown, or malformed.
InvalidScope,
/// The authorization server encountered an unexpected error.
ServerError,
/// The authorization server is currently unable to handle the request due
/// to a temporary overloading or maintenance of the server.
TemporarilyUnavailable,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
impl ErrorResponseType for AuthorizationCodeErrorResponseType {}
@@ -146,45 +146,52 @@
//! [`AuthenticateError::InsufficientScope`]: ruma::api::client::error::AuthenticateError
//! [`examples/oidc_cli`]: https://github.com/matrix-org/matrix-rust-sdk/tree/main/examples/oidc_cli
use std::{collections::HashMap, fmt, future::Future, pin::Pin, sync::Arc};
use std::{borrow::Cow, collections::HashMap, fmt, future::Future, pin::Pin, sync::Arc};
use as_variant::as_variant;
use chrono::Utc;
use error::{CrossProcessRefreshLockError, OauthDiscoveryError, RedirectUriQueryParseError};
use error::{
CrossProcessRefreshLockError, OauthAuthorizationCodeError, OauthDiscoveryError,
RedirectUriQueryParseError,
};
use eyeball::SharedObservable;
use futures_core::Stream;
use mas_oidc_client::{
http_service::HttpService,
requests::{
account_management::{build_account_management_url, AccountManagementActionFull},
authorization_code::{access_token_with_authorization_code, AuthorizationValidationData},
discovery::{discover, insecure_discover},
registration::register_client,
revocation::revoke_token,
},
types::{
client_credentials::ClientCredentials,
errors::{ClientError, ClientErrorCode::AccessDenied},
iana::oauth::OAuthTokenTypeHint,
oidc::{
AccountManagementAction, ProviderMetadata, ProviderMetadataVerificationError,
VerifiedProviderMetadata,
},
registration::{ClientRegistrationResponse, VerifiedClientMetadata},
requests::Prompt,
scope::{MatrixApiScopeToken, ScopeToken},
},
};
pub use mas_oidc_client::{requests, types};
#[cfg(feature = "e2e-encryption")]
use matrix_sdk_base::crypto::types::qr_login::QrCodeData;
use matrix_sdk_base::{once_cell::sync::OnceCell, SessionMeta};
pub use oauth2::CsrfToken;
use oauth2::{
basic::BasicClient as OauthClient, AsyncHttpClient, HttpRequest, HttpResponse, RefreshToken,
TokenResponse, TokenUrl,
basic::BasicClient as OauthClient, AsyncHttpClient, HttpRequest, HttpResponse,
PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, StandardErrorResponse, TokenResponse,
TokenUrl,
};
use rand::{rngs::StdRng, SeedableRng};
use ruma::{
api::client::discovery::{
get_authentication_issuer,
get_authorization_server_metadata::{self, msc2965::Prompt},
},
DeviceId, OwnedDeviceId,
};
use rand::{rngs::StdRng, Rng, SeedableRng};
use ruma::api::client::discovery::{get_authentication_issuer, get_authorization_server_metadata};
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
use tokio::{spawn, sync::Mutex};
@@ -243,7 +250,7 @@ pub(crate) struct OidcAuthData {
pub(crate) client_id: ClientId,
pub(crate) tokens: OnceCell<SharedObservable<OidcSessionTokens>>,
/// The data necessary to validate authorization responses.
pub(crate) authorization_data: Mutex<HashMap<String, AuthorizationValidationData>>,
authorization_data: Mutex<HashMap<CsrfToken, AuthorizationValidationData>>,
}
impl OidcAuthData {
@@ -435,7 +442,7 @@ impl Oidc {
&self,
client_metadata: VerifiedClientMetadata,
registrations: OidcRegistrations,
prompt: Prompt,
prompt: Option<Prompt>,
) -> Result<OidcAuthorizationData, OidcError> {
let metadata = self.provider_metadata().await?;
@@ -447,7 +454,11 @@ impl Oidc {
self.configure(metadata.issuer().to_owned(), client_metadata, registrations).await?;
let mut data_builder = self.login(redirect_url.clone(), None)?;
data_builder = data_builder.prompt(vec![prompt]);
if let Some(prompt) = prompt {
data_builder = data_builder.prompt(vec![prompt]);
}
let data = data_builder.build().await?;
Ok(data)
@@ -462,16 +473,13 @@ impl Oidc {
callback_url: Url,
) -> Result<()> {
let response = AuthorizationResponse::parse_uri(&callback_url)
.or(Err(OidcError::InvalidCallbackUrl))?;
.map_err(OauthAuthorizationCodeError::from)
.map_err(OidcError::from)?;
let code = match response {
AuthorizationResponse::Success(code) => code,
AuthorizationResponse::Error(err) => {
if err.error.error == AccessDenied {
// The user cancelled the login in the web view.
return Err(OidcError::CancelledAuthorization.into());
}
return Err(OidcError::Authorization(err).into());
return Err(OidcError::from(OauthAuthorizationCodeError::from(err.error)).into());
}
};
@@ -479,7 +487,7 @@ impl Oidc {
// the client to have called `abort_authorization` which we can't guarantee so
// lets double check with their supplied authorization data to be safe.
if code.state != authorization_data.state {
return Err(OidcError::InvalidState.into());
return Err(OidcError::from(OauthAuthorizationCodeError::InvalidState).into());
};
self.finish_authorization(code).await?;
@@ -1083,21 +1091,20 @@ impl Oidc {
}
/// The scopes to request for logging in.
fn login_scopes(device_id: Option<String>) -> Result<[ScopeToken; 3], OidcError> {
// Generate the device ID if it is not provided.
let device_id = device_id.unwrap_or_else(|| {
rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.map(char::from)
.take(10)
.collect::<String>()
});
fn login_scopes(device_id: Option<OwnedDeviceId>) -> [Scope; 2] {
/// Scope to grand full access to the client-server API.
const SCOPE_MATRIX_CLIENT_SERVER_API_FULL_ACCESS: &str =
"urn:matrix:org.matrix.msc2967.client:api:*";
/// Prefix of the scope to bind a device ID to an access token.
const SCOPE_MATRIX_DEVICE_ID_PREFIX: &str = "urn:matrix:org.matrix.msc2967.client:device:";
Ok([
ScopeToken::Openid,
ScopeToken::MatrixApi(MatrixApiScopeToken::Full),
ScopeToken::try_with_matrix_device(device_id).or(Err(OidcError::InvalidDeviceId))?,
])
// Generate the device ID if it is not provided.
let device_id = device_id.unwrap_or_else(DeviceId::new);
[
Scope::new(SCOPE_MATRIX_CLIENT_SERVER_API_FULL_ACCESS.to_owned()),
Scope::new(format!("{SCOPE_MATRIX_DEVICE_ID_PREFIX}{device_id}")),
]
}
/// Login via OpenID Connect with the Authorization Code flow.
@@ -1174,11 +1181,11 @@ impl Oidc {
pub fn login(
&self,
redirect_uri: Url,
device_id: Option<String>,
device_id: Option<OwnedDeviceId>,
) -> Result<OidcAuthCodeUrlBuilder, OidcError> {
let scope = Self::login_scopes(device_id)?.into_iter().collect();
let scopes = Self::login_scopes(device_id).to_vec();
Ok(OidcAuthCodeUrlBuilder::new(self.clone(), scope, redirect_uri))
Ok(OidcAuthCodeUrlBuilder::new(self.clone(), scopes, redirect_uri))
}
/// Finish the login process.
@@ -1265,30 +1272,30 @@ impl Oidc {
auth_code: AuthorizationCode,
) -> Result<(), OidcError> {
let data = self.data().ok_or(OidcError::NotAuthenticated)?;
let client_id = data.client_id.clone();
let validation_data = data
.authorization_data
.lock()
.await
.remove(&auth_code.state)
.ok_or(OidcError::InvalidState)?;
.ok_or(OauthAuthorizationCodeError::InvalidState)?;
let provider_metadata = self.provider_metadata().await?;
let token_uri = TokenUrl::from_url(provider_metadata.token_endpoint().clone());
let (response, _) = access_token_with_authorization_code(
&self.http_service(),
data.credentials(),
provider_metadata.token_endpoint(),
auth_code.code,
validation_data,
None,
Utc::now(),
&mut rng()?,
)
.await?;
let response = OauthClient::new(client_id)
.set_token_uri(token_uri)
.exchange_code(oauth2::AuthorizationCode::new(auth_code.code))
.set_pkce_verifier(validation_data.pkce_verifier)
.set_redirect_uri(Cow::Owned(validation_data.redirect_uri))
.request_async(self.http_client())
.await
.map_err(OauthAuthorizationCodeError::RequestToken)?;
self.set_session_tokens(OidcSessionTokens {
access_token: response.access_token,
refresh_token: response.refresh_token,
access_token: response.access_token().secret().clone(),
refresh_token: response.refresh_token().map(RefreshToken::secret).cloned(),
});
Ok(())
@@ -1309,7 +1316,7 @@ impl Oidc {
/// * `state` - The state received as part of the redirect URI when the
/// authorization failed, or the one provided in [`OidcAuthorizationData`]
/// after building the authorization URL.
pub async fn abort_authorization(&self, state: &str) {
pub async fn abort_authorization(&self, state: &CsrfToken) {
if let Some(data) = self.data() {
data.authorization_data.lock().await.remove(state);
}
@@ -1320,12 +1327,10 @@ impl Oidc {
#[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))]
async fn request_device_authorization(
&self,
device_id: Option<String>,
device_id: Option<OwnedDeviceId>,
) -> Result<oauth2::StandardDeviceAuthorizationResponse, qrcode::DeviceAuthorizationOauthError>
{
let scopes = Self::login_scopes(device_id)?
.into_iter()
.map(|scope| oauth2::Scope::new(scope.to_string()));
let scopes = Self::login_scopes(device_id);
let client_id = self.client_id().ok_or(OidcError::NotRegistered)?.clone();
@@ -1635,6 +1640,17 @@ impl fmt::Debug for OidcSessionTokens {
}
}
/// The data necessary to validate a response from the Token endpoint in the
/// Authorization Code flow.
#[derive(Debug)]
struct AuthorizationValidationData {
/// The URI where the end-user will be redirected after authorization.
redirect_uri: RedirectUrl,
/// A string to correlate the authorization request to the token request.
pkce_verifier: PkceCodeVerifier,
}
/// The data returned by the provider in the redirect URI after a successful
/// authorization.
#[derive(Debug, Clone)]
@@ -1680,7 +1696,7 @@ pub struct AuthorizationCode {
/// The code to use to retrieve the access token.
pub code: String,
/// The unique identifier for this transaction.
pub state: String,
pub state: CsrfToken,
}
/// The data returned by the provider in the redirect URI after an authorization
@@ -1689,9 +1705,9 @@ pub struct AuthorizationCode {
pub struct AuthorizationError {
/// The error.
#[serde(flatten)]
pub error: ClientError,
pub error: StandardErrorResponse<error::AuthorizationCodeErrorResponseType>,
/// The unique identifier for this transaction.
pub state: String,
pub state: CsrfToken,
}
fn rng() -> Result<StdRng, OidcError> {
@@ -294,7 +294,8 @@ impl<'a> LoginWithQrCode<'a> {
device_id: Curve25519PublicKey,
) -> Result<StandardDeviceAuthorizationResponse, DeviceAuthorizationOauthError> {
let oidc = self.client.oidc();
let response = oidc.request_device_authorization(Some(device_id.to_base64())).await?;
let response =
oidc.request_device_authorization(Some(device_id.to_base64().into())).await?;
Ok(response)
}
@@ -3,14 +3,15 @@ use std::collections::HashMap;
use anyhow::Context as _;
use assert_matches::assert_matches;
use mas_oidc_client::{
requests::{
account_management::AccountManagementActionFull,
authorization_code::AuthorizationValidationData,
},
types::{errors::ClientErrorCode, registration::VerifiedClientMetadata, requests::Prompt},
requests::account_management::AccountManagementActionFull,
types::registration::VerifiedClientMetadata,
};
use matrix_sdk_test::async_test;
use ruma::ServerName;
use oauth2::{CsrfToken, PkceCodeChallenge, RedirectUrl};
use ruma::{
api::client::discovery::get_authorization_server_metadata::msc2965::Prompt, owned_device_id,
ServerName,
};
use serde_json::json;
use stream_assert::{assert_next_matches, assert_pending};
use tempfile::tempdir;
@@ -25,6 +26,10 @@ use super::{
Oidc, OidcError, OidcSessionTokens, RedirectUriQueryParseError,
};
use crate::{
authentication::oidc::{
error::AuthorizationCodeErrorResponseType, AuthorizationValidationData,
OauthAuthorizationCodeError,
},
test_utils::{
client::{
oauth::{mock_client_metadata, mock_session, mock_session_tokens},
@@ -76,7 +81,7 @@ async fn test_high_level_login() -> anyhow::Result<()> {
// When getting the OIDC login URL.
let authorization_data =
oidc.url_for_oidc(metadata.clone(), registrations, Prompt::Login).await.unwrap();
oidc.url_for_oidc(metadata.clone(), registrations, Some(Prompt::Create)).await.unwrap();
// Then the client should be configured correctly.
assert!(oidc.issuer().is_some());
@@ -84,7 +89,7 @@ async fn test_high_level_login() -> anyhow::Result<()> {
// When completing the login with a valid callback.
let mut callback_uri = metadata.redirect_uris.clone().unwrap().first().unwrap().clone();
callback_uri.set_query(Some(&format!("code=42&state={}", authorization_data.state)));
callback_uri.set_query(Some(&format!("code=42&state={}", authorization_data.state.secret())));
// Then the login should succeed.
oidc.login_with_oidc_callback(&authorization_data, callback_uri).await?;
@@ -97,20 +102,25 @@ async fn test_high_level_login_cancellation() -> anyhow::Result<()> {
// Given a client ready to complete login.
let (oidc, _server, metadata, registrations) = mock_environment().await.unwrap();
let authorization_data =
oidc.url_for_oidc(metadata.clone(), registrations, Prompt::Login).await.unwrap();
oidc.url_for_oidc(metadata.clone(), registrations, None).await.unwrap();
assert!(oidc.issuer().is_some());
assert!(oidc.client_id().is_some());
// When completing login with a cancellation callback.
let mut callback_uri = metadata.redirect_uris.clone().unwrap().first().unwrap().clone();
callback_uri
.set_query(Some(&format!("error=access_denied&state={}", authorization_data.state)));
callback_uri.set_query(Some(&format!(
"error=access_denied&state={}",
authorization_data.state.secret()
)));
let error = oidc.login_with_oidc_callback(&authorization_data, callback_uri).await.unwrap_err();
// Then a cancellation error should be thrown.
assert_matches!(error, Error::Oidc(OidcError::CancelledAuthorization));
assert_matches!(
error,
Error::Oidc(OidcError::AuthorizationCode(OauthAuthorizationCodeError::Cancelled))
);
Ok(())
}
@@ -120,7 +130,7 @@ async fn test_high_level_login_invalid_state() -> anyhow::Result<()> {
// Given a client ready to complete login.
let (oidc, _server, metadata, registrations) = mock_environment().await.unwrap();
let authorization_data =
oidc.url_for_oidc(metadata.clone(), registrations, Prompt::Login).await.unwrap();
oidc.url_for_oidc(metadata.clone(), registrations, None).await.unwrap();
assert!(oidc.issuer().is_some());
assert!(oidc.client_id().is_some());
@@ -132,7 +142,10 @@ async fn test_high_level_login_invalid_state() -> anyhow::Result<()> {
let error = oidc.login_with_oidc_callback(&authorization_data, callback_uri).await.unwrap_err();
// Then the login should fail by flagging the invalid state.
assert_matches!(error, Error::Oidc(OidcError::InvalidState));
assert_matches!(
error,
Error::Oidc(OidcError::AuthorizationCode(OauthAuthorizationCodeError::InvalidState))
);
Ok(())
}
@@ -148,7 +161,7 @@ async fn test_login() -> anyhow::Result<()> {
let client = server.client_builder().registered_with_oauth(server.server().uri()).build().await;
let oidc = client.oidc();
let device_id = "D3V1C31D".to_owned(); // yo this is 1999 speaking
let device_id = owned_device_id!("D3V1C31D"); // yo this is 1999 speaking
let redirect_uri_str = REDIRECT_URI_STRING;
let redirect_uri = Url::parse(redirect_uri_str)?;
@@ -156,8 +169,8 @@ async fn test_login() -> anyhow::Result<()> {
tracing::debug!("authorization data URL = {}", authorization_data.url);
let mut num_expected = 6;
let mut nonce = None;
let mut num_expected = 7;
let mut code_challenge = None;
for (key, val) in authorization_data.url.query_pairs() {
match &*key {
@@ -174,16 +187,20 @@ async fn test_login() -> anyhow::Result<()> {
num_expected -= 1;
}
"scope" => {
assert_eq!(val, format!("openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:{device_id}"));
assert_eq!(val, format!("urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:{device_id}"));
num_expected -= 1;
}
"state" => {
num_expected -= 1;
assert_eq!(val, authorization_data.state);
assert_eq!(val, authorization_data.state.secret().as_str());
}
"nonce" => {
"code_challenge" => {
code_challenge = Some(val);
num_expected -= 1;
}
"code_challenge_method" => {
assert_eq!(val, "S256");
num_expected -= 1;
nonce = Some(val);
}
_ => panic!("unexpected query parameter: {key}={val}"),
}
@@ -195,8 +212,11 @@ async fn test_login() -> anyhow::Result<()> {
let authorization_data_guard = data.authorization_data.lock().await;
let state = authorization_data_guard.get(&authorization_data.state).context("missing state")?;
let nonce = nonce.context("missing nonce")?;
assert_eq!(nonce, state.nonce);
let code_challenge = code_challenge.context("missing code_challenge")?;
assert_eq!(
code_challenge,
PkceCodeChallenge::from_code_verifier_sha256(&state.pkce_verifier).as_str()
);
assert!(authorization_data.url.as_str().starts_with(&issuer));
assert_eq!(authorization_data.url.path(), "/oauth2/authorize");
@@ -217,17 +237,17 @@ fn test_authorization_response() -> anyhow::Result<()> {
AuthorizationResponse::parse_uri(&uri),
Ok(AuthorizationResponse::Success(AuthorizationCode { code, state })) => {
assert_eq!(code, "123");
assert_eq!(state, "456");
assert_eq!(state.secret(), "456");
}
);
let uri = Url::parse("https://example.com?error=invalid_grant&state=456")?;
let uri = Url::parse("https://example.com?error=invalid_scope&state=456")?;
assert_matches!(
AuthorizationResponse::parse_uri(&uri),
Ok(AuthorizationResponse::Error(AuthorizationError { error, state })) => {
assert_eq!(error.error, ClientErrorCode::InvalidGrant);
assert_eq!(error.error_description, None);
assert_eq!(state, "456");
assert_eq!(*error.error(), AuthorizationCodeErrorResponseType::InvalidScope);
assert_eq!(error.error_description(), None);
assert_eq!(state.secret(), "456");
}
);
@@ -247,27 +267,30 @@ async fn test_finish_authorization() -> anyhow::Result<()> {
// If the state is missing, then any attempt to finish authorizing will fail.
let res = oidc
.finish_authorization(AuthorizationCode { code: "42".to_owned(), state: "none".to_owned() })
.finish_authorization(AuthorizationCode {
code: "42".to_owned(),
state: CsrfToken::new("none".to_owned()),
})
.await;
assert_matches!(res, Err(OidcError::InvalidState));
assert_matches!(
res,
Err(OidcError::AuthorizationCode(OauthAuthorizationCodeError::InvalidState))
);
assert!(oidc.session_tokens().is_none());
// Assuming a non-empty state "123"...
let state = "state".to_owned();
let state = CsrfToken::new("state".to_owned());
let redirect_uri = REDIRECT_URI_STRING;
let (_pkce_code_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let auth_validation_data = AuthorizationValidationData {
state: state.clone(),
nonce: "nonce".to_owned(),
redirect_uri: Url::parse(redirect_uri)?,
code_challenge_verifier: None,
redirect_uri: RedirectUrl::new(redirect_uri.to_owned())?,
pkce_verifier,
};
{
let data = oidc.data().context("missing data")?;
let prev = data.authorization_data.lock().await.insert(state.clone(), {
AuthorizationValidationData { ..auth_validation_data.clone() }
});
let prev = data.authorization_data.lock().await.insert(state.clone(), auth_validation_data);
assert!(prev.is_none());
}
@@ -275,11 +298,14 @@ async fn test_finish_authorization() -> anyhow::Result<()> {
let res = oidc
.finish_authorization(AuthorizationCode {
code: "1337".to_owned(),
state: "none".to_owned(),
state: CsrfToken::new("none".to_owned()),
})
.await;
assert_matches!(res, Err(OidcError::InvalidState));
assert_matches!(
res,
Err(OidcError::AuthorizationCode(OauthAuthorizationCodeError::InvalidState))
);
assert!(oidc.session_tokens().is_none());
assert!(oidc.data().unwrap().authorization_data.lock().await.get(&state).is_some());
+5 -5
View File
@@ -38,7 +38,8 @@ use matrix_sdk::{
registration::{ClientMetadata, Localized, VerifiedClientMetadata},
requests::GrantType,
},
AuthorizationCode, AuthorizationResponse, OidcAuthorizationData, OidcSession, UserSession,
AuthorizationCode, AuthorizationResponse, CsrfToken, OidcAuthorizationData, OidcSession,
UserSession,
},
config::SyncSettings,
encryption::{recovery::RecoveryState, CrossSigningResetAuthType},
@@ -746,7 +747,7 @@ fn client_metadata() -> VerifiedClientMetadata {
/// Returns the code to obtain the access token.
async fn use_auth_url(
url: &Url,
state: &str,
state: &CsrfToken,
data_rx: oneshot::Receiver<String>,
signal_tx: oneshot::Sender<()>,
) -> anyhow::Result<AuthorizationCode> {
@@ -759,8 +760,7 @@ async fn use_auth_url(
let code = match AuthorizationResponse::parse_query(&response_query)? {
AuthorizationResponse::Success(code) => code,
AuthorizationResponse::Error(err) => {
let err = err.error;
return Err(anyhow!("{}: {:?}", err.error, err.error_description));
return Err(anyhow!(err.error));
}
};
@@ -768,7 +768,7 @@ async fn use_auth_url(
// wrong, it is an error. Some clients might want to allow several
// authorizations at once, in which case the state string can be used to
// identify the session that was authorized.
if code.state != state {
if code.state != *state {
bail!("State strings don't match")
}