diff --git a/.env.example b/.env.example index da8dd4d..0665de5 100644 --- a/.env.example +++ b/.env.example @@ -3,11 +3,24 @@ ARCADE_API_KEY= ANTHROPIC_API_KEY= # --- Auth (Better Auth BFF + OIDC). Leave unset to keep typed-in user ids. --- + +# Sign-in without the IdP: signed in by default, sign-out works, no OIDC client to +# register. Wins over the OIDC_* settings below, and refuses to engage on the +# production deployment (VERCEL_ENV, else NODE_ENV). Runs as DEV_AUTH_USER, else +# ARCADE_USER_ID. On Vercel this belongs on the Preview environment only: a preview +# hostname cannot be registered as an OIDC redirect URI, so real login there is a +# dead end. +# DEV_AUTH=true +# DEV_AUTH_USER=you@example.com + # openssl rand -hex 32 BETTER_AUTH_SECRET= -# Public origin of this app (redirects + cookies). Under `pnpm dev`, portless -# injects PORTLESS_URL; set this only when that is wrong. +# Public origin of this app — the host in the redirect_uri the IdP is handed, so it +# has to be the origin you browse. Under `pnpm dev`, portless injects PORTLESS_URL; +# on Vercel it is derived from VERCEL_ENV / VERCEL_BRANCH_URL / VERCEL_URL. Set this +# only when both are wrong, and per environment — a value pinned to production makes +# every preview redirect there. # BETTER_AUTH_URL=https://returntypes.localhost # Register an OIDC client on Arcade's IdP (Ory / Coordinator). Redirect URI: diff --git a/CLAUDE.md b/CLAUDE.md index 0dec75c..8d88d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,51 @@ compiles it, so there is no build step. - `pnpm --filter @repo/api smoke` drives every route in one pass against the live API. Same key requirement; it is a script, not a test. +## Auth (`packages/api`) + +A Better Auth BFF (`src/auth.ts`) over the same Ory IdP as Arcade: the browser holds +only an httpOnly session cookie, the OIDC tokens stay in the database, and identity +for tool runs is `user.accountId` — the OIDC `sub`, which is the Arcade account. + +`authMode()` is the single thing to branch on, and there are three answers: + +- `oidc` — `BETTER_AUTH_SECRET` + `OIDC_CLIENT_ID` + `OIDC_DISCOVERY_URL` are set. +- `dev` — `DEV_AUTH=true`. **Signed in by default with no IdP** (`src/dev-auth.ts`): + it answers `/api/auth/*` itself in Better Auth's own shapes, so `authClient` and + everything that reads a session need no dev branch. Signing out is a cookie and + signed-in is its absence, so there is no session store — nothing to migrate, no + rows in the auth tables, and no `BETTER_AUTH_URL` to get right. It runs as + `DEV_AUTH_USER`, defaulting to `ARCADE_USER_ID` so it matches the id `smoke.ts` + and the fixtures use. It wins over `oidc`, because a machine with credentials in + `.env` is exactly the one that wants to skip the dance, and it refuses on the + production **deployment** — `VERCEL_ENV` when set, else `NODE_ENV`, because Vercel + builds previews with `NODE_ENV=production` too and a preview is the case this + exists for. Nothing there authenticates anybody, so that check is the whole + security model. +- `off` — neither. Login is unavailable and a run uses the `userId` in the request + body, which is what the UI's "Run as" field is. + +`AUTH_REQUIRED=true` refuses runs without a session in any mode. Put `DEV_AUTH=true` +in the **workspace-root `.env`**, not the shell: turbo runs tasks in strict env mode, +so an ambient variable never reaches the dev server. + +`authBaseURL()` is the host in the `redirect_uri` the IdP is handed, so getting it +wrong does not fail loudly — the IdP rejects an unregistered URI, or the browser is +sent to a machine that isn't there, and login just never completes. It reads +`BETTER_AUTH_URL`, then `PORTLESS_URL`, then Vercel's own env, and only then +localhost. Consequences worth knowing: + +- **An OIDC client registers exact redirect URIs**, so every origin that serves login + needs `{origin}/api/auth/oauth2/callback/oidc` registered on the Ory client. That is + why previews get `DEV_AUTH=true` instead: `VERCEL_URL` is per-deployment, so no + preview URL can be registered ahead of time. +- Previews therefore prefer `VERCEL_BRANCH_URL`, which is stable per branch — the one + preview host where registering a URI would actually hold. `src/auth.test.ts` pins + that precedence. +- Set `BETTER_AUTH_URL` **per environment** if at all. One pinned to the production + domain makes every preview send the browser to production, which then owns the + cookie — `.vercel.app` is on the public suffix list, so no cookie can span both. + ## Scripts (`packages/api`) Users write TypeScript against the catalog's types, validate it without running it, diff --git a/apps/frontend/src/hooks/api.ts b/apps/frontend/src/hooks/api.ts index 239b436..d327a83 100644 --- a/apps/frontend/src/hooks/api.ts +++ b/apps/frontend/src/hooks/api.ts @@ -31,8 +31,13 @@ export const authKeys = { me: () => [...authKeys.all, "me"], } +/** + * `url` is not `.url()`: a real IdP returns an absolute authorize URL, but dev + * sign-in (`DEV_AUTH=true`) returns the same-origin path to land on, since the + * server behind the portless proxy cannot know its own public origin. + */ const OAuthSignInSchema = z.object({ - url: z.string().url().optional(), + url: z.string().min(1).optional(), redirect: z.boolean().optional(), }) @@ -165,12 +170,12 @@ export function useMe(options?: ClientQueryOptions) { }) } -/** Whether the BFF has OIDC env wired up (`useMe` → `configured`). */ -export function useAuthConfigured() { +/** Which login the BFF serves: `oidc`, `dev`, `off` (`useMe` → `mode`). */ +export function useAuthMode() { const me = useMe() return { ...me, - data: me.data?.configured ?? null, + data: me.data?.mode ?? null, } } diff --git a/apps/frontend/src/routes/login.tsx b/apps/frontend/src/routes/login.tsx index 9faee05..5ef824b 100644 --- a/apps/frontend/src/routes/login.tsx +++ b/apps/frontend/src/routes/login.tsx @@ -2,11 +2,15 @@ * Sign in via Arcade's IdP (Ory / Coordinator OIDC). Only useful once * BETTER_AUTH_SECRET + OIDC_* are set on the server; otherwise the BFF returns * 503 and this page says so. + * + * `DEV_AUTH=true` makes the same button a local sign-in with no IdP at all, which + * is why this page reads `mode` rather than a boolean — the flow is identical, the + * thing on the other end is not. */ import { createFileRoute, Link } from "@tanstack/react-router" import { useEffect } from "react" import { Button } from "@/components/ui/button" -import { useAuthConfigured, useSignInOidc } from "@/hooks/api" +import { useAuthMode, useSignInOidc } from "@/hooks/api" import { authClient } from "@/lib/auth-client" export const Route = createFileRoute("/login")({ @@ -15,7 +19,7 @@ export const Route = createFileRoute("/login")({ function LoginPage() { const { data: session, isPending } = authClient.useSession() - const { data: configured = null } = useAuthConfigured() + const { data: mode = null } = useAuthMode() const signIn = useSignInOidc() useEffect(() => { @@ -35,13 +39,22 @@ function LoginPage() {

- {configured === false ? ( + {mode === "off" ? (

Auth is not configured in this server process. Put{" "} OIDC_CLIENT_ID, OIDC_CLIENT_SECRET,{" "} OIDC_DISCOVERY_URL, and BETTER_AUTH_SECRET{" "} - in the workspace .env, then restart pnpm dev{" "} - (env is only read at startup). + in the workspace .env — or just{" "} + DEV_AUTH=true to skip the IdP locally — then restart{" "} + pnpm dev (env is only read at startup). +

+ ) : null} + + {mode === "dev" ? ( +

+ DEV_AUTH=true: this signs you straight in as{" "} + DEV_AUTH_USER (defaulting to ARCADE_USER_ID) + with no IdP involved. Non-production only.

) : null} @@ -49,11 +62,15 @@ function LoginPage() {

Checking session…

) : ( )} diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 6f778a6..28b2e6f 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -5,12 +5,7 @@ import { createMiddleware } from "hono/factory" import { describeRoute, resolver, validator } from "hono-openapi" import { z } from "zod" import { agentHandler } from "./agent" -import { - getAuth, - getSessionUser, - isAuthConfigured, - resolveRunUserId, -} from "./auth" +import { authMode, getAuth, getSessionUser, resolveRunUserId } from "./auth" import { authorizationFor, type Catalog, @@ -19,6 +14,7 @@ import { } from "./catalog" import { generateTypes } from "./codegen" import { db } from "./db" +import { devAuthHandler } from "./dev-auth" import { revalidateAll, runScript, upsertScript } from "./execute" import { mcpHandler } from "./mcp" import { openApiDocument } from "./openapi" @@ -108,19 +104,20 @@ export const routes = new Hono() operationId: "getMe", summary: "Current session", description: - "Whether OIDC auth is configured on this process, and the signed-in user if any. " + - "Always 200 — `configured: false` means login is disabled; `user: null` means signed out.", + "Which login this process serves, and the signed-in user if any. Always 200 — " + + '`mode: "off"` means login is disabled; `user: null` means signed out.', tags: ["auth"], responses: { - 200: json(MeResponseSchema, "Auth config and optional session user."), + 200: json(MeResponseSchema, "Auth mode and optional session user."), }, }), async (c) => { - if (!isAuthConfigured()) { - return c.json({ configured: false, user: null }, 200) - } - const user = await getSessionUser(c.req.raw.headers) - return c.json({ configured: true, user }, 200) + const mode = authMode() + if (mode === "off") return c.json({ mode, user: null }, 200) + return c.json( + { mode, user: await getSessionUser(c.req.raw.headers) }, + 200 + ) } ) .post( @@ -608,13 +605,16 @@ export const app = new Hono() // is set — see ./auth. Mounted on `app`, not `routes`, so it stays out of the // OpenAPI / MCP surface the same way /chat and /mcp do. .all("/api/auth/*", (c) => { + // `DEV_AUTH=true` answers these paths itself, in the same shapes, so nothing + // downstream — including the browser client — knows the difference. + if (authMode() === "dev") return devAuthHandler(c.req.raw) const auth = getAuth() if (!auth) { return c.json( { error: "auth_not_configured", message: - "Set BETTER_AUTH_SECRET, OIDC_CLIENT_ID, and OIDC_DISCOVERY_URL to enable login.", + "Set BETTER_AUTH_SECRET, OIDC_CLIENT_ID, and OIDC_DISCOVERY_URL to enable login, or DEV_AUTH=true to be signed in locally without an IdP.", }, 503 ) @@ -651,7 +651,7 @@ const document = openApiDocument(app, { "`POST /api/seed` populates it; the read endpoints query it.", }, tags: [ - { name: "auth", description: "Session and OIDC configuration." }, + { name: "auth", description: "Session and login configuration." }, { name: "seed", description: "Populate the mirror from the Arcade API." }, { name: "tools", description: "Read the mirrored catalog." }, { diff --git a/packages/api/src/auth.test.ts b/packages/api/src/auth.test.ts new file mode 100644 index 0000000..2bd589a --- /dev/null +++ b/packages/api/src/auth.test.ts @@ -0,0 +1,74 @@ +/** + * `authBaseURL()` is the host in the `redirect_uri` the IdP is handed, which makes it + * the difference between login working and login looping. Nothing here reaches the + * network, so like ./dev-auth.test.ts this suite needs no API key. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { authBaseURL } from "./auth" + +const MANAGED = [ + "BETTER_AUTH_URL", + "PORTLESS_URL", + "VERCEL_ENV", + "VERCEL_URL", + "VERCEL_BRANCH_URL", + "VERCEL_PROJECT_PRODUCTION_URL", +] as const + +let saved: Partial> = {} + +beforeEach(() => { + saved = Object.fromEntries(MANAGED.map((key) => [key, process.env[key]])) + for (const key of MANAGED) delete process.env[key] +}) + +afterEach(() => { + for (const key of MANAGED) { + const value = saved[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +describe("authBaseURL", () => { + it("prefers an explicit BETTER_AUTH_URL over anything it could infer", () => { + process.env.BETTER_AUTH_URL = "https://scripts.example.com" + process.env.PORTLESS_URL = "https://returntypes.localhost" + process.env.VERCEL_URL = "deployment.vercel.app" + + expect(authBaseURL()).toBe("https://scripts.example.com") + }) + + it("uses the portless origin under `pnpm dev`", () => { + process.env.PORTLESS_URL = "https://thimphu.returntypes.localhost" + + expect(authBaseURL()).toBe("https://thimphu.returntypes.localhost") + }) + + it("names the production domain on the production deployment", () => { + process.env.VERCEL_ENV = "production" + process.env.VERCEL_PROJECT_PRODUCTION_URL = "abilities.vercel.app" + process.env.VERCEL_URL = "abilities-abc123.vercel.app" + + expect(authBaseURL()).toBe("https://abilities.vercel.app") + }) + + it("prefers the branch URL on a preview, since a per-deployment host is unregisterable", () => { + process.env.VERCEL_ENV = "preview" + process.env.VERCEL_BRANCH_URL = "abilities-git-thimphu.vercel.app" + process.env.VERCEL_URL = "abilities-abc123.vercel.app" + + expect(authBaseURL()).toBe("https://abilities-git-thimphu.vercel.app") + }) + + it("falls back to the deployment's own URL when there is no branch URL", () => { + process.env.VERCEL_ENV = "preview" + process.env.VERCEL_URL = "abilities-abc123.vercel.app" + + expect(authBaseURL()).toBe("https://abilities-abc123.vercel.app") + }) + + it("only reaches localhost when nothing has said otherwise", () => { + expect(authBaseURL()).toBe("http://localhost:3000") + }) +}) diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 73b8deb..b69bf27 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -7,7 +7,8 @@ * * Auth is optional until OIDC env is set — the rest of the API keeps working with * a typed-in Arcade user id. Set `AUTH_REQUIRED=true` to refuse unauthenticated - * runs once login works. + * runs once login works. `DEV_AUTH=true` is the third mode: a real session shape + * with no IdP, so the signed-in paths work locally — see ./dev-auth. */ import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" @@ -15,6 +16,7 @@ import { genericOAuth } from "better-auth/plugins" import { z } from "zod" import * as authSchema from "./auth-schema" import { db } from "./db" +import { devAuthEnabled, devSessionUser } from "./dev-auth" const OIDC_PROVIDER_ID = "oidc" @@ -56,19 +58,57 @@ export function isAuthConfigured(): boolean { ) } +export type AuthMode = "oidc" | "dev" | "off" + +/** + * Which login this process serves, and the one thing routes should branch on. + * + * `dev` beats `oidc` deliberately: the reason to set `DEV_AUTH=true` is to skip the + * IdP round trip, and a machine that has OIDC credentials in `.env` is exactly the + * machine that wants to skip it. + */ +export function authMode(): AuthMode { + if (devAuthEnabled()) return "dev" + return isAuthConfigured() ? "oidc" : "off" +} + /** - * Public origin Better Auth issues redirects for. Under `pnpm dev`, portless - * injects `PORTLESS_URL` (e.g. https://returntypes.localhost); override with - * `BETTER_AUTH_URL` when that is wrong (preview hosts, standalone API). + * Public origin Better Auth issues redirects for — and, more to the point, the host + * in the `redirect_uri` it sends the IdP. Under `pnpm dev`, portless injects + * `PORTLESS_URL` (e.g. https://returntypes.localhost); `BETTER_AUTH_URL` overrides + * everything, for a host none of this guesses right. + * + * The `localhost:3000` default is a *last* resort rather than the fallback for + * anything deployed. A deployment that guesses localhost does not fail loudly: it + * sends the IdP a plausible `redirect_uri`, the IdP rejects it as unregistered or + * bounces the browser to a machine that isn't there, and login simply never + * completes. So a Vercel deployment names itself from Vercel's own env instead. */ export function authBaseURL(): string { return ( process.env.BETTER_AUTH_URL ?? process.env.PORTLESS_URL ?? + vercelOrigin() ?? "http://localhost:3000" ) } +/** + * This deployment's own public origin, or null off Vercel. + * + * Previews prefer `VERCEL_BRANCH_URL` over `VERCEL_URL`: the latter changes with + * every deployment, and an OIDC client registers exact redirect URIs, so a + * per-deployment host is one nobody can ever register. The branch URL is stable, so + * it is the only preview host where real login could be made to work. + */ +function vercelOrigin(): string | null { + const host = + process.env.VERCEL_ENV === "production" + ? (process.env.VERCEL_PROJECT_PRODUCTION_URL ?? process.env.VERCEL_URL) + : (process.env.VERCEL_BRANCH_URL ?? process.env.VERCEL_URL) + return host ? `https://${host}` : null +} + function trustedOrigins(): string[] { const fromEnv = (process.env.CORS_ORIGIN ?? "") .split(",") @@ -173,6 +213,7 @@ export function arcadeUserId(user: { export async function getSessionUser( headers: Headers ): Promise { + if (devAuthEnabled()) return devSessionUser(headers) const auth = getAuth() if (!auth) return null const session = await auth.api.getSession({ headers }) diff --git a/packages/api/src/dev-auth.test.ts b/packages/api/src/dev-auth.test.ts new file mode 100644 index 0000000..8a18880 --- /dev/null +++ b/packages/api/src/dev-auth.test.ts @@ -0,0 +1,166 @@ +/** + * Dev sign-in is the one auth path with nothing upstream of it, so unlike the rest + * of the suite this file needs no API key and no catalog — it drives the real routes + * and reads the real cookies. + * + * Every case sets its own env: `authMode()` and friends read `process.env` per call + * precisely so that a machine with OIDC credentials in `.env` behaves the same here. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import app from "./app" + +const MANAGED = [ + "DEV_AUTH", + "DEV_AUTH_USER", + "NODE_ENV", + "VERCEL_ENV", + "BETTER_AUTH_SECRET", + "OIDC_CLIENT_ID", + "OIDC_DISCOVERY_URL", +] as const + +const DEV_USER = "dev@example.invalid" + +let saved: Partial> = {} + +beforeEach(() => { + saved = Object.fromEntries(MANAGED.map((key) => [key, process.env[key]])) + for (const key of MANAGED) delete process.env[key] + process.env.DEV_AUTH = "true" + process.env.DEV_AUTH_USER = DEV_USER +}) + +afterEach(() => { + for (const key of MANAGED) { + const value = saved[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +const getSession = (cookie?: string) => + app.request("/api/auth/get-session", { + headers: cookie ? { cookie } : {}, + }) + +/** The `Set-Cookie` a response sends back, as a browser would send it next time. */ +const cookieFrom = (response: Response) => { + const header = response.headers.get("set-cookie") ?? "" + return header.split(";")[0] ?? "" +} + +describe("dev sign-in", () => { + it("reports the mode and a session with no request cookies at all", async () => { + const response = await app.request("/api/me") + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + mode: "dev", + user: { + id: `dev_${DEV_USER}`, + accountId: DEV_USER, + email: DEV_USER, + name: "Dev user", + image: null, + }, + }) + }) + + it("answers get-session in Better Auth's shape, so the browser client is unchanged", async () => { + const body = await (await getSession()).json() + + // What `authClient.useSession()` reads, and what the rail mirrors into the + // run-as atom. + expect(body).toMatchObject({ + session: { userId: `dev_${DEV_USER}` }, + user: { accountId: DEV_USER, emailVerified: true }, + }) + }) + + it("signs out with a cookie, and back in by clearing it", async () => { + const signOut = await app.request("/api/auth/sign-out", { method: "POST" }) + expect(await signOut.json()).toEqual({ success: true }) + + const cookie = cookieFrom(signOut) + expect(cookie).toBe("returntypes.dev_signed_out=1") + expect(await (await getSession(cookie)).json()).toBeNull() + expect( + await (await app.request("/api/me", { headers: { cookie } })).json() + ).toEqual({ mode: "dev", user: null }) + + const signIn = await app.request("/api/auth/sign-in/oauth2", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ providerId: "oidc", callbackURL: "/scripts" }), + }) + + expect(await signIn.json()).toEqual({ redirect: true, url: "/scripts" }) + // Cleared: the browser drops it, and an empty value reads as absent regardless. + expect(signIn.headers.get("set-cookie")).toContain("Max-Age=0") + expect(await (await getSession(cookieFrom(signIn))).json()).not.toBeNull() + }) + + it("will not redirect off-origin after sign-in", async () => { + for (const callbackURL of ["https://evil.example/", "//evil.example/"]) { + const response = await app.request("/api/auth/sign-in/oauth2", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ callbackURL }), + }) + expect(await response.json()).toEqual({ redirect: true, url: "/" }) + } + }) + + it("does not pretend to serve the rest of Better Auth", async () => { + const response = await app.request("/api/auth/oauth2/callback/oidc") + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: "dev_auth_unsupported", + }) + }) + + it("refuses in production, where nothing would be authenticating anybody", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + process.env.NODE_ENV = "production" + + expect(await (await app.request("/api/me")).json()).toEqual({ + mode: "off", + user: null, + }) + // No OIDC env either, so login is simply unavailable — not faked. + expect((await getSession()).status).toBe(503) + warn.mockRestore() + }) + + it("still engages on a Vercel preview, which builds as production", async () => { + // The case dev auth exists for: NODE_ENV says production, the deployment is not. + process.env.NODE_ENV = "production" + process.env.VERCEL_ENV = "preview" + + expect(await (await app.request("/api/me")).json()).toMatchObject({ + mode: "dev", + user: { accountId: DEV_USER }, + }) + }) + + it("refuses on the production deployment even when NODE_ENV is unset", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + process.env.VERCEL_ENV = "production" + + expect(await (await app.request("/api/me")).json()).toEqual({ + mode: "off", + user: null, + }) + warn.mockRestore() + }) + + it("is off when DEV_AUTH is not set", async () => { + delete process.env.DEV_AUTH + + expect(await (await app.request("/api/me")).json()).toEqual({ + mode: "off", + user: null, + }) + }) +}) diff --git a/packages/api/src/dev-auth.ts b/packages/api/src/dev-auth.ts new file mode 100644 index 0000000..1cb828b --- /dev/null +++ b/packages/api/src/dev-auth.ts @@ -0,0 +1,199 @@ +/** + * Dev sign-in: a session with no IdP behind it. + * + * `DEV_AUTH=true` hands `/api/auth/*` to this module instead of Better Auth, so + * everything downstream of a session — the rail's session chip, `GET /api/me`, + * `resolveRunUserId`, `AUTH_REQUIRED` — is exercisable without registering an OIDC + * client on Arcade's IdP. The paths and payloads are Better Auth's, which is the + * point: the browser client needs no dev branch, and neither does anything that + * reads a session. + * + * Signed in is the default and signing out is a cookie, so there is no session + * store — nothing to migrate, nothing a restart loses, no rows in the auth tables, + * and no dependency on `BETTER_AUTH_URL` being right for this host. + * + * Nothing here authenticates anybody, so the production refusal below is the entire + * security model. Both it and `DEV_AUTH` are read per call rather than at module + * load, because the three processes that host this app pick up the workspace `.env` + * at different times. + * + * A Vercel preview is the case this exists for. Its hostname changes per deployment + * and an OIDC client registers exact redirect URIs, so no preview URL can be + * pre-registered with the IdP — real login there is a dead end, while the deployment + * is still not production. + */ +import type { SessionUser } from "./auth" + +/** Present means signed out. Absent — the default — means signed in. */ +const SIGNED_OUT_COOKIE = "returntypes.dev_signed_out" + +/** + * Fixed rather than derived from the clock: the browser re-polls `get-session` and + * compares the payload by value, so timestamps that moved would churn the atom. + */ +const ISSUED_AT = new Date(0).toISOString() +const EXPIRES_AT = new Date("2100-01-01T00:00:00.000Z").toISOString() + +let warnedInProduction = false + +export function devAuthEnabled(): boolean { + if (process.env.DEV_AUTH !== "true") return false + if (isProductionDeployment()) { + if (!warnedInProduction) { + warnedInProduction = true + console.warn( + "DEV_AUTH=true ignored: this is the production deployment. Set OIDC_CLIENT_ID / OIDC_DISCOVERY_URL / BETTER_AUTH_SECRET for real login." + ) + } + return false + } + return true +} + +/** + * The production *deployment*, which is not the same thing as a production build. + * Vercel sets `NODE_ENV=production` for preview deployments too, so `NODE_ENV` alone + * would refuse a dev session exactly where one is wanted. `VERCEL_ENV` distinguishes + * them (`production` / `preview` / `development`) and wins wherever it exists. + */ +function isProductionDeployment(): boolean { + if (process.env.VERCEL_ENV) return process.env.VERCEL_ENV === "production" + return process.env.NODE_ENV === "production" +} + +/** + * The Arcade end user a dev session runs as. Defaults to `ARCADE_USER_ID` — the + * same id `smoke.ts` and the test fixtures resolve — so turning dev auth on does + * not quietly move which account tools execute as. + */ +export function devUserId(): string { + return ( + process.env.DEV_AUTH_USER ?? + process.env.ARCADE_USER_ID ?? + "anirudh@arcade.dev" + ) +} + +/** + * The session user, or null when signed out. + * + * Guarded again here rather than trusting the caller: this is the function that + * decides somebody is signed in, and it must be impossible for it to say yes in + * production. + */ +export function devSessionUser(headers: Headers): SessionUser | null { + if (!devAuthEnabled()) return null + if (readCookie(headers, SIGNED_OUT_COOKIE)) return null + const id = devUserId() + return { + id: `dev_${id}`, + // Under real auth this is the OIDC `sub`, and `arcadeUserId()` prefers it — + // putting the dev id here is what makes tool calls run as that account. + accountId: id, + email: id, + name: "Dev user", + image: null, + } +} + +/** + * The three endpoints the Better Auth browser client actually reaches in this app: + * `useSession()` gets `get-session`, `signOut()` posts `sign-out`, and + * `signIn.oauth2()` posts `sign-in/oauth2` and follows the `url` that comes back. + */ +export async function devAuthHandler(request: Request): Promise { + const url = new URL(request.url) + const path = url.pathname.slice("/api/auth".length) + const secure = url.protocol === "https:" + + if (path === "/get-session") { + const user = devSessionUser(request.headers) + return Response.json(user ? sessionPayload(user) : null) + } + + if (path === "/sign-out") { + return Response.json( + { success: true }, + { headers: { "set-cookie": signedOutCookie("1", 31_536_000, secure) } } + ) + } + + if (path.startsWith("/sign-in")) { + // Signing back in is deleting the cookie. `redirect` + `url` is the shape the + // client's redirect plugin and ./hooks/api both follow; the url stays relative + // because this process cannot know its own public origin (portless proxies it). + const body: unknown = await request.json().catch(() => null) + return Response.json( + { redirect: true, url: callbackPath(body) }, + { headers: { "set-cookie": signedOutCookie("", 0, secure) } } + ) + } + + return Response.json( + { + error: "dev_auth_unsupported", + message: `DEV_AUTH=true serves only get-session, sign-out and sign-in; ${path} needs a real OIDC client.`, + }, + { status: 404 } + ) +} + +function sessionPayload(user: SessionUser) { + return { + session: { + id: "dev_session", + token: "dev_session", + userId: user.id, + expiresAt: EXPIRES_AT, + createdAt: ISSUED_AT, + updatedAt: ISSUED_AT, + }, + user: { + ...user, + emailVerified: true, + createdAt: ISSUED_AT, + updatedAt: ISSUED_AT, + }, + } +} + +/** + * Where to land after signing in — a same-origin path only. An absolute + * `callbackURL` would make this an open redirect, and `//host` is absolute too. + */ +function callbackPath(body: unknown): string { + const value = + body && typeof body === "object" && "callbackURL" in body + ? body.callbackURL + : null + if (typeof value !== "string") return "/" + if (!value.startsWith("/") || value.startsWith("//")) return "/" + return value +} + +function signedOutCookie(value: string, maxAge: number, secure: boolean) { + return [ + `${SIGNED_OUT_COOKIE}=${value}`, + "Path=/", + `Max-Age=${maxAge}`, + "SameSite=Lax", + "HttpOnly", + // Omitted under `pnpm dev:ports`, which serves plain http on a raw port. + secure ? "Secure" : "", + ] + .filter(Boolean) + .join("; ") +} + +/** Empty counts as absent, so a cookie a browser kept after `Max-Age=0` is ignored. */ +function readCookie(headers: Headers, name: string): string | null { + const header = headers.get("cookie") + if (!header) return null + for (const pair of header.split(";")) { + const trimmed = pair.trim() + if (trimmed.startsWith(`${name}=`)) { + return trimmed.slice(name.length + 1) || null + } + } + return null +} diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts index d36decd..868f10a 100644 --- a/packages/api/src/schemas.ts +++ b/packages/api/src/schemas.ts @@ -372,7 +372,13 @@ export const MeUserSchema = z /** Always 200: auth off, signed out, or signed in. */ export const MeResponseSchema = z .object({ - configured: z.boolean(), + mode: z + .enum(["oidc", "dev", "off"]) + .describe( + "`oidc` is login against Arcade's IdP. `dev` is DEV_AUTH=true: signed in by " + + "default, no IdP, non-production only. `off` means no login is available and " + + "runs use the user id in the request body." + ), user: MeUserSchema.nullable(), }) .meta({ id: "MeResponse" })