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
|
admin is never demoted by a claim. The sections below describe the local
|
||||||
machinery that remains behind the switch.
|
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
|
## Lockdown: the admin panel as view-only
|
||||||
|
|
||||||
`ADMIN_LOCKDOWN=true` in the environment (never a setting, so no admin can
|
`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)) {
|
if (ssoOk === 'ok' && !boundary.needsSignIn() && (!boundary.signedOut() || ssoIntent)) {
|
||||||
var ssoSid = urlParams.get('sid');
|
var ssoSid = urlParams.get('sid');
|
||||||
history.replaceState(null, '', window.location.pathname);
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
restorePendingHash();
|
||||||
// Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically)
|
// Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically)
|
||||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); })
|
.then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); })
|
||||||
|
|
@ -240,6 +241,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
} else { showAuthScreen(); }
|
} else { showAuthScreen(); }
|
||||||
})
|
})
|
||||||
.catch(function() { 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) {
|
} else if (ssoError) {
|
||||||
history.replaceState(null, '', window.location.pathname);
|
history.replaceState(null, '', window.location.pathname);
|
||||||
var errorMsgs = {
|
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(r) { if (r.ok) return r.json(); throw new Error('not-logged-in'); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data && data.user) return enterApp(data.user, '', false, null, bootstrapState);
|
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 ----
|
// ---- CLOUDFLARE TURNSTILE ----
|
||||||
// Gates registration and password reset. Login is deliberately NOT gated:
|
// 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
|
// 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 codeVerifier = oidc.randomPKCECodeVerifier();
|
||||||
var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
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');
|
var state = crypto.randomBytes(24).toString('hex');
|
||||||
res.cookie(transactionCookie, signState({
|
res.cookie(transactionCookie, signState({
|
||||||
s: state,
|
s: state,
|
||||||
n: nonce,
|
n: nonce,
|
||||||
v: codeVerifier,
|
v: codeVerifier,
|
||||||
|
q: silent ? 1 : 0,
|
||||||
expires: Date.now() + transactionTTL
|
expires: Date.now() + transactionTTL
|
||||||
}), Object.assign({ maxAge: transactionTTL }, transactionOptions));
|
}), Object.assign({ maxAge: transactionTTL }, transactionOptions));
|
||||||
|
|
||||||
var authUrl = oidc.buildAuthorizationUrl(config, {
|
var authParams = {
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope: 'openid email profile',
|
scope: 'openid email profile',
|
||||||
state: state,
|
state: state,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: 'S256'
|
code_challenge_method: 'S256'
|
||||||
});
|
};
|
||||||
|
if (silent) authParams.prompt = 'none';
|
||||||
|
var authUrl = oidc.buildAuthorizationUrl(config, authParams);
|
||||||
|
|
||||||
res.redirect(authUrl.href);
|
res.redirect(authUrl.href);
|
||||||
} catch (err) {
|
} 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) {
|
if (typeof state !== 'string' || !/^[a-f0-9]{48}$/.test(state) || !pending || pending.s !== state) {
|
||||||
return res.redirect(appUrl + '?error=invalid_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');
|
if (await db.getSetting('oidc.enabled') !== 'true') return res.redirect(appUrl + '?error=sso_disabled');
|
||||||
var issuer = await db.getSetting('oidc.issuer');
|
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(js, /setValue\('assistant-indexer-token', ''\)/);
|
||||||
assert.match(read('docs/clinical-assistant.md'), /## Library indexing: on a schedule, and on request/);
|
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