From 9f3ac54c1d056eae85ec8cdf03946c1a01783a8b Mon Sep 17 00:00:00 2001 From: Mathieu Velten Date: Mon, 14 Oct 2024 12:20:27 +0200 Subject: [PATCH] Add user_profile_method to upstream SSO provider --- crates/cli/src/sync.rs | 11 +++ crates/config/src/sections/mod.rs | 1 + crates/config/src/sections/upstream_oauth2.rs | 34 ++++++++++ crates/data-model/src/lib.rs | 1 + crates/data-model/src/upstream_oauth2/mod.rs | 1 + .../src/upstream_oauth2/provider.rs | 47 +++++++++++++ .../data-model/src/upstream_oauth2/session.rs | 21 +++++- crates/handlers/src/upstream_oauth2/cache.rs | 18 ++++- .../handlers/src/upstream_oauth2/callback.rs | 45 ++++++++++-- crates/handlers/src/upstream_oauth2/link.rs | 62 ++++++++++++----- crates/handlers/src/views/login.rs | 6 ++ crates/oauth2-types/src/oidc.rs | 9 +++ ...0451f8bcf5df20faf46a4a4c0d4a36d1ff173.json | 29 -------- ...2daf980beda7af2fd30591ec3c12cfa59991.json} | 32 ++++++--- ...7aa4dcea5d477f8fc9271fbdcc4b367b0074b.json | 40 +++++++++++ ...cfc163aba5bce95a9d6bd17ecba98af1eef98.json | 31 +++++++++ ...041f7359dacb27a909c7a82006d222dda68e.json} | 14 ++-- ...9a61e7997ff4f3e8d0dc772448a1f97e1e390.json | 38 ----------- ...48ec0d24b62b2678302754c583b4ec1e5325.json} | 5 +- ...db4a472891a6e07f92717927695f043b5d11.json} | 32 ++++++--- ...f34f8aa9ea8ed50929731845e32dc3176e39.json} | 4 +- ...20241014145741_upstream_oauth_userinfo.sql | 7 ++ crates/storage-pg/src/iden.rs | 2 + crates/storage-pg/src/upstream_oauth2/mod.rs | 8 ++- .../src/upstream_oauth2/provider.rs | 68 +++++++++++++++++-- .../storage-pg/src/upstream_oauth2/session.rs | 25 ++++--- .../storage/src/upstream_oauth2/provider.rs | 10 ++- crates/storage/src/upstream_oauth2/session.rs | 2 + docs/config.schema.json | 32 +++++++++ 29 files changed, 501 insertions(+), 134 deletions(-) delete mode 100644 crates/storage-pg/.sqlx/query-1f131aa966a4358d83e7247d3e30451f8bcf5df20faf46a4a4c0d4a36d1ff173.json rename crates/storage-pg/.sqlx/{query-5d9f3d47ce6164b3f81aa09ef4fd8d5cd070945fd497d209ac1df99abcfb7c5d.json => query-4e5d21df2707243a2170e1ab2d1c2daf980beda7af2fd30591ec3c12cfa59991.json} (73%) create mode 100644 crates/storage-pg/.sqlx/query-64af82b75223c88bee5cce91a9d7aa4dcea5d477f8fc9271fbdcc4b367b0074b.json create mode 100644 crates/storage-pg/.sqlx/query-6bc80450f55b84e9d8e3ce304f0cfc163aba5bce95a9d6bd17ecba98af1eef98.json rename crates/storage-pg/.sqlx/{query-67ab838035946ddc15b43dd2f79d10b233d07e863b3a5c776c5db97cff263c8c.json => query-8a795aa8ae8621e219cd3871263e041f7359dacb27a909c7a82006d222dda68e.json} (78%) delete mode 100644 crates/storage-pg/.sqlx/query-9aa8fa3a6277f67b2bf5a5ea5429a61e7997ff4f3e8d0dc772448a1f97e1e390.json rename crates/storage-pg/.sqlx/{query-b9875a270f7e753e48075ccae233df6e24a91775ceb877735508c1d5b2300d64.json => query-a76b7523ecffd3e15dcdc90cef5348ec0d24b62b2678302754c583b4ec1e5325.json} (64%) rename crates/storage-pg/.sqlx/{query-51b204376c63671a47b73ee8b3f8e669f90933f7e81ba744dca88d6bb94bf96a.json => query-b10c0c9618bfd0d48ded713c1565db4a472891a6e07f92717927695f043b5d11.json} (72%) rename crates/storage-pg/.sqlx/{query-64e6ea47c2e877c1ebe4338d64d9ad8a6c1c777d1daea024b8ca2e7f0dd75b0f.json => query-f5c2ec9b7038d7ed36091e670f9bf34f8aa9ea8ed50929731845e32dc3176e39.json} (72%) create mode 100644 crates/storage-pg/migrations/20241014145741_upstream_oauth_userinfo.sql diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 0a0e01ddc..5caee00c5 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -231,6 +231,15 @@ pub async fn config_sync( } }; + let user_profile_method = match provider.user_profile_method { + mas_config::UpstreamOAuth2UserProfileMethod::Auto => { + mas_data_model::UpstreamOAuthProviderUserProfileMethod::Auto + } + mas_config::UpstreamOAuth2UserProfileMethod::UserinfoEndpoint => { + mas_data_model::UpstreamOAuthProviderUserProfileMethod::UserinfoEndpoint + } + }; + repo.upstream_oauth_provider() .upsert( clock, @@ -241,6 +250,7 @@ pub async fn config_sync( brand_name: provider.brand_name, scope: provider.scope.parse()?, token_endpoint_auth_method: provider.token_endpoint_auth_method.into(), + user_profile_method, token_endpoint_signing_alg: provider .token_endpoint_auth_signing_alg .clone(), @@ -248,6 +258,7 @@ pub async fn config_sync( encrypted_client_secret, claims_imports: map_claims_imports(&provider.claims_imports), token_endpoint_override: provider.token_endpoint, + userinfo_endpoint_override: provider.userinfo_endpoint, authorization_endpoint_override: provider.authorization_endpoint, jwks_uri_override: provider.jwks_uri, discovery_mode, diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index b21957aac..94bedba00 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -52,6 +52,7 @@ pub use self::{ EmailImportPreference as UpstreamOAuth2EmailImportPreference, ImportAction as UpstreamOAuth2ImportAction, PkceMethod as UpstreamOAuth2PkceMethod, SetEmailVerification as UpstreamOAuth2SetEmailVerification, UpstreamOAuth2Config, + UserProfileMethod as UpstreamOAuth2UserProfileMethod, }, }; use crate::util::ConfigurationSection; diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index 4742b2775..03488354f 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -124,6 +124,26 @@ impl From for OAuthClientAuthenticationMethod { } } +/// Whether to fetch the user profile from the userinfo endpoint, +/// or to rely on the data returned in the id_token from the token_endpoint +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum UserProfileMethod { + /// Use the userinfo endpoint if `openid` is not included in `scopes` + #[default] + Auto, + + /// Always use the userinfo endpoint + UserinfoEndpoint, +} + +impl UserProfileMethod { + #[allow(clippy::trivially_copy_pass_by_ref)] + const fn is_default(&self) -> bool { + matches!(self, UserProfileMethod::Auto) + } +} + /// How to handle a claim #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] #[serde(rename_all = "lowercase")] @@ -401,6 +421,14 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] pub token_endpoint_auth_signing_alg: Option, + /// Whether to fetch the user profile from the userinfo endpoint, + /// or to rely on the data returned in the id_token from the token_endpoint. + /// + /// Defaults to `auto`, which uses the userinfo endpoint if `openid` is not + /// included in `scopes`, and the ID token otherwise. + #[serde(default, skip_serializing_if = "UserProfileMethod::is_default")] + pub user_profile_method: UserProfileMethod, + /// The scopes to request from the provider pub scope: String, @@ -424,6 +452,12 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] pub authorization_endpoint: Option, + /// The URL to use for the provider's userinfo endpoint + /// + /// Defaults to the `userinfo_endpoint` provided through discovery + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_endpoint: Option, + /// The URL to use for the provider's token endpoint /// /// Defaults to the `token_endpoint` provided through discovery diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 939e904f3..ae58b8e7c 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -42,6 +42,7 @@ pub use self::{ UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderSubjectPreference, + UpstreamOAuthProviderUserProfileMethod, }, user_agent::{DeviceType, UserAgent}, users::{ diff --git a/crates/data-model/src/upstream_oauth2/mod.rs b/crates/data-model/src/upstream_oauth2/mod.rs index cfa21ea1a..b998eac85 100644 --- a/crates/data-model/src/upstream_oauth2/mod.rs +++ b/crates/data-model/src/upstream_oauth2/mod.rs @@ -18,6 +18,7 @@ pub use self::{ PkceMode as UpstreamOAuthProviderPkceMode, SetEmailVerification as UpsreamOAuthProviderSetEmailVerification, SubjectPreference as UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProvider, + UserProfileMethod as UpstreamOAuthProviderUserProfileMethod, }, session::{UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState}, }; diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index 0cb976a73..1688510d3 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -116,6 +116,51 @@ impl std::fmt::Display for PkceMode { } } +/// Whether to fetch the user profile from the userinfo endpoint, +/// or to rely on the data returned in the id_token from the token_endpoint +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UserProfileMethod { + /// Use the userinfo endpoint if `openid` is not included in `scopes` + #[default] + Auto, + + /// Always use the userinfo endpoint + UserinfoEndpoint, +} + +#[derive(Debug, Clone, Error)] +#[error("Invalid user profile method {0:?}")] +pub struct InvalidUserProfileMethodError(String); + +impl std::str::FromStr for UserProfileMethod { + type Err = InvalidUserProfileMethodError; + + fn from_str(s: &str) -> Result { + match s { + "auto" => Ok(Self::Auto), + "userinfo_endpoint" => Ok(Self::UserinfoEndpoint), + s => Err(InvalidUserProfileMethodError(s.to_owned())), + } + } +} + +impl UserProfileMethod { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::UserinfoEndpoint => "userinfo_endpoint", + } + } +} + +impl std::fmt::Display for UserProfileMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct UpstreamOAuthProvider { pub id: Ulid, @@ -127,11 +172,13 @@ pub struct UpstreamOAuthProvider { pub jwks_uri_override: Option, pub authorization_endpoint_override: Option, pub token_endpoint_override: Option, + pub userinfo_endpoint_override: Option, pub scope: Scope, pub client_id: String, pub encrypted_client_secret: Option, pub token_endpoint_signing_alg: Option, pub token_endpoint_auth_method: OAuthClientAuthenticationMethod, + pub user_profile_method: UserProfileMethod, pub created_at: DateTime, pub disabled_at: Option>, pub claims_imports: ClaimsImports, diff --git a/crates/data-model/src/upstream_oauth2/session.rs b/crates/data-model/src/upstream_oauth2/session.rs index eb59000a0..17a564085 100644 --- a/crates/data-model/src/upstream_oauth2/session.rs +++ b/crates/data-model/src/upstream_oauth2/session.rs @@ -19,12 +19,14 @@ pub enum UpstreamOAuthAuthorizationSessionState { completed_at: DateTime, link_id: Ulid, id_token: Option, + userinfo: Option, }, Consumed { completed_at: DateTime, consumed_at: DateTime, link_id: Ulid, id_token: Option, + userinfo: Option, }, } @@ -42,12 +44,14 @@ impl UpstreamOAuthAuthorizationSessionState { completed_at: DateTime, link: &UpstreamOAuthLink, id_token: Option, + userinfo: Option, ) -> Result { match self { Self::Pending => Ok(Self::Completed { completed_at, link_id: link.id, id_token, + userinfo, }), Self::Completed { .. } | Self::Consumed { .. } => Err(InvalidTransitionError), } @@ -67,11 +71,13 @@ impl UpstreamOAuthAuthorizationSessionState { completed_at, link_id, id_token, + userinfo, } => Ok(Self::Consumed { completed_at, link_id, consumed_at, id_token, + userinfo, }), Self::Pending | Self::Consumed { .. } => Err(InvalidTransitionError), } @@ -124,6 +130,16 @@ impl UpstreamOAuthAuthorizationSessionState { } } + #[must_use] + pub fn userinfo(&self) -> Option<&str> { + match self { + Self::Pending => None, + Self::Completed { userinfo, .. } | Self::Consumed { userinfo, .. } => { + userinfo.as_deref() + } + } + } + /// Get the time at which the upstream OAuth 2.0 authorization session was /// consumed. /// @@ -201,8 +217,11 @@ impl UpstreamOAuthAuthorizationSession { completed_at: DateTime, link: &UpstreamOAuthLink, id_token: Option, + userinfo: Option, ) -> Result { - self.state = self.state.complete(completed_at, link, id_token)?; + self.state = self + .state + .complete(completed_at, link, id_token, userinfo)?; Ok(self) } diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index c2f307e14..7a315a0f8 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -108,6 +108,18 @@ impl<'a> LazyProviderInfos<'a> { Ok(self.load().await?.token_endpoint()) } + /// Get the userinfo endpoint for the provider. + /// + /// Uses [`UpstreamOAuthProvider.userinfo_endpoint_override`] if set, otherwise + /// uses the one from discovery. + pub async fn userinfo_endpoint(&mut self) -> Result<&Url, DiscoveryError> { + if let Some(userinfo_endpoint) = &self.provider.userinfo_endpoint_override { + return Ok(userinfo_endpoint); + } + + Ok(self.load().await?.userinfo_endpoint()) + } + /// Get the PKCE methods supported by the provider. /// /// If the mode is set to auto, it will use the ones from discovery, @@ -274,7 +286,9 @@ mod tests { // XXX: sadly, we can't test HTTPS requests with wiremock, so we can only test // 'insecure' discovery - use mas_data_model::UpstreamOAuthProviderClaimsImports; + use mas_data_model::{ + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderUserProfileMethod, + }; use mas_iana::oauth::OAuthClientAuthenticationMethod; use mas_storage::{clock::MockClock, Clock}; use oauth2_types::scope::{Scope, OPENID}; @@ -386,8 +400,10 @@ mod tests { brand_name: None, discovery_mode: UpstreamOAuthProviderDiscoveryMode::Insecure, pkce_mode: UpstreamOAuthProviderPkceMode::Auto, + user_profile_method: UpstreamOAuthProviderUserProfileMethod::Auto, jwks_uri_override: None, authorization_endpoint_override: None, + userinfo_endpoint_override: None, token_endpoint_override: None, scope: Scope::from_iter([OPENID]), client_id: "client_id".to_owned(), diff --git a/crates/handlers/src/upstream_oauth2/callback.rs b/crates/handlers/src/upstream_oauth2/callback.rs index 6b070e6dd..a61385e00 100644 --- a/crates/handlers/src/upstream_oauth2/callback.rs +++ b/crates/handlers/src/upstream_oauth2/callback.rs @@ -4,13 +4,15 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +use std::collections::HashMap; + use axum::{ extract::{Path, Query, State}, response::IntoResponse, }; use hyper::StatusCode; use mas_axum_utils::{cookies::CookieJar, sentry::SentryEventID}; -use mas_data_model::UpstreamOAuthProvider; +use mas_data_model::{UpstreamOAuthProvider, UpstreamOAuthProviderUserProfileMethod}; use mas_keystore::{Encrypter, Keystore}; use mas_oidc_client::requests::{ authorization_code::AuthorizationValidationData, jose::JwtVerificationData, @@ -25,6 +27,7 @@ use mas_storage::{ }; use oauth2_types::errors::ClientErrorCode; use serde::Deserialize; +use serde_json::json; use thiserror::Error; use ulid::Ulid; @@ -92,13 +95,14 @@ pub(crate) enum RouteError { MissingCookie, #[error(transparent)] - Internal(Box), + Internal(Box), } impl_from_error_for_route!(mas_storage::RepositoryError); impl_from_error_for_route!(mas_oidc_client::error::DiscoveryError); impl_from_error_for_route!(mas_oidc_client::error::JwksError); impl_from_error_for_route!(mas_oidc_client::error::TokenAuthorizationCodeError); +impl_from_error_for_route!(mas_oidc_client::error::UserInfoError); impl_from_error_for_route!(super::ProviderCredentialsError); impl_from_error_for_route!(super::cookie::UpstreamSessionNotFound); @@ -209,7 +213,7 @@ pub(crate) async fn get( redirect_uri, }; - let id_token_verification_data = JwtVerificationData { + let verification_data = JwtVerificationData { issuer: &provider.issuer, jwks: &jwks, // TODO: make that configurable @@ -217,20 +221,41 @@ pub(crate) async fn get( client_id: &provider.client_id, }; - let (response, id_token) = + let (response, id_token_map) = mas_oidc_client::requests::authorization_code::access_token_with_authorization_code( &client, client_credentials, lazy_metadata.token_endpoint().await?, code, validation_data, - Some(id_token_verification_data), + Some(verification_data), clock.now(), &mut rng, ) .await?; - let (_header, id_token) = id_token.ok_or(RouteError::MissingIDToken)?.into_parts(); + let (_header, id_token) = id_token_map + .clone() + .ok_or(RouteError::MissingIDToken)? + .into_parts(); + + let fetch_userinfo_endpoint = match provider.user_profile_method { + UpstreamOAuthProviderUserProfileMethod::Auto => !provider.scope.contains("openid"), + UpstreamOAuthProviderUserProfileMethod::UserinfoEndpoint => true, + }; + + let userinfo = if fetch_userinfo_endpoint { + mas_oidc_client::requests::userinfo::fetch_userinfo( + &client, + lazy_metadata.userinfo_endpoint().await?, + response.access_token.as_str(), + Some(verification_data), + &id_token_map.ok_or(RouteError::MissingIDToken)?, + ) + .await? + } else { + HashMap::new() + }; let env = { let mut env = environment(); @@ -266,9 +291,15 @@ pub(crate) async fn get( .await? }; + let userinfo_str = if userinfo.is_empty() { + None + } else { + Some(json!(userinfo).to_string()) + }; + let session = repo .upstream_oauth_session() - .complete_with_link(&clock, session, &link, response.id_token) + .complete_with_link(&clock, session, &link, response.id_token, userinfo_str) .await?; let cookie_jar = sessions_cookie diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 3f7407e72..9c0819233 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -4,6 +4,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +use std::collections::HashMap; + use axum::{ extract::{Path, State}, response::{Html, IntoResponse, Response}, @@ -101,6 +103,7 @@ impl_from_error_for_route!(super::cookie::UpstreamSessionNotFound); impl_from_error_for_route!(mas_storage::RepositoryError); impl_from_error_for_route!(mas_policy::EvaluationError); impl_from_error_for_route!(mas_jose::jwt::JwtDecodeError); +impl_from_error_for_route!(serde_json::Error); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { @@ -320,10 +323,6 @@ pub(crate) async fn get( (None, None) => { // Session not linked and used not logged in: suggest creating an // account or logging in an existing user - let id_token = upstream_session - .id_token() - .map(Jwt::<'_, minijinja::Value>::try_from) - .transpose()?; let provider = repo .upstream_oauth_provider() @@ -331,15 +330,28 @@ pub(crate) async fn get( .await? .ok_or(RouteError::ProviderNotFound)?; - let payload = id_token + let id_token = upstream_session + .id_token() + .map(Jwt::<'_, HashMap>::try_from) + .transpose()?; + + let id_token = id_token .map(|id_token| id_token.into_parts().1) .unwrap_or_default(); + let mut payload: HashMap = upstream_session + .userinfo() + .map(serde_json::from_str) + .transpose()? + .unwrap_or_default(); + + payload.extend(id_token); + let ctx = UpstreamRegister::default(); let env = { let mut e = environment(); - e.add_global("user", payload); + e.add_global("user", minijinja::value::Value::from_serialize(payload)); e }; @@ -557,31 +569,40 @@ pub(crate) async fn post( let import_display_name = import_display_name.is_some(); let accept_terms = accept_terms.is_some(); - let id_token = upstream_session - .id_token() - .map(Jwt::<'_, minijinja::Value>::try_from) - .transpose()?; - let provider = repo .upstream_oauth_provider() .lookup(link.provider_id) .await? .ok_or(RouteError::ProviderNotFound)?; - let payload = id_token + let id_token = upstream_session + .id_token() + .map(Jwt::<'_, HashMap>::try_from) + .transpose()?; + + let id_token = id_token .map(|id_token| id_token.into_parts().1) .unwrap_or_default(); + let mut payload: HashMap = upstream_session + .userinfo() + .map(serde_json::from_str) + .transpose()? + .unwrap_or_default(); + + payload.extend(id_token); + // Is the email verified according to the upstream provider? let provider_email_verified = payload - .get_item(&minijinja::Value::from("email_verified")) - .map(|v| v.is_true()) + .get("email_verified") + .map(|v| v.as_bool()) + .unwrap_or(None) .unwrap_or(false); // Let's try to import the claims from the ID token let env = { let mut e = environment(); - e.add_global("user", payload); + e.add_global("user", minijinja::value::Value::from_serialize(payload)); e }; @@ -843,6 +864,7 @@ mod tests { use hyper::{header::CONTENT_TYPE, Request, StatusCode}; use mas_data_model::{ UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderImportPreference, + UpstreamOAuthProviderUserProfileMethod, }; use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; use mas_jose::jwt::{JsonWebSignatureHeader, Jwt}; @@ -908,11 +930,13 @@ mod tests { scope: Scope::from_iter([OPENID]), token_endpoint_auth_method: OAuthClientAuthenticationMethod::None, token_endpoint_signing_alg: None, + user_profile_method: UpstreamOAuthProviderUserProfileMethod::Auto, client_id: "client".to_owned(), encrypted_client_secret: None, claims_imports, authorization_endpoint_override: None, token_endpoint_override: None, + userinfo_endpoint_override: None, jwks_uri_override: None, discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, @@ -943,7 +967,13 @@ mod tests { let session = repo .upstream_oauth_session() - .complete_with_link(&state.clock, session, &link, Some(id_token.into_string())) + .complete_with_link( + &state.clock, + session, + &link, + Some(id_token.into_string()), + None, + ) .await .unwrap(); diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 3a65c3284..dae5d1bf6 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -371,11 +371,14 @@ mod test { scope: [OPENID].into_iter().collect(), token_endpoint_auth_method: OAuthClientAuthenticationMethod::None, token_endpoint_signing_alg: None, + user_profile_method: + mas_data_model::UpstreamOAuthProviderUserProfileMethod::Auto, client_id: "client".to_owned(), encrypted_client_secret: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), authorization_endpoint_override: None, token_endpoint_override: None, + userinfo_endpoint_override: None, jwks_uri_override: None, discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, @@ -406,11 +409,14 @@ mod test { scope: [OPENID].into_iter().collect(), token_endpoint_auth_method: OAuthClientAuthenticationMethod::None, token_endpoint_signing_alg: None, + user_profile_method: + mas_data_model::UpstreamOAuthProviderUserProfileMethod::Auto, client_id: "client".to_owned(), encrypted_client_secret: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), authorization_endpoint_override: None, token_endpoint_override: None, + userinfo_endpoint_override: None, jwks_uri_override: None, discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, diff --git a/crates/oauth2-types/src/oidc.rs b/crates/oauth2-types/src/oidc.rs index c2fa68ecc..99e1eacdc 100644 --- a/crates/oauth2-types/src/oidc.rs +++ b/crates/oauth2-types/src/oidc.rs @@ -950,6 +950,15 @@ impl VerifiedProviderMetadata { } } + /// TODO + #[must_use] + pub fn userinfo_endpoint(&self) -> &Url { + match &self.userinfo_endpoint { + Some(u) => u, + None => unreachable!(), + } + } + /// URL of the authorization server's token endpoint. #[must_use] pub fn token_endpoint(&self) -> &Url { diff --git a/crates/storage-pg/.sqlx/query-1f131aa966a4358d83e7247d3e30451f8bcf5df20faf46a4a4c0d4a36d1ff173.json b/crates/storage-pg/.sqlx/query-1f131aa966a4358d83e7247d3e30451f8bcf5df20faf46a4a4c0d4a36d1ff173.json deleted file mode 100644 index 2a92e950c..000000000 --- a/crates/storage-pg/.sqlx/query-1f131aa966a4358d83e7247d3e30451f8bcf5df20faf46a4a4c0d4a36d1ff173.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,\n $10, $11, $12, $13, $14, $15, $16)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Text", - "Text", - "Text", - "Text", - "Text", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "1f131aa966a4358d83e7247d3e30451f8bcf5df20faf46a4a4c0d4a36d1ff173" -} diff --git a/crates/storage-pg/.sqlx/query-5d9f3d47ce6164b3f81aa09ef4fd8d5cd070945fd497d209ac1df99abcfb7c5d.json b/crates/storage-pg/.sqlx/query-4e5d21df2707243a2170e1ab2d1c2daf980beda7af2fd30591ec3c12cfa59991.json similarity index 73% rename from crates/storage-pg/.sqlx/query-5d9f3d47ce6164b3f81aa09ef4fd8d5cd070945fd497d209ac1df99abcfb7c5d.json rename to crates/storage-pg/.sqlx/query-4e5d21df2707243a2170e1ab2d1c2daf980beda7af2fd30591ec3c12cfa59991.json index f6f2b0dbc..5a36fce17 100644 --- a/crates/storage-pg/.sqlx/query-5d9f3d47ce6164b3f81aa09ef4fd8d5cd070945fd497d209ac1df99abcfb7c5d.json +++ b/crates/storage-pg/.sqlx/query-4e5d21df2707243a2170e1ab2d1c2daf980beda7af2fd30591ec3c12cfa59991.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n discovery_mode,\n pkce_mode,\n additional_parameters as \"additional_parameters: Json>\"\n FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ", + "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n user_profile_method,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n additional_parameters as \"additional_parameters: Json>\"\n FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ", "describe": { "columns": [ { @@ -50,46 +50,56 @@ }, { "ordinal": 9, + "name": "user_profile_method", + "type_info": "Text" + }, + { + "ordinal": 10, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "disabled_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 12, "name": "claims_imports: Json", "type_info": "Jsonb" }, { - "ordinal": 12, + "ordinal": 13, "name": "jwks_uri_override", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "authorization_endpoint_override", "type_info": "Text" }, { - "ordinal": 14, + "ordinal": 15, "name": "token_endpoint_override", "type_info": "Text" }, { - "ordinal": 15, + "ordinal": 16, + "name": "userinfo_endpoint_override", + "type_info": "Text" + }, + { + "ordinal": 17, "name": "discovery_mode", "type_info": "Text" }, { - "ordinal": 16, + "ordinal": 18, "name": "pkce_mode", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 19, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -108,15 +118,17 @@ true, false, false, + false, true, false, true, true, true, + true, false, false, true ] }, - "hash": "5d9f3d47ce6164b3f81aa09ef4fd8d5cd070945fd497d209ac1df99abcfb7c5d" + "hash": "4e5d21df2707243a2170e1ab2d1c2daf980beda7af2fd30591ec3c12cfa59991" } diff --git a/crates/storage-pg/.sqlx/query-64af82b75223c88bee5cce91a9d7aa4dcea5d477f8fc9271fbdcc4b367b0074b.json b/crates/storage-pg/.sqlx/query-64af82b75223c88bee5cce91a9d7aa4dcea5d477f8fc9271fbdcc4b367b0074b.json new file mode 100644 index 000000000..d92b6a966 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-64af82b75223c88bee5cce91a9d7aa4dcea5d477f8fc9271fbdcc4b367b0074b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n user_profile_method,\n token_endpoint_signing_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n additional_parameters,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,\n $12, $13, $14, $15, $16, $17, $18, $19)\n ON CONFLICT (upstream_oauth_provider_id)\n DO UPDATE\n SET\n issuer = EXCLUDED.issuer,\n human_name = EXCLUDED.human_name,\n brand_name = EXCLUDED.brand_name,\n scope = EXCLUDED.scope,\n token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,\n user_profile_method = EXCLUDED.user_profile_method,\n token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg,\n disabled_at = NULL,\n client_id = EXCLUDED.client_id,\n encrypted_client_secret = EXCLUDED.encrypted_client_secret,\n claims_imports = EXCLUDED.claims_imports,\n authorization_endpoint_override = EXCLUDED.authorization_endpoint_override,\n token_endpoint_override = EXCLUDED.token_endpoint_override,\n userinfo_endpoint_override = EXCLUDED.userinfo_endpoint_override,\n jwks_uri_override = EXCLUDED.jwks_uri_override,\n discovery_mode = EXCLUDED.discovery_mode,\n pkce_mode = EXCLUDED.pkce_mode,\n additional_parameters = EXCLUDED.additional_parameters\n RETURNING created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "64af82b75223c88bee5cce91a9d7aa4dcea5d477f8fc9271fbdcc4b367b0074b" +} diff --git a/crates/storage-pg/.sqlx/query-6bc80450f55b84e9d8e3ce304f0cfc163aba5bce95a9d6bd17ecba98af1eef98.json b/crates/storage-pg/.sqlx/query-6bc80450f55b84e9d8e3ce304f0cfc163aba5bce95a9d6bd17ecba98af1eef98.json new file mode 100644 index 000000000..b712fc29b --- /dev/null +++ b/crates/storage-pg/.sqlx/query-6bc80450f55b84e9d8e3ce304f0cfc163aba5bce95a9d6bd17ecba98af1eef98.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n user_profile_method,\n token_endpoint_signing_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "6bc80450f55b84e9d8e3ce304f0cfc163aba5bce95a9d6bd17ecba98af1eef98" +} diff --git a/crates/storage-pg/.sqlx/query-67ab838035946ddc15b43dd2f79d10b233d07e863b3a5c776c5db97cff263c8c.json b/crates/storage-pg/.sqlx/query-8a795aa8ae8621e219cd3871263e041f7359dacb27a909c7a82006d222dda68e.json similarity index 78% rename from crates/storage-pg/.sqlx/query-67ab838035946ddc15b43dd2f79d10b233d07e863b3a5c776c5db97cff263c8c.json rename to crates/storage-pg/.sqlx/query-8a795aa8ae8621e219cd3871263e041f7359dacb27a909c7a82006d222dda68e.json index 0b378d382..308485bc1 100644 --- a/crates/storage-pg/.sqlx/query-67ab838035946ddc15b43dd2f79d10b233d07e863b3a5c776c5db97cff263c8c.json +++ b/crates/storage-pg/.sqlx/query-8a795aa8ae8621e219cd3871263e041f7359dacb27a909c7a82006d222dda68e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n upstream_oauth_link_id,\n state,\n code_challenge_verifier,\n nonce,\n id_token,\n created_at,\n completed_at,\n consumed_at\n FROM upstream_oauth_authorization_sessions\n WHERE upstream_oauth_authorization_session_id = $1\n ", + "query": "\n SELECT\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n upstream_oauth_link_id,\n state,\n code_challenge_verifier,\n nonce,\n id_token,\n userinfo,\n created_at,\n completed_at,\n consumed_at\n FROM upstream_oauth_authorization_sessions\n WHERE upstream_oauth_authorization_session_id = $1\n ", "describe": { "columns": [ { @@ -40,16 +40,21 @@ }, { "ordinal": 7, + "name": "userinfo", + "type_info": "Text" + }, + { + "ordinal": 8, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "completed_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "consumed_at", "type_info": "Timestamptz" } @@ -67,10 +72,11 @@ true, false, true, + true, false, true, true ] }, - "hash": "67ab838035946ddc15b43dd2f79d10b233d07e863b3a5c776c5db97cff263c8c" + "hash": "8a795aa8ae8621e219cd3871263e041f7359dacb27a909c7a82006d222dda68e" } diff --git a/crates/storage-pg/.sqlx/query-9aa8fa3a6277f67b2bf5a5ea5429a61e7997ff4f3e8d0dc772448a1f97e1e390.json b/crates/storage-pg/.sqlx/query-9aa8fa3a6277f67b2bf5a5ea5429a61e7997ff4f3e8d0dc772448a1f97e1e390.json deleted file mode 100644 index c016eb215..000000000 --- a/crates/storage-pg/.sqlx/query-9aa8fa3a6277f67b2bf5a5ea5429a61e7997ff4f3e8d0dc772448a1f97e1e390.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n additional_parameters,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,\n $10, $11, $12, $13, $14, $15, $16, $17)\n ON CONFLICT (upstream_oauth_provider_id)\n DO UPDATE\n SET\n issuer = EXCLUDED.issuer,\n human_name = EXCLUDED.human_name,\n brand_name = EXCLUDED.brand_name,\n scope = EXCLUDED.scope,\n token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,\n token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg,\n disabled_at = NULL,\n client_id = EXCLUDED.client_id,\n encrypted_client_secret = EXCLUDED.encrypted_client_secret,\n claims_imports = EXCLUDED.claims_imports,\n authorization_endpoint_override = EXCLUDED.authorization_endpoint_override,\n token_endpoint_override = EXCLUDED.token_endpoint_override,\n jwks_uri_override = EXCLUDED.jwks_uri_override,\n discovery_mode = EXCLUDED.discovery_mode,\n pkce_mode = EXCLUDED.pkce_mode,\n additional_parameters = EXCLUDED.additional_parameters\n RETURNING created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Text", - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Timestamptz" - ] - }, - "nullable": [ - false - ] - }, - "hash": "9aa8fa3a6277f67b2bf5a5ea5429a61e7997ff4f3e8d0dc772448a1f97e1e390" -} diff --git a/crates/storage-pg/.sqlx/query-b9875a270f7e753e48075ccae233df6e24a91775ceb877735508c1d5b2300d64.json b/crates/storage-pg/.sqlx/query-a76b7523ecffd3e15dcdc90cef5348ec0d24b62b2678302754c583b4ec1e5325.json similarity index 64% rename from crates/storage-pg/.sqlx/query-b9875a270f7e753e48075ccae233df6e24a91775ceb877735508c1d5b2300d64.json rename to crates/storage-pg/.sqlx/query-a76b7523ecffd3e15dcdc90cef5348ec0d24b62b2678302754c583b4ec1e5325.json index 3a4483604..b2f063193 100644 --- a/crates/storage-pg/.sqlx/query-b9875a270f7e753e48075ccae233df6e24a91775ceb877735508c1d5b2300d64.json +++ b/crates/storage-pg/.sqlx/query-a76b7523ecffd3e15dcdc90cef5348ec0d24b62b2678302754c583b4ec1e5325.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE upstream_oauth_authorization_sessions\n SET upstream_oauth_link_id = $1,\n completed_at = $2,\n id_token = $3\n WHERE upstream_oauth_authorization_session_id = $4\n ", + "query": "\n UPDATE upstream_oauth_authorization_sessions\n SET upstream_oauth_link_id = $1,\n completed_at = $2,\n id_token = $3,\n userinfo = $4\n WHERE upstream_oauth_authorization_session_id = $5\n ", "describe": { "columns": [], "parameters": { @@ -8,10 +8,11 @@ "Uuid", "Timestamptz", "Text", + "Text", "Uuid" ] }, "nullable": [] }, - "hash": "b9875a270f7e753e48075ccae233df6e24a91775ceb877735508c1d5b2300d64" + "hash": "a76b7523ecffd3e15dcdc90cef5348ec0d24b62b2678302754c583b4ec1e5325" } diff --git a/crates/storage-pg/.sqlx/query-51b204376c63671a47b73ee8b3f8e669f90933f7e81ba744dca88d6bb94bf96a.json b/crates/storage-pg/.sqlx/query-b10c0c9618bfd0d48ded713c1565db4a472891a6e07f92717927695f043b5d11.json similarity index 72% rename from crates/storage-pg/.sqlx/query-51b204376c63671a47b73ee8b3f8e669f90933f7e81ba744dca88d6bb94bf96a.json rename to crates/storage-pg/.sqlx/query-b10c0c9618bfd0d48ded713c1565db4a472891a6e07f92717927695f043b5d11.json index ac9f681cd..fd7f2afc8 100644 --- a/crates/storage-pg/.sqlx/query-51b204376c63671a47b73ee8b3f8e669f90933f7e81ba744dca88d6bb94bf96a.json +++ b/crates/storage-pg/.sqlx/query-b10c0c9618bfd0d48ded713c1565db4a472891a6e07f92717927695f043b5d11.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n discovery_mode,\n pkce_mode,\n additional_parameters as \"additional_parameters: Json>\"\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", + "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n user_profile_method,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n additional_parameters as \"additional_parameters: Json>\"\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", "describe": { "columns": [ { @@ -50,46 +50,56 @@ }, { "ordinal": 9, + "name": "user_profile_method", + "type_info": "Text" + }, + { + "ordinal": 10, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "disabled_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 12, "name": "claims_imports: Json", "type_info": "Jsonb" }, { - "ordinal": 12, + "ordinal": 13, "name": "jwks_uri_override", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 14, "name": "authorization_endpoint_override", "type_info": "Text" }, { - "ordinal": 14, + "ordinal": 15, "name": "token_endpoint_override", "type_info": "Text" }, { - "ordinal": 15, + "ordinal": 16, + "name": "userinfo_endpoint_override", + "type_info": "Text" + }, + { + "ordinal": 17, "name": "discovery_mode", "type_info": "Text" }, { - "ordinal": 16, + "ordinal": 18, "name": "pkce_mode", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 19, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -110,15 +120,17 @@ true, false, false, + false, true, false, true, true, true, + true, false, false, true ] }, - "hash": "51b204376c63671a47b73ee8b3f8e669f90933f7e81ba744dca88d6bb94bf96a" + "hash": "b10c0c9618bfd0d48ded713c1565db4a472891a6e07f92717927695f043b5d11" } diff --git a/crates/storage-pg/.sqlx/query-64e6ea47c2e877c1ebe4338d64d9ad8a6c1c777d1daea024b8ca2e7f0dd75b0f.json b/crates/storage-pg/.sqlx/query-f5c2ec9b7038d7ed36091e670f9bf34f8aa9ea8ed50929731845e32dc3176e39.json similarity index 72% rename from crates/storage-pg/.sqlx/query-64e6ea47c2e877c1ebe4338d64d9ad8a6c1c777d1daea024b8ca2e7f0dd75b0f.json rename to crates/storage-pg/.sqlx/query-f5c2ec9b7038d7ed36091e670f9bf34f8aa9ea8ed50929731845e32dc3176e39.json index 55e3d87f7..71096b9ae 100644 --- a/crates/storage-pg/.sqlx/query-64e6ea47c2e877c1ebe4338d64d9ad8a6c1c777d1daea024b8ca2e7f0dd75b0f.json +++ b/crates/storage-pg/.sqlx/query-f5c2ec9b7038d7ed36091e670f9bf34f8aa9ea8ed50929731845e32dc3176e39.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_authorization_sessions (\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n state,\n code_challenge_verifier,\n nonce,\n created_at,\n completed_at,\n consumed_at,\n id_token\n ) VALUES ($1, $2, $3, $4, $5, $6, NULL, NULL, NULL)\n ", + "query": "\n INSERT INTO upstream_oauth_authorization_sessions (\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n state,\n code_challenge_verifier,\n nonce,\n created_at,\n completed_at,\n consumed_at,\n id_token,\n userinfo\n ) VALUES ($1, $2, $3, $4, $5, $6, NULL, NULL, NULL, NULL)\n ", "describe": { "columns": [], "parameters": { @@ -15,5 +15,5 @@ }, "nullable": [] }, - "hash": "64e6ea47c2e877c1ebe4338d64d9ad8a6c1c777d1daea024b8ca2e7f0dd75b0f" + "hash": "f5c2ec9b7038d7ed36091e670f9bf34f8aa9ea8ed50929731845e32dc3176e39" } diff --git a/crates/storage-pg/migrations/20241014145741_upstream_oauth_userinfo.sql b/crates/storage-pg/migrations/20241014145741_upstream_oauth_userinfo.sql new file mode 100644 index 000000000..e62ea1925 --- /dev/null +++ b/crates/storage-pg/migrations/20241014145741_upstream_oauth_userinfo.sql @@ -0,0 +1,7 @@ +-- Add migration script here +ALTER TABLE "upstream_oauth_providers" + ADD COLUMN "user_profile_method" TEXT NOT NULL DEFAULT 'auto', + ADD COLUMN "userinfo_endpoint_override" TEXT; + +ALTER TABLE "upstream_oauth_authorization_sessions" + ADD COLUMN "userinfo" TEXT; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index a87bef0db..cb0a52acd 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -98,6 +98,7 @@ pub enum UpstreamOAuthProviders { EncryptedClientSecret, TokenEndpointSigningAlg, TokenEndpointAuthMethod, + UserProfileMethod, CreatedAt, DisabledAt, ClaimsImports, @@ -107,6 +108,7 @@ pub enum UpstreamOAuthProviders { JwksUriOverride, TokenEndpointOverride, AuthorizationEndpointOverride, + UserinfoEndpointOverride, } #[derive(sea_query::Iden)] diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index c9ca8afdc..61ad255be 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -59,12 +59,15 @@ mod tests { scope: Scope::from_iter([OPENID]), token_endpoint_auth_method: mas_iana::oauth::OAuthClientAuthenticationMethod::None, + user_profile_method: + mas_data_model::UpstreamOAuthProviderUserProfileMethod::Auto, token_endpoint_signing_alg: None, client_id: "client-id".to_owned(), encrypted_client_secret: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), token_endpoint_override: None, authorization_endpoint_override: None, + userinfo_endpoint_override: None, jwks_uri_override: None, discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, @@ -143,7 +146,7 @@ mod tests { let session = repo .upstream_oauth_session() - .complete_with_link(&clock, session, &link, None) + .complete_with_link(&clock, session, &link, None, None) .await .unwrap(); // Reload the session @@ -301,12 +304,15 @@ mod tests { scope: scope.clone(), token_endpoint_auth_method: mas_iana::oauth::OAuthClientAuthenticationMethod::None, + user_profile_method: + mas_data_model::UpstreamOAuthProviderUserProfileMethod::Auto, token_endpoint_signing_alg: None, client_id, encrypted_client_secret: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), token_endpoint_override: None, authorization_endpoint_override: None, + userinfo_endpoint_override: None, jwks_uri_override: None, discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, diff --git a/crates/storage-pg/src/upstream_oauth2/provider.rs b/crates/storage-pg/src/upstream_oauth2/provider.rs index 0c54992c3..c29dbebe8 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -56,12 +56,14 @@ struct ProviderLookup { encrypted_client_secret: Option, token_endpoint_signing_alg: Option, token_endpoint_auth_method: String, + user_profile_method: String, created_at: DateTime, disabled_at: Option>, claims_imports: Json, jwks_uri_override: Option, authorization_endpoint_override: Option, token_endpoint_override: Option, + userinfo_endpoint_override: Option, discovery_mode: String, pkce_mode: String, additional_parameters: Option>>, @@ -116,6 +118,17 @@ impl TryFrom for UpstreamOAuthProvider { .source(e) })?; + let userinfo_endpoint_override = value + .userinfo_endpoint_override + .map(|x| x.parse()) + .transpose() + .map_err(|e| { + DatabaseInconsistencyError::on("upstream_oauth_providers") + .column("userinfo_endpoint_override") + .row(id) + .source(e) + })?; + let jwks_uri_override = value .jwks_uri_override .map(|x| x.parse()) @@ -141,6 +154,13 @@ impl TryFrom for UpstreamOAuthProvider { .source(e) })?; + let user_profile_method = value.user_profile_method.parse().map_err(|e| { + DatabaseInconsistencyError::on("upstream_oauth_providers") + .column("user_profile_method") + .row(id) + .source(e) + })?; + let additional_authorization_parameters = value .additional_parameters .map(|Json(x)| x) @@ -155,12 +175,14 @@ impl TryFrom for UpstreamOAuthProvider { client_id: value.client_id, encrypted_client_secret: value.encrypted_client_secret, token_endpoint_auth_method, + user_profile_method, token_endpoint_signing_alg, created_at: value.created_at, disabled_at: value.disabled_at, claims_imports: value.claims_imports.0, authorization_endpoint_override, token_endpoint_override, + userinfo_endpoint_override, jwks_uri_override, discovery_mode, pkce_mode, @@ -209,12 +231,14 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' encrypted_client_secret, token_endpoint_signing_alg, token_endpoint_auth_method, + user_profile_method, created_at, disabled_at, claims_imports as "claims_imports: Json", jwks_uri_override, authorization_endpoint_override, token_endpoint_override, + userinfo_endpoint_override, discovery_mode, pkce_mode, additional_parameters as "additional_parameters: Json>" @@ -265,18 +289,20 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' brand_name, scope, token_endpoint_auth_method, + user_profile_method, token_endpoint_signing_alg, client_id, encrypted_client_secret, claims_imports, authorization_endpoint_override, token_endpoint_override, + userinfo_endpoint_override, jwks_uri_override, discovery_mode, pkce_mode, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14, $15, $16) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18) "#, Uuid::from(id), ¶ms.issuer, @@ -284,6 +310,7 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' params.brand_name.as_deref(), params.scope.to_string(), params.token_endpoint_auth_method.to_string(), + params.user_profile_method.to_string(), params .token_endpoint_signing_alg .as_ref() @@ -299,6 +326,10 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' .token_endpoint_override .as_ref() .map(ToString::to_string), + params + .userinfo_endpoint_override + .as_ref() + .map(ToString::to_string), params.jwks_uri_override.as_ref().map(ToString::to_string), params.discovery_mode.as_str(), params.pkce_mode.as_str(), @@ -318,11 +349,13 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' encrypted_client_secret: params.encrypted_client_secret, token_endpoint_signing_alg: params.token_endpoint_signing_alg, token_endpoint_auth_method: params.token_endpoint_auth_method, + user_profile_method: params.user_profile_method, created_at, disabled_at: None, claims_imports: params.claims_imports, authorization_endpoint_override: params.authorization_endpoint_override, token_endpoint_override: params.token_endpoint_override, + userinfo_endpoint_override: params.userinfo_endpoint_override, jwks_uri_override: params.jwks_uri_override, discovery_mode: params.discovery_mode, pkce_mode: params.pkce_mode, @@ -424,19 +457,21 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' brand_name, scope, token_endpoint_auth_method, + user_profile_method, token_endpoint_signing_alg, client_id, encrypted_client_secret, claims_imports, authorization_endpoint_override, token_endpoint_override, + userinfo_endpoint_override, jwks_uri_override, discovery_mode, pkce_mode, additional_parameters, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14, $15, $16, $17) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -445,6 +480,7 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' brand_name = EXCLUDED.brand_name, scope = EXCLUDED.scope, token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method, + user_profile_method = EXCLUDED.user_profile_method, token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg, disabled_at = NULL, client_id = EXCLUDED.client_id, @@ -452,6 +488,7 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' claims_imports = EXCLUDED.claims_imports, authorization_endpoint_override = EXCLUDED.authorization_endpoint_override, token_endpoint_override = EXCLUDED.token_endpoint_override, + userinfo_endpoint_override = EXCLUDED.userinfo_endpoint_override, jwks_uri_override = EXCLUDED.jwks_uri_override, discovery_mode = EXCLUDED.discovery_mode, pkce_mode = EXCLUDED.pkce_mode, @@ -464,6 +501,7 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' params.brand_name.as_deref(), params.scope.to_string(), params.token_endpoint_auth_method.to_string(), + params.user_profile_method.to_string(), params .token_endpoint_signing_alg .as_ref() @@ -479,6 +517,10 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' .token_endpoint_override .as_ref() .map(ToString::to_string), + params + .userinfo_endpoint_override + .as_ref() + .map(ToString::to_string), params.jwks_uri_override.as_ref().map(ToString::to_string), params.discovery_mode.as_str(), params.pkce_mode.as_str(), @@ -499,11 +541,13 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' encrypted_client_secret: params.encrypted_client_secret, token_endpoint_signing_alg: params.token_endpoint_signing_alg, token_endpoint_auth_method: params.token_endpoint_auth_method, + user_profile_method: params.user_profile_method, created_at, disabled_at: None, claims_imports: params.claims_imports, authorization_endpoint_override: params.authorization_endpoint_override, token_endpoint_override: params.token_endpoint_override, + userinfo_endpoint_override: params.userinfo_endpoint_override, jwks_uri_override: params.jwks_uri_override, discovery_mode: params.discovery_mode, pkce_mode: params.pkce_mode, @@ -662,6 +706,13 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' )), ProviderLookupIden::AuthorizationEndpointOverride, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::UserinfoEndpointOverride, + )), + ProviderLookupIden::UserinfoEndpointOverride, + ) .expr_as( Expr::col(( UpstreamOAuthProviders::Table, @@ -676,6 +727,13 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' )), ProviderLookupIden::PkceMode, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::UserProfileMethod, + )), + ProviderLookupIden::UserProfileMethod, + ) .expr_as( Expr::col(( UpstreamOAuthProviders::Table, @@ -762,12 +820,14 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' encrypted_client_secret, token_endpoint_signing_alg, token_endpoint_auth_method, + user_profile_method, created_at, disabled_at, claims_imports as "claims_imports: Json", jwks_uri_override, authorization_endpoint_override, token_endpoint_override, + userinfo_endpoint_override, discovery_mode, pkce_mode, additional_parameters as "additional_parameters: Json>" diff --git a/crates/storage-pg/src/upstream_oauth2/session.rs b/crates/storage-pg/src/upstream_oauth2/session.rs index fb27da5f8..8217554b8 100644 --- a/crates/storage-pg/src/upstream_oauth2/session.rs +++ b/crates/storage-pg/src/upstream_oauth2/session.rs @@ -40,6 +40,7 @@ struct SessionLookup { code_challenge_verifier: Option, nonce: String, id_token: Option, + userinfo: Option, created_at: DateTime, completed_at: Option>, consumed_at: Option>, @@ -53,22 +54,25 @@ impl TryFrom for UpstreamOAuthAuthorizationSession { let state = match ( value.upstream_oauth_link_id, value.id_token, + value.userinfo, value.completed_at, value.consumed_at, ) { - (None, None, None, None) => UpstreamOAuthAuthorizationSessionState::Pending, - (Some(link_id), id_token, Some(completed_at), None) => { + (None, None, None, None, None) => UpstreamOAuthAuthorizationSessionState::Pending, + (Some(link_id), id_token, userinfo, Some(completed_at), None) => { UpstreamOAuthAuthorizationSessionState::Completed { completed_at, link_id: link_id.into(), id_token, + userinfo, } } - (Some(link_id), id_token, Some(completed_at), Some(consumed_at)) => { + (Some(link_id), id_token, userinfo, Some(completed_at), Some(consumed_at)) => { UpstreamOAuthAuthorizationSessionState::Consumed { completed_at, link_id: link_id.into(), id_token, + userinfo, consumed_at, } } @@ -119,6 +123,7 @@ impl<'c> UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'c> code_challenge_verifier, nonce, id_token, + userinfo, created_at, completed_at, consumed_at @@ -175,8 +180,9 @@ impl<'c> UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'c> created_at, completed_at, consumed_at, - id_token - ) VALUES ($1, $2, $3, $4, $5, $6, NULL, NULL, NULL) + id_token, + userinfo + ) VALUES ($1, $2, $3, $4, $5, $6, NULL, NULL, NULL, NULL) "#, Uuid::from(id), Uuid::from(upstream_oauth_provider.id), @@ -216,6 +222,7 @@ impl<'c> UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'c> upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + userinfo: Option, ) -> Result { let completed_at = clock.now(); @@ -224,12 +231,14 @@ impl<'c> UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'c> UPDATE upstream_oauth_authorization_sessions SET upstream_oauth_link_id = $1, completed_at = $2, - id_token = $3 - WHERE upstream_oauth_authorization_session_id = $4 + id_token = $3, + userinfo = $4 + WHERE upstream_oauth_authorization_session_id = $5 "#, Uuid::from(upstream_oauth_link.id), completed_at, id_token, + userinfo, Uuid::from(upstream_oauth_authorization_session.id), ) .traced() @@ -237,7 +246,7 @@ impl<'c> UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'c> .await?; let upstream_oauth_authorization_session = upstream_oauth_authorization_session - .complete(completed_at, upstream_oauth_link, id_token) + .complete(completed_at, upstream_oauth_link, id_token, userinfo) .map_err(DatabaseError::to_invalid_operation)?; Ok(upstream_oauth_authorization_session) diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index 7d7d4dbb0..8fc6dccac 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -9,7 +9,7 @@ use std::marker::PhantomData; use async_trait::async_trait; use mas_data_model::{ UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, - UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderUserProfileMethod, }; use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; use oauth2_types::scope::Scope; @@ -41,6 +41,10 @@ pub struct UpstreamOAuthProviderParams { /// `private_key_jwt` authentication methods are used pub token_endpoint_signing_alg: Option, + /// Whether to fetch the user profile from the userinfo endpoint, + /// or to rely on the data returned in the id_token from the token_endpoint. + pub user_profile_method: UpstreamOAuthProviderUserProfileMethod, + /// The client ID to use when authenticating to the upstream pub client_id: String, @@ -58,6 +62,10 @@ pub struct UpstreamOAuthProviderParams { /// discovered pub token_endpoint_override: Option, + /// The URL to use as the userinfo endpoint. If `None`, the URL will be + /// discovered + pub userinfo_endpoint_override: Option, + /// The URL to use when fetching JWKS. If `None`, the URL will be discovered pub jwks_uri_override: Option, diff --git a/crates/storage/src/upstream_oauth2/session.rs b/crates/storage/src/upstream_oauth2/session.rs index 80da6135a..b0f527044 100644 --- a/crates/storage/src/upstream_oauth2/session.rs +++ b/crates/storage/src/upstream_oauth2/session.rs @@ -84,6 +84,7 @@ pub trait UpstreamOAuthSessionRepository: Send + Sync { upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + userinfo: Option, ) -> Result; /// Mark a session as consumed @@ -127,6 +128,7 @@ repository_impl!(UpstreamOAuthSessionRepository: upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + userinfo: Option, ) -> Result; async fn consume( diff --git a/docs/config.schema.json b/docs/config.schema.json index 701f44013..7800698d6 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1867,6 +1867,14 @@ } ] }, + "user_profile_method": { + "description": "Whether to fetch the user profile from the userinfo endpoint, or to rely on the data returned in the id_token from the token_endpoint.\n\nDefaults to `auto`, which uses the userinfo endpoint if `openid` is not included in `scopes`, and the ID token otherwise.", + "allOf": [ + { + "$ref": "#/definitions/UserProfileMethod" + } + ] + }, "scope": { "description": "The scopes to request from the provider", "type": "string" @@ -1892,6 +1900,11 @@ "type": "string", "format": "uri" }, + "userinfo_endpoint": { + "description": "The URL to use for the provider's userinfo endpoint\n\nDefaults to the `userinfo_endpoint` provided through discovery", + "type": "string", + "format": "uri" + }, "token_endpoint": { "description": "The URL to use for the provider's token endpoint\n\nDefaults to the `token_endpoint` provided through discovery", "type": "string", @@ -1959,6 +1972,25 @@ } ] }, + "UserProfileMethod": { + "description": "Whether to fetch the user profile from the userinfo endpoint, or to rely on the data returned in the id_token from the token_endpoint", + "oneOf": [ + { + "description": "Use the userinfo endpoint if `openid` is not included in `scopes`", + "type": "string", + "enum": [ + "auto" + ] + }, + { + "description": "Always use the userinfo endpoint", + "type": "string", + "enum": [ + "userinfo_endpoint" + ] + } + ] + }, "DiscoveryMode": { "description": "How to discover the provider's configuration", "oneOf": [