OAuth Credential Lifecycle Design
Goal
MindRoom-managed OAuth credentials must remain correct when refresh grants rotate or fail, callbacks race resets, callers are cancelled, or the process stops unexpectedly.
Why one store owns the lifecycle
OAuth state used to be split across credential JSON, generation metadata, reset intents, provider wrappers, and MCP sessions.
Each extra persistence boundary required recovery rules for partial commits.
The lifecycle now stores each canonical OAuth scope in one private SQLite database.
One SQLite transaction contains the credential payload encoded under the active CredentialsManager policy, lease revision, connection generation, and completed browser-reset receipts.
This makes the local state transition atomic instead of reconstructing it after a partial multi-file write.
Invariants
- One
OAuthCredentialContextidentifies exactly one provider credential scope. - SQLite scope metadata binds a database to its provider, credential service, worker scope, worker key, and relevant routing agent.
- A copied database fails closed when opened for a different scope.
- Credential mutations enter the same per-scope SQLite write transaction, while snapshots use a deferred reader that observes the last committed state without waiting for provider network I/O.
BEGIN IMMEDIATEis the cross-process operation lock.- Provider refresh and authorization-code exchange remain inside that transaction until the matching local commit succeeds.
- Different credential scopes use different databases and may run concurrently.
- Every publication advances a lease revision used by materialized clients and MCP sessions.
- Callback publication, reset, and terminal refresh rejection also advance a connection generation used to reject stale callbacks and confirmed resets.
- A stable browser reset receipt is checked before generation comparison or deletion.
- Replaying a completed browser reset returns its original result and cannot delete a later connection.
- Resetting unreadable credentials never requires decoding their stored payload.
- Credential payloads use the existing
CredentialsManagercodec and active encryption policy before entering SQLite. - Current encrypted SQLite credentials remain recoverable when the correct key returns.
- Legacy OAuth JSON bytes never enter the authoritative SQLite store.
- Request actor identity remains raw for room and membership checks.
- Credential identity is canonicalized only while resolving
OAuthCredentialContext. - Every provider token service ends with
_oauth, which keeps OAuth tokens out of worker credential mirrors. - Provider adapters classify terminal refresh failures from structured error codes and never expose provider-controlled descriptions.
- All consumers build reconnect responses through the shared OAuth service factory.
- The reset tool is non-destructive and only issues a requester-issued browser confirmation URL.
- The authenticated requester-scoped POST or one-time shared-scope capability POST is the reset execution boundary.
- MCP retirement completes before the credential transaction commits a reset.
- A same-generation HTTP bearer rejection retires the exact MCP credential-scope session without replaying the remote call.
Ownership
SQLite credential store
src/mindroom/oauth/credential_store.py owns the database schema, scope binding, transaction admission, payload encoding, revision updates, reset receipts, file permissions, and SQLite durability settings.
The store uses rollback-journal mode, synchronous=EXTRA, a zero SQLite busy timeout, and bounded cancellable asynchronous retry around lock admission.
Commit retry remains inside the same transaction, so a reader-blocked commit never repeats provider I/O.
The database and its directory are private to the runtime user.
Credential lifecycle
src/mindroom/oauth/credential_lifecycle.py owns the canonical context and the semantic operations performed inside a store transaction.
It owns reads, refresh, callback exchange, claim validation, refresh-token preservation, terminal invalidation, reset compare-and-swap, and stable reset replay.
One lazy process-lifetime event loop lets asynchronous and synchronous provider adapters share the same transaction implementation.
Synchronous provider work runs in a worker thread while the owner loop retains the SQLite transaction.
Connection and provider adapters
src/mindroom/oauth/service.py owns connect URLs, scope-upgrade instructions, and reconnect payloads.
src/mindroom/oauth/providers.py owns asynchronous provider exchange and refresh contracts plus structured error classification.
src/mindroom/oauth/client.py adapts synchronous Google refresh and revalidates cached clients against the canonical lease and connection generation.
src/mindroom/custom_tools/github.py reloads authoritative credentials for each managed call and keeps each thread's token and PyGithub client together.
MCP sessions
src/mindroom/mcp/manager.py treats the credential revision and token hash as an authorization lease.
It revalidates that lease before publishing a session catalog and before admitting a remote tool call.
Credential-scope sessions are fenced during reset so captured stale state cannot reconnect before the SQLite reset commits.
Browser reset
src/mindroom/oauth/reset.py freezes provider, service, agent, canonical requester, scope, worker key, connection generation, and a random operation ID into a short-lived browser action.
Requester-scoped actions require the original authenticated browser user, while shared-scope actions are one-time bearer capabilities issued only to configured credential managers.
src/mindroom/api/oauth.py revalidates that target on GET and POST, while GET remains non-mutating.
src/mindroom/oauth/reset_execution.py returns completed operations before transport work, otherwise retires MCP state and asks the lifecycle to atomically reset the credential.
Transactions
Refresh
- Resolve the canonical context.
- Wait cancellably for
BEGIN IMMEDIATE. - Read and validate the credential snapshot.
- Return before the provider adapter when the credential is missing or unusable.
- Call the provider adapter while retaining the transaction; the adapter may determine locally that refresh is unnecessary.
- Publish a rotation or atomically clear a terminally rejected credential.
- Commit once.
Later same-scope callers observe the committed rotation and do not consume the same refresh grant again.
OAuth callback
- Authenticate the browser user and consume the opaque pending state.
- Enter a cancellation-safe lifecycle operation.
- Acquire the same SQLite transaction used by refresh.
- Compare the pending connection generation with the current generation.
- Exchange the authorization code and validate claims.
- Preserve an existing refresh token only for the same verified external identity and OAuth client.
- Publish the credential and advance both revisions.
- Commit before propagating cancellation.
Reset and disconnect
- Revalidate the requester, provider, agent, scope, worker key, and confirmed connection generation.
- Return a completed stable operation before MCP retirement.
- Fence and retire every cached MCP credential-scope session for the credential key.
- Enter the SQLite transaction.
- Recheck the stable receipt and connection generation.
- Clear the payload, advance both revisions, and insert the permanent receipt in one commit.
- Release the MCP fence and continue to provider authorization when requested.
If MCP teardown fails or is cancelled, no credential transaction begins. If SQLite commit fails, the whole reset rolls back. If the process stops after commit, the stable receipt and deletion recover together.
Cancellation and crash behavior
Snapshot read-lock waits and refresh or reset write-lock waits are cancellable before transaction admission. Refresh becomes cancellation-safe after admission because the remote provider may rotate its grant. Callback admission, exchange, and commit are cancellation-safe after pending browser state is consumed. Reset remains cancellable through MCP teardown and SQLite lock admission. Cancellation during reset commit rolls the transaction back unless commit already succeeded. After a successful reset commit, the durable result remains recoverable by operation ID while cancellation propagates to the caller. SQLite provides the crash boundary for payload, revisions, and reset receipt together.
Legacy credential files
The SQLite store is authoritative for OAuth credentials. The first access to a missing store initializes an empty credential row without reading JSON. Legacy OAuth credential JSON and sidecar files are ignored and left unchanged. Connections that exist only in JSON require reconnect, which publishes the new credential through a committed SQLite transaction under the active encryption policy. Changing the encryption setting never imports a legacy JSON credential. Current unreadable SQLite ciphertext remains in SQLite so restoring the correct key can recover it. Generations and reset do not decode the current SQLite payload, so a corrupt credential remains resettable.
Verification
Tests cover same-scope writer serialization, reads during in-flight provider refresh, different-scope concurrency, cross-process lock admission, reader-blocked commit retry, cancellation at each boundary, callback and reset compare-and-swap, stable reset replay, copied-database rejection, corrupt credential reset, wrong-key recovery, private file modes, Google and GitHub client invalidation, and MCP lease retirement.
Non-goals
This design does not revoke grants at external providers. It does not redesign OAuth discovery or dynamic client registration. It does not globally canonicalize Matrix identities. It does not exempt the reset tool from normal operator-configured tool approval policy.