Frontend on /app path for easy custom homepage

This commit is contained in:
lewis
2026-01-02 21:54:27 +02:00
parent df2135b5e1
commit 4375d795a0
68 changed files with 1430 additions and 841 deletions
+25 -2
View File
@@ -130,14 +130,37 @@ pub async fn proxy_handler(
Err(e) => {
warn!("Token validation failed: {:?}", e);
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
return (
StatusCode::BAD_REQUEST,
let auth_header_str = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
let is_dpop = auth_header_str
.trim()
.get(..5)
.is_some_and(|s| s.eq_ignore_ascii_case("dpop "));
let scheme = if is_dpop { "DPoP" } else { "Bearer" };
let www_auth = format!(
"{} error=\"invalid_token\", error_description=\"Token has expired\"",
scheme
);
let mut response = (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "ExpiredToken",
"message": "Token has expired"
})),
)
.into_response();
response
.headers_mut()
.insert("WWW-Authenticate", www_auth.parse().unwrap());
if is_dpop {
let nonce = crate::oauth::verify::generate_dpop_nonce();
response
.headers_mut()
.insert("DPoP-Nonce", nonce.parse().unwrap());
}
return response;
}
}
}
+12 -5
View File
@@ -42,11 +42,18 @@ pub async fn delete_record(
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
Json(input): Json<DeleteRecordInput>,
) -> Response {
let auth =
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
let auth = match prepare_repo_write(
&state,
&headers,
&input.repo,
"POST",
&crate::util::build_full_url(&uri.to_string()),
)
.await
{
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
auth.is_oauth,
+43 -12
View File
@@ -89,11 +89,28 @@ pub async fn prepare_repo_write(
)
.await
.map_err(|e| {
(
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
let mut response = (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e.to_string()})),
)
.into_response()
.into_response();
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
let scheme = if extracted.is_dpop { "DPoP" } else { "Bearer" };
let www_auth = format!(
"{} error=\"invalid_token\", error_description=\"Token has expired\"",
scheme
);
response.headers_mut().insert(
"WWW-Authenticate",
www_auth.parse().unwrap(),
);
if extracted.is_dpop {
let nonce = crate::oauth::verify::generate_dpop_nonce();
response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap());
}
}
response
})?;
if repo_did != auth_user.did {
return Err((
@@ -219,11 +236,18 @@ pub async fn create_record(
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
Json(input): Json<CreateRecordInput>,
) -> Response {
let auth =
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
let auth = match prepare_repo_write(
&state,
&headers,
&input.repo,
"POST",
&crate::util::build_full_url(&uri.to_string()),
)
.await
{
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
auth.is_oauth,
@@ -459,11 +483,18 @@ pub async fn put_record(
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
Json(input): Json<PutRecordInput>,
) -> Response {
let auth =
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
let auth = match prepare_repo_write(
&state,
&headers,
&input.repo,
"POST",
&crate::util::build_full_url(&uri.to_string()),
)
.await
{
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
auth.is_oauth,
+1 -1
View File
@@ -1257,7 +1257,7 @@ pub async fn request_passkey_recovery(
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let recovery_url = format!(
"https://{}/#/recover-passkey?did={}&token={}",
"https://{}/app/recover-passkey?did={}&token={}",
hostname,
urlencoding::encode(&user.did),
urlencoding::encode(&recovery_token)
+1
View File
@@ -396,6 +396,7 @@ pub async fn validate_token_with_dpop(
controller_did: None,
})
}
Err(crate::oauth::OAuthError::ExpiredToken(_)) => Err(TokenValidationError::TokenExpired),
Err(_) => Err(TokenValidationError::AuthenticationFailed),
}
}
+8 -8
View File
@@ -352,9 +352,9 @@ pub async fn enqueue_email_update(
let strings = get_strings(&prefs.locale);
let encoded_email = urlencoding::encode(new_email);
let encoded_token = urlencoding::encode(code);
let verify_page = format!("https://{}/#/verify", hostname);
let verify_page = format!("https://{}/app/verify", hostname);
let verify_link = format!(
"https://{}/#/verify?token={}&identifier={}",
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
);
let body = format_message(
@@ -389,9 +389,9 @@ pub async fn enqueue_email_update_token(
let prefs = get_user_comms_prefs(db, user_id).await?;
let strings = get_strings(&prefs.locale);
let current_email = prefs.email.clone().unwrap_or_default();
let verify_page = format!("https://{}/#/verify?type=email-update", hostname);
let verify_page = format!("https://{}/app/verify?type=email-update", hostname);
let verify_link = format!(
"https://{}/#/verify?type=email-update&token={}",
"https://{}/app/verify?type=email-update&token={}",
hostname,
urlencoding::encode(code)
);
@@ -556,9 +556,9 @@ pub async fn enqueue_signup_verification(
let encoded_email = urlencoding::encode(recipient);
let encoded_token = urlencoding::encode(code);
(
format!("https://{}/#/verify", hostname),
format!("https://{}/app/verify", hostname),
format!(
"https://{}/#/verify?token={}&identifier={}",
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
),
)
@@ -606,9 +606,9 @@ pub async fn enqueue_migration_verification(
let strings = get_strings(&prefs.locale);
let encoded_email = urlencoding::encode(email);
let encoded_token = urlencoding::encode(token);
let verify_page = format!("https://{}/#/verify", hostname);
let verify_page = format!("https://{}/app/verify", hostname);
let verify_link = format!(
"https://{}/#/verify?token={}&identifier={}",
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
);
let body = format_message(
+17 -2
View File
@@ -657,8 +657,23 @@ pub fn app(state: AppState) -> Router {
.exists()
{
let index_path = format!("{}/index.html", frontend_dir);
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(index_path));
router.fallback_service(serve_dir)
let homepage_path = format!("{}/homepage.html", frontend_dir);
let homepage_exists = std::path::Path::new(&homepage_path).exists();
let homepage_file = if homepage_exists {
homepage_path
} else {
index_path.clone()
};
let spa_router = Router::new().fallback_service(ServeFile::new(&index_path));
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path));
router
.route_service("/", ServeFile::new(&homepage_file))
.nest("/app", spa_router)
.fallback_service(serve_dir)
} else {
router
}
+16 -15
View File
@@ -25,7 +25,7 @@ fn redirect_see_other(uri: &str) -> Response {
fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
redirect_see_other(&format!(
"/#/oauth/error?error={}&error_description={}",
"/app/oauth/error?error={}&error_description={}",
url_encode(error),
url_encode(description)
))
@@ -236,7 +236,7 @@ pub async fn authorize_get(
if is_delegated && !has_password {
tracing::info!("Redirecting to delegation auth");
return redirect_see_other(&format!(
"/#/oauth/delegation?request_uri={}&delegated_did={}",
"/app/oauth/delegation?request_uri={}&delegated_did={}",
url_encode(&request_uri),
url_encode(&user.did)
));
@@ -259,12 +259,12 @@ pub async fn authorize_get(
&& !accounts.is_empty()
{
return redirect_see_other(&format!(
"/#/oauth/accounts?request_uri={}",
"/app/oauth/accounts?request_uri={}",
url_encode(&request_uri)
));
}
redirect_see_other(&format!(
"/#/oauth/login?request_uri={}",
"/app/oauth/login?request_uri={}",
url_encode(&request_uri)
))
}
@@ -466,7 +466,7 @@ pub async fn authorize_post(
.into_response();
}
redirect_see_other(&format!(
"/#/oauth/login?request_uri={}&error={}",
"/app/oauth/login?request_uri={}&error={}",
url_encode(&form.request_uri),
url_encode(error_msg)
))
@@ -539,7 +539,7 @@ pub async fn authorize_post(
return show_login_error("An error occurred. Please try again.", json_response);
}
let redirect_url = format!(
"/#/oauth/delegation?request_uri={}&delegated_did={}",
"/app/oauth/delegation?request_uri={}&delegated_did={}",
url_encode(&form.request_uri),
url_encode(&user.did)
);
@@ -565,7 +565,7 @@ pub async fn authorize_post(
return show_login_error("An error occurred. Please try again.", json_response);
}
let redirect_url = format!(
"/#/oauth/passkey?request_uri={}",
"/app/oauth/passkey?request_uri={}",
url_encode(&form.request_uri)
);
if json_response {
@@ -620,7 +620,7 @@ pub async fn authorize_post(
.into_response();
}
return redirect_see_other(&format!(
"/#/oauth/totp?request_uri={}",
"/app/oauth/totp?request_uri={}",
url_encode(&form.request_uri)
));
}
@@ -649,7 +649,7 @@ pub async fn authorize_post(
.into_response();
}
return redirect_see_other(&format!(
"/#/oauth/2fa?request_uri={}&channel={}",
"/app/oauth/2fa?request_uri={}&channel={}",
url_encode(&form.request_uri),
url_encode(channel_name)
));
@@ -713,7 +713,7 @@ pub async fn authorize_post(
.unwrap_or(true);
if needs_consent {
let consent_url = format!(
"/#/oauth/consent?request_uri={}",
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
if json_response {
@@ -1103,7 +1103,7 @@ pub async fn authorize_2fa_get(
};
let channel = query.channel.as_deref().unwrap_or("email");
redirect_see_other(&format!(
"/#/oauth/2fa?request_uri={}&channel={}",
"/app/oauth/2fa?request_uri={}&channel={}",
url_encode(&query.request_uri),
url_encode(channel)
))
@@ -1464,6 +1464,7 @@ pub async fn consent_post(
|| s.starts_with("blob:")
|| s.starts_with("rpc:")
|| s.starts_with("account:")
|| s.starts_with("identity:")
|| s.starts_with("include:")
});
if !has_valid_scope {
@@ -1708,7 +1709,7 @@ pub async fn authorize_2fa_post(
.unwrap_or(true);
if needs_consent {
let consent_url = format!(
"/#/oauth/consent?request_uri={}",
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
@@ -2345,7 +2346,7 @@ pub async fn passkey_finish(
if needs_consent {
let consent_url = format!(
"/#/oauth/consent?request_uri={}",
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
@@ -2729,7 +2730,7 @@ pub async fn authorize_passkey_finish(
}
let channel_name = channel_display_name(user.preferred_comms_channel);
let redirect_url = format!(
"/#/oauth/2fa?request_uri={}&channel={}",
"/app/oauth/2fa?request_uri={}&channel={}",
url_encode(&form.request_uri),
url_encode(channel_name)
);
@@ -2754,7 +2755,7 @@ pub async fn authorize_passkey_finish(
}
let redirect_url = format!(
"/#/oauth/consent?request_uri={}",
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
(
+3 -3
View File
@@ -206,7 +206,7 @@ pub async fn delegation_auth(
success: true,
needs_totp: Some(true),
redirect_uri: Some(format!(
"/#/oauth/delegation-totp?request_uri={}",
"/app/oauth/delegation-totp?request_uri={}",
urlencoding::encode(&form.request_uri)
)),
error: None,
@@ -239,7 +239,7 @@ pub async fn delegation_auth(
success: true,
needs_totp: None,
redirect_uri: Some(format!(
"/#/oauth/consent?request_uri={}",
"/app/oauth/consent?request_uri={}",
urlencoding::encode(&form.request_uri)
)),
error: None,
@@ -374,7 +374,7 @@ pub async fn delegation_totp_verify(
success: true,
needs_totp: None,
redirect_uri: Some(format!(
"/#/oauth/consent?request_uri={}",
"/app/oauth/consent?request_uri={}",
urlencoding::encode(&form.request_uri)
)),
error: None,
+2 -2
View File
@@ -168,8 +168,8 @@ pub async fn frontend_client_metadata(
client_name: "PDS Account Manager".to_string(),
client_uri: base_url.clone(),
redirect_uris: vec![
format!("{}/", base_url),
format!("{}/migrate", base_url),
format!("{}/app/", base_url),
format!("{}/app/migrate", base_url),
],
grant_types: vec![
"authorization_code".to_string(),
+7 -4
View File
@@ -94,9 +94,10 @@ pub async fn handle_authorization_code_grant(
));
}
Some(result.jkt)
} else if auth_request.parameters.dpop_jkt.is_some() {
return Err(OAuthError::InvalidRequest(
"DPoP proof required for this authorization".to_string(),
} else if auth_request.parameters.dpop_jkt.is_some() || client_metadata.requires_dpop() {
return Err(OAuthError::UseDpopNonce(
crate::oauth::dpop::DPoPVerifier::new(AuthConfig::get().dpop_secret().as_bytes())
.generate_nonce(),
));
} else {
None
@@ -138,6 +139,8 @@ pub async fn handle_authorization_code_grant(
} else {
REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL
};
let mut stored_parameters = auth_request.parameters.clone();
stored_parameters.dpop_jkt = dpop_jkt.clone();
let token_data = TokenData {
did: did.clone(),
token_id: token_id.0.clone(),
@@ -147,7 +150,7 @@ pub async fn handle_authorization_code_grant(
client_id: auth_request.client_id.clone(),
client_auth: stored_client_auth,
device_id: auth_request.device_id,
parameters: auth_request.parameters.clone(),
parameters: stored_parameters,
details: None,
code: None,
current_refresh_token: Some(refresh_token.0.clone()),
+73 -7
View File
@@ -42,21 +42,38 @@ pub async fn verify_oauth_access_token(
http_uri: &str,
) -> Result<VerifyResult, OAuthError> {
let token_info = extract_oauth_token_info(access_token)?;
tracing::debug!(
token_id = %token_info.token_id,
has_dpop_proof = dpop_proof.is_some(),
"Verifying OAuth access token"
);
let token_data = db::get_token_by_id(pool, &token_info.token_id)
.await?
.ok_or_else(|| OAuthError::InvalidToken("Token not found or revoked".to_string()))?;
.ok_or_else(|| {
tracing::warn!(token_id = %token_info.token_id, "Token not found in database");
OAuthError::InvalidToken("Token not found or revoked".to_string())
})?;
let now = chrono::Utc::now();
if token_data.expires_at < now {
return Err(OAuthError::InvalidToken("Token has expired".to_string()));
return Err(OAuthError::ExpiredToken(
"Token session has expired".to_string(),
));
}
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt {
let proof = dpop_proof
.ok_or_else(|| OAuthError::UseDpopNonce("DPoP proof required".to_string()))?;
tracing::debug!(expected_jkt = %expected_jkt, "Token requires DPoP");
let proof = dpop_proof.ok_or_else(|| {
tracing::warn!("DPoP proof required but not provided");
OAuthError::UseDpopNonce("DPoP proof required".to_string())
})?;
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let access_token_hash = compute_ath(access_token);
let result =
verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?;
let result = verifier
.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))
.map_err(|e| {
tracing::warn!(error = ?e, http_method = %http_method, http_uri = %http_uri, "DPoP proof verification failed");
e
})?;
if !db::check_and_record_dpop_jti(pool, &result.jti).await? {
return Err(OAuthError::InvalidDpopProof(
"DPoP proof has already been used".to_string(),
@@ -123,7 +140,7 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
.ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?;
let now = chrono::Utc::now().timestamp();
if exp < now {
return Err(OAuthError::InvalidToken("Token has expired".to_string()));
return Err(OAuthError::ExpiredToken("Token has expired".to_string()));
}
let token_id = payload
.get("jti")
@@ -191,6 +208,7 @@ pub struct OAuthAuthError {
pub error: String,
pub message: String,
pub dpop_nonce: Option<String>,
pub www_authenticate: Option<String>,
}
impl IntoResponse for OAuthAuthError {
@@ -208,6 +226,11 @@ impl IntoResponse for OAuthAuthError {
.headers_mut()
.insert("DPoP-Nonce", nonce.parse().unwrap());
}
if let Some(www_auth) = self.www_authenticate {
response
.headers_mut()
.insert("WWW-Authenticate", www_auth.parse().unwrap());
}
response
}
}
@@ -228,6 +251,7 @@ impl FromRequestParts<AppState> for OAuthUser {
error: "AuthenticationRequired".to_string(),
message: "Authorization header required".to_string(),
dpop_nonce: None,
www_authenticate: None,
})?;
let auth_header_trimmed = auth_header.trim();
let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7
@@ -244,6 +268,7 @@ impl FromRequestParts<AppState> for OAuthUser {
error: "InvalidRequest".to_string(),
message: "Invalid authorization scheme".to_string(),
dpop_nonce: None,
www_authenticate: None,
});
};
let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok());
@@ -275,6 +300,7 @@ impl FromRequestParts<AppState> for OAuthUser {
error: "use_dpop_nonce".to_string(),
message: "DPoP nonce required".to_string(),
dpop_nonce: Some(nonce),
www_authenticate: Some("DPoP error=\"use_dpop_nonce\"".to_string()),
}),
Err(OAuthError::InvalidDpopProof(msg)) => {
let nonce = generate_dpop_nonce();
@@ -283,6 +309,45 @@ impl FromRequestParts<AppState> for OAuthUser {
error: "invalid_dpop_proof".to_string(),
message: msg,
dpop_nonce: Some(nonce),
www_authenticate: None,
})
}
Err(OAuthError::ExpiredToken(msg)) => {
let nonce = if is_dpop_token {
Some(generate_dpop_nonce())
} else {
None
};
let scheme = if is_dpop_token { "DPoP" } else { "Bearer" };
let www_auth = format!(
"{} error=\"invalid_token\", error_description=\"{}\"",
scheme, msg
);
Err(OAuthAuthError {
status: StatusCode::UNAUTHORIZED,
error: "ExpiredToken".to_string(),
message: msg,
dpop_nonce: nonce,
www_authenticate: Some(www_auth),
})
}
Err(OAuthError::InvalidToken(msg)) => {
let nonce = if is_dpop_token {
Some(generate_dpop_nonce())
} else {
None
};
let scheme = if is_dpop_token { "DPoP" } else { "Bearer" };
let www_auth = format!(
"{} error=\"invalid_token\", error_description=\"{}\"",
scheme, msg
);
Err(OAuthAuthError {
status: StatusCode::UNAUTHORIZED,
error: "InvalidToken".to_string(),
message: msg,
dpop_nonce: nonce,
www_authenticate: Some(www_auth),
})
}
Err(e) => {
@@ -296,6 +361,7 @@ impl FromRequestParts<AppState> for OAuthUser {
error: "AuthenticationFailed".to_string(),
message: format!("{:?}", e),
dpop_nonce: nonce,
www_authenticate: None,
})
}
}