Skip to content
Open
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
2 changes: 1 addition & 1 deletion Dockerfile_website
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
FROM node:24-alpine AS base
WORKDIR /app

COPY website/package.json website/package-lock.json website/patches ./
COPY website/package.json website/package-lock.json ./

FROM base AS prod-deps
RUN npm clean-install --omit=dev
Expand Down
3 changes: 1 addition & 2 deletions website/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import node from '@astrojs/node';
import auth from 'auth-astro';
import tailwindcss from '@tailwindcss/vite';

// https://astro.build/config
export default defineConfig({
integrations: [react(), auth({ configFile: './src/auth.config' })],
integrations: [react()],
output: 'server',
adapter: node({
mode: 'standalone',
Expand Down
687 changes: 406 additions & 281 deletions website/package-lock.json

Large diffs are not rendered by default.

5 changes: 1 addition & 4 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
"check-types": "astro sync && tsc --noEmit",
"check-astro": "astro check",
"check-lint": "eslint .",
"postinstall": "patch-package",
"test": "vitest",
"test:node": "vitest --project node",
"test:headed": "vitest --browser.headless false",
Expand All @@ -26,16 +25,14 @@
},
"dependencies": {
"@astrojs/node": "^9.5.3",
"@auth/core": "^0.37.4",
"@genspectrum/dashboard-components": "^1.17.0",
"@tanstack/react-query": "^5.100.5",
"astro": "^5.18.1",
"auth-astro": "^4.2.0",
"axios": "^1.15.2",
"better-auth": "^1.6.9",
"cookie": "^1.1.1",
"dayjs": "^1.11.20",
"katex": "^0.16.45",
"patch-package": "^8.0.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-katex": "^3.1.0",
Expand Down
78 changes: 0 additions & 78 deletions website/patches/auth-astro+4.2.0.patch

This file was deleted.

32 changes: 0 additions & 32 deletions website/src/auth.config.mjs

This file was deleted.

38 changes: 38 additions & 0 deletions website/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { betterAuth } from 'better-auth';

import { getGitHubClientId, getGitHubClientSecret } from './config';

export const auth = betterAuth({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 risk: No baseURL configured. The docs recommend always setting it explicitly:

Relying on request inference is not recommended. For security and stability, always set baseURL explicitly in your config or via the BETTER_AUTH_URL environment variable.

The app runs on multiple domains (genspectrum.org, staging.genspectrum.org, localhost:4321). Options:

  • Add a BETTER_AUTH_URL env var per deployment, or
  • Use the dynamic baseURL object with allowedHosts for multi-domain support

Without it, better-auth infers the URL from the request, which depends on trustedProxyHeaders being correctly set and the proxy forwarding correct headers.

// TODO - maybe we can check again if this is read automatically? Should be, according to the docs.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do it?

secret: process.env.AUTH_SECRET,
session: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 nit: Consider making expiresIn explicit. Defaults to 7 days (604800), which is probably fine, but stating it documents the intended session lifetime — especially since maxAge (1 hour) is a different concept (cache TTL vs session lifetime) and readers may confuse the two.

session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    cookieCache: { ... },
},

cookieCache: {
enabled: true,
maxAge: 60 * 60,
strategy: 'jwe',
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 bug: Missing refreshCache: true in cookieCache. Without a database, when the 1-hour maxAge expires, better-auth tries to refresh from a DB that doesn't exist → user is silently logged out after 1 hour.

The stateless session docs explicitly show refreshCache: true as required for database-less mode. With it, the cookie auto-refreshes at 80% of maxAge (≈48 min), keeping the session alive up to expiresIn (default 7 days).

cookieCache: {
    enabled: true,
    maxAge: 60 * 60,
    strategy: 'jwe',
    refreshCache: true, // ← add this
},

},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 risk: No account.storeAccountCookie: true set. better-auth auto-enables this when no database is provided, so it works today. But if that auto-detection logic ever changes, OAuth account data silently stops persisting. Being explicit is safer for a database-less setup.

account: {
    storeAccountCookie: true,
},

user: {
additionalFields: {
githubId: {
type: 'string',
input: false,
},
},
},
socialProviders: {
github: {
clientId: getGitHubClientId(),
clientSecret: getGitHubClientSecret(),
// store the GitHub user ID (i.e. 45882389) in the 'user' object,
// which is easy to access from context later on.
// the 'id' property cannot be overwritten.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- profile.id is typed as string but GitHub returns a number at runtime; String() ensures it's always stored as a string for consistent ownership checks
mapProfileToUser: (profile) => ({ githubId: String(profile.id) }),
},
},
advanced: {
trustedProxyHeaders: true,
cookiePrefix: 'gen-spectrum',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I think usually we use either GenSpectrum or genspectrum

Suggested change
cookiePrefix: 'gen-spectrum',
cookiePrefix: 'genspectrum',

},
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 nit: better-auth logs to console by default. The project uses winston via getInstanceLogger everywhere else. Consider wiring them together so auth logs go through the same pipeline:

import { getInstanceLogger } from './logger';
const logger = getInstanceLogger('auth');

export const auth = betterAuth({
    // ...
    logger: {
        log: (level, message, ...args) => {
            logger[level === 'warn' ? 'warn' : level === 'error' ? 'error' : 'info'](message, ...args);
        },
    },
});

Low priority — but without it, auth errors/warnings go to stdout while everything else goes through structured logging.

10 changes: 2 additions & 8 deletions website/src/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { Auth, raw, skipCSRFCheck } from '@auth/core';
import type { ResponseInternal } from '@auth/core/types';
import authConfig from 'auth:config';

export type LogoutResponse = Required<Pick<ResponseInternal, 'redirect' | 'cookies'>>;
import { auth } from '../auth';

export async function logout(request: Request) {
const url = new URL(`${authConfig.prefix}/signout`, request.url);
const authRequest = new Request(url, { headers: request.headers, method: 'POST' });
return (await Auth(authRequest, { ...authConfig, raw, skipCSRFCheck })) as LogoutResponse;
return auth.api.signOut({ headers: request.headers, asResponse: true });
}
16 changes: 8 additions & 8 deletions website/src/backendApi/backendProxy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getSession } from 'auth-astro/server';
import type { APIContext } from 'astro';

import { getBackendHost } from '../config.ts';
import { getInstanceLogger } from '../logger.ts';
Expand All @@ -14,21 +14,21 @@ const API_PATHNAME_LENGTH = '/api'.length;
* This proxying through the frontend server is used, so we do the user login handling
* in here, instead of in the backend.
*/
export async function proxyToBackend({ request }: { request: Request }): Promise<Response> {
const session = await getSession(request);
export async function proxyToBackend(context: APIContext): Promise<Response> {
const userId = context.locals.user?.githubId;

if (session?.user?.id === undefined) {
return getUnauthorizedResponse(request.url);
if (userId === undefined) {
return getUnauthorizedResponse(context.request.url);
}

return proxyRequest(request, session.user.id);
return proxyRequest(context.request, userId);
}

/**
* Proxies the request to the backend without any user ID, regardless of login state.
*/
export async function proxyToBackendNoAuth({ request }: { request: Request }): Promise<Response> {
return proxyRequest(request, undefined);
export async function proxyToBackendNoAuth(context: APIContext): Promise<Response> {
return proxyRequest(context.request, undefined);
}

async function proxyRequest(request: Request, userId: string | undefined): Promise<Response> {
Expand Down
20 changes: 13 additions & 7 deletions website/src/components/auth/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { signIn } from 'auth-astro/client';
import { createAuthClient } from 'better-auth/client';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs mention that there is also a specific import for React:
https://better-auth.com/docs/integrations/astro#create-a-client

Did you try whether it makes a difference? The docs don't say what to expect.


import { getClientLogger } from '../../clientLogger.ts';
import { getErrorLogMessage } from '../../util/getErrorLogMessage.ts';
import { useErrorToast } from '../ErrorReportInstruction.tsx';

const logger = getClientLogger('LoginButton');
const authClient = createAuthClient();

export function LoginButton() {
const { showErrorToast } = useErrorToast(logger);
Expand All @@ -13,13 +14,18 @@ export function LoginButton() {
const callbackUrlThatDoesNotImmediatelyLogoutAgain = new URL(window.location.href).pathname.endsWith('/logout')
? new URL('/', window.location.href).toString()
: undefined;
signIn('github', { callbackUrl: callbackUrlThatDoesNotImmediatelyLogoutAgain }).catch((error: unknown) => {
showErrorToast({
error: error instanceof Error ? error : new Error(String(error)),
logMessage: `Login failed: ${getErrorLogMessage(error)}`,
errorToastMessages: ['Login failed. Please try again.'],
authClient.signIn
.social({
provider: 'github',
callbackURL: callbackUrlThatDoesNotImmediatelyLogoutAgain,
})
.catch((error: unknown) => {
showErrorToast({
error: error instanceof Error ? error : new Error(String(error)),
logMessage: `Login failed: ${getErrorLogMessage(error)}`,
errorToastMessages: ['Login failed. Please try again.'],
});
Comment on lines +17 to +27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preexisting issue, maybe not worth fixing here: I wonder why we didn't put this into a useMutation hook? It's an async call, I would expect it to be wrapped for proper handling of errors etc and to follow the pattern.

});
});
};

return (
Expand Down
10 changes: 4 additions & 6 deletions website/src/components/auth/LoginState.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
---
import { getSession } from 'auth-astro/server';

import { LoginButton } from './LoginButton';
import UserDropdown from './UserDropdown.astro';

Expand All @@ -10,13 +8,13 @@ interface Props {

const { forceLoggedOutState = false } = Astro.props;

const session = await getSession(Astro.request);
const showLoggedInState = !forceLoggedOutState && session?.user !== undefined;
const user = Astro.locals.user;
const showLoggedInState = !forceLoggedOutState && user !== null;
---

{
showLoggedInState ? (
<UserDropdown session={session} />
showLoggedInState && user ? (
<UserDropdown session={{ user }} />
) : (
<div class='mr-2 font-bold'>
<LoginButton client:load />
Expand Down
4 changes: 1 addition & 3 deletions website/src/components/auth/UserDropdown.astro
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
---
import type { Session } from '@auth/core/types';

import { isStaging } from '../../config';

interface Props {
session: Session;
session: { user: { name: string | null } };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we reuse the AuthUser type somehow that we have in the Astro state?

}

const { session } = Astro.props;
Expand Down
14 changes: 11 additions & 3 deletions website/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
// declare module 'set-cookie-parser' is added, because tsc checks our dependency auth-astro/server,
// which uses set-cookie-parser, and it doesn't have types.
declare module 'set-cookie-parser';

declare namespace App {
// Note: 'import {} from ""' syntax does not work in .d.ts files.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI claims that it works syntactically, but that such an import would turn the file into a module which apparently break the global availability of the declared types.

// We derive the User type from the auth config so that additionalFields (e.g. githubId) are included.
type AuthUser = NonNullable<Awaited<ReturnType<(typeof import('./auth').auth)['api']['getSession']>>>['user'];

interface Locals {
user: AuthUser | null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://better-auth.com/docs/integrations/astro#astro-locals-types has this:

Suggested change
user: AuthUser | null;
user: import("better-auth").User | null;

session: import('better-auth').Session | null;
}
}

interface ImportMetaEnv {
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down
7 changes: 2 additions & 5 deletions website/src/layouts/base/header/HamburgerMenu.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
---
import { getSession } from 'auth-astro/server';

import HamburgerMenuItem from './HamburgerMenuItem.astro';
import HamburgerMenuSection from './HamburgerMenuSection.astro';
import { getPathogenMegaMenuSections } from './getPathogenMegaMenuSections';
Expand All @@ -16,8 +14,7 @@ const { forceLoggedOutState } = Astro.props;

const pathogenMegaMenuSections = Object.values(getPathogenMegaMenuSections());

const session = await getSession(Astro.request);
const showLoggedInState = !forceLoggedOutState && session?.user !== undefined;
const showLoggedInState = !forceLoggedOutState && Astro.locals.user !== null;
const showSubscriptions = isStaging();
---

Expand All @@ -44,7 +41,7 @@ const showSubscriptions = isStaging();
showLoggedInState ? (
<HamburgerMenuSection
navigationEntries={[
{ label: session.user?.name ?? 'My Account' },
{ label: Astro.locals.user?.name ?? 'My Account' },
...(showSubscriptions ? [{ label: 'Subscriptions', href: '/subscriptions' }] : []),
{ label: 'Logout', href: '/logout' },
]}
Expand Down
4 changes: 2 additions & 2 deletions website/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { sequence } from 'astro:middleware';

import { authMiddleware } from './middleware/authMiddleware.ts';
import { errorMiddleware } from './middleware/errorMiddleware.ts';
import { loggingMiddleware } from './middleware/loggingMiddleware.ts';
import setupDayjs from './util/setupDayjs.ts';

// Workaround since Astro doesn't seem to support a global startup script for stuff that needs to be done exactly once
setupDayjs();

// Astro middleware
export const onRequest = sequence(errorMiddleware, loggingMiddleware);
export const onRequest = sequence(errorMiddleware, loggingMiddleware, authMiddleware);
Loading