Skip to main content

ignition_core/actions/
login.rs

1//! `ign session login` (QUICK-tg4) — the native IdP session exposed as
2//! JSON, in the shape a Playwright `globalSetup` can consume verbatim.
3//!
4//! The flow itself is [`crate::client::idp`]'s, untouched: this module
5//! is a PROJECTION of the `GatewaySession` that the trial-reset ladder
6//! and `ign adopt` already obtain, plus the Playwright `storageState`
7//! document derived from it.
8//!
9//! ## Why the verb exists
10//!
11//! Every Ignition browser-E2E suite otherwise reinvents the auth
12//! bootstrap: drive the login form with a headless browser, scrape the
13//! cookie, hope the IdP's challenge shape did not move. The dance is
14//! already implemented here and already live-verified; exposing it is
15//! one projection, and the scaffold `ign e2e init` writes consumes the
16//! `storage_state` field directly.
17//!
18//! ## The exposure discipline
19//!
20//! This verb emits a LIVE credential by design — that is its product.
21//! The discipline is that the human render withholds it (see
22//! [`crate::actions::login::SessionLoginResult`] consumers in the CLI's
23//! `render.rs`): the cookie NAME and the gateway URL are printed, the
24//! cookie VALUE and the CSRF token are not, and the material is
25//! reachable only under the global `--json` flag where a machine asked
26//! for it on purpose.
27
28use serde::{Deserialize, Serialize};
29
30use crate::client::idp::{self, GatewaySession, IdpLoginFlow};
31use crate::config::Secret;
32use crate::error::CoreError;
33
34/// `ign session login` options. The password never rides here — it is a
35/// [`Secret`] parameter, the redaction discipline's single channel.
36#[derive(Debug, Clone)]
37pub struct SessionLoginOptions {
38    /// The gateway username the IdP challenge is answered with.
39    pub username: String,
40}
41
42/// One Playwright `storageState` cookie entry.
43///
44/// The wire spelling is PLAYWRIGHT's (`httpOnly`, `sameSite`), not
45/// Rust's — a snake_cased key here produces a storage state Playwright
46/// silently ignores, which is why the round trip is unit-pinned.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct StorageCookie {
50    /// The session cookie's name (`webui-sid-<gatewayId>`).
51    pub name: String,
52    /// The session cookie's value.
53    pub value: String,
54    /// The gateway host WITHOUT its port — Playwright's cookie shape
55    /// rejects a port inside `domain`.
56    pub domain: String,
57    /// Always `/` (the gateway sets `Path=/`).
58    pub path: String,
59    /// Always true (the gateway sets `HttpOnly`).
60    pub http_only: bool,
61    /// True only for an `https` gateway URL.
62    pub secure: bool,
63    /// Always `Strict` (the gateway's own `SameSite` attribute).
64    pub same_site: String,
65    /// `-1` — a session cookie. Typed `f64` so it serializes unquoted
66    /// in the shape Playwright's own storage-state dumps use.
67    pub expires: f64,
68}
69
70/// A Playwright `storageState` document — exactly two keys.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct StorageState {
73    /// The one-element cookie array.
74    pub cookies: Vec<StorageCookie>,
75    /// Always empty — the gateway session is cookie-borne; nothing
76    /// lands in `localStorage`.
77    pub origins: Vec<serde_json::Value>,
78}
79
80/// The `ign session login` envelope. ALL keys always present (the
81/// family convention).
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct SessionLoginResult {
84    /// The session cookie's name.
85    pub cookie_name: String,
86    /// The session cookie's value — the live credential.
87    pub cookie_value: String,
88    /// The CSRF token for `X-CSRF-Token` on gateway mutations.
89    pub csrf_token: String,
90    /// The gateway the session belongs to (the profile URL).
91    pub gateway_url: String,
92    /// The same session as a Playwright `storageState` document.
93    pub storage_state: StorageState,
94}
95
96/// `SameSite` value the gateway's own `Set-Cookie` carries.
97const SAME_SITE: &str = "Strict";
98
99/// Project a [`GatewaySession`] into a Playwright `storageState`.
100///
101/// Pure — the cookie-shape rules (D7) are unit-testable without a
102/// server. A `base_url` that will not parse is [`CoreError::Internal`],
103/// matching [`IdpLoginFlow::new`]'s own posture on the same input.
104pub 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
129/// Run the IdP dance and project the session.
130///
131/// Reuses [`idp::login`] verbatim — there is exactly ONE login path in
132/// this crate and this verb does not add a second.
133pub 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    /// THE wire-spelling pin: a Rust-cased key here produces a storage
196    /// state Playwright ignores without complaint.
197    #[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        // -1 must not be quoted — Playwright reads a number here.
207        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}