Skip to content

fix(accounts): the refresh cookie crosses the REST transcoder as Set-Cookie - #1150

Merged
antoinetoussaint-byte merged 3 commits into
mainfrom
fix/rest-refresh-cookie
Oct 18, 2026
Merged

antoinetoussaint-byte merged 3 commits into
mainfrom
fix/rest-refresh-cookie

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

What broke

A browser sign-in through the REST surface completes (POST /v1/auth/authenticate answers 200 with an access token), then the next page load's POST /v1/auth/refresh answers 400 refresh_token: must be at least 1 characters and the browser is sent back to sign-in. Observed on a deployed cell running v0.0.101; reproduced in the browser step by step:

  • the authenticate response carries refreshToken of length 0 in its body;
  • its headers carry grpc-metadata-set-cookie and no Set-Cookie;
  • the browser therefore stores no codefly_rt, and refresh has nothing to send.

Cause

Since #1019 the Connect authentication completions lift the refresh token into the cookie (cookieTheRefreshToken) and clear it from the body. The REST mux transcodes to that Connect surface over gRPC, so the handler's Set-Cookie arrives as header metadata and grpc-gateway's default matcher writes it as Grpc-Metadata-Set-Cookie. The REST middleware (refreshTokenCookie) finds an empty body token and sets nothing. Two consequences:

  1. No browser session survives a page load (every REST sign-in).
  2. Security: the refresh credential is sent in a header page script can read — Grpc-Metadata-Set-Cookie is not a forbidden response header, so fetch exposes it — which is what the HttpOnly cookie exists to prevent.

Each half was tested alone (the REST middleware against a fake body, the Connect lift against a header), never through the transcoder.

Fix (at the hop that renamed the header)

  • restOutgoingHeaderMatcher (rest_extras.go): set-cookie metadata → Set-Cookie; every other key keeps grpc-gateway's default Grpc-Metadata- prefix.
  • rest_gen.go: one CUSTOM line wiring it (runtime.WithOutgoingHeaderMatcher), marked preserve when regenerating like the incoming matcher above it.

Tests

  • TestTheRefreshCookieCrossesTheRESTTranscoderAsSetCookie — drives grpc-gateway's own ForwardResponseMessage with the matcher: exactly one Set-Cookie equal to the refresh cookie, no prefixed copy, other metadata unchanged.
  • TestTheDefaultTranscoderPrefixesTheCookie — the same forward without the matcher reproduces the bug (no Set-Cookie, a prefixed header).
  • TestTheRESTMuxIsBuiltWithTheOutgoingHeaderMatcher — holds the generated file's wiring line.
  • go test ./pkg/adapters/ green, go vet and gofmt clean.

Reused: the existing refreshCookie builder and the incoming-matcher CUSTOM pattern. Classification: fix. Docs: none describe the transcoder's header mapping.

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 2 commits October 18, 2026 11:59
…Cookie

Since the Connect authentication completions lift the refresh token into
the cookie themselves (cookieTheRefreshToken, #1019) and clear it from the
body, the REST surface's cookie came back through grpc-gateway as header
metadata, which the default matcher writes as Grpc-Metadata-Set-Cookie. A
browser stores no cookie from that header and the REST middleware had no body
token left to lift, so every browser sign-in lost its session on the next page
load and was sent back to sign-in — and the refresh credential travelled in a
header page script can read.

restOutgoingHeaderMatcher keeps Set-Cookie's name across the transcoder; every
other header keeps grpc-gateway's default prefix. Tests drive the transcoder's
own response forwarding with and without the matcher, and hold the CUSTOM
wiring line in the generated rest_gen.go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1150: the transcoder test fed hand-built metadata, so the joined
path was still untested — the gap #1019 shipped through. The chain test now
drives REST -> refreshTokenCookie -> grpc-gateway (the matchers rest_gen.go
wires) -> gRPC over h2c -> a Connect AuthService using the production lift and
cookie readers, for authenticate, refresh and logout: exactly one codefly_rt
Set-Cookie, no token in any body, nothing prefixed, and the cookie a browser
sends back is what the handler receives. Without the matcher it fails with the
cell's symptom.

The wiring check parses rest_gen.go like the incoming matcher's lockstep test
(a commented-out option no longer passes), and the matcher no longer sits
between refreshCookie and its doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Independent security review (Opus 5.5): 0 blockers, 1 major, 3 minors. Disposition at the new head:

  • M1 — no test drives the real chain: fixed. TestABrowserSessionSurvivesTheRealRESTChain runs REST → refreshTokenCookie → grpc-gateway (with the matchers rest_gen.go wires) → gRPC over h2c → a Connect AuthService using the production unaryCookieingRefreshToken / refreshTokenFromCookie, for authenticate, refresh and logout. Mutation: removing the outgoing matcher fails it with a cookie left as Grpc-Metadata-Set-Cookie, readable by page script.
  • m1 — string-based wiring check: fixed. Now AST-parsed like rest_identity_lockstep_test.go; mutation: a commented-out option fails it.
  • m2 — misplaced doc comment: fixed.
  • m3 — pass only codefly_rt: not taken, with reason. An outgoing header matcher sees header names, not values, so filtering by cookie name would need a response rewriter on every route. The reviewer verified the only Connect-side Set-Cookie source is the lift (connect_handlers.go:151), and no value comes from the request; the chain test asserts exactly one codefly_rt per completion, so a second source fails it.

go test ./pkg/adapters/ green, vet and gofmt clean. Merge is waiting only on the repo-wide red "Codefly build" (store bootstrap lock: Alpine published curl 8.14.1-r3 for every arch but aarch64; codefly-dev/service-postgres#189).

@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 89e6799 Oct 18, 2026
24 checks passed
@antoinetoussaint-byte
antoinetoussaint-byte deleted the fix/rest-refresh-cookie branch October 25, 2026 17:07
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