Pipes (connected accounts)
Pipes (connected accounts)
Pipes (Cbox\Id\Pipes\) let a signed-in person connect their own account at a
third-party service — GitHub, Google, Microsoft 365, Slack, Salesforce, HubSpot, Linear,
Notion — so that your app can call that service's API on their behalf. The platform runs
the OAuth 2.0 authorization code flow with PKCE, keeps the access and refresh tokens
sealed in the token vault, refreshes them before they expire, and
hands a fresh access token to an app you have authorised when it asks.
Your app never stores a third-party token. It asks for one when it needs to make a call.
Mental model
| Thing | What it is | Where it lives |
|---|---|---|
| Provider | A catalogue entry: endpoints, default scopes, how tokens refresh, expire and are revoked | PipeProviderCatalog (code, the same for everyone) |
| Pipe | One environment's OAuth app at one provider: its client id, its sealed client secret, the scopes to ask for, and which of your apps may lease tokens | Models\Pipe, Models\PipeGrant |
| Connection | One person's connected account at one pipe, bound to (environment, user, pipe) | Models\PipeConnection — the tokens themselves are user-owned vault secrets |
Three contracts:
Contracts\Pipes— configure, update, remove a pipe; grant and revoke apps.Contracts\PipeConnections— the connect flow (start→complete), a person's list, anddisconnect.Contracts\PipeTokens—leasea fresh access token for an app, andrefresh.
The provider catalogue
| Key | Provider | Default scopes | Expiry and refresh | Revocation on disconnect |
|---|---|---|---|---|
github |
GitHub | read:user |
OAuth App tokens do not expire; GitHub App user tokens expire after 8 h and refresh | DELETE /applications/{client_id}/grant |
google |
openid email profile |
1 h, refresh (access_type=offline, prompt=consent) |
RFC 7009, with the refresh token | |
microsoft |
Microsoft 365 | offline_access User.Read |
~1 h, refresh; tenant parameter (default common) |
none — Microsoft has no revocation endpoint |
slack |
Slack | users:read (as user_scope) |
12 h when token rotation is on, refresh; long-lived otherwise | auth.revoke |
salesforce |
Salesforce | api refresh_token |
no expires_in; 2 h assumed (the default session policy); domain parameter |
RFC 7009, with the refresh token |
hubspot |
HubSpot | oauth crm.objects.contacts.read |
30 min, refresh | DELETE /oauth/v1/refresh-tokens/{token} |
linear |
Linear | read |
expires, refresh | POST /oauth/revoke |
notion |
Notion | — (pages chosen by the person) | no expires_in; a rotating refresh token is kept |
POST /v1/oauth/revoke (JSON, Notion-Version header) |
GitHub's endpoints and the vendor names shared with the sign-in catalogue are read from it rather than restated. A pipe is still a separate OAuth client from a sign-in connection: it asks for API scopes and keeps a refresh token, which a sign-in never does.
Per-installation values (Microsoft's tenant, Salesforce's login domain) are
parameters with a default and a strict pattern, because they are substituted into an
endpoint the client secret is sent to.
Configure a pipe
use Cbox\Id\Pipes\Contracts\Pipes;
$pipe = app(Pipes::class)->configure(
provider: 'github',
clientId: 'Iv1.8a61f9b3a7aba766',
clientSecret: $secretFromGitHub, // sealed at rest, never returned
scopes: ['read:user', 'repo'], // null = the catalogue's defaults
);
// Which of your apps may lease the tokens people connect through it.
app(Pipes::class)->grant($pipe->id, clientId: 'cid_01j9…');
Connect a person's account
Your app owns the browser. Start the flow for the person who is signed in, keep the returned state in their session, redirect, and finish on the callback:
use Cbox\Id\Pipes\Contracts\PipeConnections;
use Cbox\Id\Pipes\ValueObjects\PipeConnectState;
// GET /account/pipes/github/connect
$authorization = app(PipeConnections::class)->start('github', $user->id, route('pipes.callback', 'github'));
session()->put('pipes:github', $authorization->state->toArray());
return redirect()->away($authorization->url);
// GET /account/pipes/github/callback?code=…&state=…
$flow = PipeConnectState::fromMixed(session()->pull('pipes:github'));
$connection = app(PipeConnections::class)->complete($flow, $request->string('state'), $request->string('code'));
complete() checks the state in constant time, exchanges the code with the PKCE verifier
only that session holds, and writes the connection for the person and pipe recorded at
start, whatever the callback request says. A failure is a PipeConnectFailed with a
stable reason: state_mismatch, pipe_unavailable or exchange_failed. Connecting
again replaces the tokens in place, which is how a needs_reauth connection becomes
active again.
Lease a token
use Cbox\Id\Pipes\Contracts\PipeTokens;
$token = app(PipeTokens::class)->lease('github', $userId, clientId: 'cid_01j9…', purpose: 'list-repos');
Http::withToken($token->accessToken)->get('https://api.github.com/user/repos');
lease() refreshes first when the access token expires within
pipes.lease_refresh_skew_seconds (60 s). The answers, in order:
| Outcome | Exception | Meaning |
|---|---|---|
| refused | PipeLeaseDenied |
the app is not granted this pipe, or there is no such (enabled) pipe. Uniform; the reason is audited, never returned |
| not connected | PipeConnectionMissing |
the app is granted, the person has not connected. Show a Connect button |
| reconnect | PipeReauthorizationRequired |
the connection is needs_reauth. Send the person through Connect again |
| try again | PipeRefreshFailed |
a needed refresh could not complete right now (provider down, another refresh in progress) |
| a token | PipeAccessToken |
accessToken, expiresAt (the provider's), leaseExpiresAt (the vault's advisory window), scopes, metadata |
metadata carries the non-secret parts of the token response an app needs to call the
API at all — Salesforce's instance_url, Slack's team.id, Notion's workspace_id.
Refresh
cbox-id:pipes:refresh runs every five minutes from the scheduler (pipes.schedule) and
refreshes every active connection whose access token expires within
pipes.refresh_ahead_seconds (10 min). The sweep is latency, not correctness: a lease
refreshes on its own when it has to.
A refresh is single-flight. A refresh token is one-shot at every provider that rotates
them, and spending it twice can revoke the whole grant. A refresh first claims the
connection with one atomic UPDATE on refresh_claimed_until; whoever wins refreshes, a
lease that loses waits up to pipes.refresh_wait_milliseconds and then uses what the
winner stored, and the sweep that loses moves on. A claim held by a process that died
lapses after pipes.refresh_claim_seconds.
When the provider refuses the refresh token (invalid_grant, Slack's
invalid_refresh_token, HubSpot's BAD_REFRESH_TOKEN, GitHub's bad_refresh_token), or
the token expired with nothing to refresh it with, the connection becomes
needs_reauth and pipe.connection.needs_reauth is emitted. An outage (a 5xx, a 429,
a timeout) or a refusal of the client (invalid_client) is transient: the connection
stays active, refresh_failures and last_error record it, and the next attempt may
succeed.
Disconnect
disconnect() revokes at the provider where it can (see the catalogue table), revokes
both tokens in the vault, deletes the connection and emits pipe.connection.disconnected
with revoked_at_provider. Revoking at the provider is best effort; forgetting the tokens
here is not.
Events
| Event | When |
|---|---|
pipe.connection.connected |
a person connected or reconnected |
pipe.connection.needs_reauth |
the connection stopped working; the person must reconnect |
pipe.connection.disconnected |
the connection was removed |
Payloads name the connection, user and provider — never a token. See the webhook event reference.
Testing
Testing\InteractsWithPipes runs the real flow with only the provider faked:
uses(InteractsWithPipes::class);
$pipe = $this->configurePipe('github');
$this->grantPipe($pipe, 'cid_app');
$this->connectPipeAccount('github', 'user_1', ['access_token' => 'gho_test']);
expect($this->leasePipeToken('github', 'user_1', 'cid_app')->accessToken)->toBe('gho_test');
Where to go next
- Connect a person's third-party account — the recipe end to end.
- Security: Pipes — what protects the tokens, and the honest limits.
- AI token vault — the store every pipe token lives in.