Skip to content

feat(website): replace auth-astro with better-auth#1191

Open
fhennig wants to merge 13 commits into
mainfrom
feat/replace-auth-astro-with-better-auth
Open

feat(website): replace auth-astro with better-auth#1191
fhennig wants to merge 13 commits into
mainfrom
feat/replace-auth-astro-with-better-auth

Conversation

@fhennig
Copy link
Copy Markdown
Contributor

@fhennig fhennig commented Apr 30, 2026

resolves #1189, #682, #1188

Summary

  • Replaces auth-astro + @auth/core with better-auth, running in stateless mode (no database required, JWT-based sessions)
  • Removes the custom patch (patches/auth-astro+4.2.0.patch) that was needed to fix TypeScript issues in auth-astro

Changes

  • src/auth.ts (new) — better-auth config with GitHub OAuth provider
  • src/pages/api/auth/[...all].ts (new) — catch-all route handling all auth requests
  • src/middleware/authMiddleware.ts (new) — runs on every request, calls getSession once and populates Astro.locals
  • src/backendApi/backendProxy.ts — reads context.locals.user?.githubId via APIContext
  • src/components/auth/LoginButton.tsxsignIn('github', ...)authClient.signIn.social({ provider: 'github', ... })
  • src/auth/logout.ts — custom @auth/core logout → auth.api.signOut({ headers, asResponse: true })
  • All pages updated to read from Astro.locals instead of calling auth.api.getSession directly
  • astro.config.mjs — removed auth() Astro integration

GitHub user ID handling

better-auth's session.user.id is a randomly generated internal ID, not the GitHub numeric user ID. The backend uses the GitHub numeric ID for ownership checks (e.g. who can edit a collection).

The solution: user.additionalFields + mapProfileToUser in auth.ts store the GitHub numeric ID directly on the user object as githubId at login time:

user: {
    additionalFields: {
        githubId: { type: 'string', input: false }
    }
},
socialProviders: {
    github: {
        mapProfileToUser: (profile) => ({ githubId: String(profile.id) })
    }
}

String() is needed because profile.id from GitHub is a number, but the backend stores and compares user IDs as strings. Without the coercion, ownership checks fail due to type mismatch.

This means session.user.githubId is the GitHub numeric ID (as a string) everywhere — no secondary lookups needed. The auth middleware sets it on Astro.locals.user once per request, and all pages and the backend proxy read it from there.

E2E test cookie helper

tests/helpers/auth.ts provides createAuthCookies(accountPayload, sessionPayload) and setupAuthCookie(page, name) to craft valid better-auth session cookies for Playwright tests without a real OAuth flow. The cookies are signed/encrypted using the same AUTH_SECRET the server uses. cookieCache (JWE, 1hr) is enabled so getSession can validate the crafted session_data cookie without an in-memory store lookup.

New Cookies

better-auth sets 3 cookies. A session_token, session_data and account_data. Session data and account data are JWE, encrypted data.

Test plan

  • Sign in with GitHub works end-to-end
  • Logout works
  • Ownership checks work correctly (edit/delete only available for your own collections)
  • Protected pages (/collections, /subscriptions) show the login prompt when logged out
  • E2E tests pass: npm run e2e

Replaces auth-astro + @auth/core with better-auth, running in stateless
mode (no database, JWT-based sessions). Removes the custom patch that
was required for auth-astro TypeScript compatibility.

- Add better-auth config (src/auth.ts) with GitHub OAuth and trustedProxyHeaders
- Add catch-all API route at /api/auth/[...all] to handle auth requests
- Replace getSession() calls across all pages and backendProxy with auth.api.getSession()
- Replace signIn() in LoginButton with authClient.signIn.social()
- Replace custom logout implementation with auth.api.signOut()
- Update E2E test helper to use better-auth cookie name and JWT format
- Remove auth-astro Astro integration from astro.config.mjs
- Remove patches/auth-astro+4.2.0.patch and patch-package

NOTE: session.user.id must be verified to contain the GitHub numeric user
ID after a real login — see TODO in src/auth.ts. The E2E test token
format is also a best-guess pending verification — see TODO in
tests/helpers/auth.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dashboards Ready Ready Preview, Comment May 4, 2026 3:24pm

Request Review

auth.api.signOut() returns typed data, not a Response object, so
calling .headers.getSetCookie() on it threw a 500. The asResponse
option makes it return a proper Response with Set-Cookie headers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
better-auth's session.user.id is a randomly generated internal ID, not
the GitHub numeric ID the backend uses for ownership checks. Adds a
getGitHubUserId() helper that retrieves the correct ID via
auth.api.listUserAccounts() and updates backendProxy and the two
collection pages that do ownership comparisons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s user field

Replaces per-page auth calls with a single middleware that runs once per
request and populates Astro.locals. The GitHub numeric user ID is now stored
as a dedicated `githubId` additionalField on the user object via
`mapProfileToUser`, so it is available directly on `session.user` without
any secondary lookups.

- Add `authMiddleware` that calls `getSession` once and sets
  `context.locals.user` and `context.locals.session`
- Configure `user.additionalFields.githubId` + `mapProfileToUser` in
  `auth.ts` to populate it from the GitHub profile at login time
- Remove `getGitHubUserId` helper (no longer needed)
- Update `backendProxy` to read `context.locals.user?.githubId` via
  `APIContext` instead of re-deriving the ID from headers
- Update all pages/components to read from `Astro.locals` instead of
  calling `auth.api.getSession` directly
- Update `App.Locals` types to use the inferred auth user type so
  `githubId` is typed correctly everywhere
- Remove unused `jose` devDependency and E2E test auth helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fhennig and others added 2 commits May 4, 2026 14:26
Adds tests/helpers/auth.ts with createAuthCookies() and setupAuthCookie()
that craft valid better-auth session cookies for Playwright tests without
needing a real OAuth flow. Also sets cookiePrefix to 'gen-spectrum' in auth
config (required for the helper to match what the server sets).

Note: E2E cookie injection is untested — cookieCache also needs to be
enabled in auth.ts before getSession will accept the crafted session_data
cookie without a store lookup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The patches/ directory was deleted when auth-astro was replaced with
better-auth, causing the Docker build to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enables better-auth cookieCache (JWE, 1hr) so getSession can validate
crafted session cookies without an in-memory store lookup. This makes
the E2E auth helper work across the separate test/server processes.

Also aligns the E2E test GitHub ID with the seeded collection's userId
so ownership checks pass on the edit page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
profile.id from GitHub is a number but githubId is declared as string.
Without String(), the stored value is a number and ownership checks
(currentUserId === collection.ownedBy) fail due to type mismatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The type says string but GitHub returns a number at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@fengelniederhammer fengelniederhammer left a comment

Choose a reason for hiding this comment

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

Found a few things, but looks good otherwise 👍

Comment thread website/src/auth.ts
},
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',

Comment thread website/src/auth.ts
import { getGitHubClientId, getGitHubClientSecret } from './config';

export const auth = betterAuth({
// 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: authSecret,
salt: COOKIE_NAME,
});
const SECRET = process.env.AUTH_SECRET ?? '';
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.

Do we need to catch the case when this is undefined and throw?

{
id: crypto.randomUUID(),
providerId: 'github',
accountId: '1234567',
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.

Suggested change
accountId: '1234567',
accountId: E2E_GITHUB_ID,

image: null,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
githubId: '1234567',
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.

Suggested change
githubId: '1234567',
githubId: E2E_GITHUB_ID,

Comment thread website/src/env.d.ts
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.

Comment on lines +17 to +27
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.'],
});
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.


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?

@@ -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.

Comment thread website/src/env.d.ts
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;

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.

Replace auth-astro with better-auth

2 participants