Skip to content

Scheduling: customers' own OAuth apps - #339

Merged
pumfleet merged 5 commits into
mainfrom
worktree-byok
Nov 5, 2026
Merged

pumfleet merged 5 commits into
mainfrom
worktree-byok

Conversation

@pumfleet

@pumfleet pumfleet commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Scale and Enterprise customers can connect hosts' calendars through their own Google, Microsoft or Zoom app, so the consent screen shows their brand instead of coss.com's.

  • scheduling_oauth_app: one per environment and provider, managed from Scheduling → OAuth apps and coss.scheduling.oauthApps. The secret is encrypted with WEBHOOK_SECRETS_KEY and covered by reencryptWebhookSecrets. Never returned.
  • Pinning: a connection stays on the app that issued its tokens (oauth_app_id, null for coss.com's), so adding an app disturbs nobody already connected. A consent pins its app, redirect URI and Microsoft tenant when it starts.
  • Forwarding page: Google requires redirect URI domains to be verified by the app's owner, and customers can't verify api.coss.com. So redirect_uri can be a page on their own domain that forwards code and state to the existing callback. completion_url says where the browser lands afterwards.
  • A refused secret is one outage, not one per host: invalid_client and deleted_client are now transient, so the app goes failing with one webhook and one owner email, and no host is marked or emailed. This also applies to coss.com's own apps, where it logs to Sentry. Calendars go stale and calendarVerdict hides hosts as usual. New secrets are tried before they replace the old one.
  • Zoom isolation: deauthorization events are per app (POST /scheduling/zoom/events/:appId, its own Secret Token) and scoped to that app's connections. The old handler reached every organization's, which a customer holding their own token must never be able to do.
  • Plan gate: schedulingOauthApps on Scale and Enterprise, checked when creating an app, in sandbox too. Rotating and deleting always work. Self-serve downgrades are refused while apps exist; if a contract just ends, existing apps keep working.
  • Whitelabel: the hosted page's copy before consent names the customer and links their privacy_url, and Zoom's agenda marker no longer says coss.com (existing meetings are still found, by hash).

Migration 0140 is additive: a new table, nullable columns, and no backfill.

Testing

  • bun run verify is green on this commit.
  • New suites: packages/scheduling/test/oauth-apps.test.ts and apps/api/test/scheduling-oauth-apps.test.ts. They cover pinning, per-environment isolation, outages and recovery, the races below, Zoom tenant isolation, the real plan gate, re-encryption and environment deletion.
  • apps/site builds, so the new guide's Twoslash samples compile.
  • Drove the dashboard page in a browser: adding, refusal, the failing state, the hosts page banner, and deleting.

Not tested against real provider apps

The fakes can't prove these, and only one live call was made: real Google rejected a made-up client as invalid_client.

  • A full consent, refresh and push through real Google, Microsoft and Zoom apps.
  • Which error Microsoft and Zoom give for a wrong secret, and whether Microsoft checks the app before the token. If it doesn't, a new secret with no connections to try it on is only proven at first use.
  • ZOOM_APP_SCOPES (also in the docs) was written from memory, since the repo had no list.

Before telling customers

  • apps/site/content/legal/privacy.mdx describes coss.com's Google Limited Use. It's unchanged here, and needs a legal read for customer-owned apps, along with whether the DPA needs a line.

Review

Four Codex rounds against origin/main, with main merged in between (migration regenerated each time).

  • Round 1, 3 findings, all fixed:
    • Rotating a secret could overwrite a Zoom Secret Token saved during the provider check. It now re-reads under a row lock.
    • A successful refresh trusted a status read before the outage began. It now always compare-and-sets, fenced to the credentials that worked.
    • The expiry warning could starve apps behind the first batch. It now walks the window with a cursor.
  • Round 2: clean.
  • Round 3, 1 finding, fixed: a pending Microsoft consent didn't pin its tenant, so editing the app could exchange a code at the wrong authority.
  • Round 4: clean.

No findings were rejected.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

Scale and Enterprise customers can connect hosts' calendars through their own
Google, Microsoft or Zoom app, so the consent screen shows their brand.

- scheduling_oauth_app: one per environment and provider, with the secret
  encrypted and covered by reencryptWebhookSecrets. Connections and consents
  are pinned to the app that issued their tokens, so adding an app never
  disturbs hosts already connected.
- A customer's page on their own domain can be the redirect URI and forward to
  the existing callback, since Google verifies redirect domains as the app
  owner's. completion_url says where the browser lands afterwards.
- A refused client secret (invalid_client, deleted_client) is now transient, so
  it's one failing app with one webhook and one owner email rather than every
  host marked needs_reauth. This also covers coss.com's own apps.
- Zoom deauthorization events are per app (/scheduling/zoom/events/:appId) and
  scoped to that app's connections, so a customer holding their own event token
  can't reach other organizations'.
- Plan gate schedulingOauthApps on creating only; rotating and deleting always
  work. Self-serve downgrades are refused while apps exist.
- API, SDK, dashboard page, docs, and the hosted page's consent copy.

The migration is generated after merging main.
# Conflicts:
#	AGENTS.md
#	apps/api/openapi.json
#	apps/site/src/lib/changelog.ts
#	apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/actions.ts
#	apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/hosts/page.tsx
#	apps/web/src/lib/billing-preflight.ts
#	packages/billing/src/catalog/entitlements.ts
#	packages/billing/src/catalog/plans.ts
- Updating an app re-reads its credentials under a row lock, so a Secret Token
  saved while a new client secret is being tried isn't overwritten.
- A successful refresh or consent always tries to end an outage, fenced to the
  credentials that worked, rather than trusting the status it read beforehand.
- The expiry warning walks every app in the window with a cursor: those already
  warned stay in it for two weeks, and could starve the ones behind them.
# Conflicts:
#	AGENTS.md
#	apps/api/openapi.json
#	apps/api/src/middleware/request-log.ts
#	apps/api/src/routes/scheduling-client.ts
#	apps/api/src/routes/scheduling.ts
#	apps/api/src/webhook-events.ts
#	apps/api/test/app.test.ts
#	apps/api/test/contract-types.ts
#	apps/api/test/sdk-contract.test.ts
#	apps/scheduling/src/app/h/[sessionId]/types.ts
#	apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/actions.ts
#	packages/db/migrations/meta/0139_snapshot.json
#	packages/db/migrations/meta/_journal.json
#	packages/email/src/test-event-schemas.ts
#	packages/scheduling/AGENTS.md
#	packages/scheduling/src/schemas.ts
#	packages/sdk/src/core/routes.generated.ts
#	packages/sdk/src/index.ts
#	packages/sdk/src/resources/scheduling.ts
#	packages/sdk/src/types/scheduling.ts
#	packages/sdk/src/types/webhook.ts
#	packages/sdk/src/webhook-event-types.ts
#	packages/webhooks/src/reencrypt.ts
A Microsoft consent is exchanged at the tenant it started with: the state row
now keeps tenant_id alongside the app and redirect URI, so editing the app while
a consent is in progress can't send its code to a different authority. The
branch's one migration is regenerated with the new column.
@pumfleet
pumfleet added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 2eb7712 Nov 5, 2026
31 checks passed
@pumfleet
pumfleet deleted the worktree-byok branch November 5, 2026 18:21
@linear-code

linear-code Bot commented Nov 5, 2026

Copy link
Copy Markdown

ENG-8162

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