use crate::db::{models::User, projects, users};
use crate::server::auth::{
cookie_helpers::{self, CookieSettings},
token_storage::{
generate_code_challenge, generate_code_verifier, generate_state_token,
CompletedAuthSession, OAuth2State,
},
};
use crate::server::frontend::load_static_file;
use crate::server::state::AppState;
use axum::{
extract::{Extension, Query, State},
http::{uri::Uri, HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
Json,
};
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tera::Tera;
use tracing::instrument;
fn extract_project_url_from_redirect(redirect_url: &str, fallback_url: &str) -> String {
if let Ok(parsed_url) = url::Url::parse(redirect_url) {
if let Some(host) = parsed_url.host_str() {
let port_part = match parsed_url.port() {
Some(port) if port != 80 && port != 443 => format!(":{}", port),
_ => String::new(),
};
return format!("{}://{}{}", parsed_url.scheme(), host, port_part);
}
}
fallback_url.trim_end_matches('/').to_string()
}
fn validate_redirect_url(redirect_url: &str, public_url: &str) -> String {
const SAFE_FALLBACK: &str = "/";
let redirect_url = redirect_url.trim();
if redirect_url.is_empty() {
return SAFE_FALLBACK.to_string();
}
if redirect_url.starts_with('/') {
if redirect_url.starts_with("//") {
tracing::warn!(
redirect_url = %redirect_url,
"Blocked protocol-relative URL in redirect"
);
return SAFE_FALLBACK.to_string();
}
return redirect_url.to_string();
}
let parsed_redirect = match url::Url::parse(redirect_url) {
Ok(url) => url,
Err(e) => {
tracing::warn!(
redirect_url = %redirect_url,
error = ?e,
"Failed to parse redirect URL, using safe fallback"
);
return SAFE_FALLBACK.to_string();
}
};
let scheme = parsed_redirect.scheme().to_lowercase();
if !matches!(scheme.as_str(), "http" | "https") {
tracing::warn!(
redirect_url = %redirect_url,
scheme = %scheme,
"Blocked dangerous URL scheme in redirect"
);
return SAFE_FALLBACK.to_string();
}
let parsed_public = match url::Url::parse(public_url) {
Ok(url) => url,
Err(e) => {
tracing::error!(
public_url = %public_url,
error = ?e,
"Failed to parse public_url, blocking redirect"
);
return SAFE_FALLBACK.to_string();
}
};
let redirect_host = match parsed_redirect.host_str() {
Some(host) => host,
None => {
tracing::warn!(
redirect_url = %redirect_url,
"Redirect URL has no host, using safe fallback"
);
return SAFE_FALLBACK.to_string();
}
};
let public_host = match parsed_public.host_str() {
Some(host) => host,
None => {
tracing::error!(
public_url = %public_url,
"Public URL has no host, blocking redirect"
);
return SAFE_FALLBACK.to_string();
}
};
if redirect_host == public_host {
return redirect_url.to_string();
}
if redirect_host.ends_with(&format!(".{}", public_host)) {
return redirect_url.to_string();
}
let redirect_host_base = redirect_host.split(':').next().unwrap_or(redirect_host);
let public_host_base = public_host.split(':').next().unwrap_or(public_host);
let is_redirect_localhost =
redirect_host_base == "localhost" || redirect_host_base == "127.0.0.1";
let is_public_localhost = public_host_base == "localhost" || public_host_base == "127.0.0.1";
if is_redirect_localhost && is_public_localhost {
return redirect_url.to_string();
}
tracing::warn!(
redirect_url = %redirect_url,
redirect_host = %redirect_host,
public_host = %public_host,
"Blocked redirect to untrusted external domain"
);
SAFE_FALLBACK.to_string()
}
async fn sync_groups_after_login(
state: &AppState,
id_token: &str,
) -> Result<(), (StatusCode, String)> {
if !state.auth_settings.idp_group_sync_enabled {
return Ok(());
}
let mut expected_claims = HashMap::new();
expected_claims.insert("aud".to_string(), state.auth_settings.client_id.clone());
let claims_value = state
.jwt_validator
.validate(id_token, &state.auth_settings.issuer, &expected_claims)
.await
.map_err(|e| {
tracing::warn!("Failed to validate token for group sync: {:#}", e);
(StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e))
})?;
let claims: crate::server::auth::jwt::Claims =
serde_json::from_value(claims_value).map_err(|e| {
tracing::warn!("Failed to parse claims for group sync: {:#}", e);
(
StatusCode::UNAUTHORIZED,
format!("Invalid token claims: {}", e),
)
})?;
let user = users::find_or_create(&state.db_pool, &claims.email)
.await
.map_err(|e| {
tracing::error!("Failed to find/create user for group sync: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
if let Some(ref groups) = claims.groups {
tracing::debug!(
"Syncing {} IdP groups for user {} during login",
groups.len(),
user.email
);
if let Err(e) =
crate::server::auth::group_sync::sync_user_groups(&state.db_pool, user.id, groups).await
{
tracing::error!(
"Failed to sync IdP groups during login for user {}: {:#}",
user.email,
e
);
}
}
Ok(())
}
#[derive(Debug, Deserialize)]
pub struct CodeExchangeRequest {
pub code: String,
pub code_verifier: String,
pub redirect_uri: String,
}
#[derive(Debug, Deserialize)]
pub struct DeviceExchangeRequest {
pub device_code: String,
}
#[derive(Debug, Serialize)]
pub struct DeviceExchangeResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_description: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeRequest {
#[serde(default)]
pub redirect_uri: Option<String>,
#[serde(default)]
pub code_challenge: Option<String>,
#[serde(default)]
pub code_challenge_method: Option<String>,
pub flow: String,
}
#[derive(Debug, Serialize)]
pub struct AuthorizeResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification_uri_complete: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_in: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval: Option<u64>,
}
#[instrument(skip(state))]
pub async fn authorize(
State(state): State<AppState>,
Json(payload): Json<AuthorizeRequest>,
) -> Result<Json<AuthorizeResponse>, (StatusCode, String)> {
match payload.flow.as_str() {
"code" => {
let redirect_uri = payload.redirect_uri.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
"redirect_uri required for code flow".to_string(),
)
})?;
let code_challenge = payload.code_challenge.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
"code_challenge required for code flow".to_string(),
)
})?;
let code_challenge_method = payload.code_challenge_method.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
"code_challenge_method required for code flow".to_string(),
)
})?;
let params = crate::server::auth::oauth::AuthorizeParams {
client_id: &state.auth_settings.client_id,
redirect_uri: &redirect_uri,
response_type: "code",
scope: "openid email profile offline_access",
code_challenge: &code_challenge,
code_challenge_method: &code_challenge_method,
state: None,
};
let authorization_url = state.oauth_client.build_authorize_url(¶ms);
Ok(Json(AuthorizeResponse {
authorization_url: Some(authorization_url),
device_code: None,
user_code: None,
verification_uri: None,
verification_uri_complete: None,
expires_in: None,
interval: None,
}))
}
"device" => {
let device_response = state.oauth_client.device_flow_start().await.map_err(|e| {
tracing::error!("Failed to start device flow: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to start device flow: {}", e),
)
})?;
Ok(Json(AuthorizeResponse {
authorization_url: None,
device_code: Some(device_response.device_code),
user_code: Some(device_response.user_code),
verification_uri: Some(device_response.verification_uri.clone()),
verification_uri_complete: Some(device_response.verification_uri_complete),
expires_in: Some(device_response.expires_in),
interval: Some(device_response.interval),
}))
}
_ => Err((
StatusCode::BAD_REQUEST,
format!("Invalid flow type: {}", payload.flow),
)),
}
}
#[instrument(skip(state, payload))]
pub async fn code_exchange(
State(state): State<AppState>,
Json(payload): Json<CodeExchangeRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, String)> {
tracing::debug!(
"Code exchange request: redirect_uri={}",
payload.redirect_uri
);
let token_info = state
.oauth_client
.exchange_code_pkce(&payload.code, &payload.code_verifier, &payload.redirect_uri)
.await
.map_err(|e| {
tracing::warn!("OAuth2 code exchange failed: {:#}", e);
(
StatusCode::UNAUTHORIZED,
format!("Code exchange failed: {}", e),
)
})?;
tracing::info!(
"Code exchange successful, token_type={}, expires_in={}",
token_info.token_type,
token_info.expires_in
);
if let Ok(header) = jsonwebtoken::decode_header(&token_info.id_token) {
tracing::debug!("ID token header: {:?}", header);
}
let parts: Vec<&str> = token_info.id_token.split('.').collect();
if parts.len() == 3 {
if let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) {
if let Ok(claims_str) = String::from_utf8(decoded) {
tracing::debug!("ID token claims: {}", claims_str);
}
}
}
if let Err(e) = sync_groups_after_login(&state, &token_info.id_token).await {
tracing::warn!("Group sync failed during code exchange: {:?}", e);
}
let mut expected_claims = HashMap::new();
expected_claims.insert("aud".to_string(), state.auth_settings.client_id.clone());
let claims = state
.jwt_validator
.validate(
&token_info.id_token,
&state.auth_settings.issuer,
&expected_claims,
)
.await
.map_err(|e| {
tracing::error!("Failed to validate ID token: {:#}", e);
(StatusCode::UNAUTHORIZED, "Invalid token".to_string())
})?;
let email = claims
.get("email")
.and_then(|v| v.as_str())
.ok_or_else(|| (StatusCode::BAD_REQUEST, "Email claim missing".to_string()))?;
let user = users::find_or_create(&state.db_pool, email)
.await
.map_err(|e| {
tracing::error!("Failed to find/create user: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to process user".to_string(),
)
})?;
let rise_jwt = state
.jwt_signer
.sign_user_jwt(&claims, user.id, &state.db_pool, &state.public_url, None)
.await
.map_err(|e| {
tracing::error!("Failed to sign Rise JWT: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to create token".to_string(),
)
})?;
tracing::info!(
"CLI login successful for user {} - issued Rise JWT",
user.email
);
Ok(Json(LoginResponse { token: rise_jwt }))
}
#[instrument(skip(state, payload))]
pub async fn device_exchange(
State(state): State<AppState>,
Json(payload): Json<DeviceExchangeRequest>,
) -> Json<DeviceExchangeResponse> {
tracing::debug!(
"Device exchange request: device_code={}...",
&payload.device_code[..8.min(payload.device_code.len())]
);
match state
.oauth_client
.device_flow_poll(&payload.device_code)
.await
{
Ok(Some(token_info)) => {
tracing::info!("Device authorization successful");
if let Err(e) = sync_groups_after_login(&state, &token_info.id_token).await {
tracing::warn!("Group sync failed during device exchange: {:?}", e);
}
let mut expected_claims = HashMap::new();
expected_claims.insert("aud".to_string(), state.auth_settings.client_id.clone());
let claims = match state
.jwt_validator
.validate(
&token_info.id_token,
&state.auth_settings.issuer,
&expected_claims,
)
.await
{
Ok(claims) => claims,
Err(e) => {
tracing::error!("Failed to validate ID token: {:#}", e);
return Json(DeviceExchangeResponse {
token: None,
error: Some("invalid_token".to_string()),
error_description: Some("Failed to validate ID token".to_string()),
});
}
};
let email = match claims.get("email").and_then(|v| v.as_str()) {
Some(email) => email,
None => {
tracing::error!("Email claim missing from ID token");
return Json(DeviceExchangeResponse {
token: None,
error: Some("invalid_token".to_string()),
error_description: Some("Email claim missing".to_string()),
});
}
};
let user = match users::find_or_create(&state.db_pool, email).await {
Ok(user) => user,
Err(e) => {
tracing::error!("Failed to find/create user: {:#}", e);
return Json(DeviceExchangeResponse {
token: None,
error: Some("server_error".to_string()),
error_description: Some("Failed to process user".to_string()),
});
}
};
let rise_jwt = match state
.jwt_signer
.sign_user_jwt(&claims, user.id, &state.db_pool, &state.public_url, None)
.await
{
Ok(jwt) => jwt,
Err(e) => {
tracing::error!("Failed to sign Rise JWT: {:#}", e);
return Json(DeviceExchangeResponse {
token: None,
error: Some("server_error".to_string()),
error_description: Some("Failed to create token".to_string()),
});
}
};
tracing::info!(
"CLI device login successful for user {} - issued Rise JWT",
user.email
);
Json(DeviceExchangeResponse {
token: Some(rise_jwt),
error: None,
error_description: None,
})
}
Ok(None) => {
tracing::debug!("Device authorization pending");
Json(DeviceExchangeResponse {
token: None,
error: Some("authorization_pending".to_string()),
error_description: None,
})
}
Err(e) => {
let error_msg = e.to_string();
tracing::warn!("Device authorization error: {}", error_msg);
let (error, description) = if error_msg.contains("slow_down") {
("slow_down".to_string(), None)
} else {
("access_denied".to_string(), Some(error_msg))
};
Json(DeviceExchangeResponse {
token: None,
error: Some(error),
error_description: description,
})
}
}
}
#[derive(Debug, Serialize)]
pub struct MeResponse {
pub id: String,
pub email: String,
pub is_admin: bool,
pub can_create_teams: bool,
}
#[instrument(skip(state))]
pub async fn me(
State(state): State<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<MeResponse>, (StatusCode, String)> {
tracing::debug!("GET /me: user_id={}, email={}", user.id, user.email);
let is_admin = state.is_admin(&user.email);
let can_create_teams = is_admin || state.auth_settings.allow_team_creation;
Ok(Json(MeResponse {
id: user.id.to_string(),
email: user.email,
is_admin,
can_create_teams,
}))
}
#[derive(Debug, Deserialize)]
pub struct UsersLookupRequest {
pub emails: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct UsersLookupResponse {
pub users: Vec<UserInfo>,
}
#[derive(Debug, Serialize)]
pub struct UserInfo {
pub id: String,
pub email: String,
}
#[instrument(skip(state))]
pub async fn users_lookup(
State(state): State<AppState>,
Extension(_user): Extension<User>,
Json(payload): Json<UsersLookupRequest>,
) -> Result<Json<UsersLookupResponse>, (StatusCode, String)> {
let mut user_infos = Vec::new();
for email in payload.emails {
let user = users::find_by_email(&state.db_pool, &email)
.await
.map_err(|e| {
tracing::error!("Database error looking up user {}: {:#}", email, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
match user {
Some(u) => {
user_infos.push(UserInfo {
id: u.id.to_string(),
email: u.email,
});
}
None => {
return Err((StatusCode::NOT_FOUND, format!("User not found: {}", email)));
}
}
}
Ok(Json(UsersLookupResponse { users: user_infos }))
}
#[derive(Debug, Deserialize)]
pub struct SigninQuery {
pub redirect: Option<String>,
pub rd: Option<String>,
pub project: Option<String>,
pub skip_warning: Option<bool>,
}
#[instrument(skip(state, params, headers, uri))]
pub async fn signin_page(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
Query(params): Query<SigninQuery>,
) -> Result<Response, (StatusCode, String)> {
let project_name = params.project.as_deref().unwrap_or("Unknown");
let raw_redirect_url = params
.redirect
.as_ref()
.or(params.rd.as_ref())
.cloned()
.unwrap_or_else(|| "/".to_string());
let redirect_url = validate_redirect_url(&raw_redirect_url, &state.public_url);
tracing::info!(
project = %project_name,
has_redirect = !redirect_url.is_empty(),
raw_redirect = %raw_redirect_url,
validated_redirect = %redirect_url,
"Signin page requested"
);
let static_dir = state.server_settings.static_dir.as_deref().ok_or_else(|| {
tracing::error!("static_dir not configured");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Static dir not configured".to_string(),
)
})?;
let template_content = load_static_file(static_dir, "auth-signin.html.tera")
.await
.ok_or_else(|| {
tracing::error!("auth-signin.html.tera template not found");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template not found".to_string(),
)
})?;
let template_str = std::str::from_utf8(&template_content).map_err(|e| {
tracing::error!("Failed to parse template as UTF-8: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template encoding error".to_string(),
)
})?;
let mut tera = Tera::default();
tera.add_raw_template("auth-signin.html.tera", template_str)
.map_err(|e| {
tracing::error!("Failed to parse template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template error".to_string(),
)
})?;
let is_rise_path = uri.path().starts_with("/.rise/auth");
let mut continue_params = vec![];
if let Some(ref project) = params.project {
continue_params.push(format!("project={}", urlencoding::encode(project)));
}
if !redirect_url.is_empty() {
continue_params.push(format!("redirect={}", urlencoding::encode(&redirect_url)));
}
let continue_url = if is_rise_path {
format!(
"{}/.rise/auth/signin/start?{}",
extract_request_base_url(&headers, &state),
continue_params.join("&")
)
} else {
format!(
"{}/api/v1/auth/signin/start?{}",
state.public_url.trim_end_matches('/'),
continue_params.join("&")
)
};
let mut context = tera::Context::new();
context.insert("project_name", project_name);
context.insert("continue_url", &continue_url);
context.insert("redirect_url", &redirect_url);
let html = tera
.render("auth-signin.html.tera", &context)
.map_err(|e| {
tracing::error!("Failed to render template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template rendering error".to_string(),
)
})?;
Ok(Html(html).into_response())
}
fn host_matches_cookie_domain(hostname: &str, cookie_domain: &str) -> bool {
let cookie_domain_normalized = cookie_domain.trim_start_matches('.');
hostname == cookie_domain_normalized
|| hostname.ends_with(&format!(".{}", cookie_domain_normalized))
}
fn extract_request_base_url(headers: &HeaderMap, state: &AppState) -> String {
if let Some(host) = headers.get("host") {
if let Ok(host_str) = host.to_str() {
let scheme = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http");
return format!("{}://{}", scheme, host_str);
}
}
state.public_url.trim_end_matches('/').to_string()
}
async fn render_warning_page(
state: &AppState,
params: &SigninQuery,
warnings: Vec<String>,
request_host: &str,
) -> Result<Html<String>, (StatusCode, String)> {
let static_dir = state.server_settings.static_dir.as_deref().ok_or_else(|| {
tracing::error!("static_dir not configured");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Static dir not configured".to_string(),
)
})?;
let template_content = load_static_file(static_dir, "auth-warning.html.tera")
.await
.ok_or_else(|| {
tracing::error!("auth-warning.html.tera template not found");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template not found".to_string(),
)
})?;
let template_str = std::str::from_utf8(&template_content).map_err(|e| {
tracing::error!("Failed to parse template as UTF-8: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template encoding error".to_string(),
)
})?;
let mut tera = Tera::default();
tera.add_raw_template("auth-warning.html.tera", template_str)
.map_err(|e| {
tracing::error!("Failed to parse template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template error".to_string(),
)
})?;
let redirect_url = params.redirect.as_ref().or(params.rd.as_ref());
let redirect_host = redirect_url.and_then(|url| {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
});
let mut continue_params = vec![];
if let Some(ref project) = params.project {
continue_params.push(format!("project={}", urlencoding::encode(project)));
}
if let Some(ref redirect) = params.redirect {
continue_params.push(format!("redirect={}", urlencoding::encode(redirect)));
} else if let Some(ref rd) = params.rd {
continue_params.push(format!("rd={}", urlencoding::encode(rd)));
}
continue_params.push("skip_warning=true".to_string());
let continue_url = format!(
"{}/api/v1/auth/signin/start?{}",
state.public_url.trim_end_matches('/'),
continue_params.join("&")
);
let mut context = tera::Context::new();
context.insert("warnings", &warnings);
context.insert(
"project_name",
¶ms.project.as_deref().unwrap_or("Unknown"),
);
context.insert("request_host", request_host);
context.insert(
"cookie_domain",
&if state.cookie_settings.domain.is_empty() {
"(empty - current host only)"
} else {
&state.cookie_settings.domain
},
);
context.insert("redirect_host", &redirect_host);
context.insert("redirect_url", &redirect_url);
context.insert("continue_url", &continue_url);
let html = tera
.render("auth-warning.html.tera", &context)
.map_err(|e| {
tracing::error!("Failed to render template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template rendering error".to_string(),
)
})?;
Ok(Html(html))
}
#[instrument(skip(state, params, uri))]
pub async fn oauth_signin_start(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
Query(params): Query<SigninQuery>,
) -> Result<Response, (StatusCode, String)> {
let raw_redirect_url = params.rd.as_ref().or(params.redirect.as_ref());
let redirect_url = raw_redirect_url.map(|url| validate_redirect_url(url, &state.public_url));
tracing::info!(
project = ?params.project,
has_redirect = redirect_url.is_some(),
raw_redirect = ?raw_redirect_url,
validated_redirect = ?redirect_url,
"OAuth signin initiated"
);
let is_rise_path = uri.path().starts_with("/.rise/auth");
let request_host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
if !params.skip_warning.unwrap_or(false) && !is_rise_path {
let redirect_host = redirect_url.as_ref().and_then(|url| {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
});
let cookie_domain = &state.cookie_settings.domain;
let request_host_without_port = request_host.split(':').next().unwrap_or(request_host);
let mut warnings = Vec::new();
if let Some(ref redirect_host_str) = redirect_host {
let cookie_will_match_redirect = if cookie_domain.is_empty() {
redirect_host_str == request_host_without_port
} else {
host_matches_cookie_domain(redirect_host_str, cookie_domain)
};
if !cookie_will_match_redirect {
warnings.push(format!(
"Authentication cookies may not work correctly. The redirect target '{}' does not match the configured cookie domain '{}'.",
redirect_host_str,
if cookie_domain.is_empty() {
request_host_without_port
} else {
cookie_domain
}
));
}
}
if !cookie_domain.is_empty()
&& !request_host_without_port.is_empty()
&& !host_matches_cookie_domain(request_host_without_port, cookie_domain)
{
warnings.push(format!(
"Authentication configuration issue detected. The sign-in page is accessed from '{}' but cookies are configured for domain '{}'.",
request_host_without_port,
cookie_domain
));
}
if let Some(ref redirect_host_str) = redirect_host {
if request_host_without_port == redirect_host_str.as_str()
&& !cookie_domain.is_empty()
&& !host_matches_cookie_domain(request_host_without_port, cookie_domain)
{
warnings.push(format!(
"This application ('{}') is not covered by the configured cookie domain '{}'.",
request_host_without_port, cookie_domain
));
}
}
if !warnings.is_empty() {
tracing::warn!(
"Cookie configuration warnings detected for project {:?}: {}",
params.project,
warnings.join(" | ")
);
return Ok(render_warning_page(&state, ¶ms, warnings, request_host)
.await?
.into_response());
}
}
let code_verifier = generate_code_verifier();
let code_challenge = generate_code_challenge(&code_verifier);
let state_token = generate_state_token();
let custom_domain_base_url = if is_rise_path {
Some(extract_request_base_url(&headers, &state))
} else {
None
};
let oauth_state = OAuth2State {
code_verifier: code_verifier.clone(),
redirect_url,
project_name: params.project.clone(), custom_domain_base_url,
};
state.token_store.save(state_token.clone(), oauth_state);
let callback_url = format!(
"{}/api/v1/auth/callback",
state.public_url.trim_end_matches('/')
);
let params = crate::server::auth::oauth::AuthorizeParams {
client_id: &state.auth_settings.client_id,
redirect_uri: &callback_url,
response_type: "code",
scope: "openid email profile",
code_challenge: &code_challenge,
code_challenge_method: "S256",
state: Some(&state_token),
};
let auth_url = state.oauth_client.build_authorize_url(¶ms);
tracing::debug!("Redirecting to OIDC provider for authentication");
Ok(Redirect::to(&auth_url).into_response())
}
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
pub code: String,
pub state: String,
}
#[instrument(skip(state, params, headers))]
pub async fn oauth_callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<CallbackQuery>,
) -> Result<Response, (StatusCode, String)> {
tracing::info!("OAuth callback received");
let oauth_state = state.token_store.get(¶ms.state).ok_or_else(|| {
tracing::warn!("Invalid or expired state token");
(
StatusCode::BAD_REQUEST,
"Invalid or expired state token".to_string(),
)
})?;
let callback_url = format!(
"{}/api/v1/auth/callback",
state.public_url.trim_end_matches('/')
);
let token_info = state
.oauth_client
.exchange_code_pkce(¶ms.code, &oauth_state.code_verifier, &callback_url)
.await
.map_err(|e| {
tracing::error!("Failed to exchange code: {:#}", e);
(
StatusCode::UNAUTHORIZED,
format!("Code exchange failed: {}", e),
)
})?;
tracing::info!("Successfully exchanged code for tokens");
if let Err(e) = sync_groups_after_login(&state, &token_info.id_token).await {
tracing::warn!("Group sync failed during OAuth callback: {:?}", e);
}
let mut expected_claims = HashMap::new();
expected_claims.insert("aud".to_string(), state.auth_settings.client_id.clone());
let claims = state
.jwt_validator
.validate(
&token_info.id_token,
&state.auth_settings.issuer,
&expected_claims,
)
.await
.map_err(|e| {
tracing::error!("Failed to validate JWT: {:#}", e);
(StatusCode::UNAUTHORIZED, "Invalid token".to_string())
})?;
let max_age = state.jwt_signer.default_expiry_seconds;
let redirect_url = oauth_state.redirect_url.unwrap_or_else(|| "/".to_string());
let cookie_settings_for_response = {
let request_host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
let request_host_without_port = request_host.split(':').next().unwrap_or(request_host);
if !state.cookie_settings.domain.is_empty()
&& host_matches_cookie_domain(request_host_without_port, &state.cookie_settings.domain)
{
state.cookie_settings.clone()
} else {
CookieSettings {
domain: String::new(),
secure: state.cookie_settings.secure,
}
}
};
if let Some(ref project) = oauth_state.project_name {
tracing::info!(
"Issuing Rise JWT for ingress auth (project context: {})",
project
);
let user_email = claims["email"].as_str().ok_or_else(|| {
tracing::error!("No email in JWT claims");
(StatusCode::UNAUTHORIZED, "Invalid token claims".to_string())
})?;
let user = users::find_or_create(&state.db_pool, user_email)
.await
.map_err(|e| {
tracing::error!("Failed to find/create user: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
let project_url = extract_project_url_from_redirect(&redirect_url, &state.public_url);
let rise_jwt = state
.jwt_signer
.sign_ingress_jwt(&claims, user.id, &state.db_pool, &project_url, None)
.await
.map_err(|e| {
tracing::error!("Failed to sign Rise JWT: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to create authentication token".to_string(),
)
})?;
if let Some(custom_domain_base_url) = oauth_state.custom_domain_base_url {
let completion_token = generate_state_token();
let completed_session = CompletedAuthSession {
rise_jwt,
max_age,
redirect_url: redirect_url.clone(),
project_name: project.clone(),
};
state
.token_store
.save_completed_session(completion_token.clone(), completed_session);
let complete_url = format!(
"{}/.rise/auth/complete?token={}",
custom_domain_base_url.trim_end_matches('/'),
completion_token
);
tracing::info!(
"Redirecting to custom domain for cookie setting: {}",
complete_url
);
return Ok(Redirect::to(&complete_url).into_response());
}
let cookie = cookie_helpers::create_rise_jwt_cookie(
&rise_jwt,
&cookie_settings_for_response,
max_age,
);
return render_success_page(&state, project, &redirect_url, &cookie).await;
}
tracing::info!("Using Rise JWT for UI session");
let mut expected_claims = HashMap::new();
expected_claims.insert("aud".to_string(), state.auth_settings.client_id.clone());
let claims = state
.jwt_validator
.validate(
&token_info.id_token,
&state.auth_settings.issuer,
&expected_claims,
)
.await
.map_err(|e| {
tracing::error!("Failed to validate ID token: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to validate token".to_string(),
)
})?;
let email = claims
.get("email")
.and_then(|v| v.as_str())
.ok_or_else(|| {
tracing::error!("Email claim missing from ID token");
(
StatusCode::BAD_REQUEST,
"Email claim missing from token".to_string(),
)
})?;
let user = users::find_or_create(&state.db_pool, email)
.await
.map_err(|e| {
tracing::error!("Failed to find or create user: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to process user".to_string(),
)
})?;
sync_groups_after_login(&state, &token_info.id_token).await?;
let rise_jwt = state
.jwt_signer
.sign_user_jwt(&claims, user.id, &state.db_pool, &state.public_url, None)
.await
.map_err(|e| {
tracing::error!("Failed to sign user JWT: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to create authentication token".to_string(),
)
})?;
let cookie =
cookie_helpers::create_rise_jwt_cookie(&rise_jwt, &cookie_settings_for_response, max_age);
tracing::info!(
"Setting Rise JWT cookie and redirecting to {}",
redirect_url
);
render_ui_login_success_page(&state, &redirect_url, &cookie).await
}
async fn render_success_page(
state: &AppState,
project_name: &str,
redirect_url: &str,
cookie: &str,
) -> Result<Response, (StatusCode, String)> {
let static_dir = state.server_settings.static_dir.as_deref().ok_or_else(|| {
tracing::error!("static_dir not configured");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Static dir not configured".to_string(),
)
})?;
let template_content = load_static_file(static_dir, "auth-success.html.tera")
.await
.ok_or_else(|| {
tracing::error!("auth-success.html.tera template not found");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template not found".to_string(),
)
})?;
let template_str = std::str::from_utf8(&template_content).map_err(|e| {
tracing::error!("Failed to parse template as UTF-8: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template encoding error".to_string(),
)
})?;
let mut tera = Tera::default();
tera.add_raw_template("auth-success.html.tera", template_str)
.map_err(|e| {
tracing::error!("Failed to parse template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template error".to_string(),
)
})?;
let mut context = tera::Context::new();
context.insert("success", &true);
context.insert("project_name", project_name);
context.insert("redirect_url", redirect_url);
let html = tera
.render("auth-success.html.tera", &context)
.map_err(|e| {
tracing::error!("Failed to render template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template rendering error".to_string(),
)
})?;
tracing::info!(
"Setting ingress JWT cookie and showing success page for project: {}",
project_name
);
let response = (StatusCode::OK, [("Set-Cookie", cookie)], Html(html)).into_response();
Ok(response)
}
async fn render_ui_login_success_page(
state: &AppState,
redirect_url: &str,
cookie: &str,
) -> Result<Response, (StatusCode, String)> {
let static_dir = state.server_settings.static_dir.as_deref().ok_or_else(|| {
tracing::error!("static_dir not configured");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Static dir not configured".to_string(),
)
})?;
let template_content = load_static_file(static_dir, "auth-ui-success.html.tera")
.await
.ok_or_else(|| {
tracing::error!("auth-ui-success.html.tera template not found");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template not found".to_string(),
)
})?;
let template_str = std::str::from_utf8(&template_content).map_err(|e| {
tracing::error!("Failed to decode template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template encoding error".to_string(),
)
})?;
let mut tera = Tera::default();
tera.add_raw_template("auth-ui-success.html.tera", template_str)
.map_err(|e| {
tracing::error!("Failed to parse template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template error".to_string(),
)
})?;
let mut context = tera::Context::new();
context.insert("redirect_url", redirect_url);
let html = tera
.render("auth-ui-success.html.tera", &context)
.map_err(|e| {
tracing::error!("Failed to render template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template rendering error".to_string(),
)
})?;
tracing::info!(
"Setting UI JWT cookie and showing success page, redirecting to: {}",
redirect_url
);
let response = (StatusCode::OK, [("Set-Cookie", cookie)], Html(html)).into_response();
Ok(response)
}
#[derive(Debug, Deserialize)]
pub struct CompleteQuery {
pub token: String,
}
#[instrument(skip(state, params))]
pub async fn oauth_complete(
State(state): State<AppState>,
Query(params): Query<CompleteQuery>,
) -> Result<Response, (StatusCode, String)> {
tracing::info!("Custom domain auth complete received");
let session = state
.token_store
.get_completed_session(¶ms.token)
.ok_or_else(|| {
tracing::warn!("Invalid or expired completion token");
(
StatusCode::BAD_REQUEST,
"Invalid or expired completion token. Please try logging in again.".to_string(),
)
})?;
let cookie_settings = CookieSettings {
domain: String::new(),
secure: state.cookie_settings.secure,
};
let cookie = cookie_helpers::create_rise_jwt_cookie(
&session.rise_jwt,
&cookie_settings,
session.max_age,
);
render_success_page(
&state,
&session.project_name,
&session.redirect_url,
&cookie,
)
.await
}
#[derive(Debug, Deserialize)]
pub struct IngressAuthQuery {
pub project: String,
}
#[instrument(skip(state, params, headers))]
pub async fn ingress_auth(
State(state): State<AppState>,
Query(params): Query<IngressAuthQuery>,
headers: HeaderMap,
) -> Result<Response, (StatusCode, String)> {
let request_id = headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("none");
tracing::debug!(
project = %params.project,
request_id = %request_id,
"Ingress auth check"
);
if let Some(redirect_path) = headers
.get("x-auth-request-redirect")
.and_then(|v| v.to_str().ok())
{
if redirect_path.starts_with("/.rise/") {
tracing::debug!(
project = %params.project,
redirect_path = %redirect_path,
"Allowing unauthenticated access to .rise path"
);
return Ok((
StatusCode::OK,
[("X-Auth-Request-User", "anonymous".to_string())],
)
.into_response());
}
}
let rise_jwt = cookie_helpers::extract_rise_jwt_cookie(&headers).ok_or_else(|| {
tracing::debug!("No Rise JWT cookie found");
(StatusCode::UNAUTHORIZED, "No session cookie".to_string())
})?;
let ingress_claims = state
.jwt_signer
.verify_jwt_skip_aud(&rise_jwt)
.map_err(|e| {
tracing::warn!("Invalid or expired ingress JWT: {:#}", e);
(
StatusCode::UNAUTHORIZED,
"Invalid or expired session".to_string(),
)
})?;
let email = ingress_claims.email;
let user = users::find_or_create(&state.db_pool, &email)
.await
.map_err(|e| {
tracing::error!("Database error finding/creating user: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
tracing::debug!(
project = %params.project,
user_id = %user.id,
user_email = %user.email,
"Rise JWT validated"
);
let project = projects::find_by_name(&state.db_pool, ¶ms.project)
.await
.map_err(|e| {
tracing::error!("Database error finding project: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?
.ok_or_else(|| {
tracing::debug!("Project not found: {}", params.project);
(StatusCode::NOT_FOUND, "Project not found".to_string())
})?;
use crate::server::settings::AccessRequirement;
let access_class = state
.access_classes
.get(&project.access_class)
.ok_or_else(|| {
tracing::error!("Access class '{}' not configured", project.access_class);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid access class".to_string(),
)
})?;
match access_class.access_requirement {
AccessRequirement::None => {
tracing::warn!(
project = %params.project,
"Auth endpoint called for AccessRequirement::None project"
);
Err((
StatusCode::FORBIDDEN,
"This project should not require authentication".to_string(),
))
}
AccessRequirement::Authenticated => {
tracing::debug!(
project = %params.project,
user_id = %user.id,
user_email = %user.email,
access_class = %project.access_class,
"Access granted - authenticated user"
);
Ok((
StatusCode::OK,
[
("X-Auth-Request-Email", email),
("X-Auth-Request-User", user.id.to_string()),
],
)
.into_response())
}
AccessRequirement::Member => {
let has_member_access = projects::user_can_access(&state.db_pool, project.id, user.id)
.await
.map_err(|e| {
tracing::error!("Database error checking access: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
if has_member_access {
tracing::debug!(
project = %params.project,
user_id = %user.id,
user_email = %user.email,
"Access granted - project member"
);
return Ok((
StatusCode::OK,
[
("X-Auth-Request-Email", email),
("X-Auth-Request-User", user.id.to_string()),
],
)
.into_response());
}
let has_app_access = crate::db::project_app_users::user_can_access_app(
&state.db_pool,
project.id,
user.id,
)
.await
.map_err(|e| {
tracing::error!("Database error checking app user access: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
if has_app_access {
tracing::debug!(
project = %params.project,
user_id = %user.id,
user_email = %user.email,
"Access granted - app user"
);
Ok((
StatusCode::OK,
[
("X-Auth-Request-Email", email),
("X-Auth-Request-User", user.id.to_string()),
],
)
.into_response())
} else {
tracing::warn!(
project = %params.project,
user_id = %user.id,
user_email = %user.email,
"Access denied - not a project member or app user"
);
Err((
StatusCode::FORBIDDEN,
"You do not have access to this project".to_string(),
))
}
}
}
}
#[derive(Debug, Deserialize)]
pub struct LogoutQuery {
pub redirect: Option<String>,
}
#[instrument(skip(state))]
pub async fn oauth_logout(
State(state): State<AppState>,
Query(params): Query<LogoutQuery>,
) -> Result<Response, (StatusCode, String)> {
tracing::info!("Logout initiated");
let cookie = cookie_helpers::clear_rise_jwt_cookie(&state.cookie_settings);
let redirect_url = params.redirect.unwrap_or_else(|| "/".to_string());
tracing::info!(
"Clearing Rise JWT cookie and redirecting to {}",
redirect_url
);
let response = (
StatusCode::FOUND,
[("Location", redirect_url.as_str()), ("Set-Cookie", &cookie)],
)
.into_response();
Ok(response)
}
#[derive(Debug, Deserialize)]
pub struct CliAuthSuccessQuery {
pub success: Option<bool>,
pub error: Option<String>,
}
#[instrument(skip(state))]
pub async fn cli_auth_success(
State(state): State<AppState>,
Query(params): Query<CliAuthSuccessQuery>,
) -> Result<Response, (StatusCode, String)> {
let success = params.success.unwrap_or(true);
let static_dir = state.server_settings.static_dir.as_deref().ok_or_else(|| {
tracing::error!("static_dir not configured");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Static dir not configured".to_string(),
)
})?;
let template_content = load_static_file(static_dir, "cli-auth-success.html.tera")
.await
.ok_or_else(|| {
tracing::error!("cli-auth-success.html.tera template not found");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template not found".to_string(),
)
})?;
let template_str = std::str::from_utf8(&template_content).map_err(|e| {
tracing::error!("Failed to parse template as UTF-8: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template encoding error".to_string(),
)
})?;
let mut tera = Tera::default();
tera.add_raw_template("cli-auth-success.html.tera", template_str)
.map_err(|e| {
tracing::error!("Failed to parse template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template error".to_string(),
)
})?;
let mut context = tera::Context::new();
context.insert("success", &success);
if let Some(error) = params.error {
context.insert("error_message", &error);
}
let html = tera
.render("cli-auth-success.html.tera", &context)
.map_err(|e| {
tracing::error!("Failed to render template: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Template rendering error".to_string(),
)
})?;
tracing::info!("Showing CLI auth success page (success={})", success);
Ok(Html(html).into_response())
}
#[instrument(skip(state))]
pub async fn jwks(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
tracing::debug!("JWKS endpoint called");
let jwks = state.jwt_signer.generate_jwks().map_err(|e| {
tracing::error!("Failed to generate JWKS: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate JWKS".to_string(),
)
})?;
Ok(Json(jwks))
}
#[instrument(skip(state))]
pub async fn openid_configuration(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
tracing::debug!("OpenID configuration endpoint called");
let jwks_uri = format!("{}/api/v1/auth/jwks", state.public_url);
let authorization_endpoint = format!("{}/api/v1/auth/authorize", state.public_url);
let token_endpoint = format!("{}/api/v1/auth/code/exchange", state.public_url);
let config = serde_json::json!({
"issuer": state.public_url,
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
"jwks_uri": jwks_uri,
"response_types_supported": ["code", "token", "id_token"],
"id_token_signing_alg_values_supported": ["RS256", "HS256"],
"subject_types_supported": ["public"],
"claims_supported": ["sub", "email", "name", "groups", "iat", "exp", "iss", "aud"]
});
Ok(Json(config))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_redirect_url_relative_paths() {
let public_url = "https://rise.dev";
assert_eq!(validate_redirect_url("/", public_url), "/");
assert_eq!(
validate_redirect_url("/dashboard", public_url),
"/dashboard"
);
assert_eq!(
validate_redirect_url("/app/project/123", public_url),
"/app/project/123"
);
assert_eq!(validate_redirect_url("//evil.com", public_url), "/");
assert_eq!(validate_redirect_url("//evil.com/path", public_url), "/");
}
#[test]
fn test_validate_redirect_url_dangerous_schemes() {
let public_url = "https://rise.dev";
assert_eq!(
validate_redirect_url("javascript:alert('xss')", public_url),
"/"
);
assert_eq!(
validate_redirect_url("data:text/html,<script>alert('xss')</script>", public_url),
"/"
);
assert_eq!(
validate_redirect_url("vbscript:msgbox('xss')", public_url),
"/"
);
}
#[test]
fn test_validate_redirect_url_same_domain() {
let public_url = "https://rise.dev";
assert_eq!(
validate_redirect_url("https://rise.dev/dashboard", public_url),
"https://rise.dev/dashboard"
);
assert_eq!(
validate_redirect_url("https://rise.dev:8080/dashboard", public_url),
"https://rise.dev:8080/dashboard"
);
}
#[test]
fn test_validate_redirect_url_subdomains() {
let public_url = "https://rise.dev";
assert_eq!(
validate_redirect_url("https://app.rise.dev/dashboard", public_url),
"https://app.rise.dev/dashboard"
);
assert_eq!(
validate_redirect_url("https://staging.rise.dev/dashboard", public_url),
"https://staging.rise.dev/dashboard"
);
assert_eq!(
validate_redirect_url("https://my-project.app.rise.dev/", public_url),
"https://my-project.app.rise.dev/"
);
}
#[test]
fn test_validate_redirect_url_external_domains() {
let public_url = "https://rise.dev";
assert_eq!(validate_redirect_url("https://evil.com", public_url), "/");
assert_eq!(
validate_redirect_url("https://phishing.site/login", public_url),
"/"
);
assert_eq!(
validate_redirect_url("https://rise.dev.evil.com", public_url),
"/"
);
}
#[test]
fn test_validate_redirect_url_localhost() {
let public_url = "http://localhost:3000";
assert_eq!(
validate_redirect_url("http://localhost:3000/dashboard", public_url),
"http://localhost:3000/dashboard"
);
assert_eq!(
validate_redirect_url("http://127.0.0.1:3000/dashboard", public_url),
"http://127.0.0.1:3000/dashboard"
);
assert_eq!(
validate_redirect_url("http://localhost:evil.com/path", public_url),
"/"
);
assert_eq!(validate_redirect_url("https://evil.com", public_url), "/");
}
#[test]
fn test_validate_redirect_url_localhost_production_blocked() {
let public_url = "https://rise.dev";
assert_eq!(
validate_redirect_url("http://localhost:3000/dashboard", public_url),
"/"
);
assert_eq!(
validate_redirect_url("http://127.0.0.1:3000/dashboard", public_url),
"/"
);
}
#[test]
fn test_validate_redirect_url_empty_and_invalid() {
let public_url = "https://rise.dev";
assert_eq!(validate_redirect_url("", public_url), "/");
assert_eq!(validate_redirect_url(" ", public_url), "/");
assert_eq!(validate_redirect_url("not a url", public_url), "/");
}
#[test]
fn test_validate_redirect_url_http_vs_https() {
let public_url = "https://rise.dev";
assert_eq!(
validate_redirect_url("http://rise.dev/dashboard", public_url),
"http://rise.dev/dashboard"
);
assert_eq!(
validate_redirect_url("https://rise.dev/dashboard", public_url),
"https://rise.dev/dashboard"
);
}
}