Skip to main content

Crate cratefield_module_connections

Crate cratefield_module_connections 

Source
Expand description

§cratefield-module-connections

Per-subject third-party OAuth connections for a Cratefield venture: a person authorizes an account at a provider, both tokens are sealed at rest, and the venture can hand out a live access token, refresh it before it lapses, or revoke the whole connection.

Mounted at /v1/connections. One migration, two tables, one route. The OAuth protocol itself — authorize URLs, PKCE, state, code exchange, refresh, revoke, the token sealer — lives in cratefield-oauth-client; this module is the venture-facing storage and lifecycle around it.

§Composing it

use cratefield_module_connections::{Connections, presets};

let connections = Connections::builder()
    .provider(presets::x())
    .provider(presets::gitlab())
    // A self-managed GitLab substitutes its own host:
    .provider(presets::gitlab_at("https://gitlab.example.com"))
    .allowed_origin("https://app.example.com")
    .refresh_lead(time::Duration::minutes(5))
    .build();

Configuration keys (all prefixed CONNECTIONS_):

keymeaning
CONNECTIONS_TOKEN_KEY32 random bytes, base64. Seals every token and PKCE verifier. Secret.
CONNECTIONS_TOKEN_KEY_IDKey-ring id for rotation (default 1).
CONNECTIONS_<KEY>_CLIENT_IDThe client id the provider issued.
CONNECTIONS_<KEY>_CLIENT_SECRETThe client secret. Secret.
CONNECTIONS_API_BASEThe public base the callback is built from (default https://api.<venture domain>).

<KEY> is the provider key upper-cased with non-alphanumerics turned into _, so presets::x() reads CONNECTIONS_X_CLIENT_ID and presets::gitlab_at(..) reads CONNECTIONS_GITLAB_CLIENT_ID. The redirect URI is <API_BASE>/v1/connections/callback/<key>; register exactly that with the provider. It is built from configuration, never from a Host header.

§Driving the flow

Your own route authenticates the person and calls the handle:

ⓘ
// GET /connect/{provider}?return_to=https://app.example.com/settings
let subject = /* your session */;
let authorize = connections.api().start(&subject, provider, return_to).await?;
// 303 the browser to authorize.url

The provider sends the browser back to GET /v1/connections/callback/<provider> with state and code. The module spends the state (single-use), exchanges the code, stores the connection and 303-redirects to the return_to that was recorded on the state — with connection=<id> on success, or error=<slug> on failure. A state that is unknown, spent or expired answers an RFC 9457 problem; the callback never redirects to a URL the venture did not vouch for.

Reading a token, from anywhere in the venture:

ⓘ
let token = connections.api().access_token(&connection_id).await?;
// token.expose() for one call, or hand the whole thing to
// cratefield_oauth_client::send_with_refresh, which retries once on a 401.

send_with_refresh takes an AccessToken and a call, retries it once with a freshly-issued token on a 401, and reports SendWithRefreshError when the provider has rejected the refresh — the case where a person must reconnect.

§The API

methodwhat it does
start(subject, provider, return_to)Writes a state and returns the authorize URL. Refuses a return_to off the allowed origins.
complete(state, code)Spends the state and exchanges the code. One caller per state wins.
list(subject) / get(connection_id)Reads connections. Never a token.
access_token(connection_id)A live token, refreshed first when due.
revoke(connection_id)Best-effort provider revoke, then clears the tokens locally.
mark_needs_reconnect(connection_id, reason)Flags a connection a person must re-authorize.
set_account(connection_id, external_account_id, display_name)Records the provider-side account identity.

Every method names its subject or connection explicitly. No route in this module picks the subject for you, so one person’s connection cannot be reached by another’s request.

§Status and events

A connection is active, needs_reconnect (the provider refused the refresh token) or revoked. Events, all carrying the connection id, subject and provider only:

  • connections.connected
  • connections.refreshed
  • connections.revoked
  • connections.needs_reconnect

A scheduled pass purges spent and expired states and refreshes the active connections due within refresh_lead, spending one unit of the invocation budget per connection (ADR 0023).

§Presets

Each preset is verified against the provider’s own documentation, cited in a comment beside it.

presetauthorizeclient authscopesrefreshPKCE
x()x.com/i/oauth2/authorizeBasictweet.read users.read offline.accessrotatesrequired
google()accounts.google.com/o/oauth2/v2/authBasicopenid email profile (+access_type=offline, prompt=consent)stableS256
linkedin()www.linkedin.com/oauth/v2/authorizationPostopenid profile emailstable—
vercel()vercel.com/oauth/authorizePostopenid email profilerotatesrequired
gitlab() / gitlab_at(base)<base>/oauth/authorizePostread_userrotatesS256
linear()linear.app/oauth/authorizePostread,write (comma-separated)rotatesS256

A provider the presets do not carry is a Provider::new(..) with the same with_* knobs. None of them holds a secret.

§Storage

connection — one row per connected account: the subject, the provider, the provider-side account id and display name, the granted scopes, the status, and the two tokens. Both tokens are stored only as XChaCha20-Poly1305 ciphertext, each bound by its AAD to connections/connection/<id>/<column>, so a blob copied into another row or column fails to open (ADR 0102). Indexed by subject, and by (status, access_expires_at) for the refresh scan.

connection_state — one row per attempt in flight, keyed by the SHA-256 hash of the state, holding the return_to, the sealed PKCE verifier, a ten-minute deadline and the spent_at stamp that makes the state single-use. Purged on the scheduled pass.

§Security

No token — access, refresh, code, PKCE verifier or client secret — is ever written to a log line, a response body or a response header. A failure talking to a provider is logged and recorded on the connection as structured facts only: the problem slug, the HTTP status and the OAuth error code, and only when that code is short, ASCII token-ish text — anything else becomes other. The provider’s own error_description is never stored or echoed: LinkedIn and others put text an attacker chose there, so reflecting it would be a reflected XSS on this venture’s own domain. For the same reason the error parameter on a callback redirect is sanitised before it is logged.

The callback is public, so the only thing standing between a stranger and a stolen code is the state: a 256-bit value this module generated, stored as its SHA-256 hash, single-use via one guarded UPDATE, and expiring in ten minutes. Spending it is one statement whose affected-row count decides the winner, so two callbacks racing on one state cannot both redeem the code.

Every Problem this module answers with comes from a ProblemDef whose slug starts connections-.

§Personal data (ADR 0015)

Two tables hold personal data, declared through Module::personal_data:

tablesubjectkinddispositionredacted
connectionsubjectidentifiereraseaccess_token_sealed, refresh_token_sealed
connection_statesubjectidentifiererasestate_hash, verifier_sealed

The connection row is a connected third-party account — the provider, the account id and name it gave us, the scopes and the status — with both tokens held only as ciphertext. The connection_state row is one attempt in flight, deleted once spent or ten minutes old.

§Ports

Requires Db and HttpClient; optionally uses Clock, IdGen and Defer.

Modules§

presets
Ready-made Providers for the six services a venture most often needs to connect (issue #624).

Structs§

AccessToken
A live access token for one connection. It is wiped on drop and its Debug prints nothing, so a token cannot reach a log or a panic message by accident. Its value is only reachable through AccessToken::expose, which is the one place a caller has to mean it.
AuthorizeUrl
The URL a browser opens to start a connection, and the deadline on the state that goes with it.
Connection
A connected account, as a venture reads it. It never carries a token — the access token is a separate call (ConnectionsApi::access_token), so a token cannot leak by someone serializing a Connection.
Connections
The connections module (issue #624). Compose it with Connections::builder, and reach the lifecycle through Connections::api.
ConnectionsApi
The venture-facing lifecycle handle. Every method names its subject or connection explicitly — there is no “current caller” here.
ConnectionsBuilder
Builds a Connections.
Provider
One provider a venture lets people connect: where its endpoints live, how it wants the client authenticated, and how it behaves on a refresh.
ProviderFailure
A failed provider call, reduced to the two facts this module will show or record: the HTTP status it answered with and the RFC 6749 §5.2 error code. The oauth client’s own error carries the provider’s error_description and raw body, and a token endpoint can echo the very token it refused in that prose — so none of it is kept. This type is therefore safe to log, to store in last_error, and to hand to a venture.

Enums§

ConnectionError
What a connection call can fail with.
ConnectionStatus
Where a connection stands.