feat: signed in at PedsHub means signed in here — one silent prompt=none attempt before the sign-in page, hash kept
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
e23d35a570
commit
18f7651362
4 changed files with 75 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue