Opening one PedsHub app while already signed in at the provider for the
other one should not produce a sign-in page. It asks the provider once,
with prompt=none — "do you already know this person?" — and if the
answer is yes the round trip finishes with no screen and no click.
The refusal is the interesting half. login_required,
interaction_required, consent_required and account_selection_required
are the provider saying nobody is signed in, which is an answer rather
than a failure: the visitor lands on the page they asked for, with no
message and no sign of having been anywhere. Anything else still goes to
/login?error=sso_failed, and a silent attempt that throws is swallowed
too — nobody should be interrupted by a request they did not make.
The whole risk in this is a loop between two sites, so: at most one
attempt per browser session, never after somebody has signed themselves
out, and never inside a native shell where there is no third-party
cookie to carry the provider's session. Signing out sets a marker that
outlives the tab; pressing any sign-in control clears it, because that
is a person saying they have changed their mind.
A deep link survives the trip. The intended path rides in the server
session rather than the URL, and is validated on the way back — a
scheme, a host or a protocol-relative //evil all collapse to "/",
because a sign-in round trip is exactly where an open redirect would
live.
Verified against the live provider: /api/auth/sso/login?prompt=none
answers 302 to Authentik carrying prompt=none, state and nonce, and a
visitor with no session anywhere lands on the landing page with the
attempt marked spent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The account settings offered "Change password", and there is no password
to change: accounts live at PedsHub SSO, which is also where a passkey,
an address or a second factor is set. A form that writes a credential
nothing checks is worse than no form — it invites somebody to secure
their account by a route that does not secure it. The panel now keeps
the display name and points at the provider for the rest.
Retired with it: GET /auth/verify-email, POST /auth/resend-verification,
POST /auth/forgot-password, POST /auth/reset-password, the new_password
branch of PUT /auth/me, the two schemas behind them, and the three pages
— VerifyEmailPage, ForgotPasswordPage, ResetPasswordPage — with their
routes and the links into them. An account with no password cannot
forget one.
POST /auth/login stays, still refused while sso_only is set. It is the
way back in if the provider is ever unreachable, together with the
DEFAULT_ADMIN_EMAIL seed at startup, and removing it would leave no door
at all on a bad day. The test that walked five password doors now walks
that one and asserts the other four answer 404 rather than 403 — gone,
not guarded.
Verified live: all four endpoints 404, and the account panel shows a
name field and "Manage your account ↗" to
sso.pedshub.com/if/user/#/settings, with no password field anywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Gone: /auth/register, /auth/signup-policy, POST /admin/users, the
RegisterPage, the register half of the landing modal, the Register
button, the "Sign up" link, the "Create an account" hero button, and the
UserCreate schema. /register redirects to /login for anybody holding a
bookmark. A first admin on a fresh install still comes from
DEFAULT_ADMIN_EMAIL at startup, so nothing is locked out.
And no flash of the old way in. Both sign-in surfaces defaulted to "no
provider" and drew the email form while /auth/sso/config was in flight,
then swapped it — so a reload showed a form that does not exist, briefly,
every time. They render nothing until the answer arrives. The landing
modal is now one button, "Sign in with PedsHub SSO", with no sentence
under it: the button already says where you are going.
Also, the section strip takes the width it has. It sat inside the 1200px
measure that keeps an article readable, so on a wide desktop the last
entries fell off the end and a scroll arrow appeared beside acres of
empty space. Verified at 1280, 1600 and 1920: ten links, no arrows.
And "Make a deck" comes out of the strip and the phone menu — that was
an over-reach on my part. The landing CTA keeps it, pointing at
app.pedshub.com/#resources, which is what was actually asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
/register drew the whole form under SSO-only — name, email, password
twice, invite code, Sign Up — and only the POST refused it. That is
asking somebody for four fields and a password they will never use
before telling them the door does not exist.
/auth/signup-policy is the question the form already asks, so it answers
it: sso_only, the provider's name, and registration_open false whenever
single sign-on is the only way in. The page says one line instead. The
Register tab on the landing modal and the Register button in the header
go with it — and the modal falls back to Sign in if it was opened
straight into Register.
The sign-in page needed nothing: it already hid its "Sign up" link on
registration_open false, which is now also true under SSO.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two findings from a security pass on the SSO path, both real.
sso_only gated login and the login codes and nothing else. Register,
forgot-password, reset-password, resend-verification and setting a
password through PUT /auth/me all went through — so a site running
single sign-on could still mint a password account nobody vetted, and if
the flag were ever turned off, there it would be. One helper, five doors,
403 with a reason at each.
And the access token travelled in a query string. The SSO redirect is a
page load, so the browser asked nginx for /sso-callback?token=<a live
bearer token, good for a day> and nginx logs the request line — every
sign-in wrote one into the frontend container's access log, the
browser's history, and the Referer of whatever loaded next. It carries a
one-time code now: a random 32 bytes parked in Redis for sixty seconds,
traded at POST /auth/sso/exchange for the token, and deleted as it is
read, so a code replayed from any of those places buys nothing.
Also the OIDC state cookie, which is what stops an authorization
response being replayed at you: same_site lax (strict drops it on the
provider's top-level GET and fails every sign-in) and secure whenever
APP_URL is https.
And one cross, not two. The header's menu button is already a cross
while a drawer is open, so the drawer's own close button was a second
control an inch below it for the same job — gone from the article, the
player and the review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN