Skip to content

fix(auth): redirect to login from authorize whatever the Accept header - #59

Open
ExtraToast wants to merge 1 commit into
mainfrom
fix/authorize-redirects-non-html
Open

ExtraToast wants to merge 1 commit into
mainfrom
fix/authorize-redirects-non-html

Conversation

@ExtraToast

Copy link
Copy Markdown
Contributor

Summary

Outline sign-in from a phone showed a bare 401 where the login page belongs. notes.jorisjonkers.dev/auth/oidc redirects to /api/oauth2/authorize, and that endpoint's entry point redirects to /login only when the request's Accept matches text/html. Anything else falls through to a bodyless 401.

From the Traefik access log, 2026-09-18 — same URL, same client, three times:

20:06:31  notes.jorisjonkers.dev   200  GET  /static/sw.js        (service worker + workbox)
20:06:31  notes.jorisjonkers.dev   302  GET  /auth/oidc
20:06:32  auth.jorisjonkers.dev    401  GET  /api/oauth2/authorize?client_id=outline…
20:06:38  auth.jorisjonkers.dev    401  GET  /api/oauth2/authorize?client_id=outline…
20:06:53  auth.jorisjonkers.dev    302  GET  /api/oauth2/authorize?client_id=outline…  → /login

The query strings are byte-identical, so only a header can explain the difference. Confirmed against production and reproduced locally:

Accept before after
text/html 302 → /login 302 → /login
*/* 302 → /login 302 → /login
application/json 401, empty body 302 → /login

Outline registered its service worker one second before the first 401, which is the likeliest re-issuer of that navigation as a non-HTML fetch; an installed PWA shell produces the same shape. The initiator does not need naming to fix this — the authorize endpoint is only ever a browser destination, so no Accept value should decide whether the user sees a login page.

This is the third instance of the same mechanism on this chain: JWKS answered 401 to Accept: application/json (#50), and the discovery document escaped only because its filter short-circuits before authorization.

Fix

/api/oauth2/authorize gets its own path-scoped LoginUrlAuthenticationEntryPoint, registered before the text/html media-type mapping. Every other endpoint on the chain keeps the behaviour it has: /api/userinfo still answers 401 to a JSON request, which a new test pins so a later broadening cannot turn an API endpoint into a redirect.

Validation

  • Four new @ParameterizedTest cases cover application/json, text/html, */* and application/json, text/plain, */*. The application/json case returns 401 on the unfixed chain — run before the change, not inferred.
  • One new test asserts /api/userinfo with Accept: application/json still returns 401.
  • ./gradlew :api:integrationTest212 tests, 0 failures. :api:test175, 0 failures. detektMain, detektTest, detektIntegrationTest clean.
  • The test writes its query string out instead of using param(): MockMvc puts param() values in the query string and the parameter map, and the authorization endpoint rejects the duplicated response_type with invalid_request. A pre-encoded redirect_uri is double-encoded for the same reason, and + never decodes to a space — each of those returns a 400 that looks like the endpoint refusing the request. Written up in the test.

Risk

  • Low. One additional entry-point mapping, scoped to a single path, on a chain whose other endpoints are covered by a test asserting they did not change.
  • Not the whole of the reported incident. The twelve session-login attempts that followed were 400 invalid-credentials, not 401 — wrong credentials on that device, then a first-try success on desktop. findCredentialsByUsername is an exact match with no email fallback and no case folding, so autofilling an email address into that field fails with no hint. Out of scope here; worth its own change.

The authorization-server entry point redirected only requests whose Accept
matched text/html, so a request arriving with Accept: application/json was
answered with a bodyless 401 instead of the login page. Outline sign-in on
mobile hit exactly that after /auth/oidc redirected to the authorize
endpoint.

The authorize endpoint now has its own path-scoped entry point. /api/userinfo
keeps answering 401 for a JSON request, which a test now pins.
@ExtraToast ExtraToast added type: bug Something is broken or behaving incorrectly. area: auth Authentication, authorization, sessions, or identity. component: security Security, permissions, secrets, or vulnerability handling. labels Sep 19, 2026
@ExtraToast ExtraToast self-assigned this Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: auth Authentication, authorization, sessions, or identity. component: security Security, permissions, secrets, or vulnerability handling. type: bug Something is broken or behaving incorrectly.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant