diff --git a/crates/hephd/src/auth.rs b/crates/hephd/src/auth.rs index e3081a1..c601d90 100644 --- a/crates/hephd/src/auth.rs +++ b/crates/hephd/src/auth.rs @@ -49,6 +49,13 @@ pub trait TokenVerifier: Send + Sync { /// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its /// claims, or an [`AuthError`]. fn verify(&self, bearer: &str) -> Result; + + /// The public OIDC parameters a browser client (the `heph-pwa`) needs to + /// start a login: `(issuer, client_id)`. Neither is a secret. `None` for + /// non-OIDC verifiers (e.g. test stubs). + fn oidc_config(&self) -> Option<(&str, &str)> { + None + } } /// What an OIDC provider's discovery document tells us (we only need the JWKS). @@ -156,4 +163,9 @@ impl TokenVerifier for OidcVerifier { .map_err(|e| AuthError::Invalid(e.to_string()))?; Ok(data.claims) } + + fn oidc_config(&self) -> Option<(&str, &str)> { + // The audience is the OIDC client id (Authentik sets `aud` to it). + Some((&self.issuer, &self.audience)) + } } diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index 41e5524..bfaa323 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -135,6 +135,10 @@ pub fn router_with_web( .route("/sync/push", post(push)) .route("/rpc", post(rpc_call)) .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) + // Unauthenticated: the public OIDC params (issuer + client id) a browser + // client reads to start a PKCE login. Added after the auth `route_layer` + // so it is NOT gated — the app needs it *before* it has a token. + .route("/config", get(config)) // The static shell is unauthenticated and lives behind the API routes. .fallback(serve_static) // Outermost: stamp CORS headers on every response and short-circuit the @@ -174,6 +178,20 @@ async fn cors(request: Request, next: Next) -> AxumResponse { response } +/// Public OIDC parameters for a browser client (the `heph-pwa`) to start a PKCE +/// login: `{ "issuer", "client_id" }`. Unauthenticated — neither value is a +/// secret. Returns an empty object `{}` when the hub runs without OIDC, so the +/// app can detect that and fall back to a manually pasted token. +async fn config(State(state): State) -> Json { + let body = state + .verifier + .as_ref() + .and_then(|v| v.oidc_config()) + .map(|(issuer, client_id)| serde_json::json!({ "issuer": issuer, "client_id": client_id })) + .unwrap_or_else(|| serde_json::json!({})); + Json(body) +} + /// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no /// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA /// can own its own routing. Path traversal (`..`) is rejected. diff --git a/crates/hephd/tests/web_serve.rs b/crates/hephd/tests/web_serve.rs index b176137..bd43577 100644 --- a/crates/hephd/tests/web_serve.rs +++ b/crates/hephd/tests/web_serve.rs @@ -12,10 +12,23 @@ use std::thread; use std::time::Duration; use heph_core::{FixedClock, LocalStore}; +use hephd::auth::{AuthError, Claims, TokenVerifier}; use hephd::sync::{self, SharedStore}; const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z +/// A verifier that never admits a token but advertises OIDC params, so we can +/// drive the unauthenticated `/config` route without a live IdP. +struct StubOidc; +impl TokenVerifier for StubOidc { + fn verify(&self, _bearer: &str) -> Result { + Err(AuthError::Missing) + } + fn oidc_config(&self) -> Option<(&str, &str)> { + Some(("https://idp.example/application/o/heph/", "heph")) + } +} + /// One parsed HTTP response: status line code, lowercased headers, and body. struct Resp { status: u16, @@ -64,6 +77,15 @@ fn request(addr: &str, method: &str, path: &str) -> Resp { /// an ephemeral port; return its `host:port`. The server thread + temp dirs live /// for the test's duration. fn start(web_root: Option) -> String { + start_with(None, web_root) +} + +/// As [`start`], but with an explicit token verifier (to exercise the `/config` +/// route, which reports the verifier's OIDC params). +fn start_with( + verifier: Option>, + web_root: Option, +) -> String { let (tx, rx) = mpsc::channel(); thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() @@ -78,7 +100,7 @@ fn start(web_root: Option) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); tx.send(listener.local_addr().unwrap()).unwrap(); let _keep = dir; - let app = sync::router_with_web(shared, None, web_root); + let app = sync::router_with_web(shared, verifier, web_root); axum::serve(listener, app).await.unwrap(); }); }); @@ -161,3 +183,31 @@ fn no_web_root_yields_404_for_static_paths() { // Even the 404 carries CORS headers (it passed through the layer). assert_eq!(resp.header("access-control-allow-origin"), Some("*")); } + +#[test] +fn config_is_empty_without_oidc() { + let addr = start(None); + let resp = request(&addr, "GET", "/config"); + assert_eq!(resp.status, 200); + assert_eq!(resp.body.trim(), "{}"); +} + +#[test] +fn config_reports_oidc_params_unauthenticated() { + // Even on an authed hub, /config is reachable without a token (it is added + // after the auth layer) and reports the issuer + public client id. + let addr = start_with(Some(Arc::new(StubOidc)), None); + let resp = request(&addr, "GET", "/config"); + assert_eq!(resp.status, 200); + assert!( + resp.body + .contains("\"issuer\":\"https://idp.example/application/o/heph/\""), + "body was: {}", + resp.body + ); + assert!( + resp.body.contains("\"client_id\":\"heph\""), + "body was: {}", + resp.body + ); +} diff --git a/docs/changelog.d/heph-pwa-oidc-login.feature.md b/docs/changelog.d/heph-pwa-oidc-login.feature.md new file mode 100644 index 0000000..aae9d26 --- /dev/null +++ b/docs/changelog.d/heph-pwa-oidc-login.feature.md @@ -0,0 +1 @@ +heph-pwa: added a **Login with Authentik** button — a proper browser OIDC sign-in (Authorization Code + PKCE) that replaces the manual bearer-token paste. The hub exposes an unauthenticated `GET /config` (`{issuer, client_id}`) so the app is zero-config when served from the hub; the PWA discovers the IdP endpoints, runs the PKCE redirect, exchanges the code for a token, and silently refreshes it (`offline_access`). The manual token field remains as a fallback. Requires the PWA origin registered as a redirect URI on the Authentik `heph` provider. diff --git a/docs/how-to/host-heph-pwa.md b/docs/how-to/host-heph-pwa.md index 0be1d59..63b3d76 100644 --- a/docs/how-to/host-heph-pwa.md +++ b/docs/how-to/host-heph-pwa.md @@ -95,16 +95,19 @@ app defaults its hub URL to its own origin. 1. Ensure the phone is on the tailnet (or can reach the proxy). 2. Open the hub URL (`https://indri..ts.net/`) and **Add to Home Screen**. 3. The app defaults its **Hub URL** to the origin it loaded from — no typing. -4. **Token:** the hub requires an OIDC bearer token, and the PWA does **not yet - implement the in-app device-code login** — paste a token into Settings → - Token for now. Obtain one via the device-code flow against the Authentik - client (the same flow the CLI uses; e.g. reuse the access token a logged-in - spoke cached, or run a one-off device-code grant). Tap **Test** to confirm. +4. **Sign in:** open **Settings → Login with Authentik**. The app reads the + hub's `GET /config` for the issuer + client id (zero-config) and runs an + Authorization-Code + PKCE redirect to Authentik; after you approve it lands + back on the app, signed in, and silently refreshes the token from then on. + (A manual **Bearer token** field remains as a fallback for hubs without + OIDC, or for pasting a one-off token.) -> **Known gap / next step:** wire the RFC 8628 device-code flow into the PWA's -> Settings so login is in-app (open the verification URL, poll for the token, -> store it, and refresh it) — removing the manual paste. Tracked as follow-up -> work for `heph-pwa`. +**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's +origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik +also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a +trailing slash, e.g. `https://heph.ops.eblu.me/` (and `http://localhost:8787/` +for local dev). In blumeops this is the `redirect_uris` list on the heph +provider blueprint. ## Upgrades diff --git a/heph-pwa/src/app.js b/heph-pwa/src/app.js index f06ba06..32820e0 100644 --- a/heph-pwa/src/app.js +++ b/heph-pwa/src/app.js @@ -6,6 +6,7 @@ // rpc.js). Context/KB is read-only here (no nvim editing surface). import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js"; +import * as oauth from "./oauth.js"; import { parse as quickParse } from "./quickadd.js"; import { today, parseDate, toEpochMs } from "./datespec.js"; import { @@ -40,7 +41,29 @@ const state = { lastUndo: null, // { label, run } }; -state.client = new Client(state.settings); +// Build the RPC client from the current settings, wiring an OIDC silent-refresh +// hook: on a 401 the client calls this to renew the token (oauth.js) and retry +// once before surfacing the error. +function makeClient() { + return new Client({ + baseUrl: state.settings.baseUrl, + token: state.settings.token, + refresh: async () => { + const tok = await oauth.ensureFreshToken(true); + applyToken(tok || ""); + return tok; + }, + }); +} + +// Adopt `token` as the active bearer: persist it and rebuild the client. +function applyToken(token) { + state.settings.token = token || ""; + saveSettings(state.settings); + state.client = makeClient(); +} + +state.client = makeClient(); // --- tiny DOM helper -------------------------------------------------------- @@ -692,27 +715,62 @@ function openSettings() { const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" }); const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" }); const test = h("div", { class: "settings-test" }); + const setTest = (msg, ok) => { + test.textContent = msg; + test.className = "settings-test" + (ok === true ? " ok" : ok === false ? " bad" : ""); + }; const save = async () => { state.settings.baseUrl = url.value.trim(); state.settings.token = tok.value.trim(); saveSettings(state.settings); - state.client = new Client(state.settings); + state.client = makeClient(); closeModal(); reload(); }; const check = async () => { - test.textContent = "Checking…"; + setTest("Checking…", null); const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() }); try { const v = await probe.call("version", {}); - test.textContent = `✓ Connected (hephd ${v.version})`; - test.className = "settings-test ok"; + setTest(`✓ Connected (hephd ${v.version})`, true); } catch (e) { - test.textContent = `✗ ${e.message}`; - test.className = "settings-test bad"; + setTest(`✗ ${e.message}`, false); } }; + // Login with Authentik: read the hub's /config for the issuer + client id, + // then start the PKCE redirect (this navigates away and returns to init()). + const login = async () => { + const hub = url.value.trim() || state.settings.baseUrl; + if (!hub) return setTest("✗ Set the hub URL first.", false); + setTest("Contacting hub…", null); + const cfg = await oauth.fetchHubConfig(hub); + if (!cfg) { + return setTest("✗ Hub has no OIDC (/config) — paste a token, or enable OIDC on the hub.", false); + } + state.settings.baseUrl = hub; + saveSettings(state.settings); // persist before we navigate away + try { + await oauth.beginLogin(cfg); + } catch (e) { + setTest(`✗ ${e.message}`, false); + } + }; + const logout = () => { + oauth.clearAuth(); + applyToken(""); + closeModal(); + reload(); + }; + + const authRow = oauth.loggedIn() + ? h( + "div", + { class: "settings-auth" }, + h("span", { class: "settings-test ok" }, "✓ Signed in with Authentik"), + h("button", { class: "act", onclick: logout }, "Log out"), + ) + : h("button", { class: "qa-add settings-login", onclick: login }, "Login with Authentik"); openModal( h( @@ -721,8 +779,14 @@ function openSettings() { h("div", { class: "modal-title" }, "Settings"), h("label", { class: "settings-label" }, "Hub URL"), url, - h("label", { class: "settings-label" }, "Token"), - tok, + h("label", { class: "settings-label" }, "Sign-in"), + authRow, + h( + "details", + { class: "settings-manual" }, + h("summary", {}, "Or paste a bearer token"), + tok, + ), test, h( "div", @@ -730,7 +794,7 @@ function openSettings() { h("button", { class: "act", onclick: check }, "Test"), h("button", { class: "qa-add", onclick: save }, "Save"), ), - h("div", { class: "settings-hint" }, "The hub is your server-mode hephd. Leave the token blank if the hub runs without OIDC."), + h("div", { class: "settings-hint" }, "“Login with Authentik” needs OIDC enabled on the hub. Leave sign-in empty for a hub running without OIDC."), ), ); } @@ -802,6 +866,20 @@ async function init() { } }); + // OIDC: finish a redirect callback (back from Authentik), or refresh an + // existing session, so the first reload() already carries a valid bearer. + if (oauth.isCallback()) { + try { + applyToken(await oauth.completeLogin()); + toast("Signed in."); + } catch (e) { + toast(`Sign-in failed: ${e.message}`); + } + } else if (oauth.loggedIn()) { + const tok = await oauth.ensureFreshToken(); + if (tok) applyToken(tok); + } + render(); reload(); diff --git a/heph-pwa/src/oauth.js b/heph-pwa/src/oauth.js new file mode 100644 index 0000000..0bc0763 --- /dev/null +++ b/heph-pwa/src/oauth.js @@ -0,0 +1,204 @@ +// Browser OIDC sign-in for the PWA: Authorization Code + PKCE (RFC 7636) against +// the hub's IdP (Authentik). Unlike the CLI's device-code flow, a browser SPA +// uses a redirect + PKCE — no client secret, no polling. The resulting access +// token is the same bearer the hub's OidcVerifier checks (iss / aud=client_id / +// RS256 / exp), so once signed in the app talks to /rpc exactly as a pasted +// token would. We also keep a refresh token (offline_access) to renew silently. +// +// Zero-config: the hub serves GET /config -> { issuer, client_id }, so the app +// learns the IdP without the user typing anything when served from the hub. + +const AUTH_KEY = "heph-pwa:auth"; // localStorage: { issuer, clientId, access, refresh, expiresAt } +const PKCE_KEY = "heph-pwa:pkce"; // sessionStorage: in-flight { verifier, state, ... } + +// --- persistence ------------------------------------------------------------ + +export function loadAuth() { + try { + return JSON.parse(localStorage.getItem(AUTH_KEY) || "null"); + } catch { + return null; + } +} +function saveAuth(a) { + localStorage.setItem(AUTH_KEY, JSON.stringify(a)); +} +export function clearAuth() { + localStorage.removeItem(AUTH_KEY); +} +export function loggedIn() { + return !!loadAuth(); +} + +// --- PKCE helpers ----------------------------------------------------------- + +function b64url(bytes) { + return btoa(String.fromCharCode(...new Uint8Array(bytes))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} +function randomString(nbytes = 32) { + const a = new Uint8Array(nbytes); + crypto.getRandomValues(a); + return b64url(a); +} +async function challengeOf(verifier) { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return b64url(digest); +} + +// The redirect URI is the app's own base directory (query/hash stripped), so it +// is stable across the login start and the callback. Register this exact value +// (with trailing slash) on the Authentik provider, e.g. https://heph.ops.eblu.me/. +function redirectUri() { + return new URL(".", location.href).href; +} + +// --- discovery -------------------------------------------------------------- + +async function discover(issuer) { + const url = issuer.replace(/\/+$/, "") + "/.well-known/openid-configuration"; + const r = await fetch(url); + if (!r.ok) throw new Error(`OIDC discovery failed (HTTP ${r.status}).`); + const d = await r.json(); + if (!d.authorization_endpoint || !d.token_endpoint) { + throw new Error("OIDC discovery is missing authorization/token endpoints."); + } + return { authorize: d.authorization_endpoint, token: d.token_endpoint }; +} + +/** Read the hub's public OIDC params. Returns { issuer, clientId } or null. */ +export async function fetchHubConfig(baseUrl) { + try { + const r = await fetch(baseUrl.replace(/\/+$/, "") + "/config"); + if (!r.ok) return null; + const d = await r.json(); + if (!d.issuer || !d.client_id) return null; + return { issuer: d.issuer, clientId: d.client_id }; + } catch { + return null; + } +} + +// --- login (redirect away) -------------------------------------------------- + +/** Begin a PKCE login: stash the verifier+state and redirect to the IdP. */ +export async function beginLogin({ issuer, clientId }) { + const { authorize } = await discover(issuer); + const verifier = randomString(48); + const state = randomString(16); + const redirect_uri = redirectUri(); + sessionStorage.setItem( + PKCE_KEY, + JSON.stringify({ verifier, state, issuer, clientId, redirect_uri }), + ); + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri, + scope: "openid offline_access", + state, + code_challenge: await challengeOf(verifier), + code_challenge_method: "S256", + }); + location.assign(`${authorize}?${params}`); +} + +// --- callback (back from the IdP) ------------------------------------------- + +/** True when the current URL is an OAuth redirect callback. */ +export function isCallback() { + const p = new URLSearchParams(location.search); + return (p.has("code") && p.has("state")) || p.has("error"); +} + +/** Exchange the callback code for tokens. Always cleans the URL. Returns the + * access token on success; throws on failure. */ +export async function completeLogin() { + const p = new URLSearchParams(location.search); + const cleanUrl = () => history.replaceState(null, "", redirectUri()); + let pkce = null; + try { + pkce = JSON.parse(sessionStorage.getItem(PKCE_KEY) || "null"); + } catch { + pkce = null; + } + sessionStorage.removeItem(PKCE_KEY); + + if (p.get("error")) { + cleanUrl(); + throw new Error(p.get("error_description") || p.get("error")); + } + if (!pkce || pkce.state !== p.get("state")) { + cleanUrl(); + throw new Error("Login state mismatch — please try again."); + } + + const { token } = await discover(pkce.issuer); + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: p.get("code"), + client_id: pkce.clientId, + redirect_uri: pkce.redirect_uri, + code_verifier: pkce.verifier, + }); + const r = await fetch(token, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + cleanUrl(); + if (!r.ok) throw new Error(`Token exchange failed (HTTP ${r.status}).`); + const t = await r.json(); + saveAuth({ + issuer: pkce.issuer, + clientId: pkce.clientId, + access: t.access_token, + refresh: t.refresh_token || null, + expiresAt: Date.now() + (t.expires_in || 3600) * 1000, + }); + return t.access_token; +} + +// --- token lifecycle -------------------------------------------------------- + +/** Return a usable access token, refreshing if it is near expiry (or `force`). + * Returns null when not logged in or when a refresh fails (caller re-prompts). */ +export async function ensureFreshToken(force = false) { + const a = loadAuth(); + if (!a) return null; + const stillFresh = a.expiresAt - Date.now() > 60_000; + if (!force && stillFresh) return a.access; + if (!a.refresh) return force ? null : a.access; + try { + const { token } = await discover(a.issuer); + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: a.refresh, + client_id: a.clientId, + }); + const r = await fetch(token, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!r.ok) { + // Refresh token rejected (expired/revoked) — drop the session so the UI + // shows "signed out" and the user can log in again. + clearAuth(); + return null; + } + const t = await r.json(); + saveAuth({ + ...a, + access: t.access_token, + refresh: t.refresh_token || a.refresh, + expiresAt: Date.now() + (t.expires_in || 3600) * 1000, + }); + return t.access_token; + } catch { + // Network blip — keep the (possibly stale) token; the RPC layer will retry. + return a.access; + } +} diff --git a/heph-pwa/src/rpc.js b/heph-pwa/src/rpc.js index 8f8b690..0a33631 100644 --- a/heph-pwa/src/rpc.js +++ b/heph-pwa/src/rpc.js @@ -49,8 +49,10 @@ export class Client { return !!this.settings.baseUrl; } - /** Low-level call: returns the `result` value, or throws RpcError. */ - async call(method, params = {}) { + /** Low-level call: returns the `result` value, or throws RpcError. On a 401, + * tries the optional `settings.refresh()` hook once (OIDC silent refresh) and + * retries before surfacing the error. */ + async call(method, params = {}, _retried = false) { if (!this.configured) { throw new RpcError("No hub configured — open Settings and set the hub URL.", 0); } @@ -68,7 +70,16 @@ export class Client { } catch (e) { throw new RpcError(`Cannot reach hub at ${base} (${e.message}).`, 0); } - if (resp.status === 401) throw new RpcError("Unauthorized — set or refresh your token.", 401); + if (resp.status === 401) { + if (!_retried && typeof this.settings.refresh === "function") { + const fresh = await this.settings.refresh(); + if (fresh) { + this.settings.token = fresh; + return this.call(method, params, true); + } + } + throw new RpcError("Unauthorized — sign in again (Settings).", 401); + } if (resp.status === 403) throw new RpcError("Forbidden — this token does not own the hub.", 403); if (!resp.ok) throw new RpcError(`Hub returned HTTP ${resp.status}.`, resp.status); diff --git a/heph-pwa/styles.css b/heph-pwa/styles.css index ed64983..ec734a8 100644 --- a/heph-pwa/styles.css +++ b/heph-pwa/styles.css @@ -435,6 +435,26 @@ body { font-size: 12px; color: var(--fg-dim); } +.settings-login { + align-self: stretch; +} +.settings-auth { + display: flex; + align-items: center; + gap: 10px; +} +.settings-auth .settings-test { + flex: 1; +} +.settings-manual > summary { + font-size: 13px; + color: var(--fg-dim); + cursor: pointer; + margin-bottom: 6px; +} +.settings-manual[open] > summary { + margin-bottom: 10px; +} /* --- Search --- */ .search-pane { diff --git a/heph-pwa/sw.js b/heph-pwa/sw.js index 571d1e6..5793eab 100644 --- a/heph-pwa/sw.js +++ b/heph-pwa/sw.js @@ -1,7 +1,7 @@ // Service worker: cache the app shell so heph launches offline. Data is never // cached — every /rpc call must hit the live hub (and POSTs aren't cacheable // anyway). Bump CACHE when shell assets change to evict the old set. -const CACHE = "heph-pwa-v3"; +const CACHE = "heph-pwa-v4"; const SHELL = [ "./", "./index.html", @@ -9,6 +9,7 @@ const SHELL = [ "./manifest.webmanifest", "./src/app.js", "./src/rpc.js", + "./src/oauth.js", "./src/quickadd.js", "./src/datespec.js", "./src/fmt.js", @@ -31,8 +32,10 @@ self.addEventListener("activate", (e) => { self.addEventListener("fetch", (e) => { const req = e.request; // Only cache same-origin GETs (the shell). Everything else (RPC, cross-origin) - // goes straight to the network. - if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) { + // goes straight to the network. Skip URLs with a query string too, so the OAuth + // redirect callback (`/?code=…&state=…`) is never cached or served from cache. + const u = new URL(req.url); + if (req.method !== "GET" || u.origin !== self.location.origin || u.search) { return; } e.respondWith(