diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index b8f105f4..dde95638 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -57,11 +57,6 @@ impl Options { let span = info_span!("cli.run.init").entered(); let config: AppConfig = root.load_config()?; - // XXX: there should be a generic config verification step - if config.http.public_base.path() != "/" { - anyhow::bail!("The http.public_base path is not set to /, this is not supported"); - } - // Connect to the database info!("Connecting to the database"); let pool = database_pool_from_config(&config.database).await?; @@ -201,19 +196,22 @@ impl Options { let router = crate::server::build_router( state.clone(), &config.resources, + config.prefix.as_deref(), config.name.as_deref(), ); + // Display some informations about where we'll be serving connections let proto = if config.tls.is_some() { "https" } else { "http" }; + let prefix = config.prefix.unwrap_or_default(); let addresses= listeners .iter() .map(|listener| { if let Ok(addr) = listener.local_addr() { - format!("{proto}://{addr:?}") + format!("{proto}://{addr:?}{prefix}") } else { warn!("Could not get local address for listener, something might be wrong!"); - format!("{proto}://???") + format!("{proto}://???{prefix}") } }) .join(", "); diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 2e96c455..50c74d8c 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -175,6 +175,7 @@ fn on_http_response_labels(res: &Response) -> Vec { pub fn build_router( state: AppState, resources: &[HttpResource], + prefix: Option<&str>, name: Option<&str>, ) -> Router<(), B> where @@ -244,6 +245,11 @@ where } } + if let Some(prefix) = prefix { + let path = format!("{}/", prefix.trim_end_matches('/')); + router = Router::new().nest(&path, router); + } + router = router.fallback(mas_handlers::fallback); router diff --git a/crates/config/src/sections/http.rs b/crates/config/src/sections/http.rs index 978f3404..879e6307 100644 --- a/crates/config/src/sections/http.rs +++ b/crates/config/src/sections/http.rs @@ -312,6 +312,10 @@ pub struct ListenerConfig { /// List of resources to mount pub resources: Vec, + /// HTTP prefix to mount the resources on + #[serde(default)] + pub prefix: Option, + /// List of sockets to bind pub binds: Vec, @@ -359,6 +363,7 @@ impl Default for HttpConfig { path: http_listener_assets_path_default(), }, ], + prefix: None, tls: None, proxy_protocol: false, binds: vec![BindConfig::Address { @@ -368,6 +373,7 @@ impl Default for HttpConfig { ListenerConfig { name: Some("internal".to_owned()), resources: vec![Resource::Health], + prefix: None, tls: None, proxy_protocol: false, binds: vec![BindConfig::Listen { diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 9aeeb1e9..6626f1df 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -27,7 +27,7 @@ use mas_axum_utils::{ FancyError, SessionInfoExt, }; use mas_data_model::Device; -use mas_router::{CompatLoginSsoAction, PostAuthAction, Route}; +use mas_router::{CompatLoginSsoAction, PostAuthAction, UrlBuilder}; use mas_storage::{ compat::{CompatSessionRepository, CompatSsoLoginRepository}, job::{JobRepositoryExt, ProvisionDeviceJob}, @@ -65,6 +65,7 @@ pub async fn get( clock: BoxClock, mut repo: BoxRepository, State(templates): State, + State(url_builder): State, cookie_jar: CookieJar, Path(id): Path, Query(params): Query, @@ -78,10 +79,10 @@ pub async fn get( // If there is no session, redirect to the login or register screen let url = match params.action { Some(CompatLoginSsoAction::Register) => { - mas_router::Register::and_continue_compat_sso_login(id).go() + url_builder.redirect(&mas_router::Register::and_continue_compat_sso_login(id)) } Some(CompatLoginSsoAction::Login) | None => { - mas_router::Login::and_continue_compat_sso_login(id).go() + url_builder.redirect(&mas_router::Login::and_continue_compat_sso_login(id)) } }; @@ -92,7 +93,7 @@ pub async fn get( if session.user.primary_user_email_id.is_none() { let destination = mas_router::AccountAddEmail::default() .and_then(PostAuthAction::continue_compat_sso_login(id)); - return Ok((cookie_jar, destination.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } let login = repo @@ -134,6 +135,7 @@ pub async fn post( mut repo: BoxRepository, PreferredLanguage(locale): PreferredLanguage, State(templates): State, + State(url_builder): State, cookie_jar: CookieJar, Path(id): Path, Query(params): Query, @@ -148,10 +150,10 @@ pub async fn post( // If there is no session, redirect to the login or register screen let url = match params.action { Some(CompatLoginSsoAction::Register) => { - mas_router::Register::and_continue_compat_sso_login(id).go() + url_builder.redirect(&mas_router::Register::and_continue_compat_sso_login(id)) } Some(CompatLoginSsoAction::Login) | None => { - mas_router::Login::and_continue_compat_sso_login(id).go() + url_builder.redirect(&mas_router::Login::and_continue_compat_sso_login(id)) } }; @@ -162,7 +164,7 @@ pub async fn post( if session.user.primary_user_email_id.is_none() { let destination = mas_router::AccountAddEmail::default() .and_then(PostAuthAction::continue_compat_sso_login(id)); - return Ok((cookie_jar, destination.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } let login = repo diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 6b980542..5c882b96 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -30,7 +30,7 @@ clippy::let_with_type_underscore, )] -use std::{borrow::Cow, convert::Infallible, time::Duration}; +use std::{convert::Infallible, time::Duration}; use axum::{ body::{Bytes, HttpBody}, @@ -276,6 +276,18 @@ where mas_router::CompatRefresh::route(), post(self::compat::refresh::post), ) + .route( + mas_router::CompatLoginSsoRedirect::route(), + get(self::compat::login_sso_redirect::get), + ) + .route( + mas_router::CompatLoginSsoRedirectIdp::route(), + get(self::compat::login_sso_redirect::get), + ) + .route( + mas_router::CompatLoginSsoRedirectSlash::route(), + get(self::compat::login_sso_redirect::get), + ) .layer( CorsLayer::new() .allow_origin(Any) @@ -318,16 +330,19 @@ where // XXX: hard-coded redirect from /account to /account/ .route( "/account", - get(|RawQuery(query): RawQuery| async { - let route = mas_router::Account::route(); - let destination = if let Some(query) = query { - Cow::Owned(format!("{route}?{query}")) - } else { - Cow::Borrowed(route) - }; + get( + |State(url_builder): State, RawQuery(query): RawQuery| async move { + let prefix = url_builder.prefix().unwrap_or_default(); + let route = mas_router::Account::route(); + let destination = if let Some(query) = query { + format!("{prefix}{route}?{query}") + } else { + format!("{prefix}{route}") + }; - axum::response::Redirect::to(&destination) - }), + axum::response::Redirect::to(&destination) + }, + ), ) .route(mas_router::Account::route(), get(self::views::app::get)) .route( @@ -336,7 +351,9 @@ where ) .route( mas_router::ChangePasswordDiscovery::route(), - get(|| async { mas_router::AccountPassword.go() }), + get(|State(url_builder): State| async move { + url_builder.redirect(&mas_router::AccountPassword) + }), ) .route(mas_router::Index::route(), get(self::views::index::get)) .route( @@ -378,18 +395,6 @@ where mas_router::Consent::route(), get(self::oauth2::consent::get).post(self::oauth2::consent::post), ) - .route( - mas_router::CompatLoginSsoRedirect::route(), - get(self::compat::login_sso_redirect::get), - ) - .route( - mas_router::CompatLoginSsoRedirectIdp::route(), - get(self::compat::login_sso_redirect::get), - ) - .route( - mas_router::CompatLoginSsoRedirectSlash::route(), - get(self::compat::login_sso_redirect::get), - ) .route( mas_router::CompatLoginSsoComplete::route(), get(self::compat::login_sso_complete::get).post(self::compat::login_sso_complete::post), diff --git a/crates/handlers/src/oauth2/authorization/complete.rs b/crates/handlers/src/oauth2/authorization/complete.rs index 2ca2ba81..c0036e9a 100644 --- a/crates/handlers/src/oauth2/authorization/complete.rs +++ b/crates/handlers/src/oauth2/authorization/complete.rs @@ -21,7 +21,7 @@ use mas_axum_utils::{cookies::CookieJar, csrf::CsrfExt, sentry::SentryEventID, S use mas_data_model::{AuthorizationGrant, BrowserSession, Client, Device}; use mas_keystore::Keystore; use mas_policy::{EvaluationResult, Policy}; -use mas_router::{PostAuthAction, Route, UrlBuilder}; +use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2SessionRepository}, user::BrowserSessionRepository, @@ -117,7 +117,11 @@ pub(crate) async fn get( let Some(session) = maybe_session else { // If there is no session, redirect to the login screen, redirecting here after // logout - return Ok((cookie_jar, mas_router::Login::and_then(continue_grant).go()).into_response()); + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::Login::and_then(continue_grant)), + ) + .into_response()); }; activity_tracker @@ -137,7 +141,7 @@ pub(crate) async fn get( repo, key_store, policy, - url_builder, + &url_builder, grant, &client, &session, @@ -150,12 +154,12 @@ pub(crate) async fn get( } Err(GrantCompletionError::RequiresReauth) => Ok(( cookie_jar, - mas_router::Reauth::and_then(continue_grant).go(), + url_builder.redirect(&mas_router::Reauth::and_then(continue_grant)), ) .into_response()), Err(GrantCompletionError::RequiresConsent) => { let next = mas_router::Consent(grant_id); - Ok((cookie_jar, next.go()).into_response()) + Ok((cookie_jar, url_builder.redirect(&next)).into_response()) } Err(GrantCompletionError::PolicyViolation(grant, res)) => { warn!(violation = ?res, "Authorization grant for client {} denied by policy", client.id); @@ -206,7 +210,7 @@ pub(crate) async fn complete( mut repo: BoxRepository, key_store: Keystore, mut policy: Policy, - url_builder: UrlBuilder, + url_builder: &UrlBuilder, grant: AuthorizationGrant, client: &Client, browser_session: &BrowserSession, @@ -273,7 +277,7 @@ pub(crate) async fn complete( params.id_token = Some(generate_id_token( rng, clock, - &url_builder, + url_builder, &key_store, client, &grant, diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index c733960f..1d52737f 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -21,7 +21,7 @@ use mas_axum_utils::{cookies::CookieJar, csrf::CsrfExt, sentry::SentryEventID, S use mas_data_model::{AuthorizationCode, Pkce}; use mas_keystore::Keystore; use mas_policy::Policy; -use mas_router::{PostAuthAction, Route, UrlBuilder}; +use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository}, BoxClock, BoxRepository, BoxRng, @@ -313,16 +313,14 @@ pub(crate) async fn get( // Client asked for a registration, show the registration prompt repo.save().await?; - mas_router::Register::and_then(continue_grant) - .go() + url_builder.redirect(&mas_router::Register::and_then(continue_grant)) .into_response() } None => { // Other cases where we don't have a session, ask for a login repo.save().await?; - mas_router::Login::and_then(continue_grant) - .go() + url_builder.redirect(&mas_router::Login::and_then(continue_grant)) .into_response() } @@ -336,8 +334,7 @@ pub(crate) async fn get( activity_tracker.record_browser_session(&clock, &session).await; - mas_router::Reauth::and_then(continue_grant) - .go() + url_builder.redirect(&mas_router::Reauth::and_then(continue_grant)) .into_response() } @@ -353,7 +350,7 @@ pub(crate) async fn get( repo, key_store, policy, - url_builder, + &url_builder, grant, &client, &user_session, @@ -403,7 +400,7 @@ pub(crate) async fn get( repo, key_store, policy, - url_builder, + &url_builder, grant, &client, &user_session, @@ -412,7 +409,7 @@ pub(crate) async fn get( { Ok(params) => callback_destination.go(&templates, params).await?, Err(GrantCompletionError::RequiresConsent) => { - mas_router::Consent(grant_id).go().into_response() + url_builder.redirect(&mas_router::Consent(grant_id)).into_response() } Err(GrantCompletionError::PolicyViolation(grant, res)) => { warn!(violation = ?res, "Authorization grant for client {} denied by policy", client.id); @@ -426,8 +423,7 @@ pub(crate) async fn get( Html(content).into_response() } Err(GrantCompletionError::RequiresReauth) => { - mas_router::Reauth::and_then(continue_grant) - .go() + url_builder.redirect(&mas_router::Reauth::and_then(continue_grant)) .into_response() } Err(GrantCompletionError::Internal(e)) => { diff --git a/crates/handlers/src/oauth2/consent.rs b/crates/handlers/src/oauth2/consent.rs index 6e671a32..ba6d7bde 100644 --- a/crates/handlers/src/oauth2/consent.rs +++ b/crates/handlers/src/oauth2/consent.rs @@ -25,7 +25,7 @@ use mas_axum_utils::{ }; use mas_data_model::{AuthorizationGrantStage, Device}; use mas_policy::Policy; -use mas_router::{PostAuthAction, Route}; +use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository}, BoxClock, BoxRepository, BoxRng, @@ -84,6 +84,7 @@ pub(crate) async fn get( clock: BoxClock, PreferredLanguage(locale): PreferredLanguage, State(templates): State, + State(url_builder): State, mut policy: Policy, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -142,7 +143,7 @@ pub(crate) async fn get( } } else { let login = mas_router::Login::and_continue_grant(grant_id); - Ok((cookie_jar, login.go()).into_response()) + Ok((cookie_jar, url_builder.redirect(&login)).into_response()) } } @@ -159,6 +160,7 @@ pub(crate) async fn post( mut repo: BoxRepository, activity_tracker: BoundActivityTracker, cookie_jar: CookieJar, + State(url_builder): State, Path(grant_id): Path, Form(form): Form>, ) -> Result { @@ -177,7 +179,7 @@ pub(crate) async fn post( let Some(session) = maybe_session else { let login = mas_router::Login::and_then(next); - return Ok((cookie_jar, login.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; activity_tracker @@ -222,5 +224,5 @@ pub(crate) async fn post( repo.save().await?; - Ok((cookie_jar, next.go_next()).into_response()) + Ok((cookie_jar, next.go_next(&url_builder)).into_response()) } diff --git a/crates/handlers/src/upstream_oauth2/callback.rs b/crates/handlers/src/upstream_oauth2/callback.rs index 41d90ab3..62dbe653 100644 --- a/crates/handlers/src/upstream_oauth2/callback.rs +++ b/crates/handlers/src/upstream_oauth2/callback.rs @@ -25,7 +25,7 @@ use mas_keystore::{Encrypter, Keystore}; use mas_oidc_client::requests::{ authorization_code::AuthorizationValidationData, jose::JwtVerificationData, }; -use mas_router::{Route, UrlBuilder}; +use mas_router::UrlBuilder; use mas_storage::{ upstream_oauth2::{ UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository, @@ -268,6 +268,6 @@ pub(crate) async fn get( Ok(( cookie_jar, - mas_router::UpstreamOAuth2Link::new(link.id).go(), + url_builder.redirect(&mas_router::UpstreamOAuth2Link::new(link.id)), )) } diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index b559bc4b..364694dd 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -27,6 +27,7 @@ use mas_axum_utils::{ use mas_data_model::{UpstreamOAuthProviderImportPreference, User}; use mas_jose::jwt::Jwt; use mas_policy::Policy; +use mas_router::UrlBuilder; use mas_storage::{ job::{JobRepositoryExt, ProvisionUserJob}, upstream_oauth2::{UpstreamOAuthLinkRepository, UpstreamOAuthSessionRepository}, @@ -191,6 +192,7 @@ pub(crate) async fn get( mut repo: BoxRepository, PreferredLanguage(locale): PreferredLanguage, State(templates): State, + State(url_builder): State, cookie_jar: CookieJar, user_agent: Option>, Path(link_id): Path, @@ -248,7 +250,7 @@ pub(crate) async fn get( repo.save().await?; - post_auth_action.go_next().into_response() + post_auth_action.go_next(&url_builder).into_response() } (Some(user_session), Some(user_id)) => { @@ -311,7 +313,7 @@ pub(crate) async fn get( repo.save().await?; - post_auth_action.go_next().into_response() + post_auth_action.go_next(&url_builder).into_response() } (None, None) => { @@ -383,6 +385,7 @@ pub(crate) async fn post( cookie_jar: CookieJar, user_agent: Option>, mut policy: Policy, + State(url_builder): State, Path(link_id): Path, Form(form): Form>, ) -> Result { @@ -577,5 +580,5 @@ pub(crate) async fn post( repo.save().await?; - Ok((cookie_jar, post_auth_action.go_next())) + Ok((cookie_jar, post_auth_action.go_next(&url_builder))) } diff --git a/crates/handlers/src/views/account/emails/add.rs b/crates/handlers/src/views/account/emails/add.rs index 0a8b7ab8..d8f33f6b 100644 --- a/crates/handlers/src/views/account/emails/add.rs +++ b/crates/handlers/src/views/account/emails/add.rs @@ -22,7 +22,7 @@ use mas_axum_utils::{ FancyError, SessionInfoExt, }; use mas_policy::Policy; -use mas_router::Route; +use mas_router::UrlBuilder; use mas_storage::{ job::{JobRepositoryExt, VerifyEmailJob}, user::UserEmailRepository, @@ -44,6 +44,7 @@ pub(crate) async fn get( clock: BoxClock, PreferredLanguage(locale): PreferredLanguage, State(templates): State, + State(url_builder): State, activity_tracker: BoundActivityTracker, mut repo: BoxRepository, cookie_jar: CookieJar, @@ -55,7 +56,7 @@ pub(crate) async fn get( let Some(session) = maybe_session else { let login = mas_router::Login::default(); - return Ok((cookie_jar, login.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; activity_tracker @@ -80,6 +81,7 @@ pub(crate) async fn post( PreferredLanguage(locale): PreferredLanguage, mut policy: Policy, cookie_jar: CookieJar, + State(url_builder): State, activity_tracker: BoundActivityTracker, Query(query): Query, Form(form): Form>, @@ -91,7 +93,7 @@ pub(crate) async fn post( let Some(session) = maybe_session else { let login = mas_router::Login::default(); - return Ok((cookie_jar, login.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; // XXX: we really should show human readable errors on the form here @@ -135,9 +137,9 @@ pub(crate) async fn post( next }; - next.go() + url_builder.redirect(&next) } else { - query.go_next_or_default(&mas_router::Account::default()) + query.go_next_or_default(&url_builder, &mas_router::Account::default()) }; repo.save().await?; diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs index ad0f5028..47355bbe 100644 --- a/crates/handlers/src/views/account/emails/verify.rs +++ b/crates/handlers/src/views/account/emails/verify.rs @@ -22,7 +22,7 @@ use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, FancyError, SessionInfoExt, }; -use mas_router::Route; +use mas_router::UrlBuilder; use mas_storage::{ job::{JobRepositoryExt, ProvisionUserJob}, user::UserEmailRepository, @@ -50,6 +50,7 @@ pub(crate) async fn get( clock: BoxClock, PreferredLanguage(locale): PreferredLanguage, State(templates): State, + State(url_builder): State, activity_tracker: BoundActivityTracker, mut repo: BoxRepository, Query(query): Query, @@ -63,7 +64,7 @@ pub(crate) async fn get( let Some(session) = maybe_session else { let login = mas_router::Login::default(); - return Ok((cookie_jar, login.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; activity_tracker @@ -79,7 +80,7 @@ pub(crate) async fn get( if user_email.confirmed_at.is_some() { // This email was already verified, skip - let destination = query.go_next_or_default(&mas_router::Account::default()); + let destination = query.go_next_or_default(&url_builder, &mas_router::Account::default()); return Ok((cookie_jar, destination).into_response()); } @@ -103,6 +104,7 @@ pub(crate) async fn post( clock: BoxClock, mut repo: BoxRepository, cookie_jar: CookieJar, + State(url_builder): State, activity_tracker: BoundActivityTracker, Query(query): Query, Path(id): Path, @@ -115,7 +117,7 @@ pub(crate) async fn post( let Some(session) = maybe_session else { let login = mas_router::Login::default(); - return Ok((cookie_jar, login.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; let user_email = repo @@ -157,6 +159,6 @@ pub(crate) async fn post( .record_browser_session(&clock, &session) .await; - let destination = query.go_next_or_default(&mas_router::Account::default()); + let destination = query.go_next_or_default(&url_builder, &mas_router::Account::default()); Ok((cookie_jar, destination).into_response()) } diff --git a/crates/handlers/src/views/account/password.rs b/crates/handlers/src/views/account/password.rs index 5a591897..954d2fae 100644 --- a/crates/handlers/src/views/account/password.rs +++ b/crates/handlers/src/views/account/password.rs @@ -26,7 +26,7 @@ use mas_axum_utils::{ use mas_data_model::BrowserSession; use mas_i18n::DataLocale; use mas_policy::Policy; -use mas_router::Route; +use mas_router::UrlBuilder; use mas_storage::{ user::{BrowserSessionRepository, UserPasswordRepository}, BoxClock, BoxRepository, BoxRng, Clock, @@ -53,12 +53,15 @@ pub(crate) async fn get( State(templates): State, State(password_manager): State, activity_tracker: BoundActivityTracker, + State(url_builder): State, mut repo: BoxRepository, cookie_jar: CookieJar, ) -> Result { // If the password manager is disabled, we can go back to the account page. if !password_manager.is_enabled() { - return Ok(mas_router::Account::default().go().into_response()); + return Ok(url_builder + .redirect(&mas_router::Account::default()) + .into_response()); } let (session_info, cookie_jar) = cookie_jar.session_info(); @@ -73,7 +76,7 @@ pub(crate) async fn get( render(&mut rng, &clock, locale, templates, session, cookie_jar).await } else { let login = mas_router::Login::and_then(mas_router::PostAuthAction::ChangePassword); - Ok((cookie_jar, login.go()).into_response()) + Ok((cookie_jar, url_builder.redirect(&login)).into_response()) } } @@ -105,6 +108,7 @@ pub(crate) async fn post( State(password_manager): State, State(templates): State, activity_tracker: BoundActivityTracker, + State(url_builder): State, mut policy: Policy, mut repo: BoxRepository, cookie_jar: CookieJar, @@ -123,7 +127,7 @@ pub(crate) async fn post( let Some(session) = maybe_session else { let login = mas_router::Login::and_then(mas_router::PostAuthAction::ChangePassword); - return Ok((cookie_jar, login.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; let user_password = repo diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs index b88805ee..e8c0ae07 100644 --- a/crates/handlers/src/views/app.rs +++ b/crates/handlers/src/views/app.rs @@ -17,7 +17,7 @@ use axum::{ response::{Html, IntoResponse}, }; use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt}; -use mas_router::{PostAuthAction, Route}; +use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository}; use mas_templates::{AppContext, TemplateContext, Templates}; @@ -28,6 +28,7 @@ pub async fn get( PreferredLanguage(locale): PreferredLanguage, State(templates): State, activity_tracker: BoundActivityTracker, + State(url_builder): State, action: Option>, mut repo: BoxRepository, clock: BoxClock, @@ -41,7 +42,9 @@ pub async fn get( let Some(session) = session else { return Ok(( cookie_jar, - mas_router::Login::and_then(PostAuthAction::manage_account(action)).go(), + url_builder.redirect(&mas_router::Login::and_then( + PostAuthAction::manage_account(action), + )), ) .into_response()); }; @@ -50,7 +53,7 @@ pub async fn get( .record_browser_session(&clock, &session) .await; - let ctx = AppContext::default().with_language(locale); + let ctx = AppContext::from_url_builder(&url_builder).with_language(locale); let content = templates.render_app(&ctx)?; Ok((cookie_jar, Html(content)).into_response()) diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 0a67bf7f..94b9cf20 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -26,7 +26,7 @@ use mas_axum_utils::{ }; use mas_data_model::BrowserSession; use mas_i18n::DataLocale; -use mas_router::{Route, UpstreamOAuth2Authorize}; +use mas_router::{UpstreamOAuth2Authorize, UrlBuilder}; use mas_storage::{ upstream_oauth2::UpstreamOAuthProviderRepository, user::{BrowserSessionRepository, UserPasswordRepository, UserRepository}, @@ -59,6 +59,7 @@ pub(crate) async fn get( PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, + State(url_builder): State, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, Query(query): Query, @@ -74,7 +75,7 @@ pub(crate) async fn get( .record_browser_session(&clock, &session) .await; - let reply = query.go_next(); + let reply = query.go_next(&url_builder); return Ok((cookie_jar, reply).into_response()); }; @@ -91,7 +92,7 @@ pub(crate) async fn get( destination = destination.and_then(action); }; - return Ok((cookie_jar, destination.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); }; let content = render( @@ -117,6 +118,7 @@ pub(crate) async fn post( PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, + State(url_builder): State, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, Query(query): Query, @@ -185,7 +187,7 @@ pub(crate) async fn post( .await; let cookie_jar = cookie_jar.set_session(&session_info); - let reply = query.go_next(); + let reply = query.go_next(&url_builder); Ok((cookie_jar, reply).into_response()) } Err(e) => { @@ -360,7 +362,7 @@ mod test { let response = state.request(Request::get("/login").empty()).await; response.assert_status(StatusCode::SEE_OTHER); - response.assert_header_value(LOCATION, &first_provider_login.relative_url()); + response.assert_header_value(LOCATION, &first_provider_login.path_and_query()); // Adding a second provider should show a login page with both providers let mut repo = state.repository().await.unwrap(); @@ -391,13 +393,13 @@ mod test { .contains(&escape_html(&first_provider.issuer))); assert!(response .body() - .contains(&escape_html(&first_provider_login.relative_url()))); + .contains(&escape_html(&first_provider_login.path_and_query()))); assert!(response .body() .contains(&escape_html(&second_provider.issuer))); assert!(response .body() - .contains(&escape_html(&second_provider_login.relative_url()))); + .contains(&escape_html(&second_provider_login.path_and_query()))); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/views/logout.rs b/crates/handlers/src/views/logout.rs index b2498d36..aac33712 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -12,13 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use axum::{extract::Form, response::IntoResponse}; +use axum::{ + extract::{Form, State}, + response::IntoResponse, +}; use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, FancyError, SessionInfoExt, }; -use mas_router::{PostAuthAction, Route}; +use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{user::BrowserSessionRepository, BoxClock, BoxRepository}; use crate::BoundActivityTracker; @@ -28,6 +31,7 @@ pub(crate) async fn post( clock: BoxClock, mut repo: BoxRepository, cookie_jar: CookieJar, + State(url_builder): State, activity_tracker: BoundActivityTracker, Form(form): Form>>, ) -> Result { @@ -49,9 +53,9 @@ pub(crate) async fn post( repo.save().await?; let destination = if let Some(action) = form { - action.go_next() + action.go_next(&url_builder) } else { - mas_router::Login::default().go() + url_builder.redirect(&mas_router::Login::default()) }; Ok((cookie_jar, destination)) diff --git a/crates/handlers/src/views/reauth.rs b/crates/handlers/src/views/reauth.rs index e5d9c738..bd1e01e4 100644 --- a/crates/handlers/src/views/reauth.rs +++ b/crates/handlers/src/views/reauth.rs @@ -23,7 +23,7 @@ use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, FancyError, SessionInfoExt, }; -use mas_router::Route; +use mas_router::UrlBuilder; use mas_storage::{ user::{BrowserSessionRepository, UserPasswordRepository}, BoxClock, BoxRepository, BoxRng, @@ -47,6 +47,7 @@ pub(crate) async fn get( PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, + State(url_builder): State, activity_tracker: BoundActivityTracker, mut repo: BoxRepository, Query(query): Query, @@ -54,7 +55,9 @@ pub(crate) async fn get( ) -> Result { if !password_manager.is_enabled() { // XXX: do something better here - return Ok(mas_router::Account::default().go().into_response()); + return Ok(url_builder + .redirect(&mas_router::Account::default()) + .into_response()); } let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); @@ -66,7 +69,7 @@ pub(crate) async fn get( // If there is no session, redirect to the login screen, keeping the // PostAuthAction let login = mas_router::Login::from(query.post_auth_action); - return Ok((cookie_jar, login.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; activity_tracker @@ -95,6 +98,7 @@ pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, State(password_manager): State, + State(url_builder): State, mut repo: BoxRepository, Query(query): Query, cookie_jar: CookieJar, @@ -115,7 +119,7 @@ pub(crate) async fn post( // If there is no session, redirect to the login screen, keeping the // PostAuthAction let login = mas_router::Login::from(query.post_auth_action); - return Ok((cookie_jar, login.go()).into_response()); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); }; // Load the user password @@ -162,6 +166,6 @@ pub(crate) async fn post( let cookie_jar = cookie_jar.set_session(&session); repo.save().await?; - let reply = query.go_next(); + let reply = query.go_next(&url_builder); Ok((cookie_jar, reply).into_response()) } diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 2cc86f83..6e3cfec2 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -29,7 +29,7 @@ use mas_axum_utils::{ }; use mas_i18n::DataLocale; use mas_policy::Policy; -use mas_router::Route; +use mas_router::UrlBuilder; use mas_storage::{ job::{JobRepositoryExt, ProvisionUserJob, VerifyEmailJob}, user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, @@ -64,6 +64,7 @@ pub(crate) async fn get( PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(password_manager): State, + State(url_builder): State, mut repo: BoxRepository, Query(query): Query, cookie_jar: CookieJar, @@ -74,14 +75,14 @@ pub(crate) async fn get( let maybe_session = session_info.load_session(&mut repo).await?; if maybe_session.is_some() { - let reply = query.go_next(); + let reply = query.go_next(&url_builder); return Ok((cookie_jar, reply).into_response()); } if !password_manager.is_enabled() { // If password-based login is disabled, redirect to the login page here - return Ok(mas_router::Login::from(query.post_auth_action) - .go() + return Ok(url_builder + .redirect(&mas_router::Login::from(query.post_auth_action)) .into_response()); } @@ -106,6 +107,7 @@ pub(crate) async fn post( PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, + State(url_builder): State, mut policy: Policy, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -239,7 +241,7 @@ pub(crate) async fn post( .await; let cookie_jar = cookie_jar.set_session(&session); - Ok((cookie_jar, next.go()).into_response()) + Ok((cookie_jar, url_builder.redirect(&next)).into_response()) } async fn render( @@ -282,12 +284,12 @@ mod tests { state }; - let request = Request::get(&*mas_router::Register::default().relative_url()).empty(); + let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); let response = state.request(request).await; response.assert_status(StatusCode::SEE_OTHER); response.assert_header_value(LOCATION, "/login"); - let request = Request::post(&*mas_router::Register::default().relative_url()).form( + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( serde_json::json!({ "csrf": "abc", "username": "john", diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index 385cba40..1aeaf3b6 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -13,7 +13,7 @@ // limitations under the License. use anyhow::Context; -use mas_router::{PostAuthAction, Route}; +use mas_router::{PostAuthAction, Route, UrlBuilder}; use mas_storage::{ compat::CompatSsoLoginRepository, oauth2::OAuth2AuthorizationGrantRepository, @@ -30,14 +30,19 @@ pub(crate) struct OptionalPostAuthAction { } impl OptionalPostAuthAction { - pub fn go_next_or_default(&self, default: &T) -> axum::response::Redirect { - self.post_auth_action - .as_ref() - .map_or_else(|| default.go(), mas_router::PostAuthAction::go_next) + pub fn go_next_or_default( + &self, + url_builder: &UrlBuilder, + default: &T, + ) -> axum::response::Redirect { + self.post_auth_action.as_ref().map_or_else( + || url_builder.redirect(default), + |action| action.go_next(url_builder), + ) } - pub fn go_next(&self) -> axum::response::Redirect { - self.go_next_or_default(&mas_router::Index) + pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect { + self.go_next_or_default(url_builder, &mas_router::Index) } pub async fn load_context<'a>( diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index d7f22549..c6dcde8b 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize}; use ulid::Ulid; pub use crate::traits::*; +use crate::UrlBuilder; #[derive(Deserialize, Serialize, Clone, Debug)] #[serde(rename_all = "snake_case", tag = "kind")] @@ -57,16 +58,19 @@ impl PostAuthAction { PostAuthAction::ManageAccount { action } } - pub fn go_next(&self) -> axum::response::Redirect { + pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect { match self { - Self::ContinueAuthorizationGrant { id } => ContinueAuthorizationGrant(*id).go(), - Self::ContinueCompatSsoLogin { id } => CompatLoginSsoComplete::new(*id, None).go(), - Self::ChangePassword => AccountPassword.go(), - Self::LinkUpstream { id } => UpstreamOAuth2Link::new(*id).go(), - Self::ManageAccount { action } => Account { - action: action.clone(), + Self::ContinueAuthorizationGrant { id } => { + url_builder.redirect(&ContinueAuthorizationGrant(*id)) } - .go(), + Self::ContinueCompatSsoLogin { id } => { + url_builder.redirect(&CompatLoginSsoComplete::new(*id, None)) + } + Self::ChangePassword => url_builder.redirect(&AccountPassword), + Self::LinkUpstream { id } => url_builder.redirect(&UpstreamOAuth2Link::new(*id)), + Self::ManageAccount { action } => url_builder.redirect(&Account { + action: action.clone(), + }), } } } @@ -219,10 +223,10 @@ impl Login { self.post_auth_action.as_ref() } - pub fn go_next(&self) -> axum::response::Redirect { + pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect { match &self.post_auth_action { - Some(action) => action.go_next(), - None => Index.go(), + Some(action) => action.go_next(url_builder), + None => url_builder.redirect(&Index), } } } @@ -268,10 +272,10 @@ impl Reauth { self.post_auth_action.as_ref() } - pub fn go_next(&self) -> axum::response::Redirect { + pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect { match &self.post_auth_action { - Some(action) => action.go_next(), - None => Index.go(), + Some(action) => action.go_next(url_builder), + None => url_builder.redirect(&Index), } } } @@ -328,10 +332,10 @@ impl Register { self.post_auth_action.as_ref() } - pub fn go_next(&self) -> axum::response::Redirect { + pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect { match &self.post_auth_action { - Some(action) => action.go_next(), - None => Index.go(), + Some(action) => action.go_next(url_builder), + None => url_builder.redirect(&Index), } } } diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index 4cfdd93c..c22f0f03 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -38,12 +38,12 @@ mod tests { #[test] fn test_relative_urls() { assert_eq!( - OidcConfiguration.relative_url(), + OidcConfiguration.path_and_query(), Cow::Borrowed("/.well-known/openid-configuration") ); - assert_eq!(Index.relative_url(), Cow::Borrowed("/")); + assert_eq!(Index.path_and_query(), Cow::Borrowed("/")); assert_eq!( - Login::and_continue_grant(Ulid::nil()).relative_url(), + Login::and_continue_grant(Ulid::nil()).path_and_query(), Cow::Borrowed("/login?kind=continue_authorization_grant&id=00000000000000000000000000") ); } diff --git a/crates/router/src/traits.rs b/crates/router/src/traits.rs index 033bc660..3c395477 100644 --- a/crates/router/src/traits.rs +++ b/crates/router/src/traits.rs @@ -28,7 +28,7 @@ pub trait Route { Cow::Borrowed(Self::route()) } - fn relative_url(&self) -> Cow<'static, str> { + fn path_and_query(&self) -> Cow<'static, str> { let path = self.path(); if let Some(query) = self.query() { let query = serde_urlencoded::to_string(query).unwrap(); @@ -39,17 +39,10 @@ pub trait Route { } fn absolute_url(&self, base: &Url) -> Url { - let relative = self.relative_url(); + let relative = self.path_and_query(); + let relative = relative.trim_start_matches('/'); base.join(relative.borrow()).unwrap() } - - fn go(&self) -> axum::response::Redirect { - axum::response::Redirect::to(&self.relative_url()) - } - - fn go_absolute(&self, base: &Url) -> axum::response::Redirect { - axum::response::Redirect::to(self.absolute_url(base).as_str()) - } } pub trait SimpleRoute { diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index 9e93e235..2883461e 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -14,8 +14,6 @@ //! Utility to build URLs -use std::borrow::Cow; - use ulid::Ulid; use url::Url; @@ -24,32 +22,91 @@ use crate::traits::Route; #[derive(Clone, Debug, PartialEq, Eq)] pub struct UrlBuilder { http_base: Url, - assets_base: Cow<'static, str>, + prefix: String, + assets_base: String, issuer: Url, } impl UrlBuilder { - fn url_for(&self, destination: &U) -> Url + fn absolute_url_for(&self, destination: &U) -> Url where U: Route, { destination.absolute_url(&self.http_base) } + /// Create a relative URL for a route, prefixed with the base URL + #[must_use] + pub fn relative_url_for(&self, destination: &U) -> String + where + U: Route, + { + format!( + "{prefix}{destination}", + prefix = self.prefix, + destination = destination.path_and_query() + ) + } + + /// The prefix added to all relative URLs + #[must_use] + pub fn prefix(&self) -> Option<&str> { + if self.prefix.is_empty() { + None + } else { + Some(&self.prefix) + } + } + + /// Create a (relative) redirect response to a route + pub fn redirect(&self, destination: &U) -> axum::response::Redirect + where + U: Route, + { + let uri = self.relative_url_for(destination); + axum::response::Redirect::to(&uri) + } + + /// Create an absolute redirect response to a route pub fn absolute_redirect(&self, destination: &U) -> axum::response::Redirect where U: Route, { - destination.go_absolute(&self.http_base) + let uri = self.absolute_url_for(destination); + axum::response::Redirect::to(uri.as_str()) } /// Create a new [`UrlBuilder`] from a base URL + /// + /// # Panics + /// + /// Panics if the base URL contains a fragment, a query, credentials or + /// isn't HTTP/HTTPS; #[must_use] pub fn new(base: Url, issuer: Option, assets_base: Option) -> Self { + assert!( + base.scheme() == "http" || base.scheme() == "https", + "base URL must be HTTP/HTTPS" + ); + assert_eq!(base.query(), None, "base URL must not contain a query"); + assert_eq!( + base.fragment(), + None, + "base URL must not contain a fragment" + ); + assert_eq!(base.username(), "", "base URL must not contain credentials"); + assert_eq!( + base.password(), + None, + "base URL must not contain credentials" + ); + let issuer = issuer.unwrap_or_else(|| base.clone()); - let assets_base = assets_base.map_or(Cow::Borrowed("/assets/"), Cow::Owned); + let prefix = base.path().trim_end_matches('/').to_owned(); + let assets_base = assets_base.unwrap_or_else(|| format!("{prefix}/assets/")); Self { http_base: base, + prefix, assets_base, issuer, } @@ -70,49 +127,49 @@ impl UrlBuilder { /// OAuth 2.0 authorization endpoint #[must_use] pub fn oauth_authorization_endpoint(&self) -> Url { - self.url_for(&crate::endpoints::OAuth2AuthorizationEndpoint) + self.absolute_url_for(&crate::endpoints::OAuth2AuthorizationEndpoint) } /// OAuth 2.0 token endpoint #[must_use] pub fn oauth_token_endpoint(&self) -> Url { - self.url_for(&crate::endpoints::OAuth2TokenEndpoint) + self.absolute_url_for(&crate::endpoints::OAuth2TokenEndpoint) } /// OAuth 2.0 introspection endpoint #[must_use] pub fn oauth_introspection_endpoint(&self) -> Url { - self.url_for(&crate::endpoints::OAuth2Introspection) + self.absolute_url_for(&crate::endpoints::OAuth2Introspection) } /// OAuth 2.0 revocation endpoint #[must_use] pub fn oauth_revocation_endpoint(&self) -> Url { - self.url_for(&crate::endpoints::OAuth2Revocation) + self.absolute_url_for(&crate::endpoints::OAuth2Revocation) } /// OAuth 2.0 client registration endpoint #[must_use] pub fn oauth_registration_endpoint(&self) -> Url { - self.url_for(&crate::endpoints::OAuth2RegistrationEndpoint) + self.absolute_url_for(&crate::endpoints::OAuth2RegistrationEndpoint) } // OIDC userinfo endpoint #[must_use] pub fn oidc_userinfo_endpoint(&self) -> Url { - self.url_for(&crate::endpoints::OidcUserinfo) + self.absolute_url_for(&crate::endpoints::OidcUserinfo) } /// JWKS URI #[must_use] pub fn jwks_uri(&self) -> Url { - self.url_for(&crate::endpoints::OAuth2Keys) + self.absolute_url_for(&crate::endpoints::OAuth2Keys) } /// Static asset #[must_use] pub fn static_asset(&self, path: String) -> Url { - self.url_for(&crate::endpoints::StaticAsset::new(path)) + self.absolute_url_for(&crate::endpoints::StaticAsset::new(path)) } /// Static asset base @@ -124,18 +181,83 @@ impl UrlBuilder { /// GraphQL endpoint #[must_use] pub fn graphql_endpoint(&self) -> Url { - self.url_for(&crate::endpoints::GraphQL) + self.absolute_url_for(&crate::endpoints::GraphQL) } /// Upstream redirect URI #[must_use] pub fn upstream_oauth_callback(&self, id: Ulid) -> Url { - self.url_for(&crate::endpoints::UpstreamOAuth2Callback::new(id)) + self.absolute_url_for(&crate::endpoints::UpstreamOAuth2Callback::new(id)) } /// Upstream authorize URI #[must_use] pub fn upstream_oauth_authorize(&self, id: Ulid) -> Url { - self.url_for(&crate::endpoints::UpstreamOAuth2Authorize::new(id)) + self.absolute_url_for(&crate::endpoints::UpstreamOAuth2Authorize::new(id)) + } +} + +#[cfg(test)] +mod tests { + #[test] + #[should_panic] + fn test_invalid_base_url_scheme() { + let _ = super::UrlBuilder::new(url::Url::parse("file:///tmp/").unwrap(), None, None); + } + + #[test] + #[should_panic] + fn test_invalid_base_url_query() { + let _ = super::UrlBuilder::new( + url::Url::parse("https://example.com/?foo=bar").unwrap(), + None, + None, + ); + } + + #[test] + #[should_panic] + fn test_invalid_base_url_fragment() { + let _ = super::UrlBuilder::new( + url::Url::parse("https://example.com/#foo").unwrap(), + None, + None, + ); + } + + #[test] + #[should_panic] + fn test_invalid_base_url_credentials() { + let _ = super::UrlBuilder::new( + url::Url::parse("https://foo@example.com/").unwrap(), + None, + None, + ); + } + + #[test] + fn test_url_prefix() { + let builder = super::UrlBuilder::new( + url::Url::parse("https://example.com/foo/").unwrap(), + None, + None, + ); + assert_eq!(builder.prefix, "/foo"); + + let builder = + super::UrlBuilder::new(url::Url::parse("https://example.com/").unwrap(), None, None); + assert_eq!(builder.prefix, ""); + } + + #[test] + fn test_absolute_uri_prefix() { + let builder = super::UrlBuilder::new( + url::Url::parse("https://example.com/foo/").unwrap(), + None, + None, + ); + + let uri = builder.absolute_url_for(&crate::endpoints::OAuth2AuthorizationEndpoint); + assert_eq!(uri.as_str(), "https://example.com/foo/authorize"); } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 7a74b937..54021f9c 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -23,7 +23,7 @@ use mas_data_model::{ UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail, UserEmailVerification, }; use mas_i18n::DataLocale; -use mas_router::{PostAuthAction, Route}; +use mas_router::{Account, GraphQL, PostAuthAction, Route, UrlBuilder}; use rand::Rng; use serde::{ser::SerializeStruct, Deserialize, Serialize}; use ulid::Ulid; @@ -276,30 +276,29 @@ impl TemplateContext for IndexContext { /// Config used by the frontend app #[derive(Serialize)] +#[serde(rename_all = "camelCase")] pub struct AppConfig { root: String, -} - -impl Default for AppConfig { - fn default() -> Self { - Self { - root: "/account/".into(), - } - } + graphql_endpoint: String, } /// Context used by the `app.html` template -#[derive(Serialize, Default)] +#[derive(Serialize)] pub struct AppContext { app_config: AppConfig, } impl AppContext { - /// Constructs the context for the app page with the given app root + /// Constructs the context given the [`UrlBuilder`] #[must_use] - pub fn with_app_root(root: String) -> Self { + pub fn from_url_builder(url_builder: &UrlBuilder) -> Self { + let root = url_builder.relative_url_for(&Account::default()); + let graphql_endpoint = url_builder.relative_url_for(&GraphQL); Self { - app_config: AppConfig { root }, + app_config: AppConfig { + root, + graphql_endpoint, + }, } } } @@ -309,7 +308,8 @@ impl TemplateContext for AppContext { where Self: Sized, { - vec![Self::default()] + let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); + vec![Self::from_url_builder(&url_builder)] } } @@ -935,7 +935,7 @@ impl UpstreamRegister { fn for_link_id(id: Ulid) -> Self { let login_link = mas_router::Login::and_link_upstream(id) - .relative_url() + .path_and_query() .into(); Self { diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index 1fd91d4a..6a5285c7 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -52,7 +52,7 @@ pub fn register( env.add_global( "include_asset", Value::from_object(IncludeAsset { - url_builder, + url_builder: url_builder.clone(), vite_manifest, }), ); @@ -60,6 +60,19 @@ pub fn register( "translator", Value::from_object(TranslatorFunc { translator }), ); + env.add_filter("prefix_url", move |url: &str| -> String { + if !url.starts_with('/') { + // Let's assume it's not an internal URL and return it as-is + return url.to_owned(); + } + + let Some(prefix) = url_builder.prefix() else { + // If there is no prefix to add, return the URL as-is + return url.to_owned(); + }; + + format!("{prefix}{url}") + }); } fn tester_empty(seq: &dyn SeqObject) -> bool { diff --git a/docs/config.schema.json b/docs/config.schema.json index 4ca08ee5..68a29a50 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1278,6 +1278,10 @@ "description": "A unique name for this listener which will be shown in traces and in metrics labels", "type": "string" }, + "prefix": { + "description": "HTTP prefix to mount the resources on", + "type": "string" + }, "proxy_protocol": { "description": "Accept HAProxy's Proxy Protocol V1", "default": false, diff --git a/frontend/index.html b/frontend/index.html index f8ab2e9e..ba693ada 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,7 +23,7 @@ limitations under the License. matrix-authentication-service