Save zitadel access token #348

Merged
reda merged 1 commits from feat/zitadel-access-token-cookie into master 2026-08-21 09:31:29 +00:00
3 changed files with 170 additions and 10 deletions

View File

@@ -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<ZitadelAuthConfig>,
@@ -74,12 +77,14 @@ fn build_logout_response(
session_jar: PrivateCookieJar,
config: &ZitadelAuthConfig,
) -> Result<impl IntoResponse> {
// 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<i64>,
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::<Vec<_>>()
.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"));
}
}

View File

@@ -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.

View File

@@ -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};