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
3 changes: 2 additions & 1 deletion server/.env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -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` ---
Expand Down
11 changes: 10 additions & 1 deletion server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
18 changes: 16 additions & 2 deletions server/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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',
Expand Down
61 changes: 50 additions & 11 deletions server/src/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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)
}
6 changes: 6 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getStoredPopupToken } from 'better-auth/client/plugins'
import { apiOrigin } from './config'

/**
Expand All @@ -18,10 +19,15 @@ export class ApiError extends Error {
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
// 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,
Expand Down
25 changes: 23 additions & 2 deletions web/src/lib/auth-client.ts
Original file line number Diff line number Diff line change
@@ -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
45 changes: 38 additions & 7 deletions web/src/pages/SignInPage.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<string | null>(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<EnabledProviders>('/providers'),
Expand All @@ -41,21 +68,25 @@ export function SignInPage() {
</p>
)}

{error && (
<p className="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-center text-sm text-red-800">
{error}
</p>
)}

<div className="space-y-3">
{providers?.google && (
<ProviderButton onClick={() => signIn.social({ provider: 'google', callbackURL })}>
<ProviderButton onClick={() => start({ provider: 'google' })}>
Continue with Google
</ProviderButton>
)}
{providers?.github && (
<ProviderButton onClick={() => signIn.social({ provider: 'github', callbackURL })}>
<ProviderButton onClick={() => start({ provider: 'github' })}>
Continue with GitHub
</ProviderButton>
)}
{providers?.orcid && (
<ProviderButton
onClick={() => authClient.signIn.oauth2({ providerId: 'orcid', callbackURL })}
>
<ProviderButton onClick={() => start({ providerId: 'orcid' })}>
Continue with ORCID
</ProviderButton>
)}
Expand Down
Loading