Merge branch 'feature/phase-10-firstrun'

This commit is contained in:
michael
2026-04-27 12:05:18 -07:00
7 changed files with 317 additions and 15 deletions

5
debian/rules vendored
View File

@@ -23,6 +23,11 @@ override_dh_auto_install:
debian/authforge-gui/usr/share/applications/io.dangerousthings.AuthForge.desktop debian/authforge-gui/usr/share/applications/io.dangerousthings.AuthForge.desktop
install -D -m 0644 gui/data/io.dangerousthings.AuthForge.svg \ install -D -m 0644 gui/data/io.dangerousthings.AuthForge.svg \
debian/authforge-gui/usr/share/icons/hicolor/scalable/apps/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 # PAM
$(MAKE) -C pam install DESTDIR=$(CURDIR)/debian/authforge-pam $(MAKE) -C pam install DESTDIR=$(CURDIR)/debian/authforge-pam
# D-Bus activation file # D-Bus activation file

View File

@@ -37,7 +37,7 @@
| 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands | | 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands |
| 8 | GUI: app shell + Security Keys tab | 68 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` | | 8 | GUI: app shell + Security Keys tab | 68 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` | | 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) | | 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 | | 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 | | 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:** **Deferred until Phase 14 VM smoke:**
- Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session with a Yubikey. - 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) # Phase 0: Repository Scaffolding (FULLY DETAILED)

53
gui/FIRSTRUN-TESTING.md Normal file
View File

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

View File

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

View File

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

181
gui/src/firstrun/mod.rs Normal file
View File

@@ -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::<adw::Application>()
.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::<gtk::Window>(),
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<RefCell<WatchdogState>>) {
// 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<RefCell<WatchdogState>>) {
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
});
}

View File

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