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