diff --git a/debian/rules b/debian/rules index 10beb6f..ab24138 100755 --- a/debian/rules +++ b/debian/rules @@ -23,6 +23,11 @@ override_dh_auto_install: debian/authforge-gui/usr/share/applications/io.dangerousthings.AuthForge.desktop install -D -m 0644 gui/data/io.dangerousthings.AuthForge.svg \ debian/authforge-gui/usr/share/icons/hicolor/scalable/apps/io.dangerousthings.AuthForge.svg + # First-run autostart entry (Phase 10): runs `authforge --first-run` + # at GNOME session start; binary exits ~immediately when no pending + # flag is set, so the per-session cost is one D-Bus round-trip. + install -D -m 0644 gui/data/authforge-firstrun.desktop \ + debian/authforge-gui/etc/xdg/autostart/authforge-firstrun.desktop # PAM $(MAKE) -C pam install DESTDIR=$(CURDIR)/debian/authforge-pam # D-Bus activation file diff --git a/docs/plans/2026-04-26-authforge-implementation.md b/docs/plans/2026-04-26-authforge-implementation.md index 7951f80..619aec0 100644 --- a/docs/plans/2026-04-26-authforge-implementation.md +++ b/docs/plans/2026-04-26-authforge-implementation.md @@ -37,7 +37,7 @@ | 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands | | 8 | GUI: app shell + Security Keys tab | 6–8 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` | | 9 | GUI: Policy tab + lockout-warning UX | 4 days | ✅ **Code complete** (2026-04-27) — bundles Phase 12 GUI tab; 3 unit tests in `policy_form` | -| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | **Spec'd** — open now (Phase 6 ✓ + Phase 8 ✓) | +| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | ✅ **Code complete** (2026-04-27) — VM smoke deferred | | 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — open now (Phase 2 ✓ + Phase 4 ✓ + Phase 8 ✓ for the GUI tab) | | 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM + GUI tab all landed | | 13 | Debian packaging finalization (postinst, debconf, purge) | 4 days | **Spec'd** — sequential tail; needs all binaries built | @@ -300,6 +300,19 @@ Landed via the plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and **Deferred until Phase 14 VM smoke:** - Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session with a Yubikey. +### Phase 10 closeout notes (2026-04-27) + +Landed via [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md). Built atop the prep lane's `--first-run` flag scaffold and the `GetPendingStatus` D-Bus method. + +**Done:** +- `gui/src/firstrun/{mod,watchdog}.rs` — fullscreen `adw::ApplicationWindow` modal, 60s idle watchdog (3 unit tests on `WatchdogState`), enrollment via the Phase 8 `enroll_dialog`, `ClearPendingFlag` on success, `gnome-session-quit --logout` on idle. Daemon-connect / `GetPendingStatus` failures render a Skip button via a shared `render_unavailable` helper instead of stranding the user for 60s. +- `gui/data/authforge-firstrun.desktop` — autostart entry installed to `/etc/xdg/autostart/`. `OnlyShowIn=GNOME`, `X-GNOME-Autostart-Phase=Initialization`. Cost on every session start: one D-Bus call to `GetPendingStatus`; no pending flag → immediate exit 0. +- `debian/authforge-gui.install` — installs the autostart entry alongside the binary. + +**Plan deviations:** Task 4's Skip-button code was factored into a single `render_unavailable(win, status, message)` helper called from both the daemon-connect and `GetPendingStatus` error arms, rather than inlining the snippet twice as the plan literal suggested. Same UX, no duplication. + +**Deferred until Phase 14 VM smoke:** the full Flow C walkthrough (`useradd -m testuser; chage -d 0 testuser; sudo authforgectl pending set testuser --methods fido2; logout; login; see modal; enroll; see desktop`). Recipe lives in `gui/FIRSTRUN-TESTING.md`. + --- # Phase 0: Repository Scaffolding (FULLY DETAILED) diff --git a/gui/FIRSTRUN-TESTING.md b/gui/FIRSTRUN-TESTING.md new file mode 100644 index 0000000..088a711 --- /dev/null +++ b/gui/FIRSTRUN-TESTING.md @@ -0,0 +1,53 @@ +# Testing the First-Run Modal (Phase 10) + +Phase 10's user-visible flow can only be exercised against a real GTK +session. CI verifies that the binary builds and `--first-run` parses; +this recipe is the manual gate. + +## Prerequisites + +- Ubuntu 24.04 VM with GNOME on Wayland (matches the project's target). +- `authforge-daemon` + `authforge-gui` debs installed (Phase 14 PPA build). +- A USB FIDO2 device (Yubikey, Solo, etc.) attached to the VM. + +## Walk-through + +```bash +# As root in the VM: +sudo useradd -m testuser +sudo passwd testuser # set a temporary password +sudo chage -d 0 testuser # force password change at next login +sudo authforgectl pending set testuser --methods fido2 + +# Log out of the current session, log in as testuser. +# Expected sequence: +# 1. GDM forces a password change → set the real password. +# 2. Session starts. +# 3. authforge --first-run launches via the autostart entry. +# 4. Fullscreen modal appears: "Welcome — Enroll a security key…" +# 5. Click "Get started" → enrollment dialog appears. +# 6. Touch the Yubikey → modal closes → desktop usable. +# 7. Verify: authforgectl pending list (run as root in another tty) shows +# no pending flag for testuser. + +# Re-test the auto-logout watchdog: +sudo authforgectl pending set testuser --methods fido2 +# Log out, log in as testuser, do nothing for 60+ seconds. +# Expected: the user is logged out via gnome-session-quit and returned +# to GDM. + +# Re-test the daemon-down case: +sudo systemctl stop authforge-daemon.service +# Log out, log in as testuser. The modal shows "Setup unavailable" with +# a Skip button; clicking it returns to the desktop. +sudo systemctl start authforge-daemon.service +``` + +## Pass criteria + +- [ ] The modal appears within 2s of session start. +- [ ] Successful enrollment clears the pending flag and exits the modal. +- [ ] 60s idle triggers a clean session logout. +- [ ] Daemon-down shows a graceful Skip button rather than hanging. +- [ ] No pending flag → no modal → session start cost is < 100ms (verify + with `journalctl --since=now -f` for the autostart unit). diff --git a/gui/data/authforge-firstrun.desktop b/gui/data/authforge-firstrun.desktop new file mode 100644 index 0000000..6ed155f --- /dev/null +++ b/gui/data/authforge-firstrun.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=AuthForge First-Run Setup +Comment=Prompts new users to enroll a security key. +Exec=authforge --first-run +NoDisplay=true +OnlyShowIn=GNOME; +X-GNOME-Autostart-Phase=Initialization +X-GNOME-Autostart-enabled=true diff --git a/gui/src/firstrun.rs b/gui/src/firstrun.rs deleted file mode 100644 index 970c361..0000000 --- a/gui/src/firstrun.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! First-login modal. Phase 10 implements the body of `run`. - -#![allow(dead_code)] // body lands in Phase 10. - -use crate::app_context::AppContext; - -/// Entry point for `authforge --first-run`. Phase 10 fills this with the -/// fullscreen modal + watchdog + enroll-then-clear-pending logic. -/// Until then it exits cleanly so a developer who passes `--first-run` -/// doesn't get a stuck process. -pub(crate) fn run(_ctx: AppContext) { - eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting."); - std::process::exit(0); -} diff --git a/gui/src/firstrun/mod.rs b/gui/src/firstrun/mod.rs new file mode 100644 index 0000000..d2a2831 --- /dev/null +++ b/gui/src/firstrun/mod.rs @@ -0,0 +1,181 @@ +//! First-login modal. Fullscreen `adw::ApplicationWindow` with a 60s idle +//! watchdog: if the user goes silent we fall back to `gnome-session-quit +//! --logout`. On a present pending flag, we run the Phase 8 enrollment +//! dialog, clear the flag on success, and exit. + +mod watchdog; +pub(crate) use watchdog::WatchdogState; + +use crate::app_context::AppContext; +use crate::bus::{current_user, Daemon}; +use crate::error::user_message; +use adw::prelude::*; +use gtk::glib; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +const WATCHDOG_TIMEOUT: Duration = Duration::from_secs(60); +const WATCHDOG_TICK: Duration = Duration::from_secs(5); + +pub(crate) fn run(ctx: AppContext) { + // Reuse the application from the parent window the activate handler + // already created — avoids re-fetching the default Application. + let app = ctx + .parent_window + .application() + .expect("parent window has its application set in main.rs"); + let app = app + .downcast::() + .expect("authforge uses adw::Application"); + + let win = adw::ApplicationWindow::builder() + .application(&app) + .title("Authentication setup") + .decorated(false) + .fullscreened(true) + .build(); + + let status = adw::StatusPage::builder() + .icon_name("dialog-password-symbolic") + .title("Setting up authentication") + .description("Connecting to the authentication daemon…") + .build(); + win.set_content(Some(&status)); + + // Watchdog state shared across the activity-poke handlers and the timer. + let watchdog = Rc::new(RefCell::new(WatchdogState::new(WATCHDOG_TIMEOUT))); + + install_activity_pokes(&win, watchdog.clone()); + install_watchdog_timer(&win, watchdog); + + // Async startup: GetPendingStatus → render flow. + let win_for_async = win.clone(); + let status_for_async = status.clone(); + glib::MainContext::default().spawn_local(async move { + let daemon = match Daemon::connect().await { + Ok(d) => d, + Err(e) => { + render_unavailable(&win_for_async, &status_for_async, &user_message(&e)); + return; + } + }; + let user = current_user(); + let s = match daemon.get_pending_status(&user).await { + Ok(s) => s, + Err(e) => { + render_unavailable(&win_for_async, &status_for_async, &user_message(&e)); + return; + } + }; + if !s.present { + // Nothing for us to do — exit cleanly. Most session starts + // hit this path; cost is one D-Bus call. + win_for_async.close(); + std::process::exit(0); + } + render_get_started(&win_for_async, &status_for_async, daemon, user); + }); + + win.present(); +} + +fn render_get_started( + win: &adw::ApplicationWindow, + status: &adw::StatusPage, + daemon: Daemon, + user: String, +) { + status.set_title("Welcome"); + status.set_description(Some( + "Enroll a security key to finish setting up your account.", + )); + let go = gtk::Button::builder() + .label("Get started") + .css_classes(["pill", "suggested-action"]) + .halign(gtk::Align::Center) + .build(); + let win_for_click = win.clone(); + let daemon_for_click = daemon.clone(); + let user_for_click = user.clone(); + go.connect_clicked(move |btn| { + // Disable the button so a second click doesn't double-prompt. + btn.set_sensitive(false); + let win_inner = win_for_click.clone(); + let daemon_inner = daemon_for_click.clone(); + let user_inner = user_for_click.clone(); + crate::enroll_dialog::present( + &win_inner.clone().upcast::(), + daemon_inner.clone(), + move || { + // On success: clear the pending flag, then exit. + let win_done = win_inner.clone(); + let daemon_done = daemon_inner.clone(); + let user_done = user_inner.clone(); + glib::MainContext::default().spawn_local(async move { + if let Err(e) = daemon_done.clear_pending_flag(&user_done).await { + // Even if clearing fails, exit; the user enrolled + // a key and the next login will succeed via pam_u2f + // even if pending is still set. + eprintln!("authforge: ClearPendingFlag failed: {}", user_message(&e)); + } + win_done.close(); + std::process::exit(0); + }); + }, + ); + }); + status.set_child(Some(&go)); +} + +fn render_unavailable(win: &adw::ApplicationWindow, status: &adw::StatusPage, message: &str) { + // Daemon-connect or GetPendingStatus failure: surface the error and give + // the user a Skip button so they aren't stuck waiting 60s for the + // watchdog to log them out. + status.set_title("Setup unavailable"); + status.set_description(Some(message)); + let skip = gtk::Button::builder() + .label("Skip") + .css_classes(["pill"]) + .halign(gtk::Align::Center) + .build(); + let win_skip = win.clone(); + skip.connect_clicked(move |_| { + win_skip.close(); + std::process::exit(0); + }); + status.set_child(Some(&skip)); +} + +fn install_activity_pokes(win: &adw::ApplicationWindow, watchdog: Rc>) { + // Any keypress or click counts as activity. + let key = gtk::EventControllerKey::new(); + let w = watchdog.clone(); + key.connect_key_pressed(move |_, _, _, _| { + w.borrow_mut().poke(); + glib::Propagation::Proceed + }); + win.add_controller(key); + + let click = gtk::GestureClick::new(); + let w = watchdog; + click.connect_pressed(move |_, _, _, _| w.borrow_mut().poke()); + win.add_controller(click); +} + +fn install_watchdog_timer(win: &adw::ApplicationWindow, watchdog: Rc>) { + let win_for_timer = win.clone(); + glib::source::timeout_add_local(WATCHDOG_TICK, move || { + if watchdog.borrow().expired() { + // 60s idle — kick the user back out. gnome-session-quit + // is the documented path for "log this user out cleanly." + let _ = std::process::Command::new("gnome-session-quit") + .arg("--logout") + .arg("--no-prompt") + .spawn(); + win_for_timer.close(); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); +} diff --git a/gui/src/firstrun/watchdog.rs b/gui/src/firstrun/watchdog.rs new file mode 100644 index 0000000..de1231b --- /dev/null +++ b/gui/src/firstrun/watchdog.rs @@ -0,0 +1,55 @@ +//! Tracks the last-activity timestamp; the GTK timer queries this each tick +//! to decide whether 60s of idle has elapsed. + +use std::time::{Duration, Instant}; + +pub(crate) struct WatchdogState { + last_activity: Instant, + timeout: Duration, +} + +impl WatchdogState { + pub fn new(timeout: Duration) -> Self { + Self { + last_activity: Instant::now(), + timeout, + } + } + + pub fn poke(&mut self) { + self.last_activity = Instant::now(); + } + + /// True iff `timeout` has passed since the last poke. Once expired, + /// stays expired until the caller drops + recreates. + pub fn expired(&self) -> bool { + self.last_activity.elapsed() >= self.timeout + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_state_is_not_expired() { + let w = WatchdogState::new(Duration::from_secs(60)); + assert!(!w.expired()); + } + + #[test] + fn zero_timeout_is_immediately_expired() { + let w = WatchdogState::new(Duration::from_millis(0)); + std::thread::sleep(Duration::from_millis(2)); + assert!(w.expired()); + } + + #[test] + fn poke_resets_the_clock() { + let mut w = WatchdogState::new(Duration::from_millis(100)); + std::thread::sleep(Duration::from_millis(150)); + assert!(w.expired()); + w.poke(); + assert!(!w.expired()); + } +}