Skip to content

feat(server): move browser sign-in onto Better Auth sessions - #201

Merged
bestony merged 3 commits into
mainfrom
refactor/better-auth-browser-session
Aug 27, 2026
Merged

feat(server): move browser sign-in onto Better Auth sessions#201
bestony merged 3 commits into
mainfrom
refactor/better-auth-browser-session

Conversation

@bestony

@bestony bestony commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Third step of the Better Auth migration, and the first that changes what a request does. Google sign-in now issues a Better Auth session and every authenticated route resolves one. Credentials issued before this deploys keep working until they expire, so the rollout signs nobody out.

Until now the browser held a stateless HS256 JWT with no server-side session row, which meant logout could not revoke anything and the only kill switch for an Account was users.suspended_at re-read on each request.

The published surface is an allowlist

Better Auth serves its entire API from one handler. Reviewers of #196 showed what that costs: /update-user writes Better Auth's name straight into users.display_name, bypassing UserDisplayNameSchema, the suspension guard, and the authenticated, origin-checked /api/v1/me.

So only the OAuth callback is published — the provider redirects the browser straight to it and nothing else can serve it. Sign-in and sign-out run through OpenTag routes that call Better Auth server-side, keeping the request contract, the origin check, and the response shape ours.

better-auth-surface.test.ts asserts the unpublished paths 404 and names the one that escaped if that regresses. Restoring a catch-all fails it on get-session.

Account invariants are enforced at issuance, not after it

onSessionCreating runs before any session row exists and must throw to prevent one. There is therefore no window in which a session exists for an Account that is suspended, or that lacks the compatibility Workspace grant every authenticated route derives authority from.

ensureAccountReady derives was this Account just created? from the absence of a grant. Better Auth owns account creation on its own sign-in paths and cannot tell us, and deriving it makes the call idempotent: a returning Account is a no-op, one that never received a grant gets one.

Resolution stays live

Better Auth returns an identity. Suspension and Workspace grants are still read from the database on every request, exactly as the legacy path does, so revoking either takes effect immediately rather than at session expiry.

Other behaviour changes

  • Sign-out revokes the session row, not just the cookie. It also clears the legacy cookies, because a browser mid-rollout can hold either credential and signing out must end both.
  • The origin check moved back behind the credential check. I had put it first, which turned an unauthenticated request into 403 instead of 401; the existing contract test caught it.
  • The double-submit CSRF cookie stays. It guards /api/v1/* business routes, which are not Better Auth endpoints, so Better Auth's own protection does not cover them.

Not in this step

The dev sign-in bypass and POST /api/v1/auth/browser/refresh still issue and accept legacy JWTs. They keep working through the same preHandler and move in the CLI step, which is where the legacy token service is retired.

Testing

  • pnpm check, pnpm build, pnpm typecheck, pnpm test
  • pnpm --filter @opentag/server test:integration — 191 passing
  • New better-auth-surface.test.ts: the allowlist holds, and the forwarded URL is built from the configured origin rather than the request Host.
  • New integration coverage: an Account receives its compatibility Workspace grant before a session exists, idempotently; a suspended Account's session creation is refused with AUTH_USER_SUSPENDED.

Not covered by automated tests: a live Google sign-in, which needs OAuth credentials.

Breaking changes

None for users. Operationally, the Google OAuth client needs the new redirect URI before this deploys — see below.

Deployment prerequisite

Add https://<app>/api/v1/auth/callback/google to the Google OAuth client's authorized redirect URIs, and keep the existing /api/v1/auth/google/callback until the legacy path is retired. Better Auth's callback path is <basePath>/callback/<provider>, so sign-in fails at the provider without it.

Checklist

  • The change is focused and contains no unrelated work.
  • Tests and documentation cover the changed behavior.
  • pnpm check, pnpm build, pnpm typecheck, and pnpm test pass.
  • No credentials, private data, or generated build output are included.
  • Canonical English docs and Chinese mirrors are synchronized when applicable.

Google sign-in now issues a Better Auth session, and every authenticated route
resolves one. Credentials issued before this change keep working until they
expire, so nobody is signed out by the deployment.

**The published surface is an allowlist, not a catch-all.** Better Auth serves
its whole API from one handler, including `/update-user` — a second Account
profile writer that bypasses `UserDisplayNameSchema`, the suspension guard, and
the authenticated, origin-checked `/api/v1/me`. Only the OAuth callback is
published, because the provider redirects the browser straight to it; sign-in and
sign-out run through OpenTag routes that call Better Auth server-side, so the
request contract and the origin check stay ours. A regression asserts the
unpublished paths 404 and fails naming the path that escaped.

**Account invariants are enforced at issuance.** `onSessionCreating` runs before
any session row exists and must throw to prevent one, so there is no window in
which a session exists for an Account that is suspended or lacks the
compatibility Workspace grant every authenticated route derives authority from.
`ensureAccountReady` derives "was this Account just created?" from the absence of
a grant, which makes it idempotent — Better Auth owns account creation on its own
sign-in paths and cannot tell us.

**Session resolution stays live.** Better Auth returns an identity; suspension
and grants are still read from the database on every request, so revoking either
takes effect immediately rather than at session expiry.

Sign-out now revokes the session row instead of only dropping a cookie, which the
stateless JWTs could never do, and clears the legacy cookies too because a browser
mid-rollout can hold either credential.

The origin check moved back behind the credential check: a request with no
credential reads as unauthenticated again rather than forbidden.

`publicOrigin` is threaded as the shared preHandler options object rather than a
bare string, so the seven route modules that only ever used it to build that
preHandler did not each grow a parameter.

@baixiaohang baixiaohang 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.

Recommendation: request changes

  • Rationale: The Better Auth cutover currently leaves newly signed-in browsers unable to mutate or sign out, can restore revoked Workspace authority on the next session issuance, and does not migrate the Slack OAuth flow.

Risk level: B-high

  • Path baseline: packages/server/** -> B-low
  • Semantic lift: the change replaces browser session issuance and resolution across authenticated server routes -> B-high

PR summary

  • Author / repo: bestony / first-tree-ai/opentag
  • Problem: Browser sign-in needs revocable server-side sessions without signing out users who still hold pre-deploy JWT credentials.
  • Approach: Google sign-in and authenticated-route resolution move to Better Auth while legacy credentials remain accepted; only the provider callback is publicly mounted, and authorization is intended to remain live through Account and Workspace-grant reads.
  • Impacted modules: server auth integration, browser auth routes, authenticated route preHandlers, post-authentication provisioning, and auth integration tests.

Review findings

❌ 1. A fresh Better Auth sign-in never issues the opentag_csrf cookie required by every browser mutation. The new callback only forwards Better Auth's cookies, while requireBrowserMutationSecurity and the Web API still use the separate opentag_csrf / X-OpenTag-CSRF pair. A user who signs in after this deploy can read data, but profile/Agent/IM mutations and even /auth/browser/logout fail with 403. Please issue the OpenTag double-submit token as part of the Better Auth callback/session transition and cover a fresh sign-in followed by an authenticated mutation and logout. [R4, R5 / packages/server/src/auth/fastify-handler.ts:31]

❌ 2. ensureAccountReady equates "no active grant" with "new Account" and calls establishDefaultWorkspaceForNewAccount(..., true). A previously revoked Account therefore gets a brand-new Workspace and active Admin grant on its next Better Auth session, undoing the live revocation semantics already asserted by auth-migrations.test.ts. The code needs to distinguish first-time provisioning from an existing Account whose grants were revoked; absence of an active grant is not sufficient. [R3, R4 / packages/server/src/services/auth/post-authentication.ts:39]

❌ 3. Slack OAuth remains wired exclusively to the legacy access JWT. Its start route constructs a preHandler without the Better Auth resolver, and its callback passes only opentag_access into SlackOAuthService; a browser holding only the new session cannot start or complete Slack authorization. Please include both halves of this flow in the migration and add Better Auth-session coverage. [R5 / packages/server/src/api/slack-oauth.ts:75]

⚠️ 4. Better Auth 1.7.2's /sign-out catches session-deletion failures and still returns success, while this route always returns 204. That means the claimed server-side revocation can silently degrade to cookie-only logout during a database failure. Please either use a revocation path whose failure is observable to this route or explicitly narrow the guarantee and add diagnostics/coverage for the failure case. [R4 / packages/server/src/api/browser-auth.ts:210]

✅ The explicit callback allowlist is the right boundary for keeping Better Auth's profile-writing endpoints unpublished.

Action taken

  • Submitted request changes.

… revoked authority

Four gaps in the cutover, all reachable by a browser that signs in after it
deploys.

**A fresh sign-in could read but never write.** Better Auth's callback issues its
own session cookie and knows nothing about OpenTag's double-submit token, which
`requireBrowserMutationSecurity` demands of every browser mutation — profile
edits, Agent and IM changes, and sign-out itself. The callback now issues that
token whenever the response carries a session cookie, and only then, so a failed
sign-in hands out nothing.

**Revoked authority came back.** `ensureAccountReady` read "no active grant" as
"new Account", so an Account whose grants were revoked was re-provisioned with a
fresh Workspace and Admin grant on its next sign-in. Whether the Account has
*ever* held a grant is the discriminator that separates first-time provisioning
from revocation, and it keeps the call idempotent.

**Slack authorization only understood the legacy token.** Its start route built a
preHandler without the Better Auth resolver and its callback read `opentag_access`
directly, so a browser holding only the new session could neither start nor
complete it. Authentication moves out of `SlackOAuthService` — the route resolves
the identity through the same resolver every other route uses and passes it in.
The service compares identities, which is all it ever needed to do.

**Sign-out promised more than it could observe.** Better Auth's `/sign-out`
swallows a failed session delete and still reports success, so returning `204`
unconditionally would let revocation degrade silently to a cookie-only logout.
The route reads the session row back and fails loudly if it survived.
@bestony

bestony commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

All four correct. Fixed in ecbfc1c.

1 — a fresh sign-in could read but never write. This one follows directly from a call I made badly. I kept the double-submit CSRF specifically to avoid touching the web app's security posture in the same PR that swaps the session mechanism, and then did not follow that decision through: Better Auth's callback issues its own session cookie and knows nothing about opentag_csrf, so every mutation and sign-out would have 403'd for anyone who signed in after the deploy.

The callback now issues the token, and only when the response actually carries a session cookie — matched against authCookies.sessionToken.name rather than guessed — so a failed sign-in hands out nothing. Two regressions cover it; deleting the issuance fails the first.

2 — revoked authority came back. Correct, and the sharper framing is the useful part: "has no active grant" and "is new" are not the same predicate. Whether the Account has ever held a grant is what separates first-time provisioning from revocation, and it keeps the call idempotent. A regression provisions an Account, revokes its grant, signs in again, and asserts the grant count stays at one and stays revoked.

3 — Slack authorization only understood the legacy token. Correct. Rather than teach the callback about a second cookie, authentication moved out of SlackOAuthService: the route resolves the identity through a shared resolveAuthenticatedUserId — the same answer the preHandler gives, across every credential this server accepts — and passes it in. The service compares identities, which is all it ever needed to do; authenticateUser is gone from its constructor.

That is also the better seam. The old shape meant every new credential type had to be taught to a Slack service.

4 — sign-out promised more than it could observe. Correct, and I had written the stronger claim into the commit message. The route now reads the session row back after sign-out and fails loudly if it survived, so the guarantee is checkable rather than inherited from a call that swallows its own failure.

Validation on ecbfc1c: pnpm check, pnpm build, pnpm typecheck, pnpm test, and pnpm --filter @opentag/server test:integration (192/192) all pass.

One note in case it shows up in your run: agents.test.ts > linearizes a collection read when Admin authority is valid before a later revocation failed once for me with workspace_admin_grants_revoked_after_granted, then passed on every later run — three full integration passes since, plus the file on its own. The test revokes with the database's now() while the grant carries the fixture's clock, so the two can cross; that reads as a pre-existing timing flake rather than anything this branch introduced, but I want to be precise that I have not reproduced it on main to prove that. Flagging it rather than claiming it is unrelated.

@baixiaohang baixiaohang 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.

Recommendation: request changes

  • Rationale: The follow-up fixes address the original findings, but the logout failure path still strands a live session after removing the browser's token, and the Slack callback's shared resolver omits the live Account check performed by the normal preHandler.

Risk level: B-high

  • Path baseline: packages/server/** -> B-low
  • Semantic lift: browser session resolution and revocation are core authentication/session behavior -> B-high

PR summary

  • Author / repo: bestony / first-tree-ai/opentag
  • Problem: Browser sign-in needs revocable Better Auth sessions while pre-deploy JWTs remain usable through rollout.
  • Approach: Google sign-in issues Better Auth cookies, authenticated routes resolve either credential type, the provider callback remains the only published Better Auth endpoint, and live Account/Workspace state remains the authorization source.
  • Impacted modules: server auth integration, browser and Slack OAuth routes, authenticated-route preHandlers, post-authentication provisioning, and auth integration tests.

Review findings

❌ 1. The revocation failure path copies Better Auth's clearing Set-Cookie headers before checking whether the session survived. Better Auth clears that cookie even when its caught database delete failed, and the Fastify error handler does not remove headers already placed on the reply. The resulting 500 therefore leaves the server-side session live while deleting the browser's only copy of its token; a retry cannot identify and revoke that survivor, so a stolen copy remains usable until expiry. Delay propagating the sign-out cookies until deletion is verified (or otherwise preserve a retryable credential on failure), and cover the survivor response headers as well as the status. [R4 / packages/server/src/api/browser-auth.ts:217]

❌ 2. resolveAuthenticatedUserId does not actually preserve the preHandler's live Account invariant for Better Auth credentials: it returns session.user.id directly instead of calling getActiveUserById. An Account suspended after session issuance can therefore pass the Slack callback identity check; SlackOAuthService consumes the nonce and exchanges the provider code before configure reaches the later resource-authority check. Restore the active-Account check before those side effects and add a suspended Better Auth-session callback regression. [R1, R4, R5 / packages/server/src/plugins/user-auth.ts:37]

⚠️ 3. The corrected provisioning behavior intentionally allows a historically granted but now-revoked Account to receive a session with no active Workspace grant, yet BetterAuthConfig.onSessionCreating and the integration-test comment still state that no such session can exist. Please align those core-invariant comments with the identity-versus-authority behavior now enforced by the code. [R1 / packages/server/src/auth/better-auth.ts:21]

✅ The new callback CSRF issuance, historical-grant discriminator, and credential-neutral Slack route wiring resolve the three original functional blockers.

Action taken

  • Submitted request changes on the new head.

…t revoke

The revocation check added last round ran after Better Auth's clearing cookies
were already on the reply, and Fastify keeps headers placed before an error. A
failed delete therefore returned `500` having destroyed the browser's only copy
of a token whose session was still live: nothing left to retry the revocation
with, and a stolen copy usable until expiry. Nothing reaches the reply now until
the session is confirmed gone.

`resolveAuthenticatedUserId` also claimed to give the same answer as the
preHandler and did not: it returned the id on the session without the live
Account read. An Account suspended after issuance therefore passed the Slack
callback's identity check, and `SlackOAuthService` consumed the nonce and
exchanged the provider code before any authority check ran. It resolves through
`getActiveUserById` now, so suspension is enforced before those side effects.

Both are covered by regressions that fail on the previous behaviour.

Also corrects two comments that described an invariant the code deliberately does
not hold. Session issuance is a question about identity, not authority: an Account
whose grants were all revoked can still sign in and see that it has no Workspace,
and re-provisioning it at issuance would hand the revoked authority back.
@bestony

bestony commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

All three correct. Fixed in b331a34.

1 — a failed sign-out stranded a live session. This is worse than the problem I was fixing, and I introduced it in the fix. Copying the clearing cookies before the survivor check meant a failed delete returned 500 having already destroyed the browser's only copy of a token whose session was still live: nothing left to retry revocation with, and a stolen copy usable until expiry. Cookie-only logout at least left the caller able to try again.

Nothing reaches the reply now until the session is confirmed gone. The regression drives the route with a surviving session and asserts the response carries no Set-Cookie at all; restoring the old ordering fails it on exactly that.

2 — resolveAuthenticatedUserId did not do what its name and comment claimed. It returned the id on the session without the live Account read, so an Account suspended after issuance passed the Slack callback's identity check — and SlackOAuthService consumes the nonce and exchanges the provider code before any authority check runs, so the side effects land first. It resolves through getActiveUserById now.

Worth noting the shape of this one: I extracted that helper in the previous round precisely so routes authenticating outside the preHandler would get the same answer, and then wrote a version that did not. The regression asserts the same request resolves before suspension and rejects with AUTH_USER_SUSPENDED after.

3 — comments overstated the invariant. Correct, and the distinction is worth stating explicitly rather than just softening the wording: session issuance is a question about identity, not authority. An Account whose grants were all revoked can still sign in and see it has no Workspace; re-provisioning at issuance would hand the revoked authority straight back — which is the bug you caught last round. onSessionCreating's contract and the test comment now say that.

Validation on b331a34: pnpm check, pnpm build, pnpm typecheck, pnpm test, and pnpm --filter @opentag/server test:integration (193/193) all pass.

@baixiaohang baixiaohang 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.

Recommendation: approve

  • Rationale: The latest head resolves every blocking authentication, authorization, Slack OAuth, and logout-atomicity issue raised in the prior review rounds, with focused regression coverage and green CI.

Risk level: B-high

  • Path baseline: packages/server/** -> B-low
  • Semantic lift: browser session issuance, resolution, and revocation are core authentication/session behavior -> B-high

PR summary

  • Author / repo: bestony / first-tree-ai/opentag
  • Problem: Browser users need revocable server-side sessions without invalidating credentials issued before the migration.
  • Approach: Google sign-in now issues a Better Auth session while legacy JWTs remain accepted; only the provider callback is public, and live Account and Workspace state remains authoritative on each request.
  • Impacted modules: server auth integration, browser and Slack OAuth routes, authenticated-route preHandlers, post-authentication provisioning, and auth integration tests.

Review findings

✅ 1. Fresh Better Auth sign-in now issues the OpenTag double-submit CSRF token only when a real session cookie is present, preserving the mutation and logout boundary.

✅ 2. First-time provisioning now uses grant history rather than active-grant absence, so a later sign-in cannot restore revoked Workspace authority.

✅ 3. Slack OAuth accepts both credential generations and performs the live Account suspension check before nonce consumption or provider exchange side effects.

✅ 4. Logout withholds clearing cookies until session deletion is verified, preserving a retryable credential when revocation fails.

⚠️ 5. resolveAuthenticatedUserId and the regular preHandler retain some duplicated credential-resolution logic. Their behavior is aligned and covered here, so this is non-blocking; consolidating them before adding another credential type would reduce future drift risk.

Action taken

  • Approved current head b331a34.

@bestony
bestony merged commit e82d00a into main Aug 27, 2026
6 checks passed
@bestony
bestony deleted the refactor/better-auth-browser-session branch August 27, 2026 15:29
bestony added a commit that referenced this pull request Aug 27, 2026
…er Auth sessions (#203)

Stage 4 of the Better Auth migration: the CLI's credential becomes a
Better Auth session, and development sign-in stops being the one path
that produces a session nothing can revoke.

## The CLI

The connect-code exchange hands out a session row instead of a signed
pair, so a CLI credential can finally be withdrawn rather than only
waited out.

Nothing else changes shape. Every caller already speaks
`AuthTokenProvider`, so Better Auth implements that interface and the
exchange, refresh, and request paths are untouched — as is the
four-field response the CLI stores and the exactly-four-keys validator
that reads it back. **A CLI built before this keeps working without an
upgrade**, which matters because the production `open-tag` package only
ships on a `vX.Y.Z` tag.

`accessToken` and `refreshToken` carry the same token, because a session
is not a pair: it is one credential the server can revoke, and
re-presenting it is what extends it. The pair existed to limit the
damage of a leaked access token that could not be withdrawn, which no
longer describes the system.

Verification tries the session store first and falls back to the legacy
signature, so a CLI that has not reached the server since the cutover
still authenticates. Issuance only ever produces a session, which makes
refresh the step that quietly moves such a credential across — no
re-login, and the old access token keeps working until it expires.

## Development sign-in

Development sign-in went through `AuthService.issueTokensForUser`, so
the change above repointed it at Better Auth too — and it was writing
the result into OpenTag's own `opentag_access` cookie.

That combination is worse than either half. The token still
authenticates, because the legacy cookie path falls through to the same
session store, so the sign-in looks correct. But `getSession` reads
Better Auth's cookie, not this one, so sign-out finds nothing to revoke
and clears cookies while the session row lives on — the failure #201
removed for Google, now with a session lifetime rather than fifteen
minutes behind it. Every browser refresh minted another orphan row on
top.

Better Auth mints it instead, through a plugin endpoint that holds the
request context `setSessionCookie` needs. The endpoint takes no input:
the Account comes from a resolver fixed at construction, so it cannot be
aimed at another Account even by a caller that reaches it — and reaching
it takes doing, since it is absent from the published path allowlist and
the plugin is only registered when development sign-in is configured,
which `parseServerConfig` already restricts to a loopback
`OPENTAG_ENV=dev` server. The route keeps its own loopback fences and
rate limit ahead of all of this.

The two commits are not independently revertable: reverting only the
second puts a session token back in the legacy cookie.

## On the tests

The existing connect-code contract tests construct `AuthService` with
the legacy provider directly, so they would have passed against a
completely unwired change. The new coverage exercises the composition
the server actually runs: exchange yields a session row that
authenticates as a bearer token and stops working the moment it is
deleted, and a legacy refresh token comes back as a session.

For development sign-in the assertion that matters is the negative one —
that no `opentag_access` cookie is set — because that is precisely the
mistake that hides itself. Integration coverage drives the real endpoint
against Postgres and asserts the session row appears, resolves through
`getSession`, and is gone after sign-out.

## Deployment

No schema change, no environment variable, no manual step. Server and
`open-tag-staging` go out together on merge; the production CLI waits
for the next tag and is covered by the bridge until then.

Stage 5 removes `AuthTokenService`, the hand-rolled OAuth modules, and
`OPENTAG_JWT_SECRET` — and must not go out until the production CLI has
shipped and been adopted, since it is the one step no rollback recovers.

## Validation

`pnpm check`, `pnpm build`, `pnpm typecheck`, `pnpm test` (1274), and
`pnpm --filter @opentag/server test:integration` (209) all pass.
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.

2 participants