Skip to content

fix(auth): fold /api/auth routes into Roaster authorization ($request?user) - #814

Open
joewiz wants to merge 9 commits into
eXist-db:developfrom
joewiz:fix/auth-routes-roaster-authz
Open

fix(auth): fold /api/auth routes into Roaster authorization ($request?user)#814
joewiz wants to merge 9 commits into
eXist-db:developfrom
joewiz:fix/auth-routes-roaster-authz

Conversation

@joewiz

@joewiz joewiz commented Jun 6, 2026

Copy link
Copy Markdown
Member

[This PR was co-authored with Claude Code. -Joe]

Stacked on #813 (fix/auth-identity-from-request-user). Targets develop; rebase once #813 merges. Review #813 first.

Summary

Removes the security: [] overrides on POST /api/auth/session and GET /api/auth/whoami so they participate in Roaster's standard-authorization middleware like every other route, and reads identity from $request?user instead of the app-local auth:current-user() helper that #813 introduced.

Why

#813 fixed the identity source (sm:id() not the persistent-login attribute) but left the two auth routes opted out of the Roaster auth middleware via security: [], with a local helper doing the sm:id() read. That override is itself the last bit of auth-route special-casing. Removing it means one identity path for every route$request?user, populated by rutil:getDBUser()sm:id() — matching existdb-openapi and the rest of the stack.

Net identity resolution is unchanged (both are sm:id()-based); this is convergence/cleanup, plus it makes the login POST a genuine cookie-auth route that Roaster's csrf:enforce can protect once CSRF is enabled.

Safety

  • No CSRF behaviour change. Neither eXide nor existdb-openapi currently declares x-csrf, so Roaster's csrf:enforce is inert — removing security: [] from the login POST triggers nothing. (Enabling CSRF protection is tracked as a separate, deliberate hardening step.)
  • cookieAuth declares its cookie name (org.exist.login), so use-cookie-auth resolves correctly on these routes.
  • Cookie minting still happens in controller.xq's login:set-user (form params) before the forward; the handler only reports.

Verification

Follow-ups (separate, tracked)

  • Enable x-csrf: {same-origin: true} on cookie-authed writes in both apps (real CSRF gap today — csrf:enforce is a no-op without it). Needs a cy.request-Origin check first.
  • Retire the legacy controller.xq /login branch in favour of /api/auth/session.

Comment thread modules/api/auth.xqm Outdated
Comment thread modules/api/auth.xqm Outdated
@line-o

line-o commented Jun 16, 2026

Copy link
Copy Markdown
Member

We need to start fresh:

joewiz added a commit to joewiz/eXide that referenced this pull request Jun 16, 2026
…r.xq

Addresses @line-o's review on eXist-db#814. eXide stops authenticating in controller.xq
and delegates login/logout entirely to Roaster's auth module, matching the
roasted reference app.

- controller.xq: drop the persistent-login import, login:set-user, the
  index-vs-login auth routing, the AJAX login route, and the unauthorized
  redirect/401 handling. The app page always loads; Roaster enforces access on
  the API. Identity for the one privileged non-Roaster route (/execute) is now
  derived from sm:id() rather than the persistent-login attribute, and the gate
  returns a clean 403 (it previously fell through to 404).
- modules/api/auth.xqm: auth:login calls roaster-auth:login-user(...) to validate
  credentials and mint the session; auth:logout calls roaster-auth:logout-user.
  login still rejects accounts the config bars (guest when restrictions/@guest =
  "no") with 401.
- modules/api.json: /api/auth/session login bypasses authorization (security: [])
  and accepts credentials as application/json (XHR) or
  application/x-www-form-urlencoded (login.html form).
- expath-pkg.xml(.tmpl): bump the Roaster dependency to >= 1.12.1.
- login.html: submit via fetch and redirect on success (the route returns JSON);
  redirect already-authenticated visitors to index.html.

The SPA login modal already POSTs form-encoded credentials to /api/auth/session,
so the frontend needed no change. auth_spec updated: the app no longer gates the
page server-side, so the former page-redirect assertions become "the page loads;
the API enforces access."

Verified against a Roaster 1.12.1 instance: login (form + JSON), whoami, logout,
bad-credential 401, guest-barred-under-guest=no 401, and the /execute gate
(dba 200, guest 403 under restrictions). auth_spec 16/16; file_save, dbmanager,
run_as_test_*, query_execution green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread modules/api.json
"properties": {
"user": { "type": "string" },
"password": { "type": "string", "format": "password" },
"duration": { "type": "string" }

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.

Duration was added but is never read. I think this parameter should be dropped entirely. A boolean "remember-me" might be what we are after here.

Comment thread modules/api/auth.xqm
rauth:login-user(
string($request?body?user),
string($request?body?password),
rauth:add-cookie-name($request, map {})

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.

the empty map that is passed to rauth:add-cookie-name is the options map where we might want to set the duration for the session based if the user selected "remember-me" in the login form.

Comment thread controller.xq Outdated
@joewiz

joewiz commented Jun 16, 2026

Copy link
Copy Markdown
Member Author

[This response was co-authored with Claude Code. -Joe]

Thanks @line-o — done, modeled on the roasted reference app. Pushed in 834036f:

  • controller.xq now carries no login logic: dropped the persistent-login import, login:set-user, the index-vs-login routing, the AJAX login route, and the unauthorized redirect/401 block. The page always loads and Roaster enforces access on the API — like roasted's controller.
  • Roaster >= 1.12.1 declared in expath-pkg.xml(.tmpl).
  • api.jsonPOST /api/auth/session now has security: [] and a request body accepting both application/json (XHR) and application/x-www-form-urlencoded (the login.html form).
  • auth.xqmauth:login calls auth:login-user(...) and auth:logout calls auth:logout-user(...) (cookie name read from the spec via auth:add-cookie-name), following the roasted login handler you linked. Login still returns 401 for an account the config bars (guest when restrictions/@guest = "no").

login.html now submits via fetch and redirects on success (the route returns JSON), and redirects already-authenticated visitors to index.html. The SPA's login modal already POSTed form-encoded credentials to /api/auth/session, so the frontend needed no change. auth_spec is updated for the new model (the app no longer gates the page server-side) and is green, along with the storage/query specs.

One thing I'd like your read on: /execute is the one privileged route that isn't a Roaster route (it forwards arbitrary XQuery to XQueryServlet), so I kept its authorization gate rather than drop it — re-sourced from sm:id() instead of the persistent-login attribute, returning a clean 403. Happy to move /execute under Roaster or drop the gate instead if you'd prefer; it just seemed safer not to leave arbitrary query execution ungated in this PR.

@joewiz

joewiz commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

[This response was co-authored with Claude Code. -Joe]

Pushed the guest=no fix and the auth-route cleanup — the controller is now fully auth-free. Summary for review:

Guest gate — the hard refusal (58da41a)

The app shell is now served by a Roaster route, view:index (GET /index.html), gated by auth:is-allowed. A disallowed guest — e.g. while restrictions/@guest = "no" — is refused and 302-redirected to login.html; the app HTML is never sent. login.html stays on an open route so login still works. So strict guest=no is back, but enforced by Roaster rather than the controller — per your suggestion to deliver the HTML via a route handler. The handler reproduces the former view.xq runtime-config injection and serializes as HTML5.

Retired the legacy /execute + /results routes (56dc3e0, afea6db)

The editor runs queries through existdb-openapi's /api/query cursor, so the controller's /execute (XQueryServlet) and /results (session paging) routes were dead. Removing them deleted the controller's last authorization logic — the sm:id-derived query-execution gate you flagged. The controller no longer imports config, reads sm:id(), or makes any auth decision; it routes /api/* and the app shell to Roaster and otherwise serves static files. Frontend result paging now uses the cursor exclusively (dropped retrieveNext). Also deleted the now-dead modules/{session,view}.xq and the already-retired (410) modules/{debuger,git}.xq (debuger.xq imported session.xq).

Verified on a Roaster 1.12.1 instance: guest under guest=no → 302 to login (no app HTML); admin → app loads; query execution + paging work via the cursor; auth/storage/query Cypress specs green (73/73). auth_spec gains the guest hard-refusal tests and drops the now-moot /execute-gate test.

Still open (not eXide): for guest=no to fully close the editor's query path, existdb-openapi's /api/query needs to reject unauthenticated calls. Working on this now in existdb-openapi.

Merge ordering: this branch is behind develop, which already has #825 (the cy.execXQuery test helper + the cleanupTestFiles fix) and #826. Best to bring #814 up to date with develop before merging it — at which point the dbmanager setup query here (currently an inline eXist REST <query> envelope) can switch to cy.execXQuery, and #825's cleanupTestFiles fix comes along for free.

@duncdrum

Copy link
Copy Markdown
Contributor

I think this needs a rebase

joewiz and others added 9 commits August 18, 2026 23:32
…ribute

GET /api/auth/whoami and POST /api/auth/session resolved the current user
by reading request:get-attribute("org.exist.login.user"). That attribute
is populated only by the persistent-login flow (login-form params or the
remember-me cookie), so a request authenticated with the HTTP Basic
header reported as "guest" even though it executed as the real user:

  before:  curl -u admin: .../api/auth/whoami  ->  user=guest, isAdmin=false
  after:   curl -u admin: .../api/auth/whoami  ->  user=admin, isAdmin=true

Replace the attribute-based auth:get-user() with auth:current-user(),
which reads the actual eXist subject via sm:id() (sm:effective preferred,
matching the cookie/token case). This works because controller.xq calls
login:set-user before forwarding to the Roaster handlers, so by handler
time the subject already reflects whatever authenticated the request --
Basic header, the shared org.exist.login cookie (Path=/exist), or a
freshly minted login session.

This puts eXide's notion of "who is logged in" on the same sm:id() basis
as Roaster's rutil:getDBUser() and existdb-openapi -- the first step of
converging the stock apps onto a single identity model.

Verified: auth_spec 16/16; identity correct under Basic, cookie, and
unauthenticated, including with a temporary non-empty-password user
(confirming the fix is not an artifact of the default empty admin
password) and correctly distinguishing dba from non-dba accounts.

Beachhead only -- deliberate follow-ups (each its own verified step):
removing the security:[] overrides on the two /api/auth routes so they
read $request?user via Roaster's middleware; retiring the legacy
controller /login branch; the same sm:id() fix in documentation-next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…?user)

Removes the `security: []` overrides on POST /api/auth/session and
GET /api/auth/whoami so they participate in Roaster's
standard-authorization middleware like every other route, and reads
identity from `$request?user` (populated via rutil:getDBUser() ->
sm:id()) instead of the app-local auth:current-user() helper from the
previous commit.

Net effect is identical identity resolution (both are sm:id()-based),
but the auth routes are no longer special-cased out of the framework:
one identity path for all routes, and the login POST is now a
cookie-auth route that Roaster's csrf:enforce can protect once x-csrf
is enabled (currently a no-op in both apps — neither declares x-csrf;
tracked separately).

No CSRF behaviour change here: with no x-csrf config, csrf:enforce is
inert, so removing security:[] from the login POST is safe. cookieAuth
declares its cookie name (org.exist.login), so use-cookie-auth resolves
correctly on these routes.

Verified: identity correct under Basic, cookie, unauthenticated, and
fresh login POST; full eXide suite 263 passing / 0 failing / 2 skipped.

Stacked on fix/auth-identity-from-request-user (PR eXist-db#813); rebase onto
develop once that merges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
$request?user is populated by Roaster's standard-authorization for every
route (= the guest account, name "guest", when unauthenticated), as the
module doc states — so $user?name is always present and the
($user?name, "guest")[1] fallback is dead code. Folding these routes back
into the auth middleware (this PR's change) is exactly what makes the
fallback unnecessary; removing it also avoids masking a genuinely empty
$request?user behind a silent "guest". Per @line-o's review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r.xq

Addresses @line-o's review on eXist-db#814. eXide stops authenticating in controller.xq
and delegates login/logout entirely to Roaster's auth module, matching the
roasted reference app.

- controller.xq: drop the persistent-login import, login:set-user, the
  index-vs-login auth routing, the AJAX login route, and the unauthorized
  redirect/401 handling. The app page always loads; Roaster enforces access on
  the API. Identity for the one privileged non-Roaster route (/execute) is now
  derived from sm:id() rather than the persistent-login attribute, and the gate
  returns a clean 403 (it previously fell through to 404).
- modules/api/auth.xqm: auth:login calls roaster-auth:login-user(...) to validate
  credentials and mint the session; auth:logout calls roaster-auth:logout-user.
  login still rejects accounts the config bars (guest when restrictions/@guest =
  "no") with 401.
- modules/api.json: /api/auth/session login bypasses authorization (security: [])
  and accepts credentials as application/json (XHR) or
  application/x-www-form-urlencoded (login.html form).
- expath-pkg.xml(.tmpl): bump the Roaster dependency to >= 1.12.1.
- login.html: submit via fetch and redirect on success (the route returns JSON);
  redirect already-authenticated visitors to index.html.

The SPA login modal already POSTs form-encoded credentials to /api/auth/session,
so the frontend needed no change. auth_spec updated: the app no longer gates the
page server-side, so the former page-redirect assertions become "the page loads;
the API enforces access."

Verified against a Roaster 1.12.1 instance: login (form + JSON), whoami, logout,
bad-credential 401, guest-barred-under-guest=no 401, and the /execute gate
(dba 200, guest 403 under restrictions). auth_spec 16/16; file_save, dbmanager,
run_as_test_*, query_execution green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds auth_spec coverage for the privileged /execute route's authorization:
under guest=no a guest is denied (403) and a dba is still allowed (200) — the
API-level enforcement that replaced the old controller page-redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st gate

Adds view:index — a Roaster handler that serves index.html (with the runtime
config injection) gated by auth:is-allowed. A disallowed guest (e.g. while
restrictions/@guest = "no") is refused and redirected to login.html, so the app
never loads for an anonymous visitor. Authorization moves into Roaster, off the
controller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Query execution and result paging go through existdb-openapi's /api/query cursor
(runQueryCursor/fetchCursorPage). Drop the legacy session path: remove
app.retrieveNext() (which fetched the controller's /results/{n}) and the dead
else-branches in browseNext/Previous/First/Last. Move dbmanager's setup query off
the /eXide/execute route (retired next) to the eXist REST query envelope. Tidy the
stale /execute-fallback note in ws-eval.js (WebSocket eval is in eXist core 7.0.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The editor runs queries through /api/query, so the legacy /execute (XQueryServlet)
and /results (session paging) controller routes are dead. Removing them deletes
the controller's last authorization logic (the sm:id-derived query-execution
gate): it no longer imports config, reads sm:id(), or makes any auth decision —
it routes /api/* and the app shell to Roaster and otherwise serves static files.
Delete the now-unused modules/{session,view}.xq and the already-retired (410)
modules/{debuger,git}.xq (debuger imported session.xq). auth_spec gains the guest
hard-refusal tests and drops the /execute-gate test (query-execution authorization
now belongs to /api/query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cute

This PR retires eXide's /execute route, but develop's cy.execXQuery helper
(added by eXist-db#825) and dbmanager_spec's inline setup call both still POST to it, so
after the rebase every spec that seeds or cleans up state 404s.

Point the helper at eXist-db's REST query envelope instead, and switch
dbmanager_spec's inline call to the helper so there is one transport. The helper
now asserts a 2xx: posting *raw* XQuery to /exist/rest does not execute it
(eXist parses the body as XML and 400s), a failure mode that previously passed
silently because the caller ignored the status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@joewiz
joewiz force-pushed the fix/auth-routes-roaster-authz branch from afea6db to e73d059 Compare August 19, 2026 03:49
@joewiz

joewiz commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]

@duncdrum rebased onto current develop — the eight commits replayed with no conflicts, and the branch head is now e73d059.

One extra commit was needed to make the rebase actually work. This PR retires the /execute route, but develop has since grown a cy.execXQuery test helper (from #825) that posts to exactly that route, and dbmanager_spec calls it directly for setup. Straight after the rebase, every spec that seeds or cleans up state 404s. So the helper now posts an <exist:query> envelope to eXist-db's REST endpoint instead, dbmanager_spec's inline call goes through the same helper, and the helper asserts a 2xx — posting raw XQuery to /exist/rest does not execute it (eXist-db parses the body as XML and 400s), a failure mode that used to pass silently because the caller ignored the status.

Verified on the stack the eXist-db image bundles (7.0.0-SNAPSHOT, existdb-openapi 0.10.0, Roaster 1.12.2): 278 tests, with the only failure being query_error_structured, which #840 fixes.

The failing CI test here is not this PR. Against the current image, the pinned roaster-1.12.0 that CI installs cannot serialize eXide's login response:

400  Error while serializing xml: org.xml.sax.SAXException:
     err:SENR0001 Cannot serialize a map(*) with the XML or text output method

Every spec calls cy.loginXHR first, so the whole suite fails at spec 1 — on this branch, on #820, and on plain develop when reproduced locally. Swapping only the Roaster version to the bundled 1.12.2 makes the same request return 200. #865 fixes CI by deploying the packages the image bundles instead of pinned ones; this PR is additionally blocked on it, since it declares Roaster >= 1.12.1 and the pinned bed supplies 1.12.0.

So the order is #840, then #865, and this goes green.

@duncdrum

Copy link
Copy Markdown
Contributor

the quick fix is to allign the version in the workflow with the one specified in expath. See my response in the other PR

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.

3 participants