---
title: Connect accounts
description: Separate application credentials, user grants, callbacks, token storage, reconnects, and tenant authorization.
---

Authentication has two layers. The platform or managed provider authenticates the connection. Your application authenticates the user and decides which tenant may use that connection.

## Keep credentials server-side

Platform app secrets, OAuth client secrets, access and refresh tokens, provider API keys, and webhook secrets belong in a server-side secret store. A browser can request a connection or operation through your application, but it must not receive unrestricted credentials or native clients.

## Connection lifecycle

Treat start, callback, successful connection, reconnect, unlink, disconnect, and upstream revocation as separate states. Store the backend instance, platform account identifier, tenant membership, scopes, token revision, and reconnect status in application-owned records. A display handle is not a stable authorization key.

The current adapter reports permission and reconnect failures as structured outcomes. The application chooses whether to ask the user to reconnect, remove a membership, or revoke an upstream grant.

See [tenant authorization](/concepts/tenant-authorization) and the [account ownership diagram](/concepts/architecture#account-connection-and-ownership) for the request boundary.

## Discover accounts before selection

Use `ConnectionManager` from `@opencoredev/social-sdk/server` with a `ConnectionStore` and the platform's OAuth provider factory. Call `begin` with the authenticated tenant and principal, requested platforms, and an exact redirect allowlist. Keep the returned attempt ID in your server session.

In the callback handler, pass the original callback URL and returned state to `discover`. The manager checks the authenticated principal, redirect, state, and expiry before claiming the code exchange. It saves the provider's account references before returning them for your account picker. Browser-supplied account identifiers do not create grants.

After the user chooses accounts, call `select` with the attempt ID, authenticated tenant and principal, and `selectedAccountIds`. Selection uses the saved discovery result and consumes the attempt together with its grants in one store transaction. `complete` combines discovery and selection for applications that already know their selected accounts.

The [integration skill](/agents/integrate-social-sdk) includes an executable handler recipe. Its provider and storage are injected by the application.

## Store contract and recovery

A production `ConnectionStore` must persist attempts and discovered accounts, atomically claim discovery, and atomically consume an attempt with all selected grants. The exchange claim must survive process restarts and must not expire automatically. `MemoryConnectionStore` implements this contract for tests, but loses state when the process exits.

Repeated callbacks can read a saved discovery result without exchanging the code again. If a code exchange is still running, or the process lost its response before saving discovery, the manager reports `reconnect_required`. Let an in-flight exchange finish or start a fresh connection. Replaying a one-use authorization code cannot safely recover missing credentials.

Connection attempts expire after ten minutes by default. Account selection must finish before expiry. The PKCE verifier stays in the store, and `begin` never returns it. `begin` returns `providerState` unless the provider's `start` result sets `providerStateSecret: true`, in which case that value also stays in the store. The Bluesky provider sets it because its provider state holds the DPoP private key. Keep them private in server storage, encrypt stored credentials separately, and use compare-and-set token revisions when refreshing.

## Direct OAuth providers

The server package includes direct OAuth factories for YouTube, X, TikTok, Threads, Instagram Login, LinkedIn, and Bluesky. Create them only in server code. Each factory accepts an injected `fetch` for deterministic tests, an optional `credentialSink`, and provider credentials. The `redirectUri` passed to the factory must match the exact callback URI registered with the provider and with `ConnectionManager.begin`. When one callback discovers several accounts, the sink's `save` runs once per selected account in order. If a later save fails, the earlier ones stay stored, so make `save` an idempotent upsert and let the user retry the connection.

### YouTube

**Default scopes:** `openid`, profile, `youtube.readonly`, and
`youtube.upload`. **PKCE:** the provider flow does not add PKCE.

Google refresh tokens use `refreshOAuthToken("youtube", ...)`. Pass narrower
`scopes` if your app only reads channel data.

```ts
const provider = youtubeOAuth({ clientId, clientSecret, redirectUri, credentialSink });
const next = await refreshOAuthToken("youtube", { clientId, clientSecret }, current);
```

### X

**Default scopes:** `tweet.read`, `tweet.write`, `users.read`, `offline.access`,
and `media.write`.
**PKCE:** S256.

Refresh tokens can rotate. Persist the returned replacement atomically.

```ts
const provider = xOAuth({ clientId, clientSecret, redirectUri, credentialSink });
const next = await refreshOAuthToken("x", { clientId, clientSecret }, current);
// Save next.refreshToken in the same write that replaces current.
```

### TikTok

**Default scopes:** `user.info.basic` and `video.publish`. **PKCE:** the web Login Kit flow
does not add PKCE.

Refresh tokens can rotate. Scopes are sent comma-delimited.

```ts
const provider = tiktokOAuth({ clientId, clientSecret, redirectUri, credentialSink });
const next = await refreshOAuthToken("tiktok", { clientId, clientSecret }, current);
```

### Threads

**Default scopes:** `threads_basic` and `threads_content_publish`. **PKCE:**
none is fabricated.

Call `exchangeLongLivedOAuthToken("threads", ...)`, then
`refreshOAuthToken("threads", ...)`.

```ts
const provider = threadsOAuth({ clientId, clientSecret, redirectUri, credentialSink });
const longLived = await exchangeLongLivedOAuthToken(
  "threads",
  { clientId, clientSecret },
  shortLived,
);
const next = await refreshOAuthToken("threads", { clientId, clientSecret }, longLived);
```

### Instagram Login

**Default scopes:** `instagram_business_basic` and
`instagram_business_content_publish`. **PKCE:** none is fabricated.

Call `exchangeLongLivedOAuthToken("instagram", ...)`, then
`refreshOAuthToken("instagram", ...)`.

```ts
const provider = instagramOAuth({ clientId, clientSecret, redirectUri, credentialSink });
const longLived = await exchangeLongLivedOAuthToken(
  "instagram",
  { clientId, clientSecret },
  shortLived,
);
const next = await refreshOAuthToken("instagram", { clientId, clientSecret }, longLived);
```

### LinkedIn

**Default scopes:** `openid`, `profile`, and `w_member_social`. **PKCE:** none
is fabricated.

LinkedIn's authorization-code response does not expose refresh, so reconnect
when the grant is unavailable. Set `linkedinApiVersion` explicitly as `YYYYMM`.

```ts
const provider = linkedinOAuth({
  clientId,
  clientSecret,
  redirectUri,
  credentialSink,
  linkedinApiVersion: "202609",
});
```

### Bluesky

**Default scopes:** `atproto` and `transition:generic`. **PKCE:** S256, sent in a
pushed authorization request with DPoP-bound tokens.

Bluesky uses `blueskyOAuth`, which follows the AT Protocol OAuth profile instead
of a fixed provider endpoint. Pass the handle, DID, or server URL the user typed
as `loginHint` to `begin`. There is no client secret: `clientId` is the URL of
your client metadata document, and confidential clients sign with an ES256
`clientKey`. The verified session goes to `sessionSink` rather than
`credentialSink`. Refresh tokens rotate.

```ts
const provider = blueskyOAuth({ clientId, clientKey, redirectUri, sessionSink });
await manager.begin({
  ...request,
  platforms: ["bluesky"],
  provider,
  loginHint: "alice.bsky.social",
});
const next = await refreshBlueskyOAuthSession(current, { clientId, clientKey });
```

See the [Bluesky page](/platforms/bluesky) for client metadata and the adapter transport.

The defaults are a starting point. Pass `scopes` for the operation you are connecting, such as a read-only Google scope list for an analytics dashboard or LinkedIn's organization permission when your app has that product approval. Provider app review, account eligibility, callback registration, and quota remain application setup responsibilities.

When a provider discovers more than one account, the default credential sink receives credentials for every validated account. Pass `selectAccounts` to an OAuth factory when credentials should be persisted for only a chosen subset. The selector receives the discovered accounts and attempt and must return distinct discovered account IDs. This selection controls credential persistence; `ConnectionManager.select` still controls which discovered accounts become tenant grants.

A complete server flow looks like this:

```ts
import {
  ConnectionManager,
  MemoryConnectionStore,
  type OAuthCredentialSink,
  youtubeOAuth,
} from "@opencoredev/social-sdk/server";

const store = new MemoryConnectionStore(); // tests only; use durable storage in production
const credentialRows = new Map<string, unknown>();
const credentials: OAuthCredentialSink = {
  async save(input) {
    // Replace this map with encrypted, tenant-scoped persistence in production.
    const key = `${input.attempt.tenantId}:${input.account.ref.platform}:${input.account.ref.accountId}`;
    credentialRows.set(key, input.token);
  },
};
const provider = youtubeOAuth({
  clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
  redirectUri: "https://app.example.com/oauth/youtube/callback",
  scopes: ["openid", "https://www.googleapis.com/auth/youtube.upload"],
  credentialSink: credentials,
});
const manager = new ConnectionManager({ store });

export async function handleConnection(input: {
  readonly tenantId: string;
  readonly principalId: string;
  readonly callbackUrl: string;
  readonly returnedState: string;
  readonly selectedAccountId: string;
}) {
  // Authenticated server request: return started.authorizationUrl to the browser.
  const started = await manager.begin({
    backend: "direct",
    tenantId: input.tenantId,
    principalId: input.principalId,
    platforms: ["youtube"],
    capabilities: ["posts.publish"],
    redirectUri: "https://app.example.com/oauth/youtube/callback",
    allowedRedirectUris: ["https://app.example.com/oauth/youtube/callback"],
    provider,
  });

  // Callback request: keep the complete callback URL and the state extracted from it.
  const discovered = await manager.discover({
    attemptId: started.attempt.id,
    tenantId: input.tenantId,
    principalId: input.principalId,
    callbackUrl: input.callbackUrl,
    returnedState: input.returnedState,
    allowedRedirectUris: ["https://app.example.com/oauth/youtube/callback"],
    provider,
  });
  // Show discovered account IDs to the authenticated user, then accept only their choice.
  const grants = await manager.select({
    attemptId: started.attempt.id,
    tenantId: input.tenantId,
    principalId: input.principalId,
    selectedAccountIds: [input.selectedAccountId],
  });
  return { started, discovered, grants };
}
```

`MemoryConnectionStore` and an in-memory credential sink are suitable for tests only. A production sink must encrypt tokens at rest, restrict reads by tenant and principal, redact tokens from logs, and support atomic refresh-token rotation. The callback URL and returned state come from the provider redirect; do not accept an account ID or token from browser form data. The repository contains runnable TypeScript versions of this flow in `examples/snippets/oauth-youtube.ts` and `examples/snippets/oauth-tiktok.ts`. They use environment variables for client credentials and stop after returning the authorization URL until an application supplies a callback.
