diff --git a/server/.env.production.example b/server/.env.production.example index 54121eb..38794a2 100644 --- a/server/.env.production.example +++ b/server/.env.production.example @@ -14,7 +14,8 @@ WEB_ORIGIN=https://forrt.org/replicate-this BETTER_AUTH_URL=https://api-replicate-this.keeganvaz.in # --- Database (PostgreSQL on the same box) --- -# URL-encode the password if it contains @ : / ? # or &. +# Percent-encode the password: / ? # break the URL outright. Get the encoded +# form with: node -e "console.log(encodeURIComponent('your-password'))" DATABASE_URL=postgresql://replicate_this:CHANGE_ME@127.0.0.1:5432/replicate_this # --- Secrets: generate each with `openssl rand -hex 32` --- diff --git a/server/src/app.ts b/server/src/app.ts index c2c7f77..771508d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -25,11 +25,20 @@ export function createApp() { cors({ origin: config.webOrigin, credentials: true, - allowHeaders: ['Content-Type'], + // Authorization carries the bearer token when the session cookie cannot + // cross sites — see the oauthPopup/bearer plugins in auth.ts. + allowHeaders: ['Content-Type', 'Authorization'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }), ) + // Cloudflare serves its own robots.txt for this host, which can shadow the + // route below — a response header cannot be intercepted the same way. + app.use('*', async (c, next) => { + await next() + c.header('X-Robots-Tag', 'noindex, nofollow') + }) + // Better Auth owns everything under /api/auth/*. app.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw)) diff --git a/server/src/auth.ts b/server/src/auth.ts index fe3054b..afb9a8f 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { genericOAuth } from 'better-auth/plugins' +import { genericOAuth, bearer, oauthPopup } from 'better-auth/plugins' import { eq } from 'drizzle-orm' import { db, schema } from '@/db' import { config } from '@/lib/env' @@ -212,11 +212,25 @@ export const auth = betterAuth({ enabled: true, trustedProviders: ['google', 'github', 'orcid'], }, + // Better Auth double-checks the OAuth state against a cookie it sets on the + // API domain during sign-in. Cross-site that is a third-party cookie, which + // many browsers refuse to store, and the callback then fails with + // state_mismatch. The state itself is a single-use random value held in the + // verification table — the cookie is only defence in depth, so drop it when + // the two are on different sites. + skipStateCookieCheck: crossSite, }, socialProviders, - plugins: orcidConfig.length ? [genericOAuth({ config: orcidConfig })] : [], + plugins: [ + ...(orcidConfig.length ? [genericOAuth({ config: orcidConfig })] : []), + // Cross-site, the session cookie is third-party and current browsers drop + // it, so the SPA never sees a session however well the callback goes. The + // popup flow posts the session token back to the opener instead, and bearer + // lets the SPA present it as an Authorization header. + ...(crossSite ? [oauthPopup(), bearer()] : []), + ], advanced: { cookiePrefix: 'replicate-this', diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 53ff232..e956fdf 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -15,19 +15,58 @@ try { } catch (error) { // This runs mid-deploy and is read in CI logs, where a driver stack trace // buries the one line that says what to fix. - const { code } = (error as { cause?: { code?: string } }).cause ?? {} - const { hostname, port, pathname } = new URL(config.databaseUrl) + const { code, message } = (error as { cause?: { code?: string; message?: string } }).cause ?? {} + const { hostname, port, pathname, username } = new URL(config.databaseUrl) const target = `${hostname}:${port || '5432'}` - const hint = - code === 'ECONNREFUSED' - ? `nothing is listening at ${target} — is PostgreSQL running?` - : code === '28P01' - ? `password rejected at ${target}` - : code === '3D000' - ? `database "${pathname.slice(1)}" does not exist at ${target}` - : null + const database = pathname.slice(1) + const hints: Record = { + ECONNREFUSED: `nothing is listening at ${target} — is PostgreSQL running?`, + '28P01': `password rejected at ${target}`, + '3D000': `database "${database}" does not exist at ${target}`, + // Postgres names the exact object it refused — database or schema — which + // matters: owning the database does not grant rights inside a schema that + // another role owns. + '42501': `${message ?? 'permission denied'} (role "${username}" on "${database}") — that object needs to be owned by the role, see docs/DEPLOYMENT.md`, + } + const hint = code ? hints[code] : undefined - if (hint === null) throw error + if (hint === undefined) throw error console.error(`Migration failed: ${hint}\nCheck DATABASE_URL in .env.`) + + // A privilege error where the grants look right usually means this connection + // is not reaching the cluster you granted on. Ask it who it actually is. + if (code === '42501') { + try { + const { rows } = await pool.query( + `select current_user, current_database(), inet_server_port() as port, + has_database_privilege(current_user, current_database(), 'CREATE') as can_create_schema, + has_schema_privilege(current_user, 'public', 'CREATE') as can_create_tables, + case when to_regnamespace('drizzle') is null then null else + pg_get_userbyid((select nspowner from pg_namespace where nspname = 'drizzle')) + end as drizzle_schema_owner, + case when to_regnamespace('drizzle') is null then null else + has_schema_privilege(current_user, 'drizzle', 'CREATE') + end as can_write_drizzle_schema`, + ) + console.error('This connection reports:', rows[0]) + + // Objects left behind by an earlier run as another role are the usual + // cause, and they surface one at a time — list them all in one go. + const { rows: foreign } = await pool.query( + `select n.nspname || '.' || c.relname as object, pg_get_userbyid(c.relowner) as owner + from pg_class c join pg_namespace n on n.oid = c.relnamespace + where n.nspname in ('public', 'drizzle') + and c.relkind in ('r', 'v', 'm', 'S') + and pg_get_userbyid(c.relowner) <> current_user + order by 1`, + ) + if (foreign.length > 0) { + console.error('Objects not owned by this role:', foreign) + } + } catch (probe) { + console.error('Could not query the connection for details:', probe) + } + } + process.exit(1) } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4a6cc68..448a7b3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,3 +1,4 @@ +import { getStoredPopupToken } from 'better-auth/client/plugins' import { apiOrigin } from './config' /** @@ -18,10 +19,15 @@ export class ApiError extends Error { } async function request(path: string, init?: RequestInit): Promise { + // Cross-origin the session cookie is dropped by the browser, so the token the + // sign-in popup handed back is what actually authenticates these calls. + const token = getStoredPopupToken() + const res = await fetch(`${apiOrigin}/api${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init?.headers ?? {}), }, ...init, diff --git a/web/src/lib/auth-client.ts b/web/src/lib/auth-client.ts index 6dddd95..4881f70 100644 --- a/web/src/lib/auth-client.ts +++ b/web/src/lib/auth-client.ts @@ -1,16 +1,37 @@ import { createAuthClient } from 'better-auth/react' -import { genericOAuthClient } from 'better-auth/client/plugins' +import { + genericOAuthClient, + oauthPopupClient, + getStoredPopupToken, +} from 'better-auth/client/plugins' import { apiOrigin } from './config' /** * Better Auth lives in the Hono server under /api/auth. In dev, Vite proxies * /api to the server (see vite.config.ts); on GitHub Pages the server is on a * separate origin, so VITE_API_ORIGIN points the client at it. + * + * Cross-origin the session cookie is third-party and browsers drop it, so + * sign-in goes through a popup that hands back a token. The bundled fetch + * plugin only attaches that token inside an iframe, so a top-level page has to + * send it itself. */ export const authClient = createAuthClient({ ...(apiOrigin ? { baseURL: apiOrigin } : {}), basePath: '/api/auth', - plugins: [genericOAuthClient()], + plugins: [genericOAuthClient(), oauthPopupClient()], + fetchOptions: { + onRequest(context) { + const token = getStoredPopupToken() + if (!token) return context + const headers = new Headers(context.headers) + if (!headers.has('authorization')) headers.set('authorization', `Bearer ${token}`) + return { ...context, headers } + }, + }, }) +/** True when the SPA and API are on different origins, so cookies won't carry. */ +export const usePopupSignIn = apiOrigin !== '' + export const { useSession, signIn, signOut } = authClient diff --git a/web/src/pages/SignInPage.tsx b/web/src/pages/SignInPage.tsx index 6927c83..8c4dc17 100644 --- a/web/src/pages/SignInPage.tsx +++ b/web/src/pages/SignInPage.tsx @@ -1,6 +1,7 @@ -import { useSearchParams } from 'react-router-dom' +import { useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { signIn, authClient } from '@/lib/auth-client' +import { signIn, authClient, usePopupSignIn } from '@/lib/auth-client' import { api } from '@/lib/api' import { withBase } from '@/lib/config' @@ -17,6 +18,32 @@ export function SignInPage() { // strips the base path from the redirect, so put it back. const callbackURL = `${window.location.origin}${withBase(params.get('redirect') ?? '/dashboard')}` + const navigate = useNavigate() + const [error, setError] = useState(null) + const target = params.get('redirect') ?? '/dashboard' + + /** + * Cross-origin the session cookie never reaches the SPA, so sign-in runs in a + * popup that returns a token. Same-origin keeps the plain redirect flow. + */ + async function start(provider: { provider: string } | { providerId: string }) { + if (!usePopupSignIn) { + if ('provider' in provider) { + await signIn.social({ provider: provider.provider, callbackURL }) + } else { + await authClient.signIn.oauth2({ providerId: provider.providerId, callbackURL }) + } + return + } + setError(null) + const { error: popupError } = await authClient.signIn.popup({ ...provider, callbackURL }) + if (popupError) { + setError(popupError.message || 'Sign-in was cancelled.') + return + } + navigate(target, { replace: true }) + } + const { data: providers, isLoading } = useQuery({ queryKey: ['providers'], queryFn: () => api.get('/providers'), @@ -41,21 +68,25 @@ export function SignInPage() {

)} + {error && ( +

+ {error} +

+ )} +
{providers?.google && ( - signIn.social({ provider: 'google', callbackURL })}> + start({ provider: 'google' })}> Continue with Google )} {providers?.github && ( - signIn.social({ provider: 'github', callbackURL })}> + start({ provider: 'github' })}> Continue with GitHub )} {providers?.orcid && ( - authClient.signIn.oauth2({ providerId: 'orcid', callbackURL })} - > + start({ providerId: 'orcid' })}> Continue with ORCID )}