Skip to content

feat: Google OAuth via ZeroID + cross-session usage analytics panel - #48

Merged
saucam merged 8 commits into
mainfrom
feat/oauth-zeroid-idp-federation
Jul 2, 2026
Merged

feat: Google OAuth via ZeroID + cross-session usage analytics panel#48
saucam merged 8 commits into
mainfrom
feat/oauth-zeroid-idp-federation

Conversation

@saucam

@saucam saucam commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Google OAuth via ZeroID: External IdP federation so users can sign in with Google through ZeroID rather than only with an API key.
  • Analytics panel: 14-day cost/token bar chart in the session list sidebar, toggled via a button in the Sessions header.

Analytics details

  • SqliteEpisodeStore.dailyUsage(days)GROUP BY date(created_at/1000, 'unixepoch') over turn_usage; returns daily cost, input/output tokens, turn and session counts
  • SqliteEpisodeStore.lifetimeTotals() — all-time aggregate across all workspaces
  • New usage.daily WebSocket message → response.ok { daily, lifetime } (no separate result message type needed)
  • web/src/state/analytics.ts — Solid signals + fetchAnalytics()
  • AnalyticsPanel.tsx — 3 lifetime stat tiles (cost / turns / tokens) + pure SVG 14-bar chart, zero-padded, today highlighted, no external chart deps
  • Per-session cost badge in the session list was already implemented; no change there

Test plan

  • bun run typecheck passes (clean)
  • bun test — 609 pass, 0 fail
  • button in session list header toggles analytics panel
  • Lifetime stat tiles reflect correct aggregated totals from turn_usage
  • 14-day bar chart renders correct proportional bars; today's bar highlighted; days with no activity show stub bars
  • Google OAuth sign-in flow via ZeroID completes and returns a valid JWT

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added conditional “Sign in with Google” alongside API key login when OAuth is available.
    • Implemented silent auto-bootstrap using remembered API key or remembered OAuth token.
    • Added Sessions usage analytics (daily cost chart + lifetime totals) with a toggle in the Sessions sidebar.
  • Bug Fixes
    • Improved OAuth callback to directly exchange credentials and complete sign-in on the web side via stored token handling.
    • Fixed duplicate assistant message persistence during finalization of artificial streaming.
  • Tests
    • Updated OAuth/analytics-related tests to match the new OAuth availability rules and usage metrics flow.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR changes OAuth to a server-side ZeroID token exchange with Google login, adds usage analytics data collection, protocol support, client state, and sidebar UI, and updates artificial streaming finalization in session persistence.

Changes

Google OAuth via ZeroID RFC 8693 token exchange

Layer / File(s) Summary
Config schema and VerifiedUser contract changes
src/config.ts, src/daemon/identity-provider.ts, src/tests/config.test.ts
OAuthSchemaFields drops hmacSecret; loadConfig now enables oauth from GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and builds zeroidTokenEndpoint. VerifiedUser gains optional rawIdToken populated by GoogleOAuthProvider.handleCallback. Config tests were updated to match the new env gating and output shape.
OAuthHandler: server-side token exchange
src/daemon/oauth.ts
OAuthConfig now uses ZeroID token-exchange inputs. #handleAuthorize drops PKCE handling, stores redirect state, and routes to Google or local login. #completeAuth exchanges user.rawIdToken with ZeroID and redirects with token= or error=. callbackPage stores the token in localStorage and redirects to /ui/.
DaemonServer: GoogleOAuthProvider wiring
src/daemon/server.ts
Removes LocalProvider and replaces conditional IdP selection with direct GoogleOAuthProvider construction using config.oauth.googleClientId and config.oauth.googleClientSecret.
Web auth library: token reuse and OAuth helpers
web/src/lib/auth.ts
resolveToken returns a stored codeoid.token when no API key is supplied. Adds rememberedOAuthToken, fetchOAuthProvider, and startOAuthLogin.
Web UI: silent bootstrap and Google sign-in button
web/src/App.tsx, web/src/components/SignIn.tsx
App.tsx bootstraps from a remembered OAuth token when no API key exists. SignIn.tsx fetches oauthProvider on mount and conditionally renders a Google sign-in button with GoogleIcon.

Usage analytics reporting and sidebar UI

Layer / File(s) Summary
Usage analytics types and message contract
src/protocol/types.ts, web/src/protocol/types.ts
DailyUsageBucket and LifetimeUsageTotals are added to both protocol packages, and ClientMessage gains usage.daily with an optional days field.
Usage aggregates and session response
src/daemon/memory/store.ts, src/daemon/session-manager.ts
SqliteEpisodeStore.dailyUsage and lifetimeTotals compute grouped and lifetime turn_usage aggregates. session-manager routes usage.daily to a new handler that returns daily and lifetime data or empty totals when memory is unavailable.
Client analytics state and panel
web/src/state/analytics.ts, web/src/components/AnalyticsPanel.tsx
Client state stores daily buckets, lifetime totals, and loading status while fetching usage.daily. AnalyticsPanel fetches 14-day data, renders summary totals, shows loading state, and draws the usage bar chart.
Sidebar analytics toggle and panel rendering
web/src/components/SessionListPane.tsx
SessionListPane adds showAnalytics state, passes toggle props into SectionHeader, and conditionally renders AnalyticsPanel alongside the session list.

Session artificial streaming finalization

Layer / File(s) Summary
Final message persistence path
src/daemon/session.ts, src/tests/memory.test.ts
The artificial streaming finalization path updates scrollback in place, appends the final assistant message to the transcript store with error logging, and notifies the chunker instead of re-persisting the same message. The adjacent test helper comment suppression is removed.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • saucam/codeoid#47: Both PRs touch src/daemon/session.ts, specifically the Session.#artificiallyStreamText/finalization behavior used when emitting “batch”/artificial streaming assistant text.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: Google OAuth via ZeroID and the usage analytics panel.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/oauth-zeroid-idp-federation

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.72%. Comparing base (523314b) to head (5c93318).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #48      +/-   ##
==========================================
- Coverage   81.07%   80.72%   -0.35%     
==========================================
  Files          55       56       +1     
  Lines        7597     7895     +298     
==========================================
+ Hits         6159     6373     +214     
- Misses       1438     1522      +84     
Flag Coverage Δ
daemon 80.72% <100.00%> (-0.35%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/config.ts 89.62% <100.00%> (ø)
src/daemon/memory/store.ts 87.40% <100.00%> (+1.29%) ⬆️
src/daemon/oauth.ts 67.18% <100.00%> (ø)
src/daemon/session.ts 70.31% <100.00%> (+0.09%) ⬆️
src/protocol/types.ts 92.85% <ø> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/config.ts (1)

306-310: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep Google OAuth credentials in the validated config contract.

loadConfig now gates OAuth from raw env.GOOGLE_*, but OAuthSchemaFields does not validate or preserve those values. This breaks config-file support and makes opts.env diverge from server.ts’s later process.env reads. Add Google client ID/secret to the Zod schema/env overrides and pass the validated values through the daemon config.

As per coding guidelines, src/config.ts: “Validate configuration from ~/.codeoid/config.json with environment variable precedence.”

Suggested direction
 const OAuthSchemaFields = z
   .object({
     clientId: z.string().optional(),
+    googleClientId: z.string().trim().min(1).optional(),
+    googleClientSecret: z.string().trim().min(1).optional(),
   })
   .default({});
-  const googleOAuthEnabled =
-    Boolean(env.GOOGLE_CLIENT_ID) && Boolean(env.GOOGLE_CLIENT_SECRET);
+  const googleOAuthEnabled =
+    Boolean(parsed.oauth.googleClientId) && Boolean(parsed.oauth.googleClientSecret);

Also add GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET to ENV_OVERRIDES so env values override file config before this assembly step.

Also applies to: 559-568

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.ts` around lines 306 - 310, The OAuth config contract in
OAuthSchemaFields only validates clientId, so Google OAuth values are lost when
loadConfig assembles opts.env. Extend OAuthSchemaFields and ENV_OVERRIDES to
include GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, then thread the validated
values through the config assembly so config.json values are preserved with env
precedence and available to the daemon config.

Source: Coding guidelines

🧹 Nitpick comments (1)
src/tests/config.test.ts (1)

292-304: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover partial Google credential env cases.

The gate requires both values, but the test only covers none vs both. Add one-missing assertions so a future || regression doesn’t silently enable a broken OAuth handler.

Test coverage addition
   it("populates oauth only when Google credentials are set", () => {
     const none = loadConfig({ configPath, env: {} });
     expect(none.oauth).toBeUndefined();
+
+    expect(
+      loadConfig({ configPath, env: { GOOGLE_CLIENT_ID: "client-id.apps.googleusercontent.com" } }).oauth,
+    ).toBeUndefined();
+    expect(
+      loadConfig({ configPath, env: { GOOGLE_CLIENT_SECRET: "secret" } }).oauth,
+    ).toBeUndefined();
 
     const viaEnv = loadConfig({
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/config.test.ts` around lines 292 - 304, The OAuth config test only
covers the cases where both Google credentials are absent or both are present,
so it can miss a regression in the gate logic. Update the loadConfig-based
assertions in config.test around oauth to also cover partial env inputs where
only GOOGLE_CLIENT_ID or only GOOGLE_CLIENT_SECRET is set, and verify oauth
stays undefined in those one-missing cases while still confirming the existing
full-credentials path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/daemon/oauth.ts`:
- Around line 34-39: The LocalProvider fallback path is stale because the oauth
flow still exposes local-session support while `#exchangeForZeroIDToken` always
fails when rawIdToken is missing. Update OauthDaemon and related
LocalProvider/session-token code to either remove the local-provider config/docs
and constructor default, or implement the local session token fallback so local
login can succeed without rawIdToken; make sure the behavior and comments around
localSessionSecret, LocalProvider, and `#exchangeForZeroIDToken` are consistent.
- Around line 238-240: The token exchange flow in `OAuthDaemon` is dropping
request context needed by ZeroID. Update `#exchangeForZeroIDToken`, and the
callers around the `accessToken` exchange path, to propagate both
`PendingAuth.scope` and `OAuthConfig.clientId` through to the ZeroID request
payload so the exchanged token carries the requested scopes and client identity.
Ensure the same fields are forwarded in the additional exchange call site
referenced by the review so both paths stay consistent.
- Around line 251-254: The OAuth callback redirect currently appends the bearer
token to the callback URL query string, which exposes it via history, logs, and
referers. Update the callback handling in oauth.ts to avoid putting accessToken
in search params: use a fragment-based handoff or a one-time server-side
exchange, and keep the existing state/redirect flow intact in the callback
redirect logic and the related auth completion paths.
- Around line 283-287: The ZeroID token exchange in the oauth callback path uses
fetch with no abort handling, so it can hang indefinitely after pending state is
consumed. Update the token request in the oauth.ts flow around the fetch call to
use an AbortController with a timeout, and ensure the request is canceled and
handled cleanly if the timeout elapses. Keep the change localized to the token
exchange logic so the existing callback flow continues to use the response path
and error handling.

In `@src/daemon/server.ts`:
- Around line 106-111: The Google OAuth setup in the daemon is reading
credentials directly from process.env instead of the loaded config, which
bypasses the validated config source. Update the GoogleOAuthProvider
construction in server.ts to consume the client ID and client secret from
config.oauth (or the corresponding config object returned by loadConfig) rather
than process.env, so programmatic config and env-precedence behavior remain
consistent with config.ts validation.

In `@web/src/lib/auth.ts`:
- Around line 70-77: The token resolution in resolveToken is letting a persisted
OAuth token from STORAGE_KEY_TOKEN win whenever opts.apiKey is missing, which
can shadow a saved API key. Update the logic so a stored API key from
STORAGE_KEY_API_KEY is preferred for reconnects, and only fall back to the
stored OAuth token when no API key exists; keep the behavior aligned with
resolveToken({ zeroidUrl }) and the returned { token, exchanged } shape.

---

Outside diff comments:
In `@src/config.ts`:
- Around line 306-310: The OAuth config contract in OAuthSchemaFields only
validates clientId, so Google OAuth values are lost when loadConfig assembles
opts.env. Extend OAuthSchemaFields and ENV_OVERRIDES to include GOOGLE_CLIENT_ID
and GOOGLE_CLIENT_SECRET, then thread the validated values through the config
assembly so config.json values are preserved with env precedence and available
to the daemon config.

---

Nitpick comments:
In `@src/tests/config.test.ts`:
- Around line 292-304: The OAuth config test only covers the cases where both
Google credentials are absent or both are present, so it can miss a regression
in the gate logic. Update the loadConfig-based assertions in config.test around
oauth to also cover partial env inputs where only GOOGLE_CLIENT_ID or only
GOOGLE_CLIENT_SECRET is set, and verify oauth stays undefined in those
one-missing cases while still confirming the existing full-credentials path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 53a26cbf-2e5b-4544-993c-2a564a03052b

📥 Commits

Reviewing files that changed from the base of the PR and between 8505395 and 884e679.

📒 Files selected for processing (8)
  • src/config.ts
  • src/daemon/identity-provider.ts
  • src/daemon/oauth.ts
  • src/daemon/server.ts
  • src/tests/config.test.ts
  • web/src/App.tsx
  • web/src/components/SignIn.tsx
  • web/src/lib/auth.ts

Comment thread src/daemon/oauth.ts Outdated
Comment thread src/daemon/oauth.ts Outdated
Comment thread src/daemon/oauth.ts
Comment thread src/daemon/oauth.ts Outdated
Comment thread src/daemon/server.ts Outdated
Comment thread web/src/lib/auth.ts Outdated
@saucam saucam changed the title feat: Google OAuth via ZeroID external IdP federation feat: Google OAuth via ZeroID + cross-session usage analytics panel Jun 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/daemon/memory/store.ts`:
- Around line 424-425: The day-range query in the store aggregation is using a
rolling seconds-based cutoff, which can produce a partial extra bucket when
grouped by calendar day. Update the logic in the query used by the memory store
aggregation so the cutoff is aligned to calendar days before grouping by date,
ensuring the buckets returned by the query match the padded date range expected
by the panel. Keep the fix localized around the date grouping/query path in the
store methods that build this report.

In `@src/daemon/session-manager.ts`:
- Around line 1306-1333: The `#usageDaily` handler in SessionManager is returning
store-wide daily and lifetime aggregates without checking the caller’s auth
context. Update this method to inspect auth from the incoming ClientMessage and
scope the lookup to the authenticated caller before calling
this.#memory.store.dailyUsage and this.#memory.store.lifetimeTotals. If the
caller is not authorized for the requested tenant/workspace, return an
appropriate error response instead of aggregated data.

In `@web/src/components/AnalyticsPanel.tsx`:
- Around line 67-82: The bar rendering logic in AnalyticsPanel should not force
a minimum height for zero-cost buckets; remove the hardcoded 2px floor in the
barH calculation so genuine zero-spend days render as zero-height bars. Update
the height/x/y usage in the same bar rendering block to preserve visibility only
for nonzero values, while keeping the existing bucket, maxCost(), and CHART_H
logic intact.
- Line 27: The AnalyticsPanel logic is re-fetching from a reactive createEffect
because fetchAnalytics() reads loading() before its first await, causing the
effect to subscribe and rerun on each setLoading change. Move the initial fetch
out of createEffect and trigger it in a non-reactive lifecycle or event path,
keeping fetchAnalytics() from depending on tracked state before its first await.
Also update the bar rendering in AnalyticsPanel so zero-usage days do not use
Math.max(2, ...); render them as zero-height or handle them with a separate
empty-state treatment instead.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 21335c7a-33d9-403b-bada-dc0d6b0917b4

📥 Commits

Reviewing files that changed from the base of the PR and between 884e679 and 27adb7d.

📒 Files selected for processing (7)
  • src/daemon/memory/store.ts
  • src/daemon/session-manager.ts
  • src/protocol/types.ts
  • web/src/components/AnalyticsPanel.tsx
  • web/src/components/SessionListPane.tsx
  • web/src/protocol/types.ts
  • web/src/state/analytics.ts

Comment thread src/daemon/memory/store.ts Outdated
Comment thread src/daemon/session-manager.ts
Comment thread web/src/components/AnalyticsPanel.tsx Outdated
Comment thread web/src/components/AnalyticsPanel.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/src/lib/auth.ts (1)

188-194: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Bind the OAuth callback to a per-login state. web/src/lib/auth.ts:188-194 starts /auth/authorize without a nonce, and src/daemon/oauth.ts:386-422 stores #token=... from the fragment without checking any returned state. Generate/store a state here, pass it through the authorize request, and refuse to persist the token unless the callback echoes the same value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/lib/auth.ts` around lines 188 - 194, Bind each OAuth login attempt to
a unique state value in startOAuthLogin by generating and storing a per-login
nonce, adding it to the /auth/authorize request, and ensuring the callback path
in src/daemon/oauth.ts only accepts and persists the `#token` when the returned
state matches the stored one. Update the callback handling around the token
persistence logic to validate and clear the expected state before writing
anything, and refuse to store the token if the state is missing or mismatched.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@web/src/lib/auth.ts`:
- Around line 188-194: Bind each OAuth login attempt to a unique state value in
startOAuthLogin by generating and storing a per-login nonce, adding it to the
/auth/authorize request, and ensuring the callback path in src/daemon/oauth.ts
only accepts and persists the `#token` when the returned state matches the stored
one. Update the callback handling around the token persistence logic to validate
and clear the expected state before writing anything, and refuse to store the
token if the state is missing or mismatched.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 09c63e1b-7223-48e8-8dd2-605e0bc80cbd

📥 Commits

Reviewing files that changed from the base of the PR and between d45ad28 and bcd8316.

📒 Files selected for processing (4)
  • src/config.ts
  • src/daemon/oauth.ts
  • src/daemon/server.ts
  • web/src/lib/auth.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/daemon/server.ts
  • src/config.ts
  • src/daemon/oauth.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/daemon/session.ts`:
- Around line 2141-2148: The scrollback byte accounting is happening after
`msg.content` and `msg.parts` are already mutated, so
`ScrollbackBuffer.updateMessage` sees no size change when called with the no-op
updater. Update the `Session` flow in `src/daemon/session.ts` so the
content/parts mutation happens inside the `updateMessage(msg.messageId, ...)`
callback, letting the buffer measure the old/new sizes correctly before the
transcript append. Keep the `append` call unchanged, but ensure the updater
returns the updated message state rather than mutating `msg` first.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e9537bc-7700-44a4-946e-b7988b96cacb

📥 Commits

Reviewing files that changed from the base of the PR and between bcd8316 and 5fdecc5.

📒 Files selected for processing (1)
  • src/daemon/session.ts

Comment thread src/daemon/session.ts Outdated
saucam and others added 7 commits July 2, 2026 10:11
Replaces the hmacSecret / HS256 auth-code approach with the correct
architecture: Google verifies the human user, ZeroID is the authority
that issues the final RS256 access token, the daemon validates it the
same way it validates any other ZeroID JWT.

Flow:
  Browser → /auth/authorize (daemon) → Google OAuth
  → /auth/idp-callback (daemon gets Google id_token)
  → daemon exchanges id_token at ZeroID /oauth2/token
    (grant_type=token-exchange, subject_token=<google-id-token>,
     account_id + project_id from daemon config)
  → ZeroID validates via Google JWKS (external_issuers config)
  → ZeroID returns RS256 access token
  → daemon sends token to /auth/callback (browser stores, redirects)
  → App.tsx auto-bootstraps from stored ZeroID token on next load

Changes:
- OAuthConfig: drop hmacSecret/issuer, add zeroidTokenEndpoint
- OAuthHandler: #completeAuth now calls ZeroID token-exchange instead
  of minting HS256 codes; callbackPage simplified (no fetch needed —
  token is delivered server-side, page just stores + redirects)
- identity-provider.ts: VerifiedUser carries rawIdToken for forwarding
- config.ts: OAuth block gated on GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET
  (not CODEOID_HMAC_SECRET); reads env correctly for testability
- server.ts: always uses GoogleOAuthProvider when config.oauth is set
- web/src/lib/auth.ts: resolveToken checks stored ZeroID token; adds
  rememberedOAuthToken, fetchOAuthProvider, startOAuthLogin (simple
  redirect — no client-side PKCE, daemon owns the exchange)
- web/src/App.tsx: auto-bootstrap fires on stored OAuth token too
- web/src/components/SignIn.tsx: "Sign in with Google" button, shown
  only when /auth/provider returns "google"

ZeroID config required (external_issuers):
  issuer: https://accounts.google.com
  jwks_uri: https://www.googleapis.com/oauth2/v3/certs

Closes #42

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a 14-day cost/token bar chart to the session list sidebar, toggled
by a new ≋ button in the header. Pulls from the existing turn_usage
SQLite table via a new usage.daily WebSocket message.

- SqliteEpisodeStore.dailyUsage() — GROUP BY day query over turn_usage
- SqliteEpisodeStore.lifetimeTotals() — all-time aggregate
- usage.daily protocol message → response.ok { daily, lifetime }
- web/state/analytics.ts — Solid signals + fetchAnalytics()
- AnalyticsPanel.tsx — 3 stat tiles + SVG bar chart, zero-padded to 14 days
- SessionListPane: ≋ toggle in header renders the panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…omments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fragment-based callback: token passed in URL hash, never sent to server
- Forward scope + client_id to ZeroID RFC 8693 token exchange
- 10s AbortController timeout on ZeroID fetch
- Pass Google credentials through OAuthConfig (config.ts) not process.env
- Remove stale LocalProvider fallback (constructor now requires IdP)
- Stored OAuth token no longer shadows a saved API key on reconnect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GET /auth/provider was referenced in web auth.ts but never registered
as a route — always returned 404, so the Google login button never
appeared even when GOOGLE_CLIENT_ID was configured.

- OAuthHandler.handleFetch now handles GET /auth/provider → { provider: <name> }
- DaemonServer returns { provider: null } when #oauthHandler is null
  (API-key-only mode), so frontend correctly hides the Google button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ting, reactive effect

- store.ts: fix dailyUsage WHERE clause to use calendar-day cutoff
  (rolling seconds window produced a partial extra bucket on the boundary day)
- store.ts + session-manager.ts: scope dailyUsage/lifetimeTotals to sessions
  owned by the caller's accountId+projectId so cross-identity data isolation
  is maintained; pass sessionIds from main store to memory store query
- session.ts: fix scrollback byte accounting in artificiallyStreamText — reset
  msg.content/parts to empty before updateMessage so the buffer measures the
  correct before/after delta; apply final values inside the updater callback
- AnalyticsPanel.tsx: move fetchAnalytics from createEffect to onMount so it
  does not re-run each time the loading signal changes
- AnalyticsPanel.tsx: render zero-cost days as zero-height bars (not 2px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
oauth-handler.test.ts (18 tests) — covers OAuthHandler end-to-end:
- GET /auth/provider returns IdP name
- GET /auth/authorize: redirect to Google, loopback URI normalization,
  400 on bad client_id / redirect_uri
- GET /auth/idp-callback: full ZeroID RFC 8693 token exchange with mocked
  fetch, forwards scope+client_id, token delivered in URL fragment (not query
  string), error redirect on ZeroID failure / IdP failure / missing state,
  pending state consumed so replay is rejected
- GET /auth/callback: callback landing page (fragment-reads token into
  localStorage), error page rendered, XSS escaping on error value
- Unknown routes return null

web/src/lib/auth.test.ts (20 tests) — covers resolveToken precedence and
localStorage helpers:
- API-key exchange: zid_sk_ key exchanged for JWT, stored key picked up,
  invalid key / ZeroID error / missing access_token handled
- OAuth fallback: codeoid.token in localStorage used when no API key present
- Priority: stored API key always beats stored OAuth token
- fetchOAuthProvider: "google" / null / 404 / network-error / unknown name
- localStorage helpers: rememberApiKey, rememberedApiKey, forgetApiKey
  (clears both key and OAuth token), rememberedOAuthToken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@saucam
saucam force-pushed the feat/oauth-zeroid-idp-federation branch from 113c0f5 to 0c7c833 Compare July 2, 2026 08:12
…ethods

Co-Authored-By: Claude Sonnet 4.6 <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.

1 participant