Skip to content

Tell a failed OIDC sign-in apart, and come back to the right address - #438

Merged
vikramsoni2 merged 7 commits into
nxzai:mainfrom
cerede2000:p3-06
Sep 26, 2026
Merged

vikramsoni2 merged 7 commits into
nxzai:mainfrom
cerede2000:p3-06

Conversation

@cerede2000

@cerede2000 cerede2000 commented Sep 26, 2026 •

Copy link
Copy Markdown

P3-06 of phase 3 (see #373). Stacked on #433, #434, #435, #436 and #437 — merge those first and this shrinks to its own diff.
its own diff.

Which of two failures it was

A sign-in that cannot start has one of two causes: nothing was configured, or what was
configured could not be made to work. The first is answered by filling in OIDC_ISSUER
and the rest; the second is answered by looking at the provider. Both answer
404 OIDC is not configured today, so an administrator whose provider is unreachable
is sent to change a configuration that is already correct.

configureOidc now records what it concluded and why, in utils/oidcAvailability.js,
and the routes read it:

Cause Answer
nothing configured 404 AUTH_OIDC_NOT_CONFIGURED
configured, and the provider could not be reached 503 AUTH_OIDC_PROVIDER_UNAVAILABLE

Neither carries the network's own words — no ENOTFOUND, no internal host name, in the
body or in the address bar. A browser asking for one of those addresses is looking at a
page rather than reading JSON, so it is redirected to the sign-in screen with the code
beside the sentence, and the screen can say what the code means in the reader's own
language.

Where the callback comes back to

The return address was built from a fixed baseURL, so a deployment reached through a
reverse proxy under a different name sent people back to the wrong origin. It is now
resolved from the request — but only from X-Forwarded-Host behind a proxy Express
trusts, and only when the result exactly matches an origin the operator configured. A
redirect target a request header could choose is an open redirect with extra steps.

The path is still held to a relative, same-site one, as it was.

Also here, because they are the same files

  • A guest session that outlived the sign-in. The guestSession cookie was cleared
    on /api only, at five sign-in and sign-out paths, while it can also be set on /.
    Now both.
  • Two local copies removed. claimsFromIdToken in the middleware does what
    utils/idToken.js already exports, and uniqueOrigins in the routes what the new
    utils/oidcRedirect.js does — and the shared one normalises each value to an origin
    rather than only trimming it.
  • ServiceUnavailableError (503) and the two AUTH_OIDC_* codes, which nothing had.

Deliberately not here: the account lockout that routes/auth.js also differs in.
It is a different subject — 8 of the file's 20 hunks are OIDC and those are the ones
applied — and it goes with releasing a locked account, in P3-24.

Checks

Seven test files, 107 tests, three of which this fork had and main did not:
oidc-middleware, oidcOrigin, auth-oidc-routes.

The proof they hold the change: neutralising getOidcAvailability so it reports one
verdict for both causes turns three of them red — exactly the three that tell the
two apart.

  • Whole backend suite: 2 391 passed, 2 failed — auth.test.js (current password)
    and browse-hidden-files.test.js, the two that fail on main on its own.
  • npm run lint: 146 against main's 143. The three are the
    'import' and 'export' may appear only with 'sourceType: module' parse error that
    141 of main's own test files already draw, because .eslintrc.cjs does not declare
    backend/tests/** as modules. P3-42 offers a fix for that.
  • npm run format:check: the same 22 files as main; everything this touches is clean.
  • npm run build, the backend module load and npm run docs:build all pass.

One assertion was rewritten rather than ported as it stood: it matched the wording
express-openid-connect gives a callback with no sign-in in progress, and that wording
differs between 2.19 and 2.20. It asserts the refusal now, and leaves the sentence to
the library.

The six strings the screens ask for are in all fifteen catalogues.

cerede2000 pushed a commit to cerede2000/NextExplorer that referenced this pull request Sep 26, 2026
The batch is reversed, so its findings move from PORT to DONE in the same commit that
sends them. What is left to reverse is whatever `scripts/parity.mjs` still reports.
Benjy added 7 commits September 26, 2026 19:06
The About page's list of optional tools showed its own keys:
`settings.about.tools.title` where the heading belongs, and
`settings.about.tools.gives.videoThumbnails` under each one. The page was
ported with no catalogue entries at all, and nothing failed — the build
succeeded, the page rendered, and the words were missing. It is the one defect
a batch of screens can ship with while passing every other gate.

Twelve more keys were already in that state, on eleven screens: the Back
button on an account, the folder-size "Excluded" mark and its explanation, the
Access heading on both share lists, the branding page's note after choosing the
default logo, the empty user list, and six strings in the share dialog —
Password, Protected, Expires, Shared with, Creating, Loading users.

So a test reads every `t('…')` a screen asks for and holds it to the English
catalogue. English alone: a key missing from the others falls back to it, which
is a reader seeing the wrong language rather than a key. A prefix being built
up — `t('settings.categories.' + name)` — is not a key and is passed over,
which is also the limit of what can be checked from the source.
…t carry

Batch 33 of the plan was "the locale catalogues, with the key-parity test". The
catalogues are at parity on `main` today; nothing holds them there. The test that
was supposed to travel with them stayed in the fork, where it is a frontend spec
and there is no frontend runner here to execute it.

So it is written where the runner CI executes lives, reading the catalogues as
files. Three things, each the failure it prevents:

- **Every English key in every language.** A key added in English and nowhere
  else falls back to English: a reader seeing the wrong language, quieter than a
  raw key and just as wrong.
- **No key English does not define.** A key left behind after it was renamed in
  English is dead weight nobody will ever see again.
- **The placeholders survive translation.** `{count} items` translated without
  `{count}` says "items", with the number silently gone.

All three pass as things stand. Each was checked by breaking one catalogue — a key
removed from French, an invented key added to German, `{percent}` dropped from
Spanish — and each assertion named its own mutation and nothing else.
`auth.sessionSecret` fell back to `crypto.randomBytes(32)` whenever
SESSION_SECRET was not set, and `configureSession` drew a second one of its own
behind it. The sessions themselves survive a restart — they are rows in
CACHE_DIR/sessions.db — but a cookie signed with the previous secret no longer
verifies, so the row was read as a stranger's. Every restart, every upgrade and
every crash asked everyone to sign in again, on the default configuration.

The secrets derived from it went with it: ONLYOFFICE without ONLYOFFICE_SECRET,
and the signed thumbnail links.

So the first start draws one and keeps it in CONFIG_DIR, and later starts read it
back. SESSION_SECRET still wins and nothing is written then.

The file is written beside its final name and renamed into place, so a start
interrupted half-way leaves either no file or a whole one, and it is created
0600 — nobody but the user the server runs as has business reading it. A
CONFIG_DIR that cannot be written is not fatal: the start says, in words, that a
secret is being used for this run only and that the next restart will sign
everyone out, which is the behaviour it had all along.

`configureSession` now takes the resolved value rather than drawing its own, so
there is one secret and one place that decides it.
Node's default for a rejected promise with no listener is to raise it as an
uncaught exception and stop the process. So one forgotten `await` anywhere —
on a path check, a database read, a stat — answers a single bad request by
taking the server down, and everybody else's work goes with it.

That price is out of proportion to the cause. A rejection raised while serving
a request is almost always confined to that request: the connection fails, and
nothing else is touched. So it is reported, with what it was rejected with, and
the server carries on.

An uncaught exception is treated as what it is. There the stack unwound through
code that had no chance to put anything back, so a lock may still be held and a
transaction half applied; serving from that is worse than stopping. It shuts
down — the ordinary cleanup first, so the store and the sweeps close as they
would on SIGTERM — and exits 1. The shutdown runs in the same unknown state, so
it is bounded: five seconds, or a failure of its own, and the process still ends.

Nothing is hidden from development. Only `server.js` installs this; the test
suites never load it, and the runner still fails a run that leaves an unhandled
rejection behind.
`POST /api/files/office-document` and `createOfficeDocument` in the API layer both
landed with nothing calling them: there is no screen from which a blank Word,
Excel or PowerPoint document can be created, so the route cannot be reached.

The New menu gains a New document row, and a drawer beside it listing what can be
made — the three office formats when an editor is configured, and text, Markdown
and CSV, which are worth creating whether one is or not. A blank .docx is only
useful if something opens it, so the office entries are hidden when neither
ONLYOFFICE nor Collabora is set up.

Naming comes first here, unlike everywhere else in the app: the document opens
straight into an editor covering the whole window, so an inline rename box would
be behind it. The dialog shows the extension and does not let it be edited — the
format was chosen from the menu and the server owns the extension either way.
Once created, the document opens in the editor rather than landing back in the
listing, which would leave the person to find and open a file they have just
asked for by name.

Two defects in the files this touches, fixed here:

`createFile` in the file store read the created name from `created?.name`, where
the route answers `{ success, item }`. That was always undefined and always fell
back to the name asked for, so when that name was taken and the server picked the
next free one, the store looked up the asked-for name in the refreshed listing,
found the file that already held it, and opened the rename box on *that* — making
a second untitled file renamed the first one.

The button that opens the New menu sits outside the menu, so `onClickOutside`
counted its click as outside: the handler shut the menu in the capture phase and
the button's own toggle opened it again, and the button could open the menu but
never close it. It is now ignored by name.

The drawer is measured against the viewport each time it opens, and hands itself
to the other side when there is not room — a menu near the right edge would open
its drawer off-screen otherwise.
The plan's own rule is that a batch carries its documentation. The trash landed in
nxzai#405 and nxzai#416, file versions in nxzai#406 and nxzai#417, and neither brought a page: there is
nothing under `docs/` that describes either, so the retention settings, what a restore
keeps, who sees what in the trash and how versions are thinned are all readable only
in the source.

Two new pages, and nine existing ones brought up to what the application now does —
the environment reference gains the variables the last twenty batches added, the
feature and workflow pages gain the trash, versions, archives and search index, and
the sidebar gains the two new entries in both of its shapes.

Both pages were trimmed to what `main` has, rather than copied. Three things are
deliberately left out and travel with the batch that brings them:

- restoring to a folder of your choosing. `POST /api/trash/restore` accepts a
  destination, but nothing on the screen offers one and `restoreTrashItems` does not
  send one, so the page would describe an action nobody can reach.
- `COPY_PRESERVE_PERMISSIONS`, `PREVIEW_MAX_RENDER_SIZE`, `BULK_DELETE_CONCURRENCY`,
  `MAX_BROWSABLE_ARCHIVE_SIZE`, `ARCHIVE_CACHE_MAX_SIZE`,
  `UPLOAD_CHUNKED_AUTO_FALLBACK` and the six `PERFORMANCE_DIAGNOSTICS_*` — twelve rows
  for variables the configuration does not read yet.
- recent destinations, per-folder preferences and the inline quick-actions menu.

## Checks

`npm run docs:build` — and it is worth saying that it was failing before this last
pass: three links pointed at an installation page that only exists in the fork, and
vitepress treats a dead link as an error. Every internal link in the eleven pages was
resolved against the tree, which is how those turned up.

`npm run format:check` reports the same 22 files as `main` does on its own; the
eleven pages and the config are clean. `npm run build` and the backend module load
both pass, untouched by a documentation batch.
Two things about OIDC that this fixes.

**Which of two failures it was.** A sign-in that cannot start has one of two causes:
nothing was configured, or what was configured could not be made to work. The first
is answered by filling in OIDC_ISSUER and the rest; the second is answered by looking
at the provider. Both answered 404 "OIDC is not configured", so an administrator whose
provider was unreachable was sent to change a configuration that was already right.
`configureOidc` now records what it concluded and why, and the routes read it: 404
AUTH_OIDC_NOT_CONFIGURED for the first, 503 AUTH_OIDC_PROVIDER_UNAVAILABLE for the
second, and neither carries the network's own words — no ENOTFOUND, no internal host
name, in the body or in the address bar.

A browser asking for one of those addresses is looking at a page, not reading JSON, so
it is sent back to the sign-in screen with the code beside the sentence. The screen can
then say what the code means in the reader's own language.

**Where the callback comes back to.** The return address was built from a fixed
`baseURL`, so a deployment reached through a reverse proxy on a different name sent
people back to the wrong origin. It is now resolved from the request — but only from a
forwarded host behind a trusted proxy, and only when the result exactly matches an
origin the operator configured. A redirect target that a request header could choose
is an open redirect with extra steps.

The path is still held to a relative, same-site one, as before.

Also here, because it is the same files:

- `claimsFromIdToken` in the middleware and `uniqueOrigins` in the routes were local
  copies of things `utils/idToken.js` and the new `utils/oidcRedirect.js` do; both go.
- The guest-session cookie was cleared on `/api` only, at five sign-in and sign-out
  paths. A guest session left on `/` outlived the sign-in that should have ended it.
- `ServiceUnavailableError` (503) and the two AUTH_OIDC_* codes, which nothing had yet.

The account lockout that `routes/auth.js` also differs in is deliberately not here: it
is a different subject and goes with releasing a locked account.

## Checks

Seven test files, 107 tests, including three this fork had and `main` did not:
`oidc-middleware`, `oidcOrigin` and `auth-oidc-routes`. Neutralising
`getOidcAvailability` so it reports one verdict for both causes turns three of them
red — the three that tell the two apart.

Whole backend suite: 2 391 passed, 2 failed, the two that fail on `main` on its own
(`auth.test.js` on the current password, `browse-hidden-files.test.js`). `npm run lint`
reports 146 against `main`'s 143, the three being the parse error on `backend/tests/**`
that 141 of `main`'s own test files already draw. Formatting clean. Frontend builds,
backend loads, documentation site builds.

One test assertion was rewritten rather than ported as it stood: it matched the wording
`express-openid-connect` uses for a callback with no sign-in in progress, and that
wording differs between 2.19 and 2.20. It now asserts the refusal.
@vikramsoni2
vikramsoni2 merged commit e9c3504 into nxzai:main Sep 26, 2026
1 check passed
@cerede2000

Copy link
Copy Markdown
Author

Correction pushed to this branch, and through the ones stacked on it.

This branch renamed what the sign-in box holds. It takes an email address or a username, so it is neither — it is whatever was typed, and the screen and the client were changed to call it identifier. The store and the route were not, so stores/auth.js passed email to a client destructuring identifier, JSON.stringify dropped the key whose value was undefined, and the request went out carrying a password and nobody to sign in.

The answer was 401 AUTH_INVALID_CREDENTIALS, which is exactly what a wrong password looks like — which is why nothing about it read as a defect. Password sign-in did not work at all on this branch as it stood.

Driven in a browser against a build of it, the body was:

{"password":"motdepasse123"}

attemptLocalLogin already took identifier; the route was what had not caught up. It now reads identifier || email || username, so a script or an older client sending either older name still works.

tests/routes/sign-in-identifier.test.js signs in with each of the three names, refuses a wrong password, and reads the three frontend files to check they all use the one name — the chain is four files long and three of them have no runner in this repository, which is how it broke silently in the middle. Putting the store back to email turns that last one red; taking identifier out of the route turns the first two red.

The fix is in this branch's commit rather than a later one, so no point in the stack is ever broken. The eleven branches stacked on it (#439–#449) were replayed on top and force-pushed; nothing else in them changed.

cerede2000 pushed a commit to cerede2000/NextExplorer that referenced this pull request Sep 26, 2026
The session that ends while nobody is navigating was going to be a batch of its
own. Driving the sign-in screen against a build of the stack showed why it could
not be: nxzai#438 had already sent the screen, and the screen reads five things
off a store that did not have them.

The identifier was the one that failed loudly — the request went out with a
password and nobody to sign in. The other four failed silently: `totpPending`
read undefined, so the box for the code from an authenticator never appeared,
and a correct password on an account with a second factor landed on a screen
that looked like it had done nothing.

So the store, the error handler and their strings went into that branch, and the
eleven branches stacked on it were replayed. There is no P3-19 PR: its findings
are DONE against nxzai#438.

What is left to reverse is whatever `scripts/parity.mjs` still reports.
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.

2 participants