Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions components/backend/internal/service/vote_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand All @@ -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())
Expand All @@ -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
}
Expand Down Expand Up @@ -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())
Expand All @@ -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
}
Expand Down
42 changes: 37 additions & 5 deletions components/frontend/src/auth.callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand All @@ -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",
Expand Down Expand Up @@ -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({
Expand All @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion components/frontend/src/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions components/frontend/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,16 @@ export const getAuthOptions = (
callbacks: {
// --- JWT Callback: Handles token creation and refresh ---
async jwt(params: JwtCallbackParams): Promise<CustomJWT> {
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) {
Expand Down Expand Up @@ -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,

Expand Down
20 changes: 18 additions & 2 deletions components/frontend/src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions components/frontend/src/routes/(app)/dashboard/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions components/frontend/src/themes/hackagon.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading