Skip to content

fix(auth): keep percent-escapes in the login callbackUrl for #794 - #824

Open
hdimer wants to merge 1 commit into
rucio:mainfrom
hdimer:fix/callback-url-encoding-794
Open

fix(auth): keep percent-escapes in the login callbackUrl for #794#824
hdimer wants to merge 1 commit into
rucio:mainfrom
hdimer:fix/callback-url-encoding-794

Conversation

@hdimer

@hdimer hdimer commented Sep 1, 2026

Copy link
Copy Markdown

Fixes #794.

What's wrong

A DID name can contain a literal /, so the UI builds DID links with the name escaped into a single [name] segment — href={`/did/${encodeURIComponent(scope)}/${encodeURIComponent(name)}`} in seven places (ListDIDMeta.tsx:19, DetailsRuleLocks.tsx:32, ListRuleTable.tsx:34, and others) — and src/app/(rucio)/did/[scope]/[name]/page.tsx:9 decodes it back when the page renders. Copying such a link and opening it logged out is the reporter's case, and it passes through two separate places that drop the escape.

1. src/proxy.ts:36initiateLogin(), the branch taken when there is no session at all, interpolates the pathname into the query string raw:

const loginPage = new URL(`/auth/login?callbackUrl=${request.nextUrl.pathname}`, `${publicHost}`);

reLogin() two lines above already wraps the same expression in encodeURIComponent(), as do /api/auth/login (route.ts:27) and the three call sites in session-monitor.tsx. initiateLogin is the only producer that doesn't, so the % reaches the query unescaped and URLSearchParams reads %2F back as /.

2. src/app/auth/login/page.tsx:446 — the login page reads the value with useSearchParams().get('callbackUrl'), which has already percent-decoded it once, and then calls decodeURIComponent() on it a second time. That decode destroys the escape whichever producer sent it, so fixing the proxy alone doesn't clear the 404 — the login page undoes it. This half also affects the ?expired=true re-login path, which was encoding correctly all along.

Either way the redirect target gains a path segment (/did/ddmadmin/foo%2Fbar/did/ddmadmin/foo/bar) and 404s.

Two things fall out of the same root cause, both worth knowing when weighing this:

  • A name with a literal % crashed rather than 404'd. /did/ddmadmin/a%25b became /did/ddmadmin/a%b, and did/[scope]/[name]/page.tsx:9 then called decodeURIComponent('a%b'), which throws URIError.
  • The raw interpolation let a path inject login-page parameters. page.tsx:23 reads expired, so a link whose path contained &expired=true produced /auth/login?callbackUrl=/foo&expired=true and the login page told the user their session had expired.

The fix

Two lines: encode in initiateLogin, and drop the second decode on the read side. redirectURL then comes out byte-identical to request.nextUrl.pathname, which is the form both router.push() and the [name] route expect.

I did not factor the two proxy.ts redirect literals into a shared helper. They now differ only by expired=true, but that is two call sites of a one-line template, and the same shape appears four more times elsewhere in the codebase — extracting it would grow this diff more than it shrinks it. Happy to do it separately if you'd rather.

Tests

Four cases, all four red on main:

  • test/api/auth/proxy-callback-url.test.ts drives the real proxy() with a null token and asserts the callbackUrl on the redirect it returns.
  • test/component/auth/login-page-callback-url.test.tsx renders the real login page with the story component stubbed to capture its props, drives the userpass success handler, and asserts the URL router.push() receives. It feeds in an already-correctly-encoded callbackUrl — what a fixed proxy emits — and still fails on main, which is how I established that the second half was needed rather than assuming it.

One case is there specifically to rule out a near-miss: encodeURI also makes the %2F test pass, because it escapes % but leaves / alone. The &-in-a-DID-name case fails under encodeURI (the parameter gets truncated at the &) and passes only under encodeURIComponent. I checked that by actually running the suite against an encodeURI variant.

npm test is green (109 suites, 459 tests). tsc --noEmit clean, npm run build succeeds, and eslint on the two touched source files reports the same 7 pre-existing warnings as main, none new. Both files have pre-existing Prettier violations on lines this diff doesn't touch (proxy.ts:25-28, page.tsx:482); I left them alone rather than mix a reformat into a two-line fix.

Adjacent, deliberately not touched

  • callbackUrl isn't origin-validated today, at page.tsx:179/:372 and api/auth/logout/route.ts:22. This change doesn't widen that, and it makes one double-encoded protocol-relative payload inert, but it isn't a fix for it. Happy to open a separate issue.
  • Both proxy.ts redirects use nextUrl.pathname only, so any query string on the original link is lost across login. Pre-existing in both functions and out of scope here.

@maany — you said on the issue you'd try to recreate this. If you already have something in flight, say the word and I'll close this. I mainly wanted to write down that it's two bugs rather than one, since the proxy half on its own doesn't clear the 404.


Used AI assistance on this; I reviewed and tested the change myself.

@hdimer hdimer closed this Sep 1, 2026
@hdimer hdimer reopened this Sep 1, 2026
@hdimer
hdimer marked this pull request as ready for review September 1, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Link to DID webpage only works if already authenticated due to callbackUrl decoding

1 participant