From 18f76513625766af095b792d3a0b8cfd901fdbb5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 17:04:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20signed=20in=20at=20PedsHub=20means=20si?= =?UTF-8?q?gned=20in=20here=20=E2=80=94=20one=20silent=20prompt=3Dnone=20a?= =?UTF-8?q?ttempt=20before=20the=20sign-in=20page,=20hash=20kept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- docs/authentication.md | 12 ++++++++++++ public/js/auth.js | 35 ++++++++++++++++++++++++++++++++-- src/routes/oidc.js | 20 +++++++++++++++++-- test/backend-hardening.test.js | 12 ++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index f3cf2f8b..e9628757 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -16,6 +16,18 @@ that account. Roles follow the SSO's groups on every sign-in when admin is never demoted by a claim. The sections below describe the local machinery that remains behind the switch. +## Signed in at PedsHub means signed in here + +A visitor with no session here is not shown the sign-in page straight away. +The page first asks the provider silently (`/api/auth/oidc/login?silent=1`, +which adds `prompt=none`): someone already signed in at sso.pedshub.com — from +the quiz app, say, following a deck link — arrives signed in without a click, +the way a Kerberos ticket carries across services. Someone not signed in there +gets the provider's refusal, which the callback turns into the ordinary +sign-in page (`?sso=none`, no message). The attempt happens once per browser +session, never after an explicit sign-out and never inside the mobile shell, +and the URL fragment (a share link, a tab) is kept across the round trip. + ## Lockdown: the admin panel as view-only `ADMIN_LOCKDOWN=true` in the environment (never a setting, so no admin can diff --git a/public/js/auth.js b/public/js/auth.js index ad68f40c..9a171127 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -231,6 +231,7 @@ document.addEventListener('DOMContentLoaded', function() { if (ssoOk === 'ok' && !boundary.needsSignIn() && (!boundary.signedOut() || ssoIntent)) { var ssoSid = urlParams.get('sid'); history.replaceState(null, '', window.location.pathname); + restorePendingHash(); // Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically) fetch('/api/auth/me', { credentials: 'same-origin' }) .then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); }) @@ -240,6 +241,12 @@ document.addEventListener('DOMContentLoaded', function() { } else { showAuthScreen(); } }) .catch(function() { showAuthScreen(); }); + } else if (urlParams.get('sso') === 'none') { + // The silent attempt found no provider session. Ordinary sign-in page, + // no message: nothing went wrong, nobody was signed in. + history.replaceState(null, '', window.location.pathname); + restorePendingHash(); + showAuthScreen(); } else if (ssoError) { history.replaceState(null, '', window.location.pathname); var errorMsgs = { @@ -306,12 +313,36 @@ document.addEventListener('DOMContentLoaded', function() { .then(function(r) { if (r.ok) return r.json(); throw new Error('not-logged-in'); }) .then(function(data) { if (data && data.user) return enterApp(data.user, '', false, null, bootstrapState); - else showAuthScreen(); + else trySilentSso(); }) - .catch(function() { showAuthScreen(); }); + .catch(function() { trySilentSso(); }); } } + // Signed in at the provider already? Then this page should not ask. One + // silent round trip per browser session (prompt=none), skipped after an + // explicit sign-out and while the account boundary is holding; the URL + // fragment (a share link, a tab) is stashed and put back afterwards. + function trySilentSso() { + var tried = false; + try { tried = sessionStorage.getItem('ped_sso_silent') === '1'; } catch (e) {} + if (tried || boundary.signedOut() || boundary.blocked() || window.Capacitor) { showAuthScreen(); return; } + fetch('/api/auth/oidc-status').then(function(r) { return r.json(); }).then(function(data) { + if (!data || !data.oidcEnabled || !data.disableLocalAuth) { showAuthScreen(); return; } + try { + sessionStorage.setItem('ped_sso_silent', '1'); + if (window.location.hash) sessionStorage.setItem('ped_pending_hash', window.location.hash); + } catch (e) {} + window.location.replace('/api/auth/oidc/login?silent=1'); + }).catch(function() { showAuthScreen(); }); + } + function restorePendingHash() { + try { + var hash = sessionStorage.getItem('ped_pending_hash'); + if (hash) { sessionStorage.removeItem('ped_pending_hash'); window.location.hash = hash; } + } catch (e) {} + } + // ---- CLOUDFLARE TURNSTILE ---- // Gates registration and password reset. Login is deliberately NOT gated: // it is already covered by a 10-per-15-min rate limit and a constant-time diff --git a/src/routes/oidc.js b/src/routes/oidc.js index 6f0fec7c..9bd238f9 100644 --- a/src/routes/oidc.js +++ b/src/routes/oidc.js @@ -142,22 +142,31 @@ router.get('/oidc', async function(req, res) { var codeVerifier = oidc.randomPKCECodeVerifier(); var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); + // A silent attempt asks the provider for an answer without showing anyone + // anything: signed in there already means signed in here, the way a + // Kerberos ticket works; not signed in there comes back as a refusal the + // callback turns into the ordinary sign-in page. The page starts it once + // per browser session, never after an explicit sign-out. + var silent = req.query.silent === '1'; var state = crypto.randomBytes(24).toString('hex'); res.cookie(transactionCookie, signState({ s: state, n: nonce, v: codeVerifier, + q: silent ? 1 : 0, expires: Date.now() + transactionTTL }), Object.assign({ maxAge: transactionTTL }, transactionOptions)); - var authUrl = oidc.buildAuthorizationUrl(config, { + var authParams = { redirect_uri: redirectUri, scope: 'openid email profile', state: state, nonce: nonce, code_challenge: codeChallenge, code_challenge_method: 'S256' - }); + }; + if (silent) authParams.prompt = 'none'; + var authUrl = oidc.buildAuthorizationUrl(config, authParams); res.redirect(authUrl.href); } catch (err) { @@ -178,6 +187,13 @@ router.get('/oidc/callback', async function(req, res) { if (typeof state !== 'string' || !/^[a-f0-9]{48}$/.test(state) || !pending || pending.s !== state) { return res.redirect(appUrl + '?error=invalid_state'); } + if (typeof req.query.error === 'string' && req.query.error) { + // login_required / interaction_required / consent_required: the provider + // could not answer without a person. For a silent attempt that is the + // expected "no session there" and the page simply shows sign-in. + if (pending.q === 1) return res.redirect(appUrl + '?sso=none'); + return res.redirect(appUrl + '?error=sso_failed'); + } if (await db.getSetting('oidc.enabled') !== 'true') return res.redirect(appUrl + '?error=sso_disabled'); var issuer = await db.getSetting('oidc.issuer'); diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index 050a4f0e..9fcd4046 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -283,3 +283,15 @@ test('the library index button is an operation, allowed under lockdown, and the assert.match(js, /setValue\('assistant-indexer-token', ''\)/); assert.match(read('docs/clinical-assistant.md'), /## Library indexing: on a schedule, and on request/); }); + + +test('silent SSO: prompt=none on request, a refusal is not an error, the page tries once and keeps the hash', () => { + const oidc = read('src/routes/oidc.js'); + assert.match(oidc, /if \(silent\) authParams\.prompt = 'none';/); + assert.match(oidc, /if \(pending\.q === 1\) return res\.redirect\(appUrl \+ '\?sso=none'\);/); + const js = read('public/js/auth.js'); + assert.match(js, /sessionStorage\.setItem\('ped_sso_silent', '1'\)/); + assert.match(js, /boundary\.signedOut\(\) \|\| boundary\.blocked\(\)/); + assert.match(js, /urlParams\.get\('sso'\) === 'none'/); + assert.match(read('docs/authentication.md'), /## Signed in at PedsHub means signed in here/); +});