use askama::Template; use askama_web::WebTemplate; use axum::{ extract::{Query, State}, http::header::SET_COOKIE, http::{HeaderMap, HeaderValue}, response::{AppendHeaders, IntoResponse, Redirect}, }; use openid::Options; use rand::Rng; use serde::Deserialize; use sqlx::FromRow; use tower_sessions::Session; use crate::flash; use crate::page::PageContext; use crate::routes::profile::update_keycloak_user_attributes; use crate::state::AppState; use irc_now_common::auth::UserClaims; #[derive(Template, WebTemplate)] #[template(path = "auth_error.html")] pub struct AuthErrorTemplate { pub page: PageContext, } pub async fn error_page( headers: HeaderMap, State(state): State, ) -> impl IntoResponse { let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = AuthErrorTemplate { page }; if clear { (AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } #[derive(FromRow)] struct UserRow { plan: String, stripe_customer_id: Option, display_name: Option, content_expires: bool, created_at: chrono::DateTime, } fn random_string(len: usize) -> String { let mut rng = rand::thread_rng(); (0..len) .map(|_| { let idx = rng.gen_range(0..36); if idx < 10 { (b'0' + idx) as char } else { (b'a' + idx - 10) as char } }) .collect() } pub async fn login(State(state): State, session: Session) -> Redirect { let csrf_state = random_string(32); let nonce = random_string(32); session .insert("oidc_state", &csrf_state) .await .expect("session insert failed"); session .insert("oidc_nonce", &nonce) .await .expect("session insert failed"); let options = Options { scope: Some("openid email profile".to_string()), state: Some(csrf_state), nonce: Some(nonce), ..Options::default() }; let auth_url = state.oidc_client.auth_url(&options); Redirect::temporary(auth_url.as_str()) } #[derive(Deserialize)] pub struct CallbackParams { code: String, state: String, } pub async fn callback( State(state): State, session: Session, Query(params): Query, ) -> Result< (AppendHeaders<[(axum::http::HeaderName, &'static str); 1]>, Redirect), (AppendHeaders<[(axum::http::HeaderName, HeaderValue); 1]>, Redirect), > { let stored_state: Option = session .get("oidc_state") .await .unwrap_or(None); let stored_nonce: Option = session .get("oidc_nonce") .await .unwrap_or(None); session.remove::("oidc_state").await.ok(); session.remove::("oidc_nonce").await.ok(); let Some(stored_state) = stored_state else { tracing::warn!("no oidc_state in session"); return Err((AppendHeaders([flash::set_flash("login_failed")]), Redirect::temporary("/auth/error"))); }; if params.state != stored_state { tracing::warn!("CSRF state mismatch"); return Err((AppendHeaders([flash::set_flash("login_failed")]), Redirect::temporary("/auth/error"))); } let nonce_ref = stored_nonce.as_deref(); let token = state .oidc_client .authenticate(¶ms.code, nonce_ref, None) .await .map_err(|e| { tracing::error!("token exchange failed: {e}"); (AppendHeaders([flash::set_flash("login_failed")]), Redirect::temporary("/auth/error")) })?; let id_token = token.id_token.ok_or_else(|| { tracing::error!("no id_token in response"); (AppendHeaders([flash::set_flash("login_failed")]), Redirect::temporary("/auth/error")) })?; let payload = id_token.payload().map_err(|e| { tracing::error!("failed to decode id_token payload: {e}"); (AppendHeaders([flash::set_flash("login_failed")]), Redirect::temporary("/auth/error")) })?; let sub = &payload.userinfo.sub; let email = payload.userinfo.email.as_deref(); let row = sqlx::query_as::<_, UserRow>( "INSERT INTO users (keycloak_sub, email) VALUES ($1, $2) ON CONFLICT (keycloak_sub) DO UPDATE SET email = EXCLUDED.email, last_login_at = NOW() RETURNING plan, stripe_customer_id, display_name, content_expires, created_at", ) .bind(sub) .bind(email) .fetch_one(&state.db) .await .map_err(|e| { tracing::error!("user upsert failed: {e}"); (AppendHeaders([flash::set_flash("login_failed")]), Redirect::temporary("/auth/error")) })?; let is_signup = (chrono::Utc::now() - row.created_at).num_seconds() < 5; if is_signup { crate::events::record(&state.db, sub, "signup", None).await; } crate::events::record(&state.db, sub, "login", None).await; crate::business_metrics::login_counter(); let claims = UserClaims { sub: sub.clone(), email: email.map(String::from), plan: Some(row.plan.clone()), stripe_customer_id: row.stripe_customer_id, display_name: row.display_name, content_expires: Some(row.content_expires), }; if let Err(e) = update_keycloak_user_attributes( &state, sub, &row.plan, row.content_expires, ) .await { tracing::warn!("keycloak attribute sync on login failed: {e}"); } session .insert("user", &claims) .await .map_err(|e| { tracing::error!("failed to store user claims in session: {e}"); (AppendHeaders([flash::set_flash("login_failed")]), Redirect::temporary("/auth/error")) })?; Ok(( AppendHeaders([(SET_COOKIE, "irc_logged_in=1; Domain=irc.now; Path=/; SameSite=Lax; Secure; Max-Age=2592000")]), Redirect::temporary("/dashboard"), )) } pub async fn logout( State(state): State, session: Session, ) -> (AppendHeaders<[(axum::http::HeaderName, &'static str); 1]>, Redirect) { session.flush().await.ok(); let origin = url::Url::parse(&state.oidc.redirect_url) .map(|u| format!("{}://{}", u.scheme(), u.host_str().unwrap_or("irc.now"))) .unwrap_or_else(|_| "https://irc.now".to_string()); let logout_url = format!( "{}/protocol/openid-connect/logout?post_logout_redirect_uri={}&client_id={}", state.oidc.issuer_url, urlencoding::encode(&origin), urlencoding::encode(&state.oidc.client_id), ); ( AppendHeaders([(SET_COOKIE, "irc_logged_in=; Domain=irc.now; Path=/; SameSite=Lax; Secure; Max-Age=0")]), Redirect::temporary(&logout_url), ) }