Skip to content

Auth: migrate to better-auth (web cookie sessions + OAuth 2.1 for the CLI)#93

Merged
cevian merged 33 commits into
mainfrom
cevian/exp-betterauth
Jun 25, 2026
Merged

Auth: migrate to better-auth (web cookie sessions + OAuth 2.1 for the CLI)#93
cevian merged 33 commits into
mainfrom
cevian/exp-betterauth

Conversation

@cevian

@cevian cevian commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrates human authentication to better-auth: GitHub/Google social login → httpOnly cookie sessions for the web UI, and an OAuth 2.1 authorization server (@better-auth/oauth-provider) that issues access + refresh tokens to the me CLI (auth-code + PKCE + loopback). Agent api keys stay custom in core (unchanged).

Full design + the alternatives considered/rejected: AUTH_DESIGN.md.

What changed

  • Server — better-auth instance on a dedicated pg pool (search_path=auth); /api/v1/auth/* → the better-auth handler (social sign-in, callbacks, sessions, /oauth2/authorize|token). Resource-server validation is a hashed oauth_access_token lookup (verifyOAuthAccessToken); the Bearer path dispatches api-key → core, else → OAuth token; cookie → getSession + CSRF gate.
  • DB (006_betterauth) — sessions gains token (drops token_hash); adds oauth_client / oauth_access_token / oauth_refresh_token / oauth_consent + jwks; seeds the trusted me-cli public PKCE client; drops the device-flow tables/functions.
  • CLIme login is now auth-code + PKCE + loopback (openid-client); the token set (access/refresh/expiry) is stored in the OS keychain / 0600 file; proactive-by-expiry + reactive-401 refresh (transport seams + session.ts), with refresh-token rotation + in-flight dedup. me serve / me mcp / the claude hook ride the same bearer sources, so they survive access-token expiry.
  • Web — the existing AuthGate login was wired to the retired device endpoints; rewired through the better-auth React SDK (signIn.social / signOut), plus a /login page that resumes the CLI authorize flow.
  • Cleanup — retired packages/auth (AuthStore) entirely; the cron now calls cleanupExpiredAuth (the schema's cleanup_expired_* sweeps). Removed the dead device-flow HTTP layer + the orphaned device-flow client.
  • Provisioning — lazy + core-only (ensureUserProvisioned) on first user RPC (better-auth owns auth.users); invitation-redemption-on-login migrated here.

Deploy / breaking changes

  • Requires BETTER_AUTH_SECRET on the server (cookie signatures, jwks encryption, the login-handoff signature).
  • sessions is truncated by 006 → everyone re-logs in once.
  • GitHub OAuth app: reuse the existing one — same callback (/api/v1/auth/callback/github) + env (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET); only update the callback if the domain changes.

Tests (all green locally)

  • unit (./bun run check): 636
  • integration (./bun run test:db): 241
  • e2e (local Postgres + real OpenAI embeddings): 26

Remaining before un-drafting (live cutover)

The one path not verifiable headless is the browser GitHub round-trip — the e2e injects tokens, deliberately bypassing me login. To finish:

  1. Set BETTER_AUTH_SECRET + GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET on the target env.
  2. Verify a live me login (CLI loopback → /login → GitHub → loopback) and web login.
  3. Deploy / cut over.

🤖 Generated with Claude Code

cevian and others added 25 commits June 23, 2026 09:41
Add the better-auth library (1.6.20) for human identity (OAuth + sessions +
device flow); api keys stay custom in core. The instance maps onto the existing
snake_case auth tables via modelName+fields (no renames), uses generateId:false
so the DB uuidv7() default keeps auth.users.id == core.principal.id, and runs on
its own node-postgres pool pinned to search_path=auth (separate from the app/
worker postgres.js pools). bearer({requireSignature:true}) hardens the CLI path.

Wired into start.ts/context.ts as ctx.betterAuth (+ pool teardown); requires the
new BETTER_AUTH_SECRET env. Not yet mounted/serving — handler routing, session
validation swap, and the DB migration follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mount better-auth as a catch-all on /api/v1/auth/* (it owns OAuth, sessions,
sign-out, and the device flow), replacing the hand-rolled device/OAuth routes.
Swap session validation in both authenticators to auth.api.getSession({headers}):

- authenticate-space: api-key path (core.validateApiKey) unchanged; the session
  branch now uses getSession. Cookie creds still gated by the CSRF origin check;
  Bearer exempt. Credential-presence check keeps "no creds" at 401.
- authenticate-user: getSession-based; carries identity (id/email/name) so the
  user RPC no longer needs AuthStore. whoami reads identity from the session.

The hand-rolled handlers/auth.ts + auth/providers/* are now unused (deletion
batched with the packages/auth retirement). check (typecheck/lint/unit) is green;
the auth-space integration test is wired to a real better-auth instance but its
valid-session cases need the DB migration + session minting (follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
incremental/006_betterauth.sql: sessions get a plaintext unique `token` +
`updated_at` and lose `token_hash` (better-auth round-trips the raw token through
the row, so hash-at-rest is impossible; the CLI/bearer path is hardened with
requireSignature instead). Existing sessions are truncated (disposable; re-login).
Replace the custom device_authorization table + its SQL functions with the
better-auth deviceAuthorization plugin's model, mapped to `device_codes` (snake
case via the plugin schema option in betterauth.ts). The session/device SQL
functions are dropped; only the expired-row cron sweeps remain (idempotent 002 +
004, the latter now sweeping device_codes). The cron's device cleanup repoints to
cleanup_expired_device_codes.

Verified: migrateAuth applies cleanly on a fresh schema and re-applies cleanly
(idempotent boot). The migrate.integration.test.ts rewrite (it asserts the old
tables/functions) is batched with the test rework.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
better-auth owns auth.users + accounts now, so first-login provisioning is
core-only and lazy: ensureUserProvisioned stands up the core.principal (sharing
the better-auth user id), a default space + its me_<slug> schema, and the creator
grants the first time a session reaches the user RPC. Idempotent (a no-op once
the principal exists, guarded by getPrincipal) so it runs on every user RPC call;
concurrent first-requests race safely. The CLI hits whoami/space.list right after
login, so the user RPC is the natural touchpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the session/device-flow approach for programmatic clients with an OAuth
2.1 authorization server. betterauth.ts now:
- drops the deviceAuthorization + bearer plugins (and requireSignature)
- adds jwt() (id-token signing + JWKS, private keys encrypted with the secret)
  and @better-auth/oauth-provider (the AS)
- registers the first-party `me` CLI as a trusted public client (CLI_CLIENT_ID)
  via cachedTrustedClients (PKCE + loopback + skip-consent)

better-auth keeps web cookie sessions (social login) + owns the AS; the CLI
becomes an OAuth public client and the API will validate opaque access tokens
(hashed at rest by default) via introspection. user/session/account/verification
stay snake-case-mapped; the new oauth + jwks tables use better-auth's native
names (library-owned, never queried directly). check (typecheck/lint/unit) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revise incremental/006 for the OAuth-provider route: drop the device_codes
table/cleanup (the CLI now uses the OAuth authorization-code flow), and create
the @better-auth/oauth-provider tables (oauthClient/oauthRefreshToken/
oauthAccessToken/oauthConsent) + the jwt plugin's jwks table. DDL mirrors
`better-auth generate` (string[] -> jsonb), with ids as DB-generated text
(uuidv7()::text; generateId:false omits them) and FK columns that reference our
uuid PKs (users.id, sessions.id) typed as uuid. Access/refresh tokens are stored
hashed at rest by the provider. The sessions token/updated_at changes stay.

idempotent/004 now defines cleanup_expired_oauth_tokens (sweeps expired access +
refresh tokens); the cron's AuthStore call is repointed to it. Verified:
migrateAuth applies + re-applies cleanly on a fresh schema; check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The file now holds cleanup_expired_oauth_tokens (no device-auth left), so the
name was a misnomer. Safe to rename: idempotent migrations re-run every boot and
are not tracked by name in the migration table (only incrementals are), so this
is a pure relabel. The incremental 004_device_authorization.sql is left as-is
(tracked history; renaming it would orphan its record and re-run it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
House style: map better-auth's camelCase oauth-provider + jwt models onto
snake_case tables/columns via `schema` overrides (modelName + fields) in
betterauth.ts, and create matching snake_case DDL in incremental/006
(oauth_client/oauth_access_token/oauth_refresh_token/oauth_consent + jwks).
Single-word fields keep their name. Also set jwt({ disableSettingJwtHeader:
true }) (recommended with an OAuth provider). cleanup_expired_oauth_tokens now
targets the snake_case tables.

Mapping validated + DDL derived from `better-auth generate`; migrateAuth applies
+ re-applies cleanly on a fresh schema. check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… seed

Add the resource-server validation primitive for the OAuth-provider route:
- hashOAuthToken (sha256 hex) wired as the provider's storeTokens hasher AND
  used by verifyOAuthAccessToken, so storage and lookup share one hash.
- verifyOAuthAccessToken(token): hashed lookup in oauth_access_token on the auth
  pool -> { userId, scopes } | null (expiry-checked; revoke = row deletion).
  Returned from createBetterAuth for the middleware to use next.

Seed the first-party `me` CLI as a trusted public client in incremental/006:
PKCE required, skip_consent, loopback redirect http://127.0.0.1/callback (RFC
8252 — the AS ignores the port for a loopback IP). Verified: seed applies and is
idempotent (on conflict do nothing); typecheck green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire verifyOAuthAccessToken (now joining users → { userId, email, name, scopes })
through context/start/router into both authenticators. Bearer credentials now
split: an api-key (me.<id>.<secret>) → core; anything else → OAuth access token
→ verifyOAuthToken (hashed lookup). The browser cookie session → getSession,
gated by the CSRF origin check. The old session-bearer path is gone — the CLI
uses OAuth tokens. The user RPC gets identity straight from the token row (powers
whoami + lazy provisioning). Fixtures updated; check green (628 unit tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hand-rolled auth-code + PKCE protocol layer for `me login` (RFC 6749/7636/8252):
generatePkce (S256), generateState, buildAuthorizeUrl, exchangeCode, and
refreshTokens against /api/v1/auth/oauth2/{authorize,token}. OAUTH_CLIENT_ID
"me-cli" matches the seeded public client. Pure/unit-testable (the loopback
server + browser launch land in the login command next). Unit tests cover the
PKCE relation + authorize-URL params; check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olling

Switch oauth.ts to the certified openid-client (v6) library, per the migration's
"don't hand-roll auth" rationale. Explicit-endpoint Configuration (no discovery)
with None() public-client auth; openid-client handles state + RFC 9207 iss
validation + token-response checks. Verified against the provider's discovery
doc that issuer = baseURL + /api/v1/auth (not bare baseURL — would've broken iss
validation). Drop the `openid` scope (offline_access only) so there's no id_token
→ no JWKS dependency; identity is resolved server-side from the access token.
allowInsecureRequests for http (local dev). Tests updated; check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the device-flow CLI login with the OAuth 2.1 public-client flow
(auth-code + PKCE + 127.0.0.1 loopback) and add best-practice CLI token
handling: a short-lived access token refreshed proactively by expiry and
reactively on a 401.

- transport: `getToken` (proactive, resolved per call) + `onUnauthorized`
  (one-shot 401 refresh-and-retry, off the retry budget) seams.
- credentials: store the OAuth token set (access/refresh/expiry) in the
  keychain (JSON) / 0600 file; resolveCredentials exposes `loggedIn`
  instead of a raw session token; legacy device tokens are scrubbed.
- session.ts: getAccessToken/refreshAccessToken with a clock-skew buffer,
  refresh-token rotation, and per-server in-flight refresh dedup;
  userBearer / memoryBearer wire credentials into the transport seams.
- rewire all consumers through bearer sources: util build*Client, whoami,
  space, serve (proxy refreshes on 401), mcp + MCP server, claude capture.
  Long-lived `me serve` / `me mcp` now survive access-token expiry.
- login: loopback redirect handler (oauth-loopback.ts) + PKCE exchange;
  logout unchanged (clears the token set).

The CLI no longer uses the device-flow client. Live end-to-end login
still needs the web /login page (#6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CLI login rewrite (OAuth auth-code + PKCE + loopback) was the last
consumer of the device-flow client, so it's now dead. Remove
packages/client/auth.ts and its re-exports (createAuthClient /
DeviceFlowError / AuthClient* / PollOptions) from the client index and
the CLI client wrapper.

Part of retiring the device flow (#8). The server-side device handlers
(handlers/auth.ts), the packages/auth AuthStore, and auth/providers are
still wired into context/start/provision + the integration tests, so they
go with the test rework (#9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CLI moved to OAuth (auth-code + PKCE + loopback) and better-auth owns
/api/v1/auth/* end-to-end, so the custom device-flow + browser-login
handlers are unreachable. Remove them and their OAuth provider helpers:
handlers/auth.ts (+ its unit/integration tests), auth/providers/{github,
google}, auth/types.ts, auth/index.ts.

Preserve the one live behavior they carried — invitation redemption on a
verified login — by moving redeemInvitationsForVerifiedLogin into
provision.ts and running it from ensureUserProvisioned on each user RPC
(idempotent + best-effort; better-auth gives no dedicated login hook).
Coverage moves to provision-invitations.integration.test.ts.

Trim server.integration.test.ts's device-endpoint block + AuthStore device
mocks (the router only mounts the better-auth catch-all now).

packages/auth (AuthStore) stays for now: provisionUser, the cron cleanup,
and the session-based integration tests still use it. Fully retiring it +
re-keying those session-bearer tests to the OAuth/context model is the test
rework (#9); the stale whoami-identity tests are known pre-existing reds,
unchanged by this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The session bearers these tests minted (authStore.createSession) no longer
authenticate — the middleware validates OAuth access tokens (or a better-auth
cookie). Re-key them to the new model:

- authenticate-space: mint a real OAuth access token (store sha256(raw) in
  oauth_access_token, present the raw bearer) instead of a session token.
- agent (user RPC): whoami now echoes the identity the middleware put on the
  context (ctx.email/ctx.name) rather than looking it up in the auth store, so
  assert the echo; drop the obsolete "user row gone -> UNAUTHORIZED" handler
  test (token validity is the middleware's job) and the now-unused AuthStore
  wiring from the direct-handler harness.
- start: pass betterAuthSecret so the boot path's required signing secret is
  satisfied in the harness.

Full server integration suite green (92 tests). Remaining #9: the CLI e2e
login (needs a live OAuth round-trip) + fully retiring packages/auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The e2e harness minted its bearer via authStore.createSession and injected it
as ME_SESSION_TOKEN — broken under the new model (the sessions table changed,
and the server validates OAuth access tokens, not session tokens). Mint a real
OAuth access token instead (store sha256(raw) in oauth_access_token, inject the
raw via ME_SESSION_TOKEN's raw-bearer override), bound to the seeded me-cli
client.

Same proven pattern as the authenticate-space integration test. Gated on
OPENAI_KEY + TEST_DATABASE_URL so it isn't run headless here; it loads + skips
cleanly and typechecks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
provisionUser was a now-runtime-dead fixture: it wrote auth.users/accounts via
AuthStore, but better-auth owns those at runtime and ensureUserProvisioned
(core-only) is the real first-login path. So test the real thing and delete the
fixture — removing the last AuthStore.createUser/upsertAccount caller.

- provision.integration: rewritten to drive ensureUserProvisioned directly
  (principal + default space + creator grants + idempotency); drops the
  auth-row assertions (better-auth's responsibility). Now core-only, no auth
  schema.
- new test-support.ts seedUserSpace(): the new-model seed for the other suites.
  Composes the same core primitives ensureUserProvisioned uses; inserts the
  auth.users row only when `auth` is passed — the two suites that mint a real
  OAuth bearer (authenticate-space + e2e), since verifyOAuthAccessToken joins
  users. Core-only consumers (memory, management, start) drop the auth schema.
- delete provisionUser + ProvisionUserParams/Result + the AuthStore import.

AuthStore's runtime footprint is now just the start.ts cleanup cron +
context.auth; retiring that (boot path) is the remaining #8 tail. Server
integration suite green (92); unit check green (636); e2e loads + skips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AuthStore's last runtime use was the start.ts cleanup cron + the vestigial
ctx.auth. Replace the cron's three AuthStore calls with a small
cleanupExpiredAuth(db, authSchema) helper that runs the schema's
cleanup_expired_{sessions,verifications,oauth_tokens}() functions directly —
same connection (the postgres.js app pool) and SQL the AuthStore wrapper used,
schema-qualified via sql(schema). Drop ctx.auth from ServerContext + start.ts,
and the vestigial auth mocks from the router/wiring/server/api-key fixtures.

Then delete packages/auth entirely (+ its workspace dep from server/e2e).

Coverage: the deleted db.integration.test.ts exercised AuthStore methods that
are now dead (device flow, AuthStore sessions/users/accounts). Its one live
piece — the expired-row sweep — is preserved and expanded in the new
cleanup.integration.test.ts (covers all three functions: deletes expired,
keeps fresh, returns counts). No live coverage lost.

Unit check green (636); server integration green (94, +2 cleanup). The auth
*migration* test (packages/database) has pre-existing reds asserting the old
device-flow schema that migration 006 already changed — untouched here, that
rework is #9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auth migrate test asserted the pre-006 device-flow schema (token_hash
sessions, device_authorization, create_session/validate_session/device
functions) that migration 006 replaced — 8 pre-existing failures. Rework it to
the current shape:

- EXPECTED_TABLES: drop device_authorization; add jwks + the
  oauth_client/oauth_access_token/oauth_refresh_token/oauth_consent tables.
- EXPECTED_MIGRATIONS: add 006_betterauth (004 stays — it ran historically).
- EXPECTED_FUNCTIONS: drop the session/device functions; add the three
  cleanup_expired_* sweeps.
- session uniqueness now keys on `token` (the dropped token_hash → token); the
  cascade test inserts `token` too.
- replace the device_authorization test with OAuth-schema coverage: the seeded
  me-cli public PKCE client + oauth_access_token token-uniqueness / client_id FK.
- drop the create_session/validate_session + device-flow function tests (those
  functions are gone); keep the still-valid user/account/citext/cascade/
  idempotency tests.

23 pass / 0 fail (was 16/8). Verified against a freshly-migrated schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hosted web UI's login was wired to the retired device-flow endpoints
(/api/v1/auth/login/:provider, /api/v1/auth/logout) — dead since better-auth
took over /api/v1/auth/*. Rewire it through the better-auth React client and
add the OAuth-provider login page the CLI's authorize flow redirects to.

- add better-auth (client) to packages/web; auth-client.ts wraps
  createAuthClient (basePath /api/v1/auth) + signInWithProvider / signOut.
- AuthGate: startLogin -> authClient.signIn.social({provider, callbackURL});
  logout -> authClient.signOut(). No more hand-rolled redirect / fetch to the
  deleted endpoints.
- SignInCard: shared GitHub/Google sign-in card; the only thing that varies
  between callers is callbackURL.
- LoginPage (/login): the better-auth `loginPage`. `me login` opens the
  authorize endpoint; with no session better-auth redirects here with the
  signed params; we sign in, then resume at /api/v1/auth/oauth2/authorize with
  those params preserved, so the trusted me-cli client issues the code to the
  CLI's loopback redirect.
- main.tsx renders LoginPage on the /login path (served by the SPA fallback).

Typechecks against better-auth's types + the web build passes. The live GitHub
round-trip still needs a configured GitHub OAuth app + GITHUB_CLIENT_ID/SECRET
on the server (#9 cutover).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
startServer now requires a signing secret (better-auth); the e2e didn't set
one, so the suite errored at boot. Pass a stable test value — the e2e
authenticates by OAuth-token injection (no sign-in), so the value is inert —
mirroring the start.integration harness.

Full e2e green: 26 pass / 0 fail against local Postgres + real OpenAI
embeddings (CLI subprocess -> server -> DB -> embeddings, on the new OAuth
access-token bearer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Specify the authentication design implemented on this branch: better-auth (web
cookie sessions + an OAuth 2.1 authorization server), the CLI auth-code + PKCE +
loopback flow with the signed loginPage handoff, resource-server token
validation (verifyOAuthAccessToken + Bearer dispatch), the CLI token lifecycle
(proactive/reactive refresh, rotation, storage), agent api keys staying in core,
lazy core-only provisioning + invitation redemption, the cleanup cron, security
properties, config/env, and the testing approach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the design debate behind the implementation so it isn't re-litigated:
why not keep home-grown auth, why agent api keys stay in core, why the
better-auth device flow was abandoned (doesn't compose with requireSignature;
spike + upstream discussion #5068), and why not better-auth sessions / a
confidential client / JWT-access-tokens+JWKS / RFC 7662 introspection / DCR /
hand-rolled PKCE+loopback / reactive-only refresh / a server-rendered login
page / a new GitHub OAuth app — with the chosen alternative for each.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…prefix

Review follow-up.

Dead device-flow leftovers (unreachable since better-auth took over
/api/v1/auth/*):
- authenticate.ts: drop extractSessionCredential + SessionCredential /
  CredentialSource — no live callers (the cookie path uses
  betterAuth.api.getSession). Keep extractBearerToken + passesCsrfCheck.
- delete server/util/cookie.ts (+ test): every export served the retired
  device/browser-login handlers or extractSessionCredential.
- delete protocol/auth/device-flow.ts (+ its index re-export): no importers.
- authenticate.test.ts: drop the extractSessionCredential cases.
- router.test.ts: swap the misleading /device/* example paths for current
  better-auth sub-paths.
- start.ts: rename deviceFlowCleanupCron -> authCleanupCron (prefer
  AUTH_CLEANUP_CRON, keep DEVICE_FLOW_CLEANUP_CRON as a fallback) + drop a
  stale "device flow" comment.
- authenticate-space.ts: the human credential is now an OAuth access token or a
  cookie session, not "a session token" — fix the header comment.

Cookie prefix: documented at the config site why we stay on `__Secure-` —
better-auth 1.6.20 has no `__Host-` option (its namer emits only `__Secure-`/
none, and getSessionCookie reads only those), and the cookies already carry the
`__Host-` properties (Secure, Path=/, no Domain) + SameSite=Lax + the Origin
CSRF gate.

check green (617); server integration green (94).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates human authentication from the custom device-flow/session implementation to better-auth: web UI uses httpOnly cookie sessions, and the me CLI uses OAuth 2.1 auth-code + PKCE + loopback with refresh tokens; agent API keys remain in core.

Changes:

  • Server: replace /api/v1/auth/* device-flow handlers with a better-auth catch-all, update RPC auth middleware to accept OAuth access tokens (Bearer) or cookie sessions, and move first-login provisioning to lazy core-side provisioning.
  • CLI: implement auth-code+PKCE login (openid-client), persist OAuth token sets, and add proactive/401-reactive refresh wiring through the client transport and long-running processes (me serve, me mcp, hooks).
  • Web: wire hosted UI sign-in/sign-out through better-auth’s React client and add a dedicated /login page to resume the CLI authorize flow.

Reviewed changes

Copilot reviewed 88 out of 89 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/web/src/main.tsx Adds /login entrypoint handling before hosted/local split.
packages/web/src/components/SignInCard.tsx New shared social sign-in UI using better-auth actions.
packages/web/src/components/LoginPage.tsx New login page that resumes OAuth authorize flow for CLI.
packages/web/src/components/AuthGate.tsx Switches hosted login/logout to better-auth client + shared card.
packages/web/src/api/auth-client.ts New better-auth React client wrapper for sign-in/sign-out.
packages/web/package.json Adds better-auth dependency for web app.
packages/server/wiring.test.ts Updates wiring mocks for better-auth + OAuth token verification.
packages/server/util/cookie.ts Removes retired custom cookie helpers.
packages/server/util/cookie.test.ts Removes tests for retired cookie helpers.
packages/server/test-support.ts Adds integration-test seeding helper for core (+ optional auth.users).
packages/server/start.ts Boots better-auth (dedicated pool), wires OAuth verification, updates cleanup cron.
packages/server/start.integration.test.ts Updates boot test to seed core-only + provide better-auth secret.
packages/server/server.integration.test.ts Removes device-flow endpoint tests (now owned by better-auth).
packages/server/rpc/user/whoami.ts Uses identity from validated session/token context (no auth-store lookup).
packages/server/rpc/user/types.ts Replaces auth-store dependency with email/name on context.
packages/server/rpc/user/api-key.integration.test.ts Removes auth-schema dependency from user RPC api-key tests.
packages/server/rpc/user/agent.integration.test.ts Updates whoami expectation to echo middleware-provided identity.
packages/server/rpc/memory/memory.integration.test.ts Switches to new core-only seeding helper.
packages/server/rpc/memory/management.integration.test.ts Switches to new core-only seeding helper.
packages/server/router.ts Mounts better-auth catch-all and adds lazy ensureUserProvisioned on user RPC.
packages/server/router.test.ts Updates route matching tests for better-auth catch-all semantics.
packages/server/provision.ts Replaces eager provisionUser with lazy core provisioning + invitation redemption hook.
packages/server/provision.integration.test.ts Updates provisioning tests for ensureUserProvisioned semantics (idempotent).
packages/server/provision-invitations.integration.test.ts Moves invitation redemption coverage to provisioning module.
packages/server/package.json Replaces @memory.build/auth with better-auth/oauth-provider + pg deps.
packages/server/middleware/authenticate.ts Removes session-credential extractor; keeps Bearer extractor + CSRF gate.
packages/server/middleware/authenticate.test.ts Removes extractSessionCredential tests; retains Bearer + CSRF tests.
packages/server/middleware/authenticate-user.ts Adds OAuth Bearer auth for user RPC; cookie sessions via better-auth getSession.
packages/server/middleware/authenticate-space.ts Adds OAuth Bearer auth for memory RPC; cookie sessions via better-auth getSession.
packages/server/middleware/authenticate-space.integration.test.ts Updates auth integration to mint/verify real OAuth access tokens.
packages/server/handlers/auth.test.ts Removes unit tests for retired custom auth handlers.
packages/server/context.ts Replaces AuthStore in context with better-auth + OAuth token verifier.
packages/server/auth/types.ts Removes retired device-flow type definitions.
packages/server/auth/providers/index.ts Removes retired custom OAuth provider registry.
packages/server/auth/providers/google.ts Removes retired custom Google OAuth implementation.
packages/server/auth/providers/github.ts Removes retired custom GitHub OAuth implementation.
packages/server/auth/index.ts Removes retired custom auth module exports.
packages/server/auth/cleanup.ts Adds auth-table sweep helper calling schema cleanup functions.
packages/server/auth/cleanup.integration.test.ts Adds integration coverage for auth-table sweep behavior.
packages/protocol/index.ts Stops exporting device-flow auth schemas.
packages/protocol/auth/device-flow.ts Removes device-flow protocol schema/types.
packages/database/auth/migrate/migrate.ts Adds auth migration 006 + replaces idempotent device-flow SQL with OAuth cleanup SQL.
packages/database/auth/migrate/incremental/006_betterauth.sql Adds better-auth/OAuth-provider tables and migrates sessions shape; drops device-flow.
packages/database/auth/migrate/idempotent/004_oauth.sql Adds cleanup function for expired OAuth access/refresh tokens.
packages/database/auth/migrate/idempotent/004_device_auth.sql Removes device-flow SQL functions.
packages/database/auth/migrate/idempotent/002_session.sql Removes session CRUD functions; keeps only expired-session cleanup.
packages/client/user.ts Adds async bearer provider + reactive 401 hook to user client options.
packages/client/transport.ts Implements proactive token resolution and 401-triggered refresh retry.
packages/client/transport.test.ts Adds coverage for getToken/onUnauthorized behavior.
packages/client/memory.ts Adds async bearer provider + reactive 401 hook to memory client options.
packages/client/index.ts Removes exported device-flow auth client; updates docs accordingly.
packages/client/auth.ts Removes device-flow auth client implementation.
packages/cli/util.ts Switches CLI clients to bearer sources with refresh; updates logout behavior.
packages/cli/session.ts Adds OAuth token lifecycle: proactive refresh, reactive refresh, in-flight dedup.
packages/cli/session.test.ts Adds end-to-end tests for refresh, rotation persistence, and concurrency.
packages/cli/serve/http-server.ts Buffers bodies to allow one-shot 401 refresh/replay in me serve proxy.
packages/cli/serve/http-server.test.ts Adds proxy 401 refresh/replay test coverage.
packages/cli/package.json Adds openid-client for OAuth 2.1 flow.
packages/cli/oauth.ts Adds OAuth protocol layer (PKCE, authorize URL, code/refresh exchange).
packages/cli/oauth.test.ts Adds PKCE + authorize URL construction tests.
packages/cli/oauth-loopback.ts Adds loopback redirect handler/server for me login.
packages/cli/oauth-loopback.test.ts Adds loopback server tests (success/error/timeout).
packages/cli/mcp/server.ts Switches MCP server to use bearer source (refresh-aware) instead of static token.
packages/cli/mcp/agent-install.ts Updates login checks to loggedIn boolean.
packages/cli/credentials.ts Replaces stored session token with stored OAuth token set + parsing/migration logic.
packages/cli/credentials.test.ts Updates tests for token-set storage, logout, and legacy migration behavior.
packages/cli/commands/whoami.ts Uses refresh-aware user client builder.
packages/cli/commands/space.ts Uses refresh-aware user client builder across space subcommands.
packages/cli/commands/serve.ts Uses refresh-aware bearer source for long-lived proxy.
packages/cli/commands/mcp.ts Uses bearer source (api key or refresh-aware OAuth) for long-lived MCP server.
packages/cli/commands/login.ts Replaces device flow with auth-code+PKCE+loopback login and token-set persistence.
packages/cli/commands/claude.ts Switches Claude hook import to refresh-aware bearer resolution.
packages/cli/client.ts Removes CLI re-export of the removed device-flow auth client.
packages/cli/claude/capture.ts Changes hook config to carry apiKey or rely on refresh-aware login session.
packages/cli/claude/capture.test.ts Updates tests for new hook config shape and logged-in detection.
packages/auth/types.ts Removes retired @memory.build/auth types.
packages/auth/token.ts Removes retired device-flow/session token helpers.
packages/auth/package.json Removes retired @memory.build/auth package.
packages/auth/index.ts Removes retired @memory.build/auth exports.
packages/auth/db.ts Removes retired auth-store implementation.
packages/auth/db.integration.test.ts Removes retired auth-store integration tests.
e2e/package.json Drops dependency on retired @memory.build/auth.
e2e/cli.e2e.test.ts Updates e2e auth injection to mint OAuth access tokens instead of sessions.
CLAUDE.md Updates repo structure notes to reflect removal of auth package/device-flow client.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/server/provision.ts Outdated
Comment on lines +125 to +130
// Join any spaces this email was invited to. better-auth gives us no
// dedicated "login" hook, so this rides every user RPC — it's idempotent and
// best-effort (a no-op once nothing is pending), and the email is one the
// identity provider verified. This is the new home of the redemption that the
// retired device-flow callback used to run on each sign-in.
await redeemInvitationsForVerifiedLogin(core, params.userId, params.email);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6e706e0.

email_verified is now plumbed through both auth paths and gates redemption:

  • verifyOAuthAccessToken joins users.email_verified and returns emailVerified; the cookie path reads session.user.emailVerified.
  • threaded onto the user-RPC context → ensureUserProvisioned, which now redeems invitations only when emailVerified is true. The principal + own default space are still provisioned regardless — only the email-keyed redemption is gated.
  • provision.integration.test.ts covers verified-redeems / unverified-skips (the unverified invite stays pending).

PR review (Copilot): `redeemInvitationsForVerifiedLogin` was called
unconditionally from `ensureUserProvisioned`, but invitations are email-keyed —
so an account whose email is unverified could auto-join spaces invited to that
address. Plumb `email_verified` through both auth paths and gate on it.

- verifyOAuthAccessToken: join `users.email_verified`, return `emailVerified`.
- authenticate-user: carry `emailVerified` on the user context (OAuth path from
  the token's user row; cookie path from `session.user.emailVerified`).
- router: thread `emailVerified` into `ensureUserProvisioned`.
- ensureUserProvisioned: redeem invitations only when `emailVerified` is true
  (the user's own principal + default space are still provisioned regardless —
  only the email-keyed redemption is gated).
- test: provision.integration now covers verified-redeems / unverified-skips
  (the unverified invite stays pending).

check green; server integration green (95).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record how a hosted MCP connector would be built on the OAuth 2.1 AS (the AS was
chosen with this in mind): what's reused unchanged (the AS, verifyOAuthAccessToken,
user -> principal -> tree_access, /login), the delta from the CLI (Dynamic Client
Registration for third-party clients, the consent page, the hosted MCP transport
+ RFC 9728/8414 discovery + RFC 8707 resource indicators), scope strategy (start
coarse), what stays the same (api keys, local me mcp, tree_access), and a rough
build order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cevian cevian marked this pull request as ready for review June 24, 2026 13:42

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better Auth packages are currently declared with ^. I’d prefer exact pins plus an explicit upgrade checklist that regenerates/compares expected schema.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in bed744b. Pinned better-auth + @better-auth/oauth-provider to exact 1.6.20 (no ^) in both packages/server and packages/web (kept equal so the web client matches the server). The upgrade checklist that regenerates + diffs the expected schema is in AUTH_DESIGN.md → "Upgrading better-auth (the DB-drift checklist)"; the auth migration test (migrate.integration.test.ts) is the automated drift guard — a schema change we haven't migrated turns it red.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a document describing how developers should go about determining how to update the database when upgrading to a new version of better auth.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in bed744b: AUTH_DESIGN.md → "Upgrading better-auth (the DB-drift checklist)", and this file's header now points to it. The procedure: bump the exact pins → regenerate the expected schema (@better-auth/cli generate against the createBetterAuth config) → diff vs the auth migrations (the better-auth-owned tables) → write a new incremental + reconcile the snake_case modelName/fields mapping for any new field → run migrate.integration.test.ts (the drift guard) + check:full + boot against an existing DB. Happy to move it to a standalone docs/ runbook if you'd prefer it outside the design doc.

cevian and others added 2 commits June 24, 2026 18:03
Allow minting an api key for the caller's OWN user principal — a personal
access token for headless/SSH/VM use — alongside agent keys. The core layer
already keyed api keys by member_id ('u'|'a'); this opens the 'u' path and
admits user PATs on the user RPC.

- protocol: apiKey.create takes a required `memberId` (was agent-only `agentId`),
  consistent with apiKey.list.
- server (rpc/user): requireOwnMember (the caller's own user, or an owned agent)
  replaces requireOwnAgent for apiKey.*; apiKey.create/delete reject a
  key-authenticated caller (denyApiKeyCaller) — keys can't mint or revoke keys,
  so a leaked key can't outlive revocation. apiKey.list/get stay open.
- server (authenticate-user): admit a user PAT (parseApiKey → validateApiKey →
  getPrincipal, kind 'u' only; an agent key → 403). UserAuthContext.viaApiKey is
  threaded onto the user-RPC context. emailVerified=false on the key path, so
  invitation redemption (email-keyed) runs only on interactive logins.
- cli: PAT minting is an explicit opt-in — `me apikey create <agent>` (unchanged)
  vs `me apikey create --self`; a bare `create` errors. No implicit full-access
  credential.

A user PAT authenticates as the user with full data-plane grants on the memory
RPC and the user RPC (except key mint/revoke). Agent keys remain barred from the
user RPC ("agents can't manage the account"). Docs: AUTH_DESIGN.md (credential
table, flow E, device-flow-vs-PAT alternative) + CLAUDE.md.

Tests: authenticate-user.integration (PAT admitted / agent key 403 / OAuth /
invalid), api-key.integration (self-mint, keys-can't-manage-keys),
authenticate-space.integration (PAT as the user on the memory RPC). check 620;
server integration 103.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…checklist

Review (jgpruitt): better-auth owns DB tables, so a transitive bump can drift
the expected schema from our hand-maintained migrations + field mapping.

- Pin `better-auth` + `@better-auth/oauth-provider` to exact `1.6.20` (no `^`)
  in packages/server + packages/web (equal versions so the web client matches
  the server). Upgrades are now a deliberate, reviewed step.
- AUTH_DESIGN.md gains an "Upgrading better-auth (the DB-drift checklist)"
  section: regenerate the expected schema (`@better-auth/cli generate` against
  the createBetterAuth config) → diff vs the auth migrations → write a new
  incremental + reconcile the snake_case field mapping → run the auth migration
  test (the drift guard) + check:full + a boot-against-existing-DB. betterauth.ts
  header points to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 93 out of 94 changed files in this pull request and generated 2 comments.

Comment thread packages/web/src/main.tsx
Comment thread packages/server/rpc/user/api-key.ts
Comment thread packages/web/src/main.tsx
Comment thread packages/cli/session.ts Outdated
Comment thread CLAUDE.md Outdated
Comment thread packages/database/auth/migrate/migrate.integration.test.ts
Comment thread packages/cli/session.ts
}

/** In-flight refresh per server origin — concurrent callers share one round-trip. */
const inFlight = new Map<string, Promise<OAuthTokenSet | undefined>>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note (non-blocking): cross-process refresh races aren't deduped. The inFlight map dedupes concurrent refreshes within one process, but me serve / me mcp are separate processes from any ad-hoc me <cmd> running in parallel. If two processes race to refresh from the same stored refresh token, rotation invalidates the loser's token; the loser's in-flight request then 401s and the reactive path re-refreshes against the now-rotated stored token (the live one), so it succeeds on the retry. Worst case is one extra round-trip, not a logout — fine to ship, but worth a sentence in AUTH_DESIGN.md § D so it's a known/accepted property rather than a surprise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 2d0add0 — AUTH_DESIGN.md §D now calls this out as an accepted property: the dedup is per-process, concurrent separate me invocations self-heal (rotation → 401 → reactive refresh, or failing that a fresh me login), and the place concurrency actually happens — the long-lived me serve / me mcp processes — is single-process, where the in-memory dedup applies. A cross-process lock isn't worth the keychain/file-locking complexity for a transient, self-healing reauth.

// email: invitations are email-keyed, so an unverified address must not
// auto-join spaces invited to it. This is the new home of the redemption the
// retired device-flow callback ran on each sign-in.
if (params.emailVerified) {

@jgpruitt jgpruitt Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider requireEmailVerification: true on better-auth (optional, defense-in-depth — not blocking).

Background:

What "email verified" means here. When someone signs in with GitHub or Google, the provider returns a profile with an email and a boolean saying whether the provider has confirmed the person actually controls that address (both make you click a confirmation link before marking it verified). better-auth stores that as users.email_verified.

Why it matters in this codebase. Authorization is email-keyed in exactly one place: space invitations. You invite alice@example.com; when a user logs in with that email, redemption auto-joins them. If a login could claim an email it doesn't actually own, it could claim invitations meant for someone else. This PR correctly defends that path — the gate right here only redeems when emailVerified is true.

The gap. That gate protects invitation redemption only. A user with an unverified email can still complete login, get an OAuth access token, create memories, and mint a PAT — they just can't auto-join invited spaces. So the blast radius of an unverified email is already small.

What the flag would add. requireEmailVerification: true is a better-auth config flag. With it on, better-auth refuses to establish a session at all for an unverified email — the user is blocked at the front door rather than admitted with a reduced-capability session. The tradeoff is "trust the provider and guard the one email-keyed op" (current) vs. "make a verified email a hard precondition of any session" (the flag).

Recommendation. Genuinely optional here: in practice GitHub and Google only release an email on the OAuth profile once they've verified it, so email_verified is almost always true for these two providers. The flag is defense-in-depth against (a) a future, laxer provider, or (b) a better-auth behavior change. Given only GitHub/Google today, the existing per-operation gate is defensible — I would not block on this. Flagging so the choice is explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recorded the decision in 2d0add0 (AUTH_DESIGN.md → Provisioning) rather than flipping the flag, per your "not blocking". Left off because (1) we enable only GitHub/Google — verified-email-only, no password — so an unverified address barely arises, and (2) the global flag would block sign-in itself, whereas the per-op invitation-redemption gate is the tighter control (it governs what an identity may do, not whether it may authenticate). Revisit if we ever add email/password or an unverified-by-default provider.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my reply above (and the doc), after reading the better-auth 1.6.20 source: flipping this would be a no-op for us, not a "block sign-in at the front door" tradeoff. requireEmailVerification is not global — it's emailAndPassword.requireEmailVerification, enforced only in the password sign-in route (dist/api/routes/sign-in.mjs:230). The social OAuth callback never consults it (callback.mjs: 0 refs); it just persists the provider's verified-email claim into users.email_verified (dist/oauth2/link-account.mjs). We run social-only (GitHub/Google) with no emailAndPassword block, so there is no surface for the flag — and setting it would require opting into a password sign-in flow we deliberately don't offer, just to set a sub-flag that still wouldn't gate a social login (so it doesn't future-proof the "laxer provider" case either, since that path is social). The real controls remain the provider's verified claim + the per-op invitation-redemption gate. Corrected the AUTH_DESIGN note in 44c7d96; your "do not block" call stands.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: we went ahead and added the gate — pushed in bfde7d7.

Rather than requireEmailVerification (which is emailAndPassword-only and a no-op for our social-only setup), we gate login itself via a better-auth databaseHooks.session.create.before hook: it throws EMAIL_NOT_VERIFIED unless users.email_verified is true. That's the single front-door chokepoint —

  • Social sign-in creates the session there, and the CLI OAuth flow rides the web session, so it blocks both front doors.
  • The memory RPC is covered transitively (no session/token → no bearer), so no per-endpoint checks.
  • Agents are unaffected (they authenticate with api keys, never sessions), and credentials minted while verified keep working.

So the blast radius you described (unverified email can log in, get a token, create memories, mint a PAT) is now closed at the door, not just at redemption. The per-op invitation-redemption gate stays as defense-in-depth — it still earns its keep for the edge case of a PAT that outlives its user's verified status (the key keeps working but won't redeem while the flag reads false).

UX: the thrown APIError makes the social callback redirect back to the sign-in page with ?error=EMAIL_NOT_VERIFIED&error_description=…, which SignInCard surfaces as a banner (not better-auth's bare /error). Tested by driving the real hook via auth.$context.internalAdapter.createSession (unverified → no session row; verified → session created). Design recorded in AUTH_DESIGN.md (Provisioning → "Login gate").

One accepted trade: if a provider ever flips a user to unverified post-signup, their next login is blocked until they re-verify with the provider — recovery is a normal re-login.

…d; harden auth drift guard

Addresses jgpruitt's PR #93 review.

BLOCKING — CLI now sends ME_API_KEY (a user PAT) to the user RPC. Both RPC
endpoints accept either bearer now, so userBearer(server, apiKey?) takes the
static key (memoryBearer delegates to it) and buildUserClient forwards
creds.apiKey. The session-only "user endpoint" premise is gone: the old
requireMemoryAuth is renamed requireAuth (session OR key) and gates every
command except api-key mint/revoke, which keep the session-only requireSession
("keys can't mint keys"; the server enforces it via denyApiKeyCaller). This also
fixes group/access/serve being needlessly session-only despite hitting the
key-accepting memory RPC. me serve now forwards the key to its upstream proxy.

Bug — /login rendered under local `me serve` (no auth backend), so its sign-in
calls 404'd. Gate isLoginPage on HOSTED; local mode falls through to the SPA.

Harden — the auth migrate drift guard now freezes the exact column set of every
better-auth / oauth-provider-owned table (new listColumns helper), so a library
upgrade or hand-edit that drifts the schema turns the suite red instead of
breaking at runtime.

Cleanups — stale device-flow prose (CLAUDE.md auth-schema table list + auth
model; router.ts catch-all comment); import AppError via the local ../errors
re-export in api-key.ts; AUTH_DESIGN notes the accepted cross-process refresh
race (§D) and records why better-auth's global requireEmailVerification stays
off (per-op gate is the tighter control).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cevian

cevian commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @jgpruitt — all addressed in 2d0add0.

Blocking — ME_API_KEY on the user RPC. Fixed. userBearer(server, apiKey?) now takes the static key (and memoryBearer delegates to it), buildUserClient forwards creds.apiKey, and the gate is relaxed: since both endpoints accept either bearer now, the old requireMemoryAuth is renamed requireAuth (session or key) and gates every command except apiKey.create/delete, which keep the session-only requireSession ("keys can't mint keys" — the server still enforces it via denyApiKeyCaller). The agent-key-on-user-RPC rejection is unchanged and still pinned by authenticate-user.integration.test.ts (PAT admitted, agent key → 403). Bonus: this also fixes group/access/serve being needlessly session-only despite hitting the key-accepting memory RPC, and me serve now forwards the key to its upstream proxy.

/login in local mode. Gated isLoginPage on HOSTED (main.tsx); under local me serve a stray /login now falls through to the SPA instead of rendering a page whose sign-in calls 404. (Also covers Copilot's same finding.)

Migrate drift guard. Added EXPECTED_COLUMNS for every better-auth / oauth-provider-owned table (new listColumns helper) with an exact-set assertion, so a library upgrade or hand-edit that drifts the schema turns the suite red instead of breaking at runtime. The 9 column-set cases pass against a freshly migrated schema.

Stale device-flow prose. Fixed CLAUDE.md (auth-schema table list — device_authorization → the real better-auth/oauth-provider tables; and the auth-model bullet) and the router.ts catch-all comment.

Cross-process refresh race (non-blocking). Documented in AUTH_DESIGN.md §D as an accepted property — the dedup is per-process; concurrent separate me invocations self-heal via reactive refresh / re-login, and the place concurrency actually happens (me serve/me mcp) is single-process.

requireEmailVerification (non-blocking). Recorded the decision in AUTH_DESIGN.md Provisioning: left off deliberately — we enable only GitHub/Google (verified-email-only, no password), and the per-op invitation-redemption gate is the tighter control; revisit if we ever add password/unverified providers.

Copilot nit. api-key.ts now imports AppError via the local ../errors re-export, matching sibling handlers.

Verified: ./bun run check (617 pass), auth migrate integration (32 pass), api-key + authenticate-user + agent integration (25 pass), web typecheck clean for the touched files.

cevian and others added 3 commits June 25, 2026 09:55
The prior note (and my PR reply) claimed the flag would "block sign-in itself."
That's wrong: in better-auth 1.6.20 `requireEmailVerification` is an
`emailAndPassword` sub-option enforced only in the password sign-in route
(sign-in.mjs); the social OAuth callback never consults it (it just persists the
provider's verified-email claim via oauth2/link-account.mjs). We're social-only
with no emailAndPassword config, so the flag has no surface and flipping it is a
no-op for GitHub/Google logins. Record the accurate reason.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hey redeem invitations like sessions

The api-key (PAT) path on the user RPC hard-coded `emailVerified: false` purely
to suppress invitation redemption on PAT calls. That overloaded a factual field
(is the email provider-verified?) with a policy decision, and it was an
arbitrary, undocumented carve-out: redemption already rides every user-RPC call
(it's idempotent, not login-only), so OAuth/cookie callers redeem on every call
while PATs never did. A PAT is meant to do everything a user can, minus the one
documented carve-out (keys can't mint keys).

Fix: fetch the real `users.email_verified` on the key path via a new
`getUserEmailVerified` (a sibling of `verifyOAuthAccessToken`, threaded through
the same context → router → authenticateUser seam as `verifyOAuthToken`), and
carry it honestly. Redemption then gates on the true fact for all three
credential paths, so a user PAT redeems exactly like a session.

Bonus: this de-arms the landmine where any future `if (ctx.emailVerified)` check
would have wrongly rejected every PAT, making a global verified-email gate clean
if we ever want one.

Tests: authenticate-user.integration asserts a PAT carries the real flag (true
for a verified user; false after flipping the column — proving it's read from
the DB, not faked). Updated the mock ServerContext in wiring/router/server tests
and AUTH_DESIGN's provisioning note (now "all three" paths carry the real flag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…int)

Refuse to establish a session for an unverified email via a better-auth
`databaseHooks.session.create.before` hook: it throws EMAIL_NOT_VERIFIED unless
`users.email_verified` is true. Social sign-in creates the session here, and the
CLI OAuth flow rides that web session, so this blocks both; the memory RPC is
covered transitively (no session/token → no bearer). Agents are unaffected (api
keys, never sessions), and credentials minted while verified keep working.

This is the social-login equivalent of better-auth's `requireEmailVerification`,
which only gates the email/password sign-in route (we're social-only, so it has
no surface). The per-op invitation-redemption gate stays as defense-in-depth —
it still matters for a PAT that outlives its user's verified status.

The thrown APIError makes the social callback redirect back to the sign-in page
with `?error=EMAIL_NOT_VERIFIED&error_description=…`; SignInCard surfaces it
(errorCallbackURL defaults to the current page), so a blocked user sees a clear
message instead of better-auth's bare /error.

Test: drives the real hook via `auth.$context.internalAdapter.createSession` —
an unverified user is blocked (no session row written), a verified user gets one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cevian cevian merged commit 031e2da into main Jun 25, 2026
3 checks passed
@cevian cevian deleted the cevian/exp-betterauth branch June 25, 2026 08:35
cevian added a commit that referenced this pull request Jun 25, 2026
…d; harden auth drift guard

Addresses jgpruitt's PR #93 review.

BLOCKING — CLI now sends ME_API_KEY (a user PAT) to the user RPC. Both RPC
endpoints accept either bearer now, so userBearer(server, apiKey?) takes the
static key (memoryBearer delegates to it) and buildUserClient forwards
creds.apiKey. The session-only "user endpoint" premise is gone: the old
requireMemoryAuth is renamed requireAuth (session OR key) and gates every
command except api-key mint/revoke, which keep the session-only requireSession
("keys can't mint keys"; the server enforces it via denyApiKeyCaller). This also
fixes group/access/serve being needlessly session-only despite hitting the
key-accepting memory RPC. me serve now forwards the key to its upstream proxy.

Bug — /login rendered under local `me serve` (no auth backend), so its sign-in
calls 404'd. Gate isLoginPage on HOSTED; local mode falls through to the SPA.

Harden — the auth migrate drift guard now freezes the exact column set of every
better-auth / oauth-provider-owned table (new listColumns helper), so a library
upgrade or hand-edit that drifts the schema turns the suite red instead of
breaking at runtime.

Cleanups — stale device-flow prose (CLAUDE.md auth-schema table list + auth
model; router.ts catch-all comment); import AppError via the local ../errors
re-export in api-key.ts; AUTH_DESIGN notes the accepted cross-process refresh
race (§D) and records why better-auth's global requireEmailVerification stays
off (per-op gate is the tighter control).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants