From 1a457d58a51b8c7f6cff52cc8ad5a71a1f53ef0f Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:59:27 +0200 Subject: [PATCH 1/4] fix(frontend): drop id_token from session JWT to stop 502 cookie chunking The refresh path re-added the Keycloak id_token to the JWT that Auth.js encrypts into the session cookie. Access + refresh tokens alone encrypt to ~3.8 kB; the ~1.2 kB id_token pushed the value past @auth/core's 3936-byte chunk threshold, splitting it into two ~4 kB Set-Cookie headers that overflow a reverse proxy's default 4 kB response-header buffer -- so every response became a 502, roughly 4.5 minutes into each session when the first refresh fired. Strip idToken ahead of every return path, including the still-valid branch so sessions minted before this fix recover without re-login, and stop storing the refreshed id_token. Nothing reads it. Ports the tested fix from main. --- components/frontend/src/auth.callback.test.ts | 42 ++++++++++++++++--- components/frontend/src/auth.d.ts | 4 +- components/frontend/src/auth.ts | 23 +++++++++- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/components/frontend/src/auth.callback.test.ts b/components/frontend/src/auth.callback.test.ts index 15a2f131..a99489ac 100644 --- a/components/frontend/src/auth.callback.test.ts +++ b/components/frontend/src/auth.callback.test.ts @@ -96,7 +96,7 @@ describe("Auth.js jwt Callback", () => { expect(result.error).toBeUndefined() // idToken and organization are intentionally not stored in the JWT // to keep the session cookie under the 4096 byte limit - expect(result.idToken).toBeUndefined() + expect(result).not.toHaveProperty("idToken") expect(result.organization).toBeUndefined() }) @@ -114,10 +114,35 @@ describe("Auth.js jwt Callback", () => { account: null, } as JwtCallbackParams)) as CustomJWT - expect(result).toBe(mockToken) // Should return the exact same object + // Value-equal rather than identical: the callback rebuilds the token to + // strip a legacy `idToken`, so the still-valid path returns a copy. + expect(result).toEqual(mockToken) expect(mockFetch).not.toHaveBeenCalled() // Fetch should not be called }) + it("should strip a legacy idToken without waiting for a refresh", async () => { + const mockToken = { + sub: "user1", + accessToken: "valid_access", + refreshToken: "valid_refresh", + expiresAt: Math.floor(Date.now() / 1000) + 600, // Nowhere near expiry + userId: "user1", + // Minted before the cookie-size fix. The refresh path is not reached on + // this request, so evicting only there would leave the oversized cookie — + // and the 502 — in place until this token neared expiry. + idToken: "stale_id", + } as CustomJWT & { idToken?: string } + + const result = (await jwtCallback({ + token: mockToken as JWT, + account: null, + } as JwtCallbackParams)) as CustomJWT + + expect(result).not.toHaveProperty("idToken") + expect(result.accessToken).toBe("valid_access") + expect(mockFetch).not.toHaveBeenCalled() + }) + it("should proactively refresh if token expires within 30 seconds", async () => { const mockToken: CustomJWT = { sub: "user1", @@ -148,13 +173,17 @@ describe("Auth.js jwt Callback", () => { }) it("should attempt refresh if token is expired", async () => { - const mockToken: CustomJWT = { + const mockToken = { sub: "user1", accessToken: "expired_access", refreshToken: "valid_refresh", // Need this to refresh expiresAt: Math.floor(Date.now() / 1000) - 60, // Expired 1 min ago userId: "user1", - } + // A session minted before the cookie-size fix still carries this. Refresh + // must evict it rather than spread it forward, or those sessions keep the + // oversized cookie — and the 502 — forever. + idToken: "stale_id", + } as CustomJWT & { idToken?: string } // Mock a successful fetch response for refresh mockFetch.mockResolvedValueOnce({ @@ -174,7 +203,10 @@ describe("Auth.js jwt Callback", () => { expect(mockFetch).toHaveBeenCalledOnce() // Ensure fetch was called expect(result.accessToken).toBe("refreshed_access") - expect(result.idToken).toBe("refreshed_id") + // Same budget as initial sign-in: the refreshed id_token must not be stored + // either, or the cookie crosses the chunk threshold ~5 min into every + // session and the proxy answers 502. + expect(result).not.toHaveProperty("idToken") expect(result.refreshToken).toBe("rotated_refresh") // Check if refresh token updated expect(result.expiresAt).toBeGreaterThan(mockToken.expiresAt!) expect(result.error).toBeUndefined() diff --git a/components/frontend/src/auth.d.ts b/components/frontend/src/auth.d.ts index 44ff709e..554c5d5c 100644 --- a/components/frontend/src/auth.d.ts +++ b/components/frontend/src/auth.d.ts @@ -19,8 +19,10 @@ declare module "@auth/core/types" { declare module "@auth/core/jwt" { interface JWT extends DefaultJWT { + // Every field here is encrypted into the session cookie, which Auth.js + // chunks past 3936 bytes. Keep it to what is actually read: the access + // token (gRPC auth) and the refresh token (renewal). No id_token. accessToken?: string - idToken?: string refreshToken?: string expiresAt?: number organization?: unknown diff --git a/components/frontend/src/auth.ts b/components/frontend/src/auth.ts index fbb8f322..b15c8b70 100644 --- a/components/frontend/src/auth.ts +++ b/components/frontend/src/auth.ts @@ -46,7 +46,16 @@ export const getAuthOptions = ( callbacks: { // --- JWT Callback: Handles token creation and refresh --- async jwt(params: JwtCallbackParams): Promise { - const token = params.token as CustomJWT + // Sessions minted before the cookie-size fix still carry `idToken`, and + // it has to come off ahead of every return path below — the still-valid + // branch hands `token` straight back, so evicting only on refresh left + // those sessions oversized, and 502-ing, until their access token neared + // expiry. It is off the JWT type deliberately, so this cast is the only + // place that admits the legacy field exists. + const { idToken, ...token } = params.token as CustomJWT & { + idToken?: string + } + void idToken const { account, profile } = params // Initial Sign-in (`account` is available) if (account && profile) { @@ -114,11 +123,21 @@ export const getAuthOptions = ( } logger.info("JWT Callback: Token refreshed successfully.") + + // Why the refreshed `id_token` is dropped rather than stored: this + // object is encrypted straight into the session cookie, and Auth.js + // splits that cookie into chunks once the value passes 3936 bytes + // (@auth/core ALLOWED_COOKIE_SIZE 4096, less 160 for attributes). + // Access + refresh token alone encrypt to ~3.8 kB, so adding the + // ~1.2 kB id_token pushed it to ~5.4 kB — two ~4 kB Set-Cookie + // headers, which overflows a reverse proxy's default 4 kB + // response-header buffer and turns every response into a 502. + // Nothing reads it, so nothing is lost. + // Update token with new values return { ...token, // Keep existing info like userId, organization, etc. accessToken: refreshedTokens.access_token, - idToken: refreshedTokens.id_token, // Keycloak often sends updated id_token expiresAt: Math.floor(Date.now() / 1000) + refreshedTokens.expires_in, From b205f30ab9c3cfd43d3b8460bbe11f2d710f6d92 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:59:40 +0200 Subject: [PATCH 2/4] fix(frontend): survive a backend outage on first login instead of 500 hooks.server.ts leaves locals.platformUser undefined when WhoAmI returns UNAVAILABLE, but the auto-Register that runs on NOT_FOUND had no such rescue, and the dashboard loader dereferenced platformUser!.id unconditionally. A backend that dropped between the two calls, or during the first page after login, surfaced as a bare TypeError 500 that named nothing. Wrap the auto-Register in the same UNAVAILABLE rescue WhoAmI already has, and guard the dashboard loader with a 503 that names the symptom. --- components/frontend/src/hooks.server.ts | 20 +++++++++++++++++-- .../routes/(app)/dashboard/+page.server.ts | 16 +++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/components/frontend/src/hooks.server.ts b/components/frontend/src/hooks.server.ts index 71d4cbc7..fe393038 100644 --- a/components/frontend/src/hooks.server.ts +++ b/components/frontend/src/hooks.server.ts @@ -201,8 +201,24 @@ const sessionSetupHandle: Handle = async ({ event, resolve }) => { event.locals.logger.info( "HOOKS: User not in DB, auto-registering via Register RPC.", ) - const regResp = await event.locals.grpc.user.register({}) - event.locals.platformUser = regResp.user ?? undefined + try { + const regResp = await event.locals.grpc.user.register({}) + event.locals.platformUser = regResp.user ?? undefined + } catch (regErr) { + // Same rescue as WhoAmI below: the backend can drop between the two + // calls, and letting that escape the hook turns a first login into an + // unexpected 500 rather than a handled "backend is down". + if ( + regErr instanceof ClientError && + regErr.code === Status.UNAVAILABLE + ) { + event.locals.logger.warn( + "HOOKS: Backend unavailable for Register, proceeding without platform user.", + ) + } else { + throw regErr + } + } } else if ( err instanceof ClientError && err.code === Status.UNAVAILABLE diff --git a/components/frontend/src/routes/(app)/dashboard/+page.server.ts b/components/frontend/src/routes/(app)/dashboard/+page.server.ts index ef338d24..b50f71b9 100644 --- a/components/frontend/src/routes/(app)/dashboard/+page.server.ts +++ b/components/frontend/src/routes/(app)/dashboard/+page.server.ts @@ -2,12 +2,24 @@ import type { Actions, PageServerLoad } from "./$types" import { requireGrpc } from "$lib/server/grpc/client" import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility" import { joinIsOffered } from "$lib/server/hackathon/joinOffer" -import { fail, redirect } from "@sveltejs/kit" +import { error, fail, redirect } from "@sveltejs/kit" import { ClientError, Status } from "nice-grpc-common" export const load: PageServerLoad = async (event) => { const { hackathon } = requireGrpc(event.locals.grpc) - const participantId = event.locals.platformUser!.id + // hooks.server.ts leaves platformUser undefined when WhoAmI (or the + // auto-Register that follows it) came back UNAVAILABLE, and also when it + // succeeded but returned no user. Without this guard the first page after + // login died on a bare TypeError, surfaced as an unexpected 500 that named + // nothing. The message stays on the symptom rather than blaming the + // connection, since both causes land here. + const participantId = event.locals.platformUser?.id + if (!participantId) { + error( + 503, + "Could not load your account from the backend. Please try again.", + ) + } const { isGlobalAdmin } = await event.parent() // TODO(backend: enroll creator as participant): myResult is participation, not From 911d986297e9a1b2087ff62a2acaaea1ba7b31e0 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:59:40 +0200 Subject: [PATCH 3/4] fix(frontend): define the .checkbox theme style three forms already use CapabilitiesPanel, PageForm and PhaseForm all render class="checkbox", but nothing defined the rule, so every switch fell back to a browser-default box that ignored the theme. Adds the appearance:none / accent-fill style from main. --- components/frontend/src/themes/hackagon.css | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/components/frontend/src/themes/hackagon.css b/components/frontend/src/themes/hackagon.css index c68b84f4..e8f0438b 100644 --- a/components/frontend/src/themes/hackagon.css +++ b/components/frontend/src/themes/hackagon.css @@ -568,6 +568,26 @@ color: var(--color-ink-3); } + /* Three forms asked for `.checkbox` while nothing defined it, so every switch + * was a browser-default box that ignored the mode. Checked is a solid accent + * field rather than a native tick: the lime sits at 80% lightness, so + * `accent-color` would draw the white checkmark `on-accent` exists to rule + * out, and filled-vs-empty carries the state on its own at this size. */ + .checkbox { + appearance: none; + height: --spacing(4); + width: --spacing(4); + flex-shrink: 0; + border: 1px solid var(--color-line-strong); + border-radius: var(--radius-field); + background-color: var(--color-raised); + cursor: pointer; + } + .checkbox:checked { + border-color: var(--color-accent); + background-color: var(--color-accent); + } + .chip { display: inline-flex; align-items: center; From 2f31b10608f144728c2814cd0c86b8f2b1b3e4bd Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:59:41 +0200 Subject: [PATCH 4/4] fix(backend): gate vote-category and ballot reads against anonymous callers ListVoteCategories, GetVoteCategory and GetVote used RequireSubject, which admits the anonymous subject. The vote-category entry mapper embeds jury members' emails, so anyone who could name a private event's id could read its jury roster; GetVote returned any voter's ballot by id, breaking ballot secrecy. Require a real user and enforce hackathon:read on the category reads (member-scoped, matching who needs to vote) and hackathon:write on GetVote (organizer/admin, matching its sibling ListVotes). --- .../backend/internal/service/vote_service.go | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go index 9ceb060d..72237313 100644 --- a/components/backend/internal/service/vote_service.go +++ b/components/backend/internal/service/vote_service.go @@ -199,15 +199,21 @@ func (s *VoteService) ListVoteCategories( ctx context.Context, req *voteMsgs.ListVoteCategoriesRequest, ) (*voteMsgs.ListVoteCategoriesResponse, error) { - // TODO: casbin check once member-read rules for votes exist; JWT-only for - // the bootstrap read path. - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } hackathonID, err := uuid.Parse(req.GetHackathonId()) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) } + // Member-scoped, never anonymous: the entry mapper embeds jury members' + // emails, so leaving this on RequireSubject let anyone who could name a + // private event's id read its jury roster. + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Hackathon, m.Read, + ); err != nil { + return nil, err + } categories, err := s.dbClient.VoteCategory.Query(). Where(entvotecategory.HasHackathonWith(enthackathon.IDEQ(hackathonID))). WithHackathon(). @@ -230,8 +236,7 @@ func (s *VoteService) GetVoteCategory( ctx context.Context, req *voteMsgs.GetVoteCategoryRequest, ) (*voteMsgs.GetVoteCategoryResponse, error) { - // TODO: casbin check once member-read rules for votes exist. - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } id, err := uuid.Parse(req.GetId()) @@ -242,6 +247,12 @@ func (s *VoteService) GetVoteCategory( if err != nil { return nil, err } + // Member-scoped for the same reason as List: the entry carries jury emails. + if err := s.enforcer.RequirePermission( + ctx, c.Edges.Hackathon.ID.String(), m.Hackathon, m.Read, + ); err != nil { + return nil, err + } return &voteMsgs.GetVoteCategoryResponse{VoteCategory: voteCategoryEntryFromEnt(c)}, nil } @@ -1013,7 +1024,7 @@ func (s *VoteService) GetVote( ctx context.Context, req *voteMsgs.GetVoteRequest, ) (*voteMsgs.GetVoteResponse, error) { - if _, _, err := m.RequireSubject(ctx); err != nil { + if _, _, err := m.RequireUser(ctx); err != nil { return nil, err } id, err := uuid.Parse(req.GetId()) @@ -1024,6 +1035,18 @@ func (s *VoteService) GetVote( if err != nil { return nil, err } + // Ballots are secret: gate reading one exactly as ListVotes gates reading + // many — organizer/admin only. Without this any authenticated member could + // fetch any voter's ballot by id, and before it an anonymous caller could. + cat, err := s.categoryWithHackathon(ctx, v.Edges.Category.ID) + if err != nil { + return nil, err + } + if err := s.enforcer.RequirePermission( + ctx, cat.Edges.Hackathon.ID.String(), m.Hackathon, m.Write, + ); err != nil { + return nil, err + } return &voteMsgs.GetVoteResponse{Vote: voteEntryFromEnt(v)}, nil }