Skip to content

feat(notifications): let OAuth clients open the realtime stream - #239

Open
Waiel5 wants to merge 2 commits into
choyiny:mainfrom
Waiel5:feat/bearer-notification-stream
Open

feat(notifications): let OAuth clients open the realtime stream#239
Waiel5 wants to merge 2 commits into
choyiny:mainfrom
Waiel5:feat/bearer-notification-stream

Conversation

@Waiel5

@Waiel5 Waiel5 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

GET /api/notifications/stream rejects any upgrade whose Origin is not in TRUSTED_ORIGINS. A native client has no browser origin to send, so the realtime stream is unreachable for exactly the clients the OAuth surface exists to serve.

Stacked on #238 — it contains that PR's commit, so review this one's diff after it lands (or read the top commit only).

Changes

The Origin check now follows the credential it protects rather than the route: unchanged for session cookies, skipped for OAuth.

Release impact

  • Minor — new backwards-compatible behaviour

  • Feature

Test plan

  • yarn test worker/src/__tests__/notifications-router.test.ts — 10 passed, including a real 101 upgrade
  • The three existing 403 cases still pass unchanged
  • prettier --check clean

Notes for reviewers

The security argument, since this relaxes a control you added deliberately (CHANGELOG.md records it as CSWSH defence).

Cross-Site WebSocket Hijacking works because a browser attaches the session cookie to a cross-origin handshake by itself: an attacker's page opens the socket and the victim's credential rides along. Origin is the right control there precisely because a page cannot forge it.

A bearer credential is not ambient. It has to be set explicitly on the request, and no attacker page can cause a victim's access token to be attached to a socket it opened — so the attack the check prevents cannot occur for these callers, while the check itself is fatal to them.

Scoped to OAuth on purpose. The same argument applies to sk_ API keys, and I nearly extended it there — but the suite asserts a 403 for API-key callers today, and changing a tested contract is not needed to unblock a native client. Left alone rather than quietly widened. Say the word if you would rather it were consistent.

One correction included: this file's header said the 101 upgrade was not covered because it needed a real WebSocket-capable client. The NOTIFICATIONS_HUB binding is in the test config and the handshake does complete under miniflare — the new cases assert it, and the comment is updated.

Checklist

  • Migration — n/a
  • Updated CHANGELOG.md
  • Docs — the route's OpenAPI description is updated to explain the split

Waiel5 added 2 commits August 5, 2026 17:11
This deployment already issues OAuth access tokens, but only `/mcp` accepted
them. `/api/*` string-matched the `Bearer sk_` API-key prefix and rejected
anything else, so a client could complete authorization, be granted
`email:read`, and then find that the scope reached nothing. A third-party or
native client had no usable path to the API at all.

Tokens become a third credential alongside the session cookie and API keys.

**One resolver, both surfaces.** Access tokens verify offline against the
JWKS, so a signature proves only that this deployment minted the token at some
point; everything that can revoke a live one — disabling the client, deleting
or banning the user, removing their passkey — lives in the database and must be
re-checked per request. `/mcp` already did all of that. Rather than write it
twice, `resolveOAuthPrincipal` holds it and both callers use it, so the two
bearer surfaces cannot drift apart. `/mcp` keeps its narrower audience and its
`WWW-Authenticate` challenge; its handler loses about 2,700 characters of
inline validation and behaves identically.

**Scopes, not audience, are the boundary.** No `/api` audience is introduced.
`@better-auth/oauth-provider` at the pinned version is subject to
GHSA-p2fr-6hmx-4528, where the authorization-time resource is dropped and the
token endpoint will mint a token for another allowlisted audience — so a
distinct API audience would look like an authorization boundary without being
one, which is worse than not having it. Scopes do the work instead, checked per
route.

**The policy classifies on method plus exact path.** Three routes send mail
from under a router whose other routes do not — template send, sequence enroll
and outbox retry — so a prefix rule would file them as `email:manage` and let a
client that was never granted `email:send` send mail. Anything unclassified is
denied, so adding a route without classifying it breaks an integration rather
than quietly widening every existing token.

**Two things are closed to tokens outright.** The credential surface
(`/api/api-keys`, `/api/user/passkeys`, `/api/auth/*`), because minting an
unscoped API key would convert a narrow mail consent into the user's whole
account and destroy whatever key they already had. And a set of admin
operations that escalate the principal or open a standing channel: rewriting
inbox assignments (a token could grant itself every inbox, then read them),
changing a user's role, minting or listing invite tokens, revoking OAuth
clients, repointing the webhook, and setting an inbox's `forwardTo`. These are
a different risk from an admin doing the same in a browser — a token is held by
software, acts with no human present, and may be compromised without anyone
noticing. `admin:manage` is for operating the deployment, not for rewriting who
may operate it.

Session and API-key callers are untouched and remain unscoped; the scope
middleware returns immediately unless the request authenticated as `oauth`.

Also here because the same clients need them:

- `admin:manage`, never implied and required in addition to `role === "admin"`.
- `GET /api/user/me`, so a client can decide whether to offer admin screens
  without probing an admin route and reading the 403.
- `Authorization` in the CORS allow-list. Hono's default is empty, so a
  cross-origin client that preflighted a bearer request had the header stripped
  and the request blocked.
`GET /api/notifications/stream` rejects any upgrade whose `Origin` is not in
`TRUSTED_ORIGINS`. A native client has no browser origin to send, so the
realtime stream was unreachable for exactly the clients the OAuth surface
exists to serve.

The check defends against Cross-Site WebSocket Hijacking. That attack works
because a browser attaches the session cookie to a cross-origin handshake by
itself: an attacker's page opens the socket and the victim's credential rides
along, which is why `Origin` — a header a page cannot forge — is the right
control there.

A bearer credential is not ambient. It has to be set explicitly on the
request, and no attacker page can cause a victim's access token to be attached
to a socket it opened, so the attack the check prevents cannot happen. The
check now follows the credential it protects rather than the route: unchanged
for session cookies, skipped for OAuth.

Scoped to OAuth deliberately rather than to every bearer credential. The same
argument applies to `sk_` API keys, but the suite asserts a 403 for them today
and changing a tested contract is not needed to unblock a native client, so
that behaviour is left alone rather than quietly widened.

Also corrects this file's header, which said the 101 upgrade was not covered
because it needed a real WebSocket client. The NOTIFICATIONS_HUB binding is
present in the test config and the handshake does complete under miniflare —
the new cases assert it.
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