ignition_core/actions/
login.rs1use serde::{Deserialize, Serialize};
29
30use crate::client::idp::{self, GatewaySession, IdpLoginFlow};
31use crate::config::Secret;
32use crate::error::CoreError;
33
34#[derive(Debug, Clone)]
37pub struct SessionLoginOptions {
38 pub username: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct StorageCookie {
50 pub name: String,
52 pub value: String,
54 pub domain: String,
57 pub path: String,
59 pub http_only: bool,
61 pub secure: bool,
63 pub same_site: String,
65 pub expires: f64,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct StorageState {
73 pub cookies: Vec<StorageCookie>,
75 pub origins: Vec<serde_json::Value>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct SessionLoginResult {
84 pub cookie_name: String,
86 pub cookie_value: String,
88 pub csrf_token: String,
90 pub gateway_url: String,
92 pub storage_state: StorageState,
94}
95
96const SAME_SITE: &str = "Strict";
98
99pub fn storage_state_for(
105 session: &GatewaySession,
106 base_url: &str,
107) -> Result<StorageState, CoreError> {
108 let url = url::Url::parse(base_url)
109 .map_err(|err| CoreError::Internal(format!("invalid gateway URL: {err}")))?;
110 let domain = url
111 .host_str()
112 .ok_or_else(|| CoreError::Internal(format!("gateway URL {base_url:?} carries no host")))?
113 .to_string();
114 Ok(StorageState {
115 cookies: vec![StorageCookie {
116 name: session.cookie_name.clone(),
117 value: session.cookie_value.clone(),
118 domain,
119 path: "/".into(),
120 http_only: true,
121 secure: url.scheme() == "https",
122 same_site: SAME_SITE.into(),
123 expires: -1.0,
124 }],
125 origins: Vec::new(),
126 })
127}
128
129pub async fn session_login(
134 base_url: &str,
135 opts: &SessionLoginOptions,
136 password: &Secret,
137) -> Result<SessionLoginResult, CoreError> {
138 let flow = IdpLoginFlow::new(base_url)?;
139 let (_flow, session) = idp::login(flow, &opts.username, password).await?;
140 let storage_state = storage_state_for(&session, base_url)?;
141 Ok(SessionLoginResult {
142 cookie_name: session.cookie_name,
143 cookie_value: session.cookie_value,
144 csrf_token: session.csrf_token,
145 gateway_url: base_url.to_string(),
146 storage_state,
147 })
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn fixture_session() -> GatewaySession {
155 GatewaySession {
156 cookie_name: "webui-sid-1766878194".into(),
157 cookie_value: "session-value".into(),
158 csrf_token: "csrf-value".into(),
159 }
160 }
161
162 #[test]
163 fn storage_state_shape_follows_the_playwright_rules() {
164 let state = storage_state_for(&fixture_session(), "http://localhost:9088")
165 .expect("a parseable URL");
166 assert_eq!(state.origins.len(), 0, "origins stays empty");
167 assert_eq!(state.cookies.len(), 1, "exactly one cookie");
168 let cookie = &state.cookies[0];
169 assert_eq!(cookie.domain, "localhost", "the port is NOT in domain");
170 assert_eq!(cookie.path, "/");
171 assert!(cookie.http_only);
172 assert!(!cookie.secure, "an http gateway is not a secure cookie");
173 assert_eq!(cookie.same_site, "Strict");
174 assert_eq!(cookie.expires, -1.0);
175 }
176
177 #[test]
178 fn an_https_gateway_marks_the_cookie_secure() {
179 let state = storage_state_for(&fixture_session(), "https://gw.example.com:8043")
180 .expect("a parseable URL");
181 assert!(state.cookies[0].secure);
182 assert_eq!(
183 state.cookies[0].domain, "gw.example.com",
184 "the port is stripped from https too"
185 );
186 }
187
188 #[test]
189 fn an_unparseable_url_is_internal_not_a_panic() {
190 let err =
191 storage_state_for(&fixture_session(), "not a url").expect_err("a garbage URL refuses");
192 assert_eq!(err.exit_code(), 1, "the IdpLoginFlow::new posture");
193 }
194
195 #[test]
198 fn the_wire_keys_are_playwrights_camel_case() {
199 let state = storage_state_for(&fixture_session(), "http://localhost:9088").unwrap();
200 let wire = serde_json::to_value(&state).expect("serializes");
201 let cookie = &wire["cookies"][0];
202 assert!(cookie.get("httpOnly").is_some(), "httpOnly, not http_only");
203 assert!(cookie.get("sameSite").is_some(), "sameSite, not same_site");
204 assert!(cookie.get("http_only").is_none());
205 assert!(cookie.get("same_site").is_none());
206 assert!(cookie["expires"].is_number(), "expires rides as a number");
208 let back: StorageState = serde_json::from_value(wire).expect("round-trips");
209 assert_eq!(back, state);
210 }
211}