feat: signed in at PedsHub means signed in here — one silent prompt=none attempt before the sign-in page, hash kept
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 7s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 17:04:25 +02:00
parent e23d35a570
commit 18f7651362
4 changed files with 75 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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