Skip to content
Social SDK megaphone markSocial SDK
Esc
navigateopen⌘Jpreview
On this page

Connect accounts

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 and the account ownership diagram 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 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.

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.

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.

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", ...).

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", ...).

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.

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.

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 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:

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.

Last updated on September 24, 2026