Skip to content

feat(api): add Better Auth session integration to oRPC router - #53

Merged
RedStar071 merged 13 commits into
mainfrom
t3code/orpc-v2-better-auth
Aug 19, 2026
Merged

feat(api): add Better Auth session integration to oRPC router#53
RedStar071 merged 13 commits into
mainfrom
t3code/orpc-v2-better-auth

Conversation

@RedStar071

@RedStar071 RedStar071 commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Added Better Auth session authentication to the oRPC router alongside operator token support. Dashboard users now authenticate via session; the router bridges session identity into the same principal model procedures already reason about, enforcing admin-only access to writable execution modes. Implemented CSRF protection and request header forwarding for session-authenticated calls.

Why

Operator tokens work well for machines but are unfit for human users (revocation is manual, audit trails see only the token name, duration is unlimited). Better Auth gives the dashboard a proper session layer; this change plumbs it into the control-plane router without compromising its architecture. Sessions and tokens both flow through the same principal interface, so procedures stay unaware of which one authenticated the call. Repository targeting, execution mode grants, and the runner boundary remain intact.

The composition root (apps/dashboard) constructs the Better Auth instance and passes it through the RPC context; the router never touches persistence or policy, keeping packages/api independent from adapters.

Verification

  • aube run check:repo
  • aube run lint:ci
  • aube run typecheck
  • aube test
  • aube run build

Safety and compatibility

  • I added deterministic tests for session authentication (admin, non-admin, anonymous, missing session).
  • I preserved observe mode as read-only; admin sessions alone do not grant writable modes.
  • Runtime commands and target-repository writes remain inside the runner boundary; context construction is transport-only.
  • No secrets, tokens, or personal data exposed in logs; only the user's email (or immutable id if blank) is recorded.
  • Updated architecture boundaries in CLAUDE.md and oRPC auth integration.

Agent context

  • Agent/tools used: None
  • What the agent did, and what you changed or verified yourself: N/A

Reviewer notes

Session identity resolution: A Better Auth session resolves to the user's email, falling back to the immutable user id if blank. This is what appears in audit logs and approval decisions.

Admin role: Hardcoded to "admin" by default; @agent-zero/auth's authBetterAuthOptions adds the role field to the User model. The deployment can override via RpcContext.adminRole if needed.

CSRF protection: Sessions authenticate over same-origin /rpc/** only, carrying the browser's session cookie. The SimpleCsrfProtectionHandlerPlugin validates the Sec-Fetch-Mode header every browser fetch() already carries; no client-side plugin needed.

Request header forwarding: RequestHeadersHandlerPlugin injects the incoming request's headers into the oRPC context as reqHeaders. The auth middleware reads the cookie header from there. SSR must forward the original cookie header to reach the same session the client will see (see orpc.server.ts); a bare header reuse would turn the browser's session into a server-to-server token, which is incorrect.

Migration: Deleted the /api/dashboard.get.ts REST endpoint; it is now GET /rpc/dashboard via the RPC transport. The aggregation logic moved into packages/api as a procedure rather than a one-off read model in the composition root.

Test doubles: Better Auth is stubbed to a structural interface (BetterAuthSessionApi) with only the one endpoint the integration calls. Test doubles do not need to fabricate dozens of unused fields.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Note

Add Better Auth session authentication to the oRPC router with RFC 8628 device authorization flow

  • Adds authMiddleware and betterAuthPrincipal to packages/api/src/orpc/auth.ts, allowing oRPC procedures to authenticate via Better Auth dashboard sessions in addition to existing operator tokens.
  • Adds zero login and zero logout CLI commands implementing RFC 8628 device authorization: the CLI posts to /api/auth/device/**, polls for approval, and persists tokens per-origin in ~/.config/agent-zero/credentials.json with strict 0600 permissions.
  • Adds a /device dashboard page (apps/dashboard/app/pages/device.vue) where authenticated operators can verify, approve, or deny a pending device code.
  • Extends packages/auth with deviceAuthorization, bearer, multiSession, lastLoginMethod, oAuthProxy, and hosted-infra (sentinel/dash) Better Auth plugins, each gated by environment variables.
  • Adds a device_code database table and a last_login_method column on user via migration 0003_messy_hitman.
  • Adds TanStack Query and oRPC client plugins to the dashboard, replaces the former GET /api/dashboard route with an rpcRouter.dashboard.overview oRPC endpoint.
  • Risk: authenticated middleware now calls useLogger() which throws if invoked outside an active requestLoggerStorage run; all authenticated RPC handlers require the logger to be initialized.

Macroscope summarized 868644f.

- Add auth middleware bridging Better Auth sessions to the principal system
- Support both token and session authentication with different execution modes
- Add sessionPrincipal function granting admins writable modes, others read-only
- Add oRPC client plugins (orpc.client.ts, orpc.server.ts) for dashboard
- Dashboard passes Better Auth instance and session cookie to RPC context
- Track PrincipalKind (token vs session) for audit logs
- Add CSRF protection and request header forwarding to /rpc/** transport
- Update test fixtures to use new Principal structure with kind field
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-zero-dashboard Ready Ready Preview Aug 19, 2026 9:45pm
agent-zero-docs Ready Ready Preview Aug 19, 2026 9:45pm
agent-zero-marketing Ready Ready Preview Aug 19, 2026 9:45pm

@socket-security

socket-security Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​tanstack/​query-core@​5.101.4971007596100
Added@​orpc/​tanstack-query@​2.0.0-beta.2610010010096100
Added@​better-auth/​infra@​0.4.09910010097100
Added@​tanstack/​vue-query@​5.101.49810010098100

View full report

@macroscopeapp

macroscopeapp Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces new authentication capabilities: RFC 8628 device flow for CLI login, Better Auth session integration in the oRPC router, bearer token support, and optional third-party infrastructure plugins. Changes to authentication mechanisms, new database schema, and new user-facing features require human review.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

The route was replaced by the rpcRouter dashboard.overview procedure in
7f82c1c; this updates README, docs/architecture.md, apps/docs, AGENTS.md,
the orpc-server/agent-zero-architecture skills, and a stray comment in
modules/vitehub.ts that still pointed at it.
orpc.client.ts/orpc.server.ts (7f82c1c) provide $orpcQuery's
queryOptions()/mutationOptions(), but useQuery/useMutation need a
QueryClient in the Vue app to consume them. Wires one up with the
documented Nuxt SSR pattern: dehydrate on app:rendered, hydrate on
app:created via a shared useState slot.
@vercel
vercel Bot temporarily deployed to Preview – agent-zero-marketing August 19, 2026 16:07 Inactive
@vercel
vercel Bot temporarily deployed to Preview – agent-zero-docs August 19, 2026 16:07 Inactive
…-auth

# Conflicts:
#	apps/dashboard/modules/vitehub.ts
…e CLI device flow

Registers five Better Auth plugins across the boundaries that own them.

Always on, because neither widens a way into the deployment:
- multiSession, capped at MAXIMUM_DEVICE_SESSIONS, so a break-glass sign-in
  no longer evicts the operator's own session in the same browser.
- lastLoginMethod with storeInDatabase, adding user.last_login_method, so the
  sign-in hint survives a new browser rather than living only in a cookie.

Gated, because each grants something a deployment should not acquire by
upgrading:
- deviceAuthorization behind AUTH_ENABLE_DEVICE_AUTHORIZATION (RFC 8628; adds
  the device_code table and the dashboard's /device approval page). Completing
  the flow mints a full session for a client that never sees the browser.
- oAuthProxy only when OAUTH_PROXY_PRODUCTION_URL and OAUTH_PROXY_SECRET are
  both set, so a preview origin the provider has no callback registered for can
  still complete OAuth. Its own secret, never BETTER_AUTH_SECRET.
- testUtils only inside apps/dashboard's AUTH_E2E_MEMORY branch: it registers no
  route but hangs privileged session-minting helpers off the auth context, so a
  real deployment's context never carries them.

Adds `zero login` / `zero logout`, which drive the device flow over plain fetch
rather than pulling a server-side auth library into a terminal adapter. Tokens
are stored per deployment origin in $XDG_CONFIG_HOME/agent-zero/credentials.json
owner-readable only, so one workstation holds a cloud-managed and a self-hosted
session at once; --url or AGENT_ZERO_URL picks which. There is deliberately no
built-in hosted default.
credential: StoredCredential,
path = credentialsPath(),
): Promise<void> {
const store = await readCredentials(path);

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.

🟡 Medium src/credentials.ts:120

Concurrent saveCredential calls can silently discard newly saved sessions: each process reads the same store, and the last writeFile overwrites the other process's entry. forgetCredential has the same unlocked read-modify-write race and can either resurrect a removed credential or discard a fresh token; serialize store mutations across processes (ideally with atomic replacement) before writing.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/cli/src/credentials.ts around line 120:

Concurrent `saveCredential` calls can silently discard newly saved sessions: each process reads the same store, and the last `writeFile` overwrites the other process's entry. `forgetCredential` has the same unlocked read-modify-write race and can either resurrect a removed credential or discard a fresh token; serialize store mutations across processes (ideally with atomic replacement) before writing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Partially addressed in 868644f — flagging what is and is not fixed, because the two halves of this finding have different consequences.

Fixed: the write itself. Both saveCredential and forgetCredential now write through an owner-only temporary file and rename over the target, so a concurrent reader always sees a complete file (the old one or the new one) rather than a half-written one, and the file is never momentarily readable by other users.

Not fixed: the read-modify-write is still unlocked, so two processes saving different origins concurrently can still drop one entry. That genuinely needs a lock file, and I left it out deliberately rather than by oversight — the failure mode is losing a session, which the operator recovers with another zero login, whereas the permission window this finding also described leaked a bearer token, which is not recoverable. The limitation is now stated in the writeStore doc comment so it is not mistaken for covered.

Happy to add a lock file if you would rather have it in this PR.

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.

The atomic write fix is solid — eliminating the permission window and concurrent-read corruption was the critical part, and writeStore handles that well.

The remaining read-modify-write race is edge-case: two zero login processes for different deployments would need to overlap within milliseconds. Given the recoverable failure mode and that you've documented the limitation, deferring the lock file is reasonable.

That said, implementing it now would be cleaner than revisiting later. If you'd like, I can add a lock file using proper-lockfile (already in your dependency tree) to serialize the mutations. Your call.

Comment thread .skills/agent-zero-cli/SKILL.md
Comment thread packages/cli/src/index.ts
Comment thread packages/cli/src/credentials.ts Outdated
Comment thread packages/auth/src/auth.ts
Adds Better Auth's hosted infrastructure plugins, confined to the deployment
that has a project on that service.

- sentinel() scores sign-in and sign-up attempts: credential stuffing,
  impossible travel, proof-of-work challenges.
- dash() reports analytics and mounts the administration API the hosted
  console drives.
- dashClient() is registered unconditionally, like every other client plugin
  here: it only adds callable methods, and the server decides whether they
  resolve.
- sentinelClient() is the one gated entry, because it is not passive — it
  fingerprints the browser and identifies the visitor against the KV service on
  every auth request. It contributes no $InferServerPlugin, so gating it costs
  no other plugin its inferred endpoints.

infraFromEnvironment() withholds the settings unless BETTER_AUTH_API_URL,
BETTER_AUTH_KV_URL, and BETTER_AUTH_API_KEY are all present, so a self-hosted
install registers neither plugin rather than mounting endpoints that fail on
their first call — and never reports its authentication events to a service its
operator did not sign up for. The same credentials derive AuthConfig.enableInfra,
which nuxt.config publishes through appConfig (no env override channel) so the
browser half cannot drift from the server half.
Auditing the ~79 endpoints dash() mounts under /api/auth/dash/** turned up two
that carry no JWT guard: accept-invitation and complete-invitation. They cannot,
because the invitee holds no API key; an invitation token validated against the
hosted API authorizes them instead.

Both create a user with emailVerified: true, optionally a password account, and
a session, and they do it through the internal adapter — so they bypass
emailAndPassword.disableSignUp and the Better Enrollment flow gated by
AUTH_ENABLE_INVITATIONS. Enabling dash() therefore delegates account creation in
this database to whoever can mint an invitation in the hosted console.

That is a property of the cloud-managed deployment rather than a defect, but it
was invisible at the call site, in .env.example, and in the deployment docs.
Records it in all three, along with the guard the other 77 endpoints do carry
(hosted-signed JWT, JWKS-verified, five-minute max age, apiKeyHash matching this
deployment's own key).

No behaviour change.
zero login stored a credential nothing could use. /device/token returns a
session token, but session resolution reads the session cookie, so a token in an
Authorization header resolved to nobody: the CLI signed in and got a file that
authenticated no request.

Registers Better Auth's bearer plugin alongside deviceAuthorization, gated by
the same AUTH_ENABLE_DEVICE_AUTHORIZATION. It converts
`Authorization: Bearer <session-token>` into the cookie before the session is
resolved. Sharing the header with the control plane's operator tokens is safe:
buildRpcContext resolves those first by constant-time comparison and only falls
through to a session lookup when none matched. The cost — a leaked session token
becomes replayable as a header, not only as a cookie — is inherent to letting a
browserless client hold a session, which is what that flag already grants, so
the two move together rather than independently.

Also gives readCredentials a consumer: zero doctor now lists which deployments
have a stored session and whether it has expired. Origins and expiry only, never
the token, because doctor output gets pasted into issues. An unparseable expiry
reads as expired rather than valid.
…e global endpoint

Every spec under test/nuxt mocked useAuthClient or useAppConfig, so nothing ever
built the real client from app/auth.config.ts. That left the useAppConfig() call
in the plugin list untested — and it runs during client construction rather than
inside a component, so a failure there takes down every authenticated page
instead of one spec.

Writing that spec surfaced a second problem immediately: sentinelClient() was
warning that "default global identify ingestion is active but not recommended",
because it was constructed without an identifyUrl and fell back to Better Auth's
shared endpoint rather than this project's. nuxt.config now publishes the KV
origin from the same BETTER_AUTH_KV_URL the server plugins read, and
auth.config passes it through. Only that origin is published, never the API key
provisioned beside it: appConfig reaches the browser.

The spec asserts construction under both branches of the enableInfra gate and
nothing about which plugin namespaces exist — Better Auth's client is a proxy
that turns any property access into a callable, so presence is not observable at
runtime. That the flat plugin literal still infers organization, invite,
multiSession, and dash is a compile-time property, enforced by nuxt typecheck
over the real call sites; the spec's comment says so rather than implying a
coverage it does not have.
… login

Three review findings on the device-flow work.

writeFile's mode only applies when it creates the file, so saving a token into
an already world-readable credentials.json published every token in it until the
follow-up chmod ran — and permanently if that chmod failed. Both writers now go
through one helper that opens an owner-only temporary file with `wx` and renames
it over the target: the bytes are never in a readable inode, and rename is
atomic within a directory, so a concurrent reader sees the old file or the new
one, never a partial write. A test asserts the inode actually changes, which is
the part the mode assertion alone could not distinguish, and that no scratch file
is left behind.

That does not make the surrounding read-modify-write atomic — two processes
saving different origins can still lose one entry. Serialising that needs a lock
file; the comment says so rather than implying it is covered, because losing a
session to a race is recoverable by signing in again while leaking one is not.

Ctrl-C during `zero login` stopped Clack's spinner but not the poll loop, so it
looked like nothing happened until approval or the ten-minute deadline. The loop
now checks spinner.isCancelled after each delay, where the signal lands.

Also removes a duplicated rules block in the CLI skill: .agents/skills is a
symlink to .skills, so an earlier edit applied the same insertion twice.
@RedStar071
RedStar071 merged commit d7f0e3d into main Aug 19, 2026
20 checks passed
@RedStar071
RedStar071 deleted the t3code/orpc-v2-better-auth branch August 19, 2026 22:05
RedStar071 added a commit that referenced this pull request Aug 20, 2026
Resolve conflicts between the audit-log work on this branch and the Better
Auth session integration from #53:

- apps/dashboard/server/routes/rpc/[...].ts: pass both the Better Auth
  instance to buildRpcContext and the audit recorder into the oRPC context.
  /api/v1/** stays token-only, so it keeps only the audit recorder.
- packages/api/src/orpc/router.ts: keep the Principal and audit type imports
  that principalActor still needs after the auth middleware moved to auth.ts.
- packages/api/src/orpc/router.test.ts: principal fixtures now carry the
  required `kind`, and the audit-trail cases run inside `instrumented()` since
  the authenticated middleware reads the request logger.
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.

1 participant