Rename redirect_to query parameter to redirect_uri in accordance with RFC 6749.

Reference: https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.6
This commit is contained in:
Sebastian Jeltsch
2025-08-05 10:16:21 +02:00
parent 65a6ed9045
commit 598f15cc06
24 changed files with 145 additions and 151 deletions

View File

@@ -26,6 +26,9 @@ check:
docker:
docker buildx build --platform linux/arm64,linux/amd64 --output=type=registry -t trailbase/trailbase:latest .
openapi:
cargo run -- openapi print > docs/openapi/schema.json
cloc:
cloc --not-match-d=".*(/target|/dist|/node_modules|/vendor|.astro|.build|.venv|/traildepot|/flutter|/assets|lock|_benchmark|/bin|/obj).*" .
@@ -42,4 +45,4 @@ publish_crates:
-p trailbase-js \
-p trailbase
.PHONY: default format check static docker cloc publish_crates
.PHONY: default format check static docker openapi cloc publish_crates

View File

@@ -82,7 +82,7 @@
"operationId": "change_password_handler",
"parameters": [
{
"name": "redirect_to",
"name": "redirect_uri",
"in": "query",
"required": false,
"schema": {
@@ -126,7 +126,23 @@
"operationId": "login_handler",
"parameters": [
{
"name": "redirect_to",
"name": "redirect_uri",
"in": "query",
"required": false,
"schema": {
"type": ["string", "null"]
}
},
{
"name": "response_type",
"in": "query",
"required": false,
"schema": {
"type": ["string", "null"]
}
},
{
"name": "pkce_code_challenge",
"in": "query",
"required": false,
"schema": {
@@ -166,7 +182,7 @@
"operationId": "logout_handler",
"parameters": [
{
"name": "redirect_to",
"name": "redirect_uri",
"in": "query",
"required": false,
"schema": {
@@ -266,7 +282,7 @@
"operationId": "login_with_external_auth_provider",
"parameters": [
{
"name": "redirect_to",
"name": "redirect_uri",
"in": "query",
"required": false,
"schema": {
@@ -511,7 +527,7 @@
"operationId": "create_record_handler",
"parameters": [
{
"name": "redirect_to",
"name": "redirect_uri",
"in": "query",
"description": "Redirect user to this address upon successful record creation.\nThis only exists to support insertions via static HTML form actions.\n\nWe may want to have a different on-error redirect to better support the static HTML use-case.",
"required": false,
@@ -826,7 +842,7 @@
"pkce_code_challenge": {
"type": ["string", "null"]
},
"redirect_to": {
"redirect_uri": {
"type": ["string", "null"]
},
"response_type": {

View File

@@ -80,7 +80,7 @@ off-site sign-in with an external provider, users are redirected back,
1. first to your TrailBase instance at
`<YOUR_SITE>/api/auth/v1/oauth/<PROVIDER_NAME>/callback?code=<AUTH_CODE>`
2. and subsequently to a URI registered and provided by your app via
`?redirect_to=my-app://callback`.
`?redirect_uri=my-app://callback`.
This allows TrailBase to observe the external provider's auth code and issue
one to your app as well.
@@ -107,7 +107,7 @@ pass:
- `response_type=code`,
- `pkce_code_challenge=<urlSafeBase64(sha256(pkce_code_verifier)))>`,
- `redirect_to=<TARGET>`, e.g. `custom-app-scheme://callback`,
- `redirect_uri=<TARGET>`, e.g. `custom-app-scheme://callback`,
as inputs to `/api/auth/v1/login` or `/api/auth/v1/oauth/<PROVIDER_NAME>/login`.
Note that native apps will need to register the custom scheme first to receive

View File

@@ -75,7 +75,7 @@ class _LoginFormState extends State<LoginFormWidget> {
// Construct the login page url
final url = Uri.parse('${widget.client.site()}/_/auth/login')
.replace(queryParameters: {
'redirect_to': redirectUri,
'redirect_uri': redirectUri,
'response_type': 'code',
'pkce_code_challenge': challenge,
});

View File

@@ -47,7 +47,7 @@ export function AuthButton() {
return (
<Switch>
<Match when={!user()}>
<a href={`${HOST}/_/auth/login?redirect_to=${redirect}`}>Log in</a>
<a href={`${HOST}/_/auth/login?redirect_uri=${redirect}`}>Log in</a>
</Match>
<Match when={user()}>

View File

@@ -14,7 +14,7 @@ const redirect = import.meta.env.DEV ? "http://localhost:4321/" : "/";
<form
method="post"
action={`${RECORD_API}/articles?redirect_to=${redirect}`}
action={`${RECORD_API}/articles?redirect_uri=${redirect}`}
enctype="multipart/form-data"
target="_self"
>

View File

@@ -89,29 +89,3 @@ const BASE_URL = import.meta.env.BASE_URL;
{"{% endif %}"}
</div>
</Form>
{
// For DEV we need to fix up redirects to point back to dev server's auth APIs.
import.meta.env.DEV && (
<script is:inline>
const urlParams = new URLSearchParams(window.location.search);
// In source order
const form = document.forms[0];
const baseAction = form.dataset.base_action;
const baseUrl = form.dataset.base_url;
// NOTE: Only setting redirect_to should be fine since other params such
// as response_type are sent via the hidden form state. This is different from
// OAuth APIs, which expect oauth_query_params.
const redirect = urlParams.get("redirect_to");
if (redirect) {
form.action = `${baseAction}?redirect_to=${redirect}`;
} else {
const devRedirect = `http://${window.location.host}${baseUrl}/profile`;
form.action = `${baseAction}?redirect_to=${devRedirect}`;
}
console.debug(`Updated form action to ${form.action}, based on redirect: ${redirect}`);
</script>
)
}

View File

@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LoginRequest = { email: string, password: string, redirect_to: string | null, response_type: string | null, pkce_code_challenge: string | null, };
export type LoginRequest = { email: string, password: string, redirect_uri: string | null, response_type: string | null, pkce_code_challenge: string | null, };

View File

@@ -80,11 +80,11 @@ where
return "".to_string();
}
pub fn redirect_to<T>(redirect_to: Option<T>) -> String
pub fn redirect_uri<T>(redirect_uri: Option<T>) -> String
where
T: AsRef<str>,
{
return hidden_input("redirect_to", redirect_to);
return hidden_input("redirect_uri", redirect_uri);
}
#[cfg(test)]
@@ -95,20 +95,20 @@ mod tests {
fn test_login_template_escaping() {
let state = hidden_input("TEST", Some("FOO"));
let alert = "<><>";
let redirect_to = "http://localhost:42";
let redirect_uri = "http://localhost:42";
let template = LoginTemplate {
state: state.clone(),
alert,
enable_registration: true,
oauth_providers: &[],
oauth_query_params: &[("redirect_to", redirect_to)],
oauth_query_params: &[("redirect_uri", redirect_uri)],
}
.render()
.unwrap();
assert!(template.contains(&state), "{template}"); // Not escaped.
assert!(!template.contains(&redirect_to), "{template}"); // Not escaped.
assert!(!template.contains(&redirect_uri), "{template}"); // Not escaped.
// Missing because no oauth provider given.
assert!(!template.contains("foo=bar"), "{template}"); // Not escaped.
assert!(!template.contains(alert), "{template}"); // Is escaped.
@@ -122,13 +122,13 @@ mod tests {
display_name: "Fancy Name".to_string(),
img_name: "oidc".to_string(),
}],
oauth_query_params: &[("redirect_to", redirect_to), ("foo", "bar")],
oauth_query_params: &[("redirect_uri", redirect_uri), ("foo", "bar")],
}
.render()
.unwrap();
assert!(oauth_template.contains(&state), "{template}"); // Not escaped.
assert!(oauth_template.contains(&redirect_to), "{template}"); // Not escaped.
assert!(oauth_template.contains(&redirect_uri), "{template}"); // Not escaped.
assert!(oauth_template.contains("foo=bar"), "{template}"); // Not escaped.
}

View File

@@ -51,6 +51,7 @@ pub enum SubCommands {
/// Export JSON Schema definitions.
Schema(JsonSchemaArgs),
/// Export OpenAPI definitions.
#[command(name = "openapi")]
OpenApi {
#[command(subcommand)]
cmd: Option<OpenApiSubCommands>,

View File

@@ -134,7 +134,7 @@ pub async fn change_email_request_handler(
#[derive(Debug, Default, Deserialize, IntoParams)]
pub(crate) struct ChangeEmailConfigQuery {
pub redirect_to: Option<String>,
pub redirect_uri: Option<String>,
}
/// Confirm a change of email address.
@@ -149,10 +149,10 @@ pub(crate) struct ChangeEmailConfigQuery {
pub async fn change_email_confirm_handler(
State(state): State<AppState>,
Path(email_verification_code): Path<String>,
Query(ChangeEmailConfigQuery { redirect_to }): Query<ChangeEmailConfigQuery>,
Query(ChangeEmailConfigQuery { redirect_uri }): Query<ChangeEmailConfigQuery>,
user: User,
) -> Result<Redirect, AuthError> {
validate_redirect(&state, redirect_to.as_deref())?;
validate_redirect(&state, redirect_uri.as_deref())?;
if email_verification_code.len() != VERIFICATION_CODE_LENGTH {
return Err(AuthError::BadRequest("Invalid code"));
@@ -200,7 +200,7 @@ pub async fn change_email_confirm_handler(
return match rows_affected {
0 => Err(AuthError::BadRequest("Invalid verification code")),
1 => Ok(Redirect::to(redirect_to.as_deref().unwrap_or(PROFILE_UI))),
1 => Ok(Redirect::to(redirect_uri.as_deref().unwrap_or(PROFILE_UI))),
_ => panic!("emails updated for multiple users at once: {rows_affected}"),
};
}

View File

@@ -18,7 +18,7 @@ use crate::{app_state::AppState, auth::util::user_by_id};
#[derive(Debug, Default, Deserialize, IntoParams)]
pub(crate) struct ChangePasswordQuery {
pub redirect_to: Option<String>,
pub redirect_uri: Option<String>,
}
#[derive(Debug, Default, Deserialize, TS, ToSchema)]
@@ -42,11 +42,11 @@ pub struct ChangePasswordRequest {
)]
pub async fn change_password_handler(
State(state): State<AppState>,
Query(ChangePasswordQuery { redirect_to }): Query<ChangePasswordQuery>,
Query(ChangePasswordQuery { redirect_uri }): Query<ChangePasswordQuery>,
user: User,
either_request: Either<ChangePasswordRequest>,
) -> Result<Redirect, AuthError> {
validate_redirect(&state, redirect_to.as_deref())?;
validate_redirect(&state, redirect_uri.as_deref())?;
let request = match either_request {
Either::Json(req) => req,
@@ -98,7 +98,7 @@ pub async fn change_password_handler(
return match rows_affected {
0 => Err(AuthError::BadRequest("Invalid old password")),
1 => Ok(Redirect::to(redirect_to.as_deref().unwrap_or(PROFILE_UI))),
1 => Ok(Redirect::to(redirect_uri.as_deref().unwrap_or(PROFILE_UI))),
_ => panic!("password changed for multiple users at once: {rows_affected}"),
};
}

View File

@@ -33,7 +33,7 @@ pub struct LoginRequest {
pub email: String,
pub password: String,
pub redirect_to: Option<String>,
pub redirect_uri: Option<String>,
pub response_type: Option<String>,
pub pkce_code_challenge: Option<String>,
}
@@ -67,7 +67,7 @@ pub(crate) async fn login_handler(
LoginRequest {
email,
password,
redirect_to,
redirect_uri,
response_type,
pkce_code_challenge,
},
@@ -81,16 +81,16 @@ pub(crate) async fn login_handler(
return match build_and_validate_input_params(
&state,
query_login_input.merge(LoginInputParams {
redirect_to,
redirect_uri,
response_type,
pkce_code_challenge,
}),
)? {
LoginParams::Password { redirect_to } => {
immediate_login(&state, &cookies, email, password, redirect_to, is_json).await
LoginParams::Password { redirect_uri } => {
immediate_login(&state, &cookies, email, password, redirect_uri, is_json).await
}
LoginParams::AuthorizationCodeFlowWithPkce {
redirect_to,
redirect_uri,
pkce_code_challenge,
} => {
login_with_authorization_code_flow_and_pkce(
@@ -98,7 +98,7 @@ pub(crate) async fn login_handler(
&cookies,
email,
password,
redirect_to,
redirect_uri,
pkce_code_challenge,
)
.await
@@ -250,7 +250,7 @@ fn auth_error_to_response(err: AuthError, cookies: &Cookies, redirect: Option<St
// error case?
let msg = urlencode(&format!("Login Failed: {status}"));
return if let Some(redirect) = redirect {
Redirect::to(&format!("{LOGIN_UI}?alert={msg}&redirect_to={redirect}")).into_response()
Redirect::to(&format!("{LOGIN_UI}?alert={msg}&redirect_uri={redirect}")).into_response()
} else {
Redirect::to(&format!("{LOGIN_UI}?alert={msg}")).into_response()
};

View File

@@ -18,7 +18,7 @@ use crate::auth::util::{
#[derive(Debug, Default, Deserialize, IntoParams)]
pub struct LogoutQuery {
redirect_to: Option<String>,
redirect_uri: Option<String>,
}
/// Logs out the current user and delete **all** pending sessions for that user.
@@ -36,11 +36,11 @@ pub struct LogoutQuery {
)]
pub async fn logout_handler(
State(state): State<AppState>,
Query(LogoutQuery { redirect_to }): Query<LogoutQuery>,
Query(LogoutQuery { redirect_uri }): Query<LogoutQuery>,
user: Option<User>,
cookies: Cookies,
) -> Result<Redirect, AuthError> {
validate_redirect(&state, redirect_to.as_deref())?;
validate_redirect(&state, redirect_uri.as_deref())?;
remove_all_cookies(&cookies);
@@ -48,7 +48,7 @@ pub async fn logout_handler(
delete_all_sessions_for_user(state.user_conn(), user.uuid).await?;
}
return Ok(Redirect::to(redirect_to.as_deref().unwrap_or_else(|| {
return Ok(Redirect::to(redirect_uri.as_deref().unwrap_or_else(|| {
if state.public_dir().is_some() {
"/"
} else {

View File

@@ -94,7 +94,7 @@ pub async fn request_email_verification_handler(
#[derive(Debug, Default, Deserialize, IntoParams)]
pub(crate) struct VerifyEmailQuery {
pub redirect_to: Option<String>,
pub redirect_uri: Option<String>,
}
/// Request a new email to verify email address.
@@ -109,9 +109,9 @@ pub(crate) struct VerifyEmailQuery {
pub async fn verify_email_handler(
State(state): State<AppState>,
Path(email_verification_code): Path<String>,
Query(VerifyEmailQuery { redirect_to }): Query<VerifyEmailQuery>,
Query(VerifyEmailQuery { redirect_uri }): Query<VerifyEmailQuery>,
) -> Result<Redirect, AuthError> {
validate_redirect(&state, redirect_to.as_deref())?;
validate_redirect(&state, redirect_uri.as_deref())?;
lazy_static! {
static ref UPDATE_CODE_QUERY: String = format!(
@@ -134,7 +134,7 @@ pub async fn verify_email_handler(
return match rows_affected {
0 => Err(AuthError::BadRequest("Invalid verification code")),
1 => Ok(Redirect::to(redirect_to.as_deref().unwrap_or(PROFILE_UI))),
1 => Ok(Redirect::to(redirect_uri.as_deref().unwrap_or(PROFILE_UI))),
_ => panic!("email verification affected multiple users: {rows_affected}"),
};
}

View File

@@ -178,7 +178,7 @@ async fn test_auth_password_login_flow_with_pkce() {
};
// Test login using the PKCE flow (?response_type="code").
let redirect_to = "test-scheme://foo".to_string();
let redirect_uri = "test-scheme://foo".to_string();
let (pkce_code_challenge, pkce_code_verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
// Missing code challenge.
@@ -187,7 +187,7 @@ async fn test_auth_password_login_flow_with_pkce() {
email: email.clone(),
password: password.clone(),
response_type: Some("code".to_string()),
redirect_to: Some(redirect_to.clone()),
redirect_uri: Some(redirect_uri.clone()),
pkce_code_challenge: None,
..Default::default()
}))
@@ -201,7 +201,7 @@ async fn test_auth_password_login_flow_with_pkce() {
email: email.clone(),
password: password.clone(),
response_type: Some("code".to_string()),
redirect_to: None,
redirect_uri: None,
pkce_code_challenge: Some(pkce_code_challenge.as_str().to_string()),
..Default::default()
}))
@@ -215,7 +215,7 @@ async fn test_auth_password_login_flow_with_pkce() {
email: email.clone(),
password: "WRONG PASSWORD".to_string(),
response_type: Some("code".to_string()),
redirect_to: Some(redirect_to.clone()),
redirect_uri: Some(redirect_uri.clone()),
pkce_code_challenge: Some(pkce_code_challenge.as_str().to_string()),
..Default::default()
}))
@@ -228,7 +228,7 @@ async fn test_auth_password_login_flow_with_pkce() {
email: email.clone(),
password: password.clone(),
response_type: Some("code".to_string()),
redirect_to: Some(redirect_to.clone()),
redirect_uri: Some(redirect_uri.clone()),
pkce_code_challenge: Some(pkce_code_challenge.as_str().to_string()),
..Default::default()
}))
@@ -586,7 +586,7 @@ async fn test_auth_change_email_flow() {
let _ = change_email::change_email_confirm_handler(
State(state.clone()),
Path(email_verification_code.clone()),
Query(ChangeEmailConfigQuery { redirect_to: None }),
Query(ChangeEmailConfigQuery { redirect_uri: None }),
user.clone(),
)
.await

View File

@@ -8,15 +8,15 @@ use crate::auth::util::validate_redirect;
#[derive(Clone, Debug, Default, Deserialize, Serialize, IntoParams, PartialEq)]
pub(crate) struct LoginInputParams {
pub redirect_to: Option<String>,
pub redirect_uri: Option<String>,
pub response_type: Option<String>,
pub pkce_code_challenge: Option<String>,
}
impl LoginInputParams {
pub(crate) fn merge(mut self, other: LoginInputParams) -> LoginInputParams {
if let Some(redirect_to) = other.redirect_to {
self.redirect_to.get_or_insert(redirect_to);
if let Some(redirect_uri) = other.redirect_uri {
self.redirect_uri.get_or_insert(redirect_uri);
}
if let Some(response_type) = other.response_type {
self.response_type.get_or_insert(response_type);
@@ -31,10 +31,10 @@ impl LoginInputParams {
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum LoginParams {
Password {
redirect_to: Option<String>,
redirect_uri: Option<String>,
},
AuthorizationCodeFlowWithPkce {
redirect_to: String,
redirect_uri: String,
pkce_code_challenge: String,
},
}
@@ -45,11 +45,11 @@ pub(crate) fn build_and_validate_input_params(
) -> Result<LoginParams, AuthError> {
return match params.response_type.as_deref() {
Some("code") => {
let Some(redirect_to) = params.redirect_to else {
return Err(AuthError::BadRequest("missing 'redirect_to'"));
let Some(redirect_uri) = params.redirect_uri else {
return Err(AuthError::BadRequest("missing 'redirect_uri'"));
};
validate_redirect(state, Some(&redirect_to))?;
validate_redirect(state, Some(&redirect_uri))?;
let Some(pkce_code_challenge) = params.pkce_code_challenge else {
return Err(AuthError::BadRequest("missing 'pkce_code_challenge'"));
@@ -61,7 +61,7 @@ pub(crate) fn build_and_validate_input_params(
.map_err(|_| AuthError::BadRequest("invalid 'pkce_code_challenge'"))?;
Ok(LoginParams::AuthorizationCodeFlowWithPkce {
redirect_to,
redirect_uri,
pkce_code_challenge,
})
}
@@ -73,10 +73,10 @@ pub(crate) fn build_and_validate_input_params(
));
}
validate_redirect(state, params.redirect_to.as_deref())?;
validate_redirect(state, params.redirect_uri.as_deref())?;
Ok(LoginParams::Password {
redirect_to: params.redirect_to,
redirect_uri: params.redirect_uri,
})
}
};
@@ -93,13 +93,13 @@ mod tests {
assert_eq!(
LoginParams::AuthorizationCodeFlowWithPkce {
redirect_to: "/redirect".to_string(),
redirect_uri: "/redirect".to_string(),
pkce_code_challenge: BASE64_URL_SAFE.encode("challenge"),
},
build_and_validate_input_params(
&state,
LoginInputParams {
redirect_to: Some("/redirect".to_string()),
redirect_uri: Some("/redirect".to_string()),
response_type: Some("code".to_string()),
pkce_code_challenge: Some(BASE64_URL_SAFE.encode("challenge")),
},
@@ -109,12 +109,12 @@ mod tests {
assert_eq!(
LoginParams::Password {
redirect_to: Some("/redirect".to_string()),
redirect_uri: Some("/redirect".to_string()),
},
build_and_validate_input_params(
&state,
LoginInputParams {
redirect_to: Some("/redirect".to_string()),
redirect_uri: Some("/redirect".to_string()),
response_type: None,
pkce_code_challenge: None,
},
@@ -126,7 +126,7 @@ mod tests {
build_and_validate_input_params(
&state,
LoginInputParams {
redirect_to: Some("invalid".to_string()),
redirect_uri: Some("invalid".to_string()),
response_type: None,
pkce_code_challenge: None,
},

View File

@@ -59,7 +59,7 @@ pub(crate) async fn callback_from_external_auth_provider(
pkce_code_verifier,
user_pkce_code_challenge,
response_type,
redirect_to,
redirect_uri,
..
} = state
.jwt()
@@ -80,7 +80,7 @@ pub(crate) async fn callback_from_external_auth_provider(
}
// NOTE: This was already validated in the login-handler, we're just pedantic.
validate_redirect(&state, redirect_to.as_deref())?;
validate_redirect(&state, redirect_uri.as_deref())?;
return match response_type {
Some(ResponseType::Code) => {
@@ -88,7 +88,7 @@ pub(crate) async fn callback_from_external_auth_provider(
&state,
&cookies,
provider,
redirect_to,
redirect_uri,
query.code,
pkce_code_verifier,
user_pkce_code_challenge,
@@ -100,7 +100,7 @@ pub(crate) async fn callback_from_external_auth_provider(
&state,
&cookies,
provider,
redirect_to,
redirect_uri,
query.code,
pkce_code_verifier,
)

View File

@@ -53,17 +53,17 @@ pub(crate) async fn login_with_external_auth_provider(
.url();
let oauth_state = match login_params {
LoginParams::Password { redirect_to } => OAuthState {
LoginParams::Password { redirect_uri } => OAuthState {
// Set short-lived CSRF and PkceCodeVerifier cookies for the callback.
exp: (chrono::Utc::now() + Duration::seconds(5 * 60)).timestamp(),
csrf_secret: csrf_state.secret().to_string(),
pkce_code_verifier: server_pkce_code_verifier.secret().to_string(),
redirect_to,
redirect_uri,
response_type: None,
user_pkce_code_challenge: None,
},
LoginParams::AuthorizationCodeFlowWithPkce {
redirect_to,
redirect_uri,
pkce_code_challenge,
} => OAuthState {
// Set short-lived CSRF and PkceCodeVerifier cookies for the callback.
@@ -72,7 +72,7 @@ pub(crate) async fn login_with_external_auth_provider(
pkce_code_verifier: server_pkce_code_verifier.secret().to_string(),
user_pkce_code_challenge: Some(pkce_code_challenge),
response_type: Some(ResponseType::Code),
redirect_to: Some(redirect_to),
redirect_uri: Some(redirect_uri),
},
};

View File

@@ -153,12 +153,12 @@ async fn test_oauth_login_flow_without_pkce() {
// Call TB's OAuth login handler, which will produce a redirect for users to get the external
// auth provider's login form.
let cookies = Cookies::default();
let redirect_to = format!("{site_url}/login-success-welcome");
let redirect_uri = format!("{site_url}/login-success-welcome");
let external_redirect: Redirect = login::login_with_external_auth_provider(
State(state.clone()),
Path(TestOAuthProvider::NAME.to_string()),
Query(LoginInputParams {
redirect_to: Some(redirect_to.to_string()),
redirect_uri: Some(redirect_uri.to_string()),
response_type: None,
pkce_code_challenge: None,
}),
@@ -174,14 +174,14 @@ async fn test_oauth_login_flow_without_pkce() {
.unwrap();
// Call the fake server's auth endpoint.
let redirect_to_external_login = get_redirect_location(external_redirect).unwrap();
let redirect_uri_external_login = get_redirect_location(external_redirect).unwrap();
// NOTE: The dummy implementation just pipes the input query params through. We could do the
// following assertions equally on `redirect_to_external_login`
let redirect_to_external_login_url = url::Url::parse(&redirect_to_external_login).unwrap();
// following assertions equally on `redirect_uri_external_login`
let redirect_uri_external_login_url = url::Url::parse(&redirect_uri_external_login).unwrap();
let query_params: HashMap<Cow<'_, str>, Cow<'_, str>> =
redirect_to_external_login_url.query_pairs().collect();
redirect_uri_external_login_url.query_pairs().collect();
let auth_query: AuthQuery = reqwest::get(&redirect_to_external_login)
let auth_query: AuthQuery = reqwest::get(&redirect_uri_external_login)
.await
.unwrap()
.json()
@@ -219,7 +219,7 @@ async fn test_oauth_login_flow_without_pkce() {
.unwrap();
let location = get_redirect_location(internal_redirect).unwrap();
assert_eq!(location, redirect_to);
assert_eq!(location, redirect_uri);
// Check user exists.
let db_user = state
@@ -256,13 +256,13 @@ async fn test_oauth_login_flow_with_pkce() {
// Call TB's OAuth login handler, which will produce a redirect for users to get the external
// auth provider's login form.
let cookies = Cookies::default();
let redirect_to = format!("{site_url}/login-success-welcome");
let redirect_uri = format!("{site_url}/login-success-welcome");
let (pkce_code_challenge, pkce_code_verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
let external_redirect: Redirect = login::login_with_external_auth_provider(
State(state.clone()),
Path(TestOAuthProvider::NAME.to_string()),
Query(LoginInputParams {
redirect_to: Some(redirect_to.to_string()),
redirect_uri: Some(redirect_uri.to_string()),
response_type: Some("code".to_string()),
pkce_code_challenge: Some(pkce_code_challenge.as_str().to_string()),
}),
@@ -278,14 +278,14 @@ async fn test_oauth_login_flow_with_pkce() {
.unwrap();
// Call the fake server's auth endpoint.
let redirect_to_external_login = get_redirect_location(external_redirect).unwrap();
let redirect_uri_external_login = get_redirect_location(external_redirect).unwrap();
// NOTE: The dummy implementation just pipes the input query params through. We could do the
// following assertions equally on `redirect_to_external_login`
let redirect_to_external_login_url = url::Url::parse(&redirect_to_external_login).unwrap();
// following assertions equally on `redirect_uri_external_login`
let redirect_uri_external_login_url = url::Url::parse(&redirect_uri_external_login).unwrap();
let query_params: HashMap<Cow<'_, str>, Cow<'_, str>> =
redirect_to_external_login_url.query_pairs().collect();
redirect_uri_external_login_url.query_pairs().collect();
let auth_query: AuthQuery = reqwest::get(&redirect_to_external_login)
let auth_query: AuthQuery = reqwest::get(&redirect_uri_external_login)
.await
.unwrap()
.json()
@@ -324,7 +324,7 @@ async fn test_oauth_login_flow_with_pkce() {
let location_str = get_redirect_location(internal_redirect).unwrap();
let location = url::Url::parse(&location_str).unwrap();
assert!(location_str.starts_with(&format!("{redirect_to}?code=")));
assert!(location_str.starts_with(&format!("{redirect_uri}?code=")));
let auth_code_re = Regex::new(r"^code=(.*)$").unwrap();
let captures = auth_code_re.captures(&location.query().unwrap()).unwrap();

View File

@@ -42,7 +42,7 @@ pub(crate) struct OAuthState {
pub response_type: Option<ResponseType>,
/// Redirect target.
pub redirect_to: Option<String>,
pub redirect_uri: Option<String>,
}
#[cfg(test)]
@@ -60,7 +60,7 @@ mod tests {
pkce_code_verifier: "server verifier".to_string(),
user_pkce_code_challenge: Some("client challenge".to_string()),
response_type: Some(ResponseType::Code),
redirect_to: Some("custom-sheme://test".to_string()),
redirect_uri: Some("custom-sheme://test".to_string()),
};
let encoded = state.jwt().encode(&oauth_state).unwrap();

View File

@@ -8,7 +8,7 @@ use serde::Deserialize;
use trailbase_assets::AssetService;
use trailbase_assets::auth::{
ChangeEmailTemplate, ChangePasswordTemplate, LoginTemplate, OAuthProvider, RegisterTemplate,
ResetPasswordRequestTemplate, ResetPasswordUpdateTemplate, hidden_input, redirect_to,
ResetPasswordRequestTemplate, ResetPasswordUpdateTemplate, hidden_input, redirect_uri,
};
use crate::AppState;
@@ -20,7 +20,7 @@ pub(crate) const PROFILE_UI: &str = "/_/auth/profile";
#[derive(Debug, Default, Deserialize)]
pub struct LoginQuery {
redirect_to: Option<String>,
redirect_uri: Option<String>,
response_type: Option<String>,
pkce_code_challenge: Option<String>,
alert: Option<String>,
@@ -31,7 +31,7 @@ async fn ui_login_handler(
Query(query): Query<LoginQuery>,
user: Option<User>,
) -> Response {
if query.redirect_to.is_none() && user.is_some() {
if query.redirect_uri.is_none() && user.is_some() {
// Already logged in. Only redirect to profile-page if no explicit other redirect is provided.
// For example, if we're already logged in the browser but want to sign-in with the browser
// from an app, we still have to go through the motions of signing in.
@@ -58,20 +58,20 @@ async fn ui_login_handler(
let form_state = indoc::formatdoc!(
r#"
{redirect_to}
{redirect_uri}
{response_type}
{pkce_code_challenge}
"#,
redirect_to = hidden_input("redirect_to", query.redirect_to.as_ref()),
redirect_uri = hidden_input("redirect_uri", query.redirect_uri.as_ref()),
response_type = hidden_input("response_type", query.response_type.as_ref()),
pkce_code_challenge = hidden_input("pkce_code_challenge", query.pkce_code_challenge.as_ref()),
);
let oauth_query_params: Vec<(&str, &str)> = [
query
.redirect_to
.redirect_uri
.as_ref()
.map(|r| ("redirect_to", r.as_str())),
.map(|r| ("redirect_uri", r.as_str())),
query
.response_type
.as_ref()
@@ -106,13 +106,13 @@ async fn ui_login_handler(
#[derive(Debug, Default, Deserialize)]
pub struct RegisterQuery {
redirect_to: Option<String>,
redirect_uri: Option<String>,
alert: Option<String>,
}
async fn ui_register_handler(Query(query): Query<RegisterQuery>) -> Response {
let html = RegisterTemplate {
state: redirect_to(query.redirect_to.as_ref()),
state: redirect_uri(query.redirect_uri.as_ref()),
alert: query.alert.as_deref().unwrap_or_default(),
}
.render();
@@ -129,7 +129,7 @@ async fn ui_register_handler(Query(query): Query<RegisterQuery>) -> Response {
#[derive(Debug, Default, Deserialize)]
pub struct ResetPasswordRequestQuery {
redirect_to: Option<String>,
redirect_uri: Option<String>,
alert: Option<String>,
}
@@ -137,7 +137,7 @@ async fn ui_reset_password_request_handler(
Query(query): Query<ResetPasswordRequestQuery>,
) -> Response {
let html = ResetPasswordRequestTemplate {
state: redirect_to(query.redirect_to.as_ref()),
state: redirect_uri(query.redirect_uri.as_ref()),
alert: query.alert.as_deref().unwrap_or_default(),
}
.render();
@@ -154,7 +154,7 @@ async fn ui_reset_password_request_handler(
#[derive(Debug, Default, Deserialize)]
pub struct ResetPasswordUpdateQuery {
redirect_to: Option<String>,
redirect_uri: Option<String>,
alert: Option<String>,
}
@@ -162,7 +162,7 @@ async fn ui_reset_password_update_handler(
Query(query): Query<ResetPasswordUpdateQuery>,
) -> Response {
let html = ResetPasswordUpdateTemplate {
state: redirect_to(query.redirect_to.as_ref()),
state: redirect_uri(query.redirect_uri.as_ref()),
alert: query.alert.as_deref().unwrap_or_default(),
}
.render();
@@ -179,13 +179,13 @@ async fn ui_reset_password_update_handler(
#[derive(Debug, Default, Deserialize)]
pub struct ChangePasswordQuery {
redirect_to: Option<String>,
redirect_uri: Option<String>,
alert: Option<String>,
}
async fn ui_change_password_handler(Query(query): Query<ChangePasswordQuery>) -> Response {
let html = ChangePasswordTemplate {
state: redirect_to(query.redirect_to.as_ref()),
state: redirect_uri(query.redirect_uri.as_ref()),
alert: query.alert.as_deref().unwrap_or_default(),
}
.render();
@@ -202,17 +202,17 @@ async fn ui_change_password_handler(Query(query): Query<ChangePasswordQuery>) ->
#[derive(Debug, Default, Deserialize)]
pub struct ChangeEmailQuery {
redirect_to: Option<String>,
redirect_uri: Option<String>,
alert: Option<String>,
}
async fn ui_change_email_handler(Query(query): Query<ChangeEmailQuery>, user: User) -> Response {
let form_state = indoc::formatdoc!(
r#"
{redirect_to}
{redirect_uri}
{csrf_token}
"#,
redirect_to = hidden_input("redirect_to", query.redirect_to.as_ref()),
redirect_uri = hidden_input("redirect_uri", query.redirect_uri.as_ref()),
csrf_token = hidden_input("csrf_token", Some(&user.csrf_token)),
);
@@ -234,12 +234,12 @@ async fn ui_change_email_handler(Query(query): Query<ChangeEmailQuery>, user: Us
#[derive(Debug, Default, Deserialize)]
pub struct LogoutQuery {
redirect_to: Option<String>,
redirect_uri: Option<String>,
}
async fn ui_logout_handler(Query(query): Query<LogoutQuery>) -> Redirect {
if let Some(redirect_to) = query.redirect_to {
return Redirect::to(&format!("/api/auth/v1/logout?redirect_to={redirect_to}"));
if let Some(redirect_uri) = query.redirect_uri {
return Redirect::to(&format!("/api/auth/v1/logout?redirect_uri={redirect_uri}"));
}
return Redirect::to("/api/auth/v1/logout");
}
@@ -337,7 +337,7 @@ mod tests {
let body = render_html(
&state,
LoginQuery {
redirect_to: None,
redirect_uri: None,
response_type: None,
pkce_code_challenge: None,
alert: None,
@@ -360,14 +360,14 @@ mod tests {
{
// Parameters: all login parameters
let redirect_to = format!("{site_url}/login-success-welcome");
let redirect_uri = format!("{site_url}/login-success-welcome");
let response_type = "code";
let pkce_code_challenge = "challenge";
let body = render_html(
&state,
LoginQuery {
redirect_to: Some(redirect_to.clone()),
redirect_uri: Some(redirect_uri.clone()),
response_type: Some(response_type.to_string()),
pkce_code_challenge: Some(pkce_code_challenge.to_string()),
alert: None,
@@ -381,7 +381,7 @@ mod tests {
let form_action = captures.get(1).unwrap();
assert_eq!(format!("/{AUTH_API_PATH}/login"), form_action.as_str());
assert!(body.contains(&hidden_input("redirect_to", Some(&redirect_to))));
assert!(body.contains(&hidden_input("redirect_uri", Some(&redirect_uri))));
assert!(body.contains(&hidden_input("response_type", Some(response_type))));
assert!(body.contains(&hidden_input(
"pkce_code_challenge",
@@ -403,8 +403,8 @@ mod tests {
let query_params: HashMap<Cow<'_, str>, Cow<'_, str>> = url.query_pairs().collect();
assert_eq!(
query_params.get("redirect_to").map(|s| &**s),
Some(redirect_to.as_str()),
query_params.get("redirect_uri").map(|s| &**s),
Some(redirect_uri.as_str()),
"href: {oauth_provider}"
);
assert_eq!(

View File

@@ -98,16 +98,16 @@ fn validate_redirect_impl(
/// Validates up to two redirects, typically from query parameter and/or request body.
pub(crate) fn validate_redirect(
state: &AppState,
redirect_to: Option<&str>,
redirect_uri: Option<&str>,
) -> Result<(), AuthError> {
if let Some(redirect_to) = redirect_to {
if let Some(redirect_uri) = redirect_uri {
let site: &Option<url::Url> = &state.site_url();
let custom_uri_schemes = state.access_config(|c| c.auth.custom_uri_schemes.clone());
validate_redirect_impl(
site.as_ref(),
&custom_uri_schemes,
redirect_to,
redirect_uri,
state.dev_mode(),
)?;
}

View File

@@ -19,7 +19,7 @@ pub struct CreateRecordQuery {
/// This only exists to support insertions via static HTML form actions.
///
/// We may want to have a different on-error redirect to better support the static HTML use-case.
pub redirect_to: Option<String>,
pub redirect_uri: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
@@ -187,8 +187,8 @@ pub async fn create_record_handler(
}
};
if let Some(redirect_to) = create_record_query.redirect_to {
return Ok(Redirect::to(&redirect_to).into_response());
if let Some(redirect_uri) = create_record_query.redirect_uri {
return Ok(Redirect::to(&redirect_uri).into_response());
}
return Ok(Json(CreateRecordResponse { ids: record_ids }).into_response());