OAuth and Connection Development
OAuth Integrations
An OAuth integration spans the package's frontend and API contributions. Declare
both surfaces in the package's playrunner.integration manifest metadata and
default-export each contribution. The frontend and API builds then generate
static imports from the installed direct production dependency; do not edit a
shared provider registry or add runtime package discovery.
Implement all of the following components in the package:
1. Frontend Settings Modal
Create a configuration modal (e.g., ProviderSettingsModal.tsx) that:
- Provides clear, step-by-step instructions in the UI explaining exactly how the user can obtain their
Client IDandClient Secretfrom the specific provider's developer console. - Allows the user to input their
Client IDandClient Secret. - Opens a popup window pointing to the provider's OAuth URL with the appropriate
scope,redirect_uri, andresponse_type=code. - Listens for an
oauth_callbackmessage from the popup viawindow.addEventListener('message', ...). - Sends the authorization code and initial credential fields to the authenticated package API endpoint. The endpoint exchanges and persists the tokens.
- Reads only the public connection returned by
useIntegrationHost().store.getIntegration. UsecredentialStatus.configuredfor connected state; secret values are never returned to the browser. - Leaves saved secret inputs blank when the modal is reopened. Blank inputs do not mean that the connection is missing.
2. The Callback Route
The provider will redirect back to the application. This is handled automatically by the application's dynamic router:
- Built-in Route: The main routing file (
apps/frontend/src/App.tsx) contains a dynamic route:<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />. You do NOT need to add a new route for each integration. - Consistent URLs: The
redirect_uriused in yourwindow.opencall must EXACTLY match the one configured in the OAuth provider portal. It should follow the format:https://[your-domain]/oauth/callback/[provider-id].
3. Package-owned API endpoints
You must create backend endpoints to handle token exchange and refresh. The backend acts as a proxy to prevent CORS issues and cleanly parse provider responses:
- Token Exchange Endpoint:
POST /api/[provider]/token- Receives the authorization code and the fields required for the initial exchange from the frontend.
- Makes a request to the provider's token URL (e.g.,
grant_type=authorization_code). - Saves provider metadata in
configand credentials insecretsthrough the request-scoped credential store. - Returns a success status, not access or refresh tokens.
- Token Refresh Endpoint:
POST /api/[provider]/refresh- Resolves the existing encrypted credentials through the request-scoped credential store.
- Makes a request to the provider's token URL (e.g.,
grant_type=refresh_token). - Updates the encrypted token fields and returns a success status.
- API contribution: Default-export the package API contribution containing its stable integration ID, mount path, and Express router. The API host mounts it from the generated build-time composition.
- Security: Protect credential endpoints with the appropriate authentication middleware. Do not expose client secrets or provider tokens to unrelated integrations.
4. Connection envelopes
Every provider uses the same envelope. Provider-specific names are expected
inside config and secrets; the consistent part is where each type of value is
stored.
// GCP connection
{
provider: 'gcp',
config: {
selectedProject: 'my-project',
cloudRunLocation: 'australia-southeast1',
},
secrets: {
clientId: '...',
clientSecret: '...',
accessToken: '...',
refreshToken: '...',
},
}
Use config for values that are safe to return to the browser, such as project,
region, app slug, or account-level defaults. Use secrets for API keys, client
credentials, OAuth tokens, passwords, and webhook URLs. The API rejects known
secret field names nested under config.
Package API contributions access resolved credentials through the SDK instead of importing Prisma or host modules:
import { getIntegrationCredentialStore } from '@playrunner/integration-sdk/api';
const store = getIntegrationCredentialStore(req);
if (!store) throw new Error('Credential storage is unavailable.');
await store.save('integration', 'provider-id', {
provider: 'provider-id',
config: { accountName },
secrets: {
clientId,
clientSecret,
accessToken,
refreshToken,
expiresAt,
},
});
save replaces only the envelope sections that are supplied. A config-only save
preserves the existing encrypted secrets. Use updateSecrets for refresh-token
updates that must merge with the current secret object.
5. Persistence and resolution
The host stores one Prisma Connection row per userId + kind + provider.
config remains JSON, while secrets is encrypted with AES-256-GCM and stored
as encryptedSecrets with an encryptionVersion. The authenticated public
store routes return config and credentialStatus.configured; they never
return decrypted secrets.
Only trusted server paths resolve secrets. Package API endpoints use the request-scoped credential store, and workflow execution resolves the required provider connections before sending package-scoped settings to a runner. Do not put credentials in workflow node config, browser state, node output, or error messages.
Deleting an integration or cloud credential deletes its Connection row.
Provider-specific node choices, such as a repository and branch, still belong
on workflow node config unless they are account-level connection defaults.