diff --git a/harmony_zitadel_auth/src/axum_login_flow.rs b/harmony_zitadel_auth/src/axum_login_flow.rs index 0a61613e..0422d662 100644 --- a/harmony_zitadel_auth/src/axum_login_flow.rs +++ b/harmony_zitadel_auth/src/axum_login_flow.rs @@ -16,10 +16,13 @@ use crate::session::LoginAttemptCookie; pub const LOGIN_ATTEMPT_COOKIE: &str = "harmony_fleet_login_attempt"; pub const HARMONY_SESSION_COOKIE: &str = "harmony_fleet_session"; +pub const HARMONY_ACCESS_TOKEN_COOKIE: &str = "harmony_fleet_access_token"; -/// Session cookie holds the raw Zitadel JWT. The `PrivateCookieJar` (AES-GCM) -/// encrypts both the login-attempt cookie (PKCE verifier) and the session cookie -/// (id_token), so the JWT is never exposed in plaintext on the wire. +/// The session cookie holds the raw Zitadel id_token. The access-token cookie +/// holds the OAuth access token, so the app can call the Zitadel API as the +/// signed-in user. The `PrivateCookieJar` (AES-GCM) encrypts the login-attempt +/// cookie (PKCE verifier) and the two token cookies, so no token goes over the +/// wire in plaintext. pub async fn login_handler( jar: PrivateCookieJar, State(config): State, @@ -74,12 +77,14 @@ fn build_logout_response( session_jar: PrivateCookieJar, config: &ZitadelAuthConfig, ) -> Result { - // The session cookie value IS the raw JWT (id_token), used as the Zitadel logout hint. + // Read the id_token before removal: Zitadel uses it as the logout hint. let id_token = session_jar .get(HARMONY_SESSION_COOKIE) .map(|c| c.value().to_string()) .unwrap_or_default(); - let session_jar = session_jar.remove(Cookie::build(HARMONY_SESSION_COOKIE).path("/").build()); + let session_jar = session_jar + .remove(Cookie::build(HARMONY_SESSION_COOKIE).path("/").build()) + .remove(Cookie::build(HARMONY_ACCESS_TOKEN_COOKIE).path("/").build()); let logout_url = build_logout_url(config, &id_token)?; Ok((session_jar, Redirect::to(logout_url.as_str()))) } @@ -120,7 +125,9 @@ async fn build_callback_response( anyhow::bail!("auth callback nonce mismatch; start again at /login"); } - let session_jar = session_jar.add(session_cookie(&tokens, config)); + let session_jar = session_cookies(&tokens, config) + .into_iter() + .fold(session_jar, PrivateCookieJar::add); let next = attempt .next .as_deref() @@ -140,11 +147,32 @@ async fn build_callback_response( } } -fn session_cookie(tokens: &TokenResponse, config: &ZitadelAuthConfig) -> Cookie<'static> { - let max_age_secs = +fn session_cookies(tokens: &TokenResponse, config: &ZitadelAuthConfig) -> [Cookie<'static>; 2] { + let id_token_max_age = jwt_exp(&tokens.id_token).map(|exp| (exp - chrono::Utc::now().timestamp()).max(0)); + [ + token_cookie( + HARMONY_SESSION_COOKIE, + tokens.id_token.clone(), + id_token_max_age, + config, + ), + token_cookie( + HARMONY_ACCESS_TOKEN_COOKIE, + tokens.access_token.clone(), + tokens.expires_in.map(|secs| secs as i64), + config, + ), + ] +} - let mut builder = Cookie::build((HARMONY_SESSION_COOKIE, tokens.id_token.clone())) +fn token_cookie( + name: &'static str, + value: String, + max_age_secs: Option, + config: &ZitadelAuthConfig, +) -> Cookie<'static> { + let mut builder = Cookie::build((name, value)) .http_only(true) .same_site(SameSite::Lax) .path("/"); @@ -175,3 +203,104 @@ fn auth_error_response(e: anyhow::Error) -> Response { ) .into_response() } + +#[cfg(test)] +mod tests { + use super::*; + + fn config(base_url: &str) -> ZitadelAuthConfig { + ZitadelAuthConfig { + zitadel_base: "https://sso.example.com".to_string(), + base_url: base_url.to_string(), + client_id: "client".to_string(), + scope: "openid".to_string(), + trusted_audiences: vec![], + logout_redirect_uri: base_url.to_string(), + } + } + + #[test] + fn callback_sets_session_and_access_token_cookies() { + let tokens = TokenResponse { + access_token: "opaque-access-token".to_string(), + id_token: "not.a-jwt.payload".to_string(), + token_type: "Bearer".to_string(), + expires_in: Some(43199), + }; + + let [session, access] = session_cookies(&tokens, &config("https://app.example.com")); + + assert_eq!(session.name(), HARMONY_SESSION_COOKIE); + assert_eq!(access.name(), HARMONY_ACCESS_TOKEN_COOKIE); + assert_eq!(access.value(), "opaque-access-token"); + assert_eq!(access.max_age(), Some(time::Duration::seconds(43199))); + for cookie in [&session, &access] { + assert_eq!(cookie.http_only(), Some(true)); + assert_eq!(cookie.same_site(), Some(SameSite::Lax)); + assert_eq!(cookie.secure(), Some(true)); + assert_eq!(cookie.path(), Some("/")); + } + } + + #[test] + fn access_token_cookie_follows_the_secure_flag_of_the_session_cookie() { + let tokens = TokenResponse { + access_token: "token".to_string(), + id_token: "not.a-jwt.payload".to_string(), + token_type: "Bearer".to_string(), + expires_in: None, + }; + + let [session, access] = session_cookies(&tokens, &config("http://localhost:8085")); + + assert_eq!(session.secure(), None); + assert_eq!(access.secure(), None); + assert_eq!(access.max_age(), None); + } + + #[test] + fn logout_removes_both_token_cookies_and_keeps_the_id_token_hint() { + // Encrypt the cookies with a throwaway jar, then feed them back as + // request cookies: the jar emits removal Set-Cookie headers only for + // cookies that came in with the request. + let key = axum_extra::extract::cookie::Key::generate(); + let seed = PrivateCookieJar::new(key.clone()) + .add(Cookie::new(HARMONY_SESSION_COOKIE, "the-id-token")) + .add(Cookie::new(HARMONY_ACCESS_TOKEN_COOKIE, "the-access-token")) + .into_response(); + let request_cookies = seed + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|v| v.to_str().unwrap().split(';').next().unwrap()) + .collect::>() + .join("; "); + let mut headers = axum::http::HeaderMap::new(); + headers.insert(axum::http::header::COOKIE, request_cookies.parse().unwrap()); + let jar = PrivateCookieJar::from_headers(&headers, key); + + let response = build_logout_response(jar, &config("https://app.example.com")) + .unwrap() + .into_response(); + + let set_cookies: Vec<&str> = response + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + for name in [HARMONY_SESSION_COOKIE, HARMONY_ACCESS_TOKEN_COOKIE] { + assert!( + set_cookies + .iter() + .any(|c| c.starts_with(&format!("{name}=")) && c.contains("Max-Age=0")), + "no removal Set-Cookie for {name}: {set_cookies:?}" + ); + } + + let location = response.headers()[axum::http::header::LOCATION] + .to_str() + .unwrap(); + assert!(location.contains("id_token_hint=the-id-token")); + } +} diff --git a/harmony_zitadel_auth/src/config.rs b/harmony_zitadel_auth/src/config.rs index 90d6d126..a1517908 100644 --- a/harmony_zitadel_auth/src/config.rs +++ b/harmony_zitadel_auth/src/config.rs @@ -40,6 +40,34 @@ impl ZitadelAuthConfig { } } +// Env-based construction kept as a compatibility bridge for pre-ConfigClient +// consumers (Recordum). New code loads ZitadelAuthConfig via ConfigClient. +pub const ZITADEL_BASE_ENV: &str = "HARMONY_SSO_ZITADEL_BASE"; +pub const BASE_URL_ENV: &str = "BASE_URL"; +pub const CLIENT_ID_ENV: &str = "HARMONY_SSO_CLIENT_ID"; +pub const SCOPE_ENV: &str = "HARMONY_SSO_SCOPE"; +pub const TRUSTED_AUDIENCES_ENV: &str = "HARMONY_SSO_TRUSTED_AUDIENCES"; +pub const LOGOUT_REDIRECT_URI_ENV: &str = "HARMONY_SSO_LOGOUT_REDIRECT_URI"; +pub const COOKIE_KEY_ENV: &str = "HARMONY_COOKIE_KEY_B64"; + +pub fn config_from_env() -> ZitadelAuthConfig { + ZitadelAuthConfig { + zitadel_base: required_env(ZITADEL_BASE_ENV), + base_url: required_env(BASE_URL_ENV), + client_id: required_env(CLIENT_ID_ENV), + scope: required_env(SCOPE_ENV), + trusted_audiences: required_env(TRUSTED_AUDIENCES_ENV) + .split(',') + .map(str::to_string) + .collect(), + logout_redirect_uri: required_env(LOGOUT_REDIRECT_URI_ENV), + } +} + +fn required_env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("missing required environment variable {name}")) +} + /// Operator session-cookie signing key: standard-base64 of ≥64 random /// bytes. Secret-class, so it resolves from OpenBao / a k8s Secret — /// never cleartext config. Whoever holds it can forge sessions. diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index 767cb056..ab2e7f0f 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -7,7 +7,10 @@ pub mod login; pub mod management; pub mod session; -pub use config::{OperatorCookieKey, ZitadelAuthConfig}; +pub use config::{ + BASE_URL_ENV, CLIENT_ID_ENV, COOKIE_KEY_ENV, LOGOUT_REDIRECT_URI_ENV, OperatorCookieKey, + SCOPE_ENV, TRUSTED_AUDIENCES_ENV, ZITADEL_BASE_ENV, ZitadelAuthConfig, config_from_env, +}; pub use device_groups::ZitadelDeviceGroups; pub use harmony_zitadel_jwt::{MachineKeyFile, ZitadelJwtBearer};