Skip to content

Connections: the platform owns OAuth sign-ins, the flow, and the audit page - #292

Merged
czpython merged 1 commit into
mainfrom
connections
Aug 20, 2026
Merged

Connections: the platform owns OAuth sign-ins, the flow, and the audit page#292
czpython merged 1 commit into
mainfrom
connections

Conversation

@czpython

@czpython czpython commented Aug 20, 2026

Copy link
Copy Markdown
Owner

An extension's OAuth surface is a declaration and a loop:

class NightWatch(Extension):
    name = "night_watch"
    acme = Acme.with_scopes("profile.read", "posts.write")
for connection in NightWatch.acme.list_for_account(account_id):
    token = await connection.mint_access_token()

The grant model, its migration, the rotation verbs with their transaction
topology, and the hand-rolled connect/callback routes all leave the author
surface.

Connections are platform rows

A connection is one signed-in provider account — a mailbox, a handle, a
workspace. oauth_connections holds one row per sign-in, N per provider,
owned by the druks account that completed the consent: the encrypted
refresh token, the granted scopes, the consent time. The engine rotates the
refresh token on mint — fresh read under the refresh lock, rotated token
committed on its own session before the cache fills — once, as private
methods on the row, instead of copied into every consumer.

McpOauthGrant is gone. MCP registers a fresh dynamic client on every
connect, so each connection keeps its own client: that half becomes
McpClientRegistration, pointing at the server row, with the RFC 8707
resource read live from the server's url. MCP keeps its
one-per-(server, account) policy over the shared table. The migration moves
the rows and re-encrypts each secret under its new table.column AAD.

Declarations feed the consent

Scopes belong to the consumer. Acme.with_scopes(...) on the Extension
class declares the app's use of a service; the consent asks for the union
of every installed declaration, derived by walking the loaded extension
classes — no registry to drift. The handle reads the connections that grant
it: list_for_account(account_id), get(connection_id).

The platform runs the flow

GET /api/oauth/{name}/connect begins consent with the scope union and
stores a new connection for the session account;
?connection=<id> reconsents an existing one — widened scopes replace its
tokens and the stale cached access token is evicted. One global
GET /api/oauth/callback serves every service: connect state is keyed by
the state value alone and the provider rides the stash.
DELETE /api/oauth/connections/{id} revokes one connection. Consent and
revocation are session-identity actions, never an agent PAT's. Replacing a
service's client credentials deletes its connections — a new client can
never refresh the old client's tokens.

The surfaces

Settings grows a Connections page — everything the signed-in user has
authenticated to, across services, revoked per connection. The service
detail card lists that service's sign-ins beside the pasted client
credentials, with reconnect offered when an installed extension declares a
scope a connection lacks. App UIs own the connect button and the domain
rendering of their sign-ins.

Notes

  • Redis token-cache and refresh-lock keys change shape (keyed by connection
    id). Deploys are stop-then-start, so the cost is one cache miss per
    connection after the deploy; stale locks expire by TTL.
  • MCP keeps its own callback path: every dynamically-registered client has
    it pinned as a redirect_uri.
  • The migration's data move is exercised only by the schema chain in CI,
    not by a seeded-data test — seeding the pre-split schema mid-chain needs
    a harness the one-time move does not justify. Flagging rather than
    hiding it.

@czpython czpython changed the title Connections: the platform owns OAuth grants, the flow, and the card Connections: the platform owns OAuth sign-ins, the flow, and the audit page Aug 20, 2026
@czpython
czpython force-pushed the connections branch 2 times, most recently from 87964f5 to b0e492f Compare August 20, 2026 18:23
…t page

A connection is one signed-in provider account — a mailbox, a handle, a
workspace — stored on the platform's oauth_connections row, owned by the
druks account that consented, N per provider. The engine rotates its
refresh token on mint: fresh read under the refresh lock, rotated token
committed on its own session before the cache fills, once, as private
methods on the row.

Extensions declare their use of a service with Acme.with_scopes(...); the
consent asks for the union of every installed declaration, and workflow
code reads the sign-ins through the handle — list_for_account(account_id),
then mint_access_token() on each connection.

Platform routes run the whole flow: /api/oauth/{name}/connect creates a
connection for the session account or reconsents one via ?connection=<id>;
one global callback serves every service; connections are revoked one by
one, session-identity only. Settings grows the Connections audit page and
the service detail lists that service's sign-ins.

McpOauthGrant is gone: its client half becomes McpClientRegistration
pointing at the server row, its grants become connections under MCP's
one-per-(server, account) policy, and the migration moves the rows,
re-encrypting each secret under its new table.column AAD.
@czpython
czpython merged commit 5cb2c43 into main Aug 20, 2026
2 checks passed
@czpython
czpython deleted the connections branch August 20, 2026 18:29
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