Something went wrong. Try again.
An OIDC provider for AT Protocol OAuth. Rust port of apenwarr/atlogin. forked from quilling.dev/ratlogin
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321use super::AppState;
use askama::Template;use axum::body::Body;use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, LOCATION, ORIGIN};use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};use axum::response::{IntoResponse, Response};use url::{Url, form_urlencoded};
use crate::clients::ClientStoreError;use crate::database::DatabaseError;use crate::oidc::AuthorizationRequest;
pub(super) struct HttpError(pub(super) StatusCode, pub(super) String);
impl HttpError { pub(super) fn bad_request(message: &str) -> Self { Self(StatusCode::BAD_REQUEST, message.to_owned()) }
pub(super) fn plain(self) -> Response { ( self.0, [(CONTENT_TYPE, "text/plain; charset=utf-8")], self.1, ) .into_response() }
pub(super) fn page(&self, extra_css: Option<&str>) -> Response { error_page(self.0, &self.1, extra_css) }}
#[derive(Template)]#[template(path = "error.html")]pub(super) struct ErrorPage<'a> { pub(super) status: u16, pub(super) reason: &'a str, pub(super) message: &'a str, pub(super) extra_css: Option<&'a str>,}
pub(super) fn error_page(status: StatusCode, message: &str, extra_css: Option<&str>) -> Response { let page = ErrorPage { status: status.as_u16(), reason: status.canonical_reason().unwrap_or("Error"), message, extra_css, }; render_page( page, status, form_csp(None), HttpError(status, message.to_owned()), )}
pub(super) fn same_origin(headers: &HeaderMap, issuer: &str) -> bool { let site = headers .get("sec-fetch-site") .and_then(|value| value.to_str().ok()); match headers.get(ORIGIN).and_then(|value| value.to_str().ok()) { Some("null") => matches!(site, Some("same-origin") | Some("none")), Some(origin) => origin.eq_ignore_ascii_case(issuer), None => matches!(site, Some("same-origin") | Some("none") | None), }}
pub(super) fn require_same_origin<L>( state: &AppState<L>, headers: &HeaderMap,) -> Result<(), HttpError> { if same_origin(headers, state.config.issuer()) { Ok(()) } else { Err(HttpError( StatusCode::FORBIDDEN, "cross-site request refused".to_owned(), )) }}
pub(super) fn form_pairs(body: &str) -> Vec<(String, String)> { form_urlencoded::parse(body.as_bytes()) .into_owned() .collect()}
impl IntoResponse for HttpError { fn into_response(self) -> Response { error_page(self.0, &self.1, None) }}
impl From<DatabaseError> for HttpError { fn from(error: DatabaseError) -> Self { tracing::error!(%error, "storage failure"); Self( StatusCode::INTERNAL_SERVER_ERROR, "storage failure".to_owned(), ) }}
impl From<ClientStoreError> for HttpError { fn from(error: ClientStoreError) -> Self { tracing::error!(%error, "client lookup failure"); Self( StatusCode::INTERNAL_SERVER_ERROR, "storage failure".to_owned(), ) }}
pub(super) fn query_pairs(query: Option<String>) -> Vec<(String, String)> { form_urlencoded::parse(query.unwrap_or_default().as_bytes()) .into_owned() .collect()}
pub(super) fn respond( status: StatusCode, headers: Vec<(HeaderName, String)>, body: Body,) -> Response { let mut map = HeaderMap::new(); for (name, value) in headers { match HeaderValue::try_from(value) { Ok(value) => { map.append(name, value); } Err(_) => { return HttpError( StatusCode::INTERNAL_SERVER_ERROR, "invalid response header".to_owned(), ) .into_response(); } } } (status, map, body).into_response()}
pub(super) fn form_csp(extra_form_action: Option<&str>) -> String { let extra = extra_form_action.map_or_else(String::new, |source| format!(" {source}")); format!( "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data: https:; form-action https:{extra}; frame-ancestors 'none'; base-uri 'none'" )}
pub(super) fn render_with(page: impl Template, csp: String) -> Response { let unavailable = HttpError( StatusCode::INTERNAL_SERVER_ERROR, "the page could not be rendered".to_owned(), ); render_page(page, StatusCode::OK, csp, unavailable)}
fn render_page( page: impl Template, status: StatusCode, csp: String, unrendered: HttpError,) -> Response { match page.render() { Ok(html) => respond( status, vec![ (CONTENT_TYPE, "text/html; charset=utf-8".to_owned()), (CACHE_CONTROL, "no-store".to_owned()), (CONTENT_SECURITY_POLICY, csp), ], Body::from(html), ), Err(error) => { tracing::error!(%error, "a page template failed to render"); unrendered.plain() } }}
pub(super) fn redirect_to( issuer: &str, status: StatusCode, mut target: Url, client_state: Option<&str>, pairs: &[(&str, &str)], extra_headers: Vec<(HeaderName, String)>,) -> Response { { let mut query = target.query_pairs_mut(); for (key, value) in pairs { query.append_pair(key, value); } if let Some(client_state) = client_state { query.append_pair("state", client_state); } query.append_pair("iss", issuer); } let mut headers = vec![ (LOCATION, target.to_string()), (CACHE_CONTROL, "no-store".to_owned()), ]; headers.extend(extra_headers); respond(status, headers, Body::empty())}
pub(super) fn error_redirect<L>( state: &AppState<L>, status: StatusCode, target: Url, client_state: Option<&str>, error: &str, description: &str, extra_headers: Vec<(HeaderName, String)>,) -> Response { redirect_to( state.config.issuer(), status, target, client_state, &[("error", error), ("error_description", description)], extra_headers, )}
pub(super) struct Requester { pub(super) host: String,}
impl Requester { pub(super) fn of(request: &AuthorizationRequest) -> Self { Self { host: request .target .redirect_uri .host_str() .unwrap_or_default() .to_owned(), } }}
#[cfg(test)]mod tests { use super::*;
struct Broken;
impl std::fmt::Display for Broken { fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Err(std::fmt::Error) } }
impl askama::FastWritable for Broken { fn write_into( &self, _: &mut dyn std::fmt::Write, _: &dyn askama::Values, ) -> askama::Result<()> { Err(askama::Error::Fmt) } }
impl Template for Broken { const SIZE_HINT: usize = 0;
fn render_into_with_values( &self, _: &mut dyn std::fmt::Write, _: &dyn askama::Values, ) -> askama::Result<()> { Err(askama::Error::Fmt) } }
#[tokio::test] async fn a_template_that_fails_gives_plain_text_with_the_intended_status() { let failure = HttpError(StatusCode::BAD_REQUEST, "enter a valid handle".to_owned()); for (response, status, text) in [ ( render_page(Broken, StatusCode::BAD_REQUEST, form_csp(None), failure), StatusCode::BAD_REQUEST, "enter a valid handle", ), ( render_with(Broken, form_csp(None)), StatusCode::INTERNAL_SERVER_ERROR, "the page could not be rendered", ), ] { assert_eq!(response.status(), status); assert_eq!( response.headers()[CONTENT_TYPE], "text/plain; charset=utf-8" ); let body = axum::body::to_bytes(response.into_body(), 1024) .await .expect("body"); assert_eq!(body, text); } }
#[test] fn a_header_value_that_cannot_be_sent_is_a_500_and_not_a_panic() { let response = respond( StatusCode::FOUND, vec![( LOCATION, "https://client.example/cb\r\nset-cookie: x=y".to_owned(), )], Body::empty(), ); assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); assert!(response.headers().get(LOCATION).is_none()); assert!(response.headers().get("set-cookie").is_none()); }}