Skip to content

fix(auth): send signed out visitors to sign in before a study - #2348

Merged
KarinePistili merged 2 commits into
ruxailab:developfrom
namanjain24-sudo:fix/redirect-unauthenticated-study-access
Sep 14, 2026
Merged

KarinePistili merged 2 commits into
ruxailab:developfrom
namanjain24-sudo:fix/redirect-unauthenticated-study-access

Conversation

@namanjain24-sudo

@namanjain24-sudo namanjain24-sudo commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

What

A signed out visitor who opens a study link is now asked to sign in and is returned to that study afterwards — except when the invitation they followed says login is not required.

Closes #2336

Demo

A signed out browser opens the same public Heuristic study link. Left is develop, right is this PR.

Signed out visitor opening a study, before and after

Full clip: redirect-before-after.mp4

Recorded with Playwright against the app running on the Firebase emulator suite.

Not blindly redirecting people

Following up on @KarinePistili's context in the issue, and @tim48-robot's note to carry it into the PR — the rule implemented here is:

Who is opening the study Behaviour
Invitation with requiredLogin === false, matching this study Enters and answers, no account needed — unchanged
Invitation that requires login Sign in, then back to the study
No invitation, study is public Sign in, then back to the study
No invitation, study is private Sign in, then back to the study
Already signed in Unchanged — existing capability checks decide

The anonymous-invite path is the one way into a study without an account, and it keeps working exactly as before. Everything else is asked to identify itself first, which is option 1 from the issue (sign in, then return to the test) rather than dropping the participant on the homepage.

Why it was letting people in

getTestViewAccessRedirect decided access with:

if (!study?.isPublic && !isAnonymousInvitation && !hasStudyCapability(...))

For a public study the first term is already false, so the whole check was skipped and null (meaning "allowed") was returned — without ever asking whether anybody was signed in. That is how a signed out visitor reached a Heuristic test and could start answering it.

This was also inconsistent with the capability model next door, where resolveStudyAccess computes isPublicParticipant: Boolean(userId && study?.isPublic) — a public study already required a signed-in participant everywhere else.

On a private study the visitor did get bounced, but to /admin carrying a red "You do not have access" error, and the study link was lost. For someone who was simply not logged in yet, that reads as a rejection rather than an invitation to sign in.

Changes

File Change
src/shared/utils/authRedirect.js New — builds /signin?redirect=…, and resolves it back after sign-in
src/shared/utils/studyNavigation.js getTestViewAccessRedirect requires a signed-in user unless the invitation waives login
src/views/public/TestView.vue Passes the current path as the return target; a sign-in redirect no longer raises the "no access" error
src/features/auth/views/SignInView.vue Honours ?redirect= after email and Google sign-in
tests/unit/authRedirect.spec.js New — 15 tests
tests/unit/studyNavigation.spec.js 7 tests for the signed out paths

One related fix that this needed

denyAccess() ran redirectIfNeeded('/admin') after handleLoadedStudy had already navigated to the destination it computed, so the computed fallback was immediately overwritten by /admin. The sign-in redirect could not survive that, so denial is now reported by the same function that chooses the destination, and denyAccess is left for the two cases it is actually about — a study or a session that could not be loaded.

Open redirect

redirect is replayed only when it is a same-origin path. //host, /\host, anything containing a backslash, anything not starting with /, and /signin itself all fall back to /admin. There is a browser check for this below, and unit tests covering each rejected shape.

Testing

  • npm test — 33 suites, 281 tests passing (259 before this branch)
  • npm run lint and Prettier clean on the changed files
  • Removing the new guard makes 6 of the new tests fail, so they are pinning the behaviour rather than passing vacuously

Verified in a real browser

Playwright against the app on the Firebase emulator suite, with a public Heuristic study and a signed out browser. The same script was run before and after the change:

Check Before After
Signed out visitor is sent to sign in ❌ stayed on /testview/… ✅ /signin?redirect=…
The study link is remembered ❌ no redirect parameter ✅ redirect=/testview/…
Signing in returns to the study n/a — no sign-in screen was reached ✅ back on /testview/…
A signed in participant opens the study directly ✅ ✅
An off-site redirect is ignored ✅ ✅ lands on /admin

The issue mentions this needs checking on other test types too. /testview/:id is the shared entry point for Heuristic, unmoderated User and Card Sorting studies, so all three go through this check.

Moderated User sessions are the exception. They enter through their own session-token branch in TestView, which returns before this check runs, so the change above does not apply there. A signed out visitor is still blocked on that path, but by the existing behaviour: they end up on /signin without the study link being remembered, and with the "no access" error. I have left that branch alone rather than restructure a flow whose session-loading rules I could not exercise end to end locally — and it may well be separate on purpose. Happy to extend the same treatment to it in this PR if you would like, or to open a follow-up.

A study link is often the first RUXAILAB page a participant opens. TestView
let anyone through when the study was public, because the access check only
asked whether the study was public and never whether somebody was signed in,
so a signed out visitor could enter and answer a study anonymously. On a
private study they were bounced to /admin with a "no access" error and lost
the link they had been sent.

Require a signed in user to enter a study, with the one exception the
maintainers described: an invitation that explicitly waives login still lets
a participant answer without an account. Everybody else is sent to the sign
in screen carrying the path they asked for, and is returned to the study once
signed in. Only same-origin paths are replayed, so the parameter cannot be
used as an open redirect.

Access denials also stop overwriting their own destination: denyAccess used
to replace the computed fallback path with /admin right after it had been
applied.
@namanjain24-sudo

Copy link
Copy Markdown
Contributor Author

Friendly bump on this one as well. Still ready for review and merging cleanly into develop. SonarCloud quality gate passed, GitGuardian clean, no conflicts. Happy to make changes if anything should be handled differently. Thanks!

@sonarqubecloud

Copy link
Copy Markdown

@KarinePistili KarinePistili left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested in multiple flows and it worked well for me. Thank you for the contribution!

@KarinePistili
KarinePistili merged commit 99e603f into ruxailab:develop Sep 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ IMPROVEMENTS ]: Not Signed In User Entering Tests Is Not Redirected

2 participants