Skip to content

fix(gateway): accept the __Secure- prefixed NextAuth session cookie - #475

Open
mrrobertkent wants to merge 1 commit into
onecli:mainfrom
mrrobertkent:fix/gateway-secure-session-cookie
Open

fix(gateway): accept the __Secure- prefixed NextAuth session cookie#475
mrrobertkent wants to merge 1 commit into
onecli:mainfrom
mrrobertkent:fix/gateway-secure-session-cookie

Conversation

@mrrobertkent

@mrrobertkent mrrobertkent commented Aug 2, 2026

Copy link
Copy Markdown

I have read the CONTRIBUTING.md file.

YES

What kind of change does this PR introduce?

Bug fix.

What is the current behavior?

On a self-hosted instance running multi-user mode (NEXTAUTH_SECRET set) and served over HTTPS, every browser call to the gateway returns 401. That covers vault pair/status, pending approvals, approval decisions, and cache invalidation — so the 1Password and Bitwarden panels, the approvals UI, and the 1Password value picker are all unusable, while the rest of the dashboard works normally.

validate_oauth reads the session cookie by exact name:

let token = parse_cookie(cookie_header, "authjs.session-token")

Auth.js prefixes that cookie with __Secure- whenever it considers the deployment secure, which it derives from the scheme of the resolved AUTH_URL / NEXTAUTH_URL. A self-hosted instance cannot avoid this: OAuth providers require an https redirect URI for anything other than localhost (Google documents the rule explicitly), so the single variable that makes login work also renames the cookie. The gateway then never finds it and logs oauth auth: session token cookie not found.

Local-mode installs are unaffected, because validate_local never inspects the cookie — which is likely why this has gone unnoticed.

Controlled reproduction

Same request, same (fabricated) JWT, only the cookie name differs:

$ curl -H "Cookie: authjs.session-token=eyJhbGciOiJIUzI1NiJ9.fake.sig"          .../me
$ curl -H "Cookie: __Secure-authjs.session-token=eyJhbGciOiJIUzI1NiJ9.fake.sig" .../me
WARN onecli_gateway::auth: oauth auth: JWT decode failed error=InvalidSignature   # bare name: found, decoded
WARN onecli_gateway::auth: oauth auth: session token cookie not found             # prefixed name: never seen

And the same instance, over HTTPS, confirming which name a browser is actually sent:

$ curl -sD - https://<instance>/api/auth/csrf | grep -i set-cookie
set-cookie: __Host-authjs.csrf-token=...
set-cookie: __Secure-authjs.callback-url=...

What is the new behavior?

The lookup accepts either spelling, via a small helper:

fn session_token_from_cookies(cookie_header: &str) -> Option<&str> {
    parse_cookie(cookie_header, "authjs.session-token")
        .or_else(|| parse_cookie(cookie_header, "__Secure-authjs.session-token"))
}

The bare name is checked first, so http/localhost installs keep their existing single-comparison path and behaviour is unchanged for them. Four unit tests cover the bare name, the __Secure- prefixed name, precedence when both are present, and neither present.

Additional context

Found while running a self-hosted instance behind a reverse proxy with TLS and an external IdP. It is reachable from any edition that can run in oauth mode over HTTPS, and it becomes considerably easier to hit alongside #430, which lets self-hosters use a non-Google IdP and so brings more TLS deployments into multi-user mode.

#474 would also resolve this, by having the web app attach an API key so the gateway never falls through to the cookie. The two are complementary rather than competing: this change makes the documented session path work as intended and is a few lines, while #474 additionally closes the local-mode trust gap in #263. Happy to close this if #474 is the preferred direction.

Verification

Run against apps/gateway (musl toolchain, matching docker/Dockerfile):

  • cargo fmt --check — clean
  • cargo clippy --all-targets — no new warnings
  • cargo test auth:: — 7 passed (3 pre-existing, 4 new), 0 failed

Also verified on a live deployment: with the fix in place, a browser request carrying __Secure-authjs.session-token reaches JWT validation instead of being rejected as missing.

It does not, on its own, make session auth work — see the correction in the comments. Auth.js issues an encrypted (JWE) session token, while validate_oauth decodes with jsonwebtoken under Algorithm::HS256, which expects a signed JWS, so validation fails one step later with Base64 error: Invalid symbol 46. That is a second, independent problem on the same path and out of scope here. This PR fixes the cookie lookup only, which is a prerequisite either way.

No TypeScript is touched, so pnpm check / pnpm build are unaffected by this change.

@mrrobertkent

Copy link
Copy Markdown
Author

Correcting my own claim in the description above, having now tested this with a real browser session rather than a fabricated token.

I wrote that "with the fix in place, a browser request carrying __Secure-authjs.session-token reaches JWT validation instead of being rejected as missing, and the vault panels load." The first half is accurate. The second half is not, and I should not have stated it — my verification used a hand-constructed three-segment JWS, which naturally reached signature verification. A real Auth.js cookie does not.

With this patch applied and a genuine session, the gateway gets past the cookie lookup and then fails one step later:

WARN onecli_gateway::auth: oauth auth: JWT decode failed error=Base64 error: Invalid symbol 46, offset 174

Symbol 46 is .. That is what decoding a five-segment JWE as a three-segment JWS looks like. Auth.js encrypts the session token by default — the built web bundle contains EncryptJWT / jwtDecrypt with A256CBC-HS512 and dir, and nextauth-config.ts sets no custom jwt.encode/decode — while validate_oauth decodes with jsonwebtoken under Algorithm::HS256, which expects a signed JWS.

So there are two independent problems on the browser→gateway session path, and this PR only addresses the first:

  1. the cookie name (this PR), and
  2. the token format — a JWE cannot be validated as a JWS, so validate_oauth cannot authenticate an Auth.js session at all, on http or https.

That reframes things. This change is still correct and still required — without it the gateway never even finds the cookie — but on its own it does not make session auth work, and I don't want the description overstating it. Fixing (2) properly means the gateway deriving the encryption key from NEXTAUTH_SECRET (Auth.js uses HKDF) and decrypting, which is a considerably larger change than this one.

Given that, #474 looks like the better path for the underlying problem: having the web app attach an API key avoids the token format question entirely, and closes #263 at the same time. I'm happy for this to be closed in favour of that, or kept as the narrow cookie-name fix if the session path is worth repairing on its own merits. Maintainers' call — I have no stake in which.

Apologies for the overstated claim.

@mrrobertkent

Copy link
Copy Markdown
Author

Update after running #474 in production: with its web half applied, the browser sends Authorization: Bearer oc_…, validate_api_key matches before the mode branch, and validate_oauth is never reached. So the code this PR touches is unreachable on any instance carrying #474.

That makes this optional. It is still a real bug — without it the gateway cannot find the cookie at all on a TLS deployment — but it repairs one step of a path whose next step (the JWE/JWS mismatch) is unfixed, so on its own it moves the failure from "cookie not found" to "JWT decode failed" and no further.

Happy either way: close it as superseded, or keep it as a narrow correctness fix if the session path is worth repairing eventually. No preference from me, and no need to spend review time on it ahead of #474.

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.

1 participant