Skip to content
Open
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
17 changes: 15 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions apps/frontend/src/hooks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})

Expand Down Expand Up @@ -165,12 +170,12 @@ export function useMe(options?: ClientQueryOptions<Me>) {
})
}

/** 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,
}
}

Expand Down
31 changes: 24 additions & 7 deletions apps/frontend/src/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")({
Expand All @@ -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(() => {
Expand All @@ -35,25 +39,38 @@ function LoginPage() {
</p>
</div>

{configured === false ? (
{mode === "off" ? (
<p className="text-sm text-destructive" role="alert">
Auth is not configured in this server process. Put{" "}
<code>OIDC_CLIENT_ID</code>, <code>OIDC_CLIENT_SECRET</code>,{" "}
<code>OIDC_DISCOVERY_URL</code>, and <code>BETTER_AUTH_SECRET</code>{" "}
in the workspace <code>.env</code>, then restart <code>pnpm dev</code>{" "}
(env is only read at startup).
in the workspace <code>.env</code> — or just{" "}
<code>DEV_AUTH=true</code> to skip the IdP locally — then restart{" "}
<code>pnpm dev</code> (env is only read at startup).
</p>
) : null}

{mode === "dev" ? (
<p className="text-sm text-muted-foreground">
<code>DEV_AUTH=true</code>: this signs you straight in as{" "}
<code>DEV_AUTH_USER</code> (defaulting to <code>ARCADE_USER_ID</code>)
with no IdP involved. Non-production only.
</p>
) : null}

{isPending ? (
<p className="text-sm text-muted-foreground">Checking session…</p>
) : (
<Button
disabled={signIn.isPending || configured === false}
disabled={signIn.isPending || mode === "off"}
onClick={() => signIn.mutate("/")}
size="lg"
>
{signIn.isPending ? "Redirecting…" : "Continue with Arcade"}
{signIn.isPending
? "Redirecting…"
: mode === "dev"
? "Continue as dev user"
: "Continue with Arcade"}
</Button>
)}

Expand Down
32 changes: 16 additions & 16 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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." },
{
Expand Down
74 changes: 74 additions & 0 deletions packages/api/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<(typeof MANAGED)[number], string | undefined>> = {}

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")
})
})
Loading