generated from eblume/project-template
Merge pull request 'heph-pwa: Login with Authentik (Authorization Code + PKCE)' (#9) from heph-pwa-oidc-login into main
All checks were successful
Build / validate (push) Successful in 7m46s
All checks were successful
Build / validate (push) Successful in 7m46s
Reviewed-on: #9
This commit is contained in:
commit
36bd27226f
10 changed files with 427 additions and 27 deletions
|
|
@ -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<Claims, AuthError>;
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HubState>) -> Json<Value> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<Claims, AuthError> {
|
||||
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<std::path::PathBuf>) -> 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<Arc<dyn TokenVerifier>>,
|
||||
web_root: Option<std::path::PathBuf>,
|
||||
) -> 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<std::path::PathBuf>) -> 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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
1
docs/changelog.d/heph-pwa-oidc-login.feature.md
Normal file
1
docs/changelog.d/heph-pwa-oidc-login.feature.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.<tailnet>.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
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();
|
||||
|
||||
|
|
|
|||
204
heph-pwa/src/oauth.js
Normal file
204
heph-pwa/src/oauth.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue