feat(website): replace auth-astro with better-auth#1191
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
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>
fengelniederhammer
left a comment
There was a problem hiding this comment.
Found a few things, but looks good otherwise 👍
| }, | ||
| advanced: { | ||
| trustedProxyHeaders: true, | ||
| cookiePrefix: 'gen-spectrum', |
There was a problem hiding this comment.
Nitpick: I think usually we use either GenSpectrum or genspectrum
| cookiePrefix: 'gen-spectrum', | |
| cookiePrefix: 'genspectrum', |
| 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. |
| secret: authSecret, | ||
| salt: COOKIE_NAME, | ||
| }); | ||
| const SECRET = process.env.AUTH_SECRET ?? ''; |
There was a problem hiding this comment.
Do we need to catch the case when this is undefined and throw?
| { | ||
| id: crypto.randomUUID(), | ||
| providerId: 'github', | ||
| accountId: '1234567', |
There was a problem hiding this comment.
| accountId: '1234567', | |
| accountId: E2E_GITHUB_ID, |
| image: null, | ||
| createdAt: now.toISOString(), | ||
| updatedAt: now.toISOString(), | ||
| githubId: '1234567', |
There was a problem hiding this comment.
| githubId: '1234567', | |
| githubId: E2E_GITHUB_ID, |
| declare module 'set-cookie-parser'; | ||
|
|
||
| declare namespace App { | ||
| // Note: 'import {} from ""' syntax does not work in .d.ts files. |
There was a problem hiding this comment.
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.
| 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.'], | ||
| }); |
There was a problem hiding this comment.
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 } }; |
There was a problem hiding this comment.
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'; | |||
There was a problem hiding this comment.
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.
| type AuthUser = NonNullable<Awaited<ReturnType<(typeof import('./auth').auth)['api']['getSession']>>>['user']; | ||
|
|
||
| interface Locals { | ||
| user: AuthUser | null; |
There was a problem hiding this comment.
https://better-auth.com/docs/integrations/astro#astro-locals-types has this:
| user: AuthUser | null; | |
| user: import("better-auth").User | null; |
resolves #1189, #682, #1188
Summary
auth-astro+@auth/corewithbetter-auth, running in stateless mode (no database required, JWT-based sessions)patches/auth-astro+4.2.0.patch) that was needed to fix TypeScript issues in auth-astroChanges
src/auth.ts(new) — better-auth config with GitHub OAuth providersrc/pages/api/auth/[...all].ts(new) — catch-all route handling all auth requestssrc/middleware/authMiddleware.ts(new) — runs on every request, callsgetSessiononce and populatesAstro.localssrc/backendApi/backendProxy.ts— readscontext.locals.user?.githubIdviaAPIContextsrc/components/auth/LoginButton.tsx—signIn('github', ...)→authClient.signIn.social({ provider: 'github', ... })src/auth/logout.ts— custom@auth/corelogout →auth.api.signOut({ headers, asResponse: true })Astro.localsinstead of callingauth.api.getSessiondirectlyastro.config.mjs— removedauth()Astro integrationGitHub user ID handling
better-auth's
session.user.idis 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+mapProfileToUserinauth.tsstore the GitHub numeric ID directly on the user object asgithubIdat login time:String()is needed becauseprofile.idfrom 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.githubIdis the GitHub numeric ID (as a string) everywhere — no secondary lookups needed. The auth middleware sets it onAstro.locals.useronce per request, and all pages and the backend proxy read it from there.E2E test cookie helper
tests/helpers/auth.tsprovidescreateAuthCookies(accountPayload, sessionPayload)andsetupAuthCookie(page, name)to craft valid better-auth session cookies for Playwright tests without a real OAuth flow. The cookies are signed/encrypted using the sameAUTH_SECRETthe server uses.cookieCache(JWE, 1hr) is enabled sogetSessioncan validate the craftedsession_datacookie without an in-memory store lookup.New Cookies
better-authsets 3 cookies. Asession_token,session_dataandaccount_data. Session data and account data are JWE, encrypted data.Test plan
/collections,/subscriptions) show the login prompt when logged outnpm run e2e