Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

1147 lines
40 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createSign } from 'crypto'
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode, toError } from '@sim/utils/errors'
import { and, desc, eq } from 'drizzle-orm'
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
import { coalesceLocally } from '@/lib/concurrency/singleflight'
import { decryptSecret } from '@/lib/core/security/encryption'
import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors'
import {
getClientCredentialAccountMinter,
parseClientCredentialAccountSecretBlob,
} from '@/lib/credentials/client-credential-accounts/server'
import {
getTokenServiceAccountDescriptor,
isTokenServiceAccountProviderId,
} from '@/lib/credentials/token-service-accounts/descriptors'
import {
parseTokenServiceAccountSecretBlob,
type TokenServiceAccountSecretBlob,
} from '@/lib/credentials/token-service-accounts/server'
import { refreshOAuthToken } from '@/lib/oauth'
import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram'
import {
getMicrosoftRefreshTokenExpiry,
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
import {
extractSlackTeamId,
fanOutSlackTokenChain,
getFreshestSlackChain,
hasSlackChainMoved,
isSlackProvider,
} from '@/lib/oauth/slack'
import {
getRecentTerminalError,
isTerminalRefreshError,
markCredentialDead,
} from '@/lib/oauth/terminal-errors'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
const logger = createLogger('OAuthUtilsAPI')
export class ServiceAccountTokenError extends Error {
constructor(
public readonly statusCode: number,
public readonly errorDescription: string
) {
super(errorDescription)
this.name = 'ServiceAccountTokenError'
}
}
interface AccountInsertData {
id: string
userId: string
providerId: string
accountId: string
accessToken: string
scope: string
createdAt: Date
updatedAt: Date
refreshToken?: string
idToken?: string
accessTokenExpiresAt?: Date
}
export interface ResolvedCredential {
accountId: string
workspaceId?: string
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
providerId?: string
}
/**
* Resolves a credential ID to its underlying account ID.
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
* For service_account credentials, returns credentialId and type instead of accountId.
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
*/
export async function resolveOAuthAccountId(
credentialId: string
): Promise<ResolvedCredential | null> {
const [credentialRow] = await db
.select({
id: credential.id,
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
providerId: credential.providerId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (credentialRow) {
if (credentialRow.type === 'service_account') {
return {
accountId: '',
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
providerId: credentialRow.providerId ?? undefined,
usedCredentialTable: true,
}
}
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
return {
accountId: credentialRow.accountId,
workspaceId: credentialRow.workspaceId,
usedCredentialTable: true,
}
}
return { accountId: credentialId, usedCredentialTable: false }
}
/**
* Userinfo scopes are excluded because service accounts don't represent a user
* and cannot request user identity information. Google rejects token requests
* that include these scopes for service account credentials.
*/
const SA_EXCLUDED_SCOPES = new Set([
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
])
/**
* Generates a short-lived access token for a Google service account credential
* using the two-legged OAuth JWT flow (RFC 7523).
*
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
* where the service account must impersonate a domain user via domain-wide delegation.
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
* authenticates directly with its own IAM permissions.
*/
export async function getServiceAccountToken(
credentialId: string,
scopes: string[],
impersonateEmail?: string
): Promise<string> {
const [credentialRow] = await db
.select({
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Service account key not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const keyData = JSON.parse(decrypted) as {
client_email: string
private_key: string
token_uri?: string
}
const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
const now = Math.floor(Date.now() / 1000)
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
const tokenUri =
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
? keyData.token_uri
: 'https://oauth2.googleapis.com/token'
const header = { alg: 'RS256', typ: 'JWT' }
const payload: Record<string, unknown> = {
iss: keyData.client_email,
scope: filteredScopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
}
if (impersonateEmail) {
payload.sub = impersonateEmail
}
logger.info('Service account JWT payload', {
iss: keyData.client_email,
sub: impersonateEmail || '(none)',
scopes: filteredScopes.join(' '),
aud: tokenUri,
})
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}`
const signer = createSign('RSA-SHA256')
signer.update(signingInput)
const signature = signer.sign(keyData.private_key, 'base64url')
const jwt = `${signingInput}.${signature}`
const response = await fetch(tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
})
if (!response.ok) {
const errorBody = await response.text()
logger.error('Service account token exchange failed', {
status: response.status,
body: errorBody,
})
let description = `Token exchange failed: ${response.status}`
try {
const parsed = JSON.parse(errorBody) as { error_description?: string }
if (parsed.error_description) {
const raw = parsed.error_description
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
description = 'Invalid account credentials.'
} else {
description = raw
}
}
} catch {
// use default description
}
throw new ServiceAccountTokenError(response.status, description)
}
const tokenData = (await response.json()) as { access_token: string }
return tokenData.access_token
}
export interface SlackBotCredentialSecrets {
signingSecret: string
botToken: string
teamId: string
botUserId?: string
teamName?: string
/** Owning workspace — callers with a user/workflow context must verify it. */
workspaceId: string | null
}
/**
* Decrypt a reusable custom Slack bot credential — a `service_account` credential
* with `providerId='slack-custom-bot'` whose encrypted blob holds the bring-your-own
* app's signing secret + bot token + derived team_id/bot_user_id. Returns null if
* the id is not such a credential (or its blob is incomplete).
*
* @remarks Server-internal. The native custom ingest route authenticates each
* request via the app's signing secret (not a user session), so this reader does
* no per-user authorization; callers with a user context authorize separately.
*/
export async function getSlackBotCredential(
credentialId: string
): Promise<SlackBotCredentialSecrets | null> {
const [row] = await db
.select({
type: credential.type,
providerId: credential.providerId,
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (
!row ||
row.type !== 'service_account' ||
row.providerId !== SLACK_CUSTOM_BOT_PROVIDER_ID ||
!row.encryptedServiceAccountKey
) {
return null
}
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
const blob = JSON.parse(decrypted) as Partial<SlackBotCredentialSecrets>
if (!blob.signingSecret || !blob.botToken || !blob.teamId) {
return null
}
return {
signingSecret: blob.signingSecret,
botToken: blob.botToken,
teamId: blob.teamId,
botUserId: blob.botUserId,
teamName: blob.teamName,
workspaceId: row.workspaceId ?? null,
}
}
interface AtlassianServiceAccountSecret {
type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
apiToken: string
domain: string
cloudId: string
atlassianAccountId?: string
}
/**
* Loads the decrypted Atlassian service account secret blob for a credential.
* Throws if the credential is missing or not an Atlassian service account.
*/
export async function getAtlassianServiceAccountSecret(
credentialId: string
): Promise<AtlassianServiceAccountSecret> {
const [credentialRow] = await db
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Atlassian service account secret not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret
if (
parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE ||
!parsed.apiToken ||
!parsed.cloudId
) {
throw new Error('Stored Atlassian service account secret is malformed')
}
return parsed
}
/**
* Result of resolving a `service_account` credential into a usable token. For
* Atlassian and the token-paste providers, the stored token IS the access
* token — no exchange or refresh is needed; Google mints a short-lived token
* via the JWT-bearer flow instead.
*/
export interface ServiceAccountTokenResult {
accessToken: string
/** Atlassian only — the resolved Jira/Confluence cloud id. */
cloudId?: string
/** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */
domain?: string
/** Salesforce only — the org's instance URL the token must be used against. */
instanceUrl?: string
/**
* Zoho Desk only — the data-center-scoped Desk REST base the token must be
* used against, forwarded to tools as their `apiDomain` param.
*/
apiDomain?: string
/**
* Set when the token must be sent in an `x-api-token` header instead of
* `Authorization: Bearer` (e.g. Pipedrive personal API tokens). Absent means
* Bearer; OAuth credentials never carry it.
*/
authStyle?: 'x-api-token'
}
/**
* Loads and parses the decrypted secret blob for a token service-account
* credential (pasted long-lived provider token). Throws if the credential is
* missing or the blob doesn't belong to the expected provider.
*/
async function getTokenServiceAccountSecret(
credentialId: string,
providerId: string
): Promise<TokenServiceAccountSecretBlob> {
const [credentialRow] = await db
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Token service account secret not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
return parseTokenServiceAccountSecretBlob(decrypted, providerId)
}
interface CachedClientCredentialToken {
accessToken: string
expiresAtMs: number
/**
* Fingerprint of the encrypted secret the token was minted from (see
* {@link secretFingerprintOf}). A reconnect that re-points the credential at
* different client credentials changes the ciphertext, so a mismatch means
* the cached token belongs to the old app and must be re-minted.
*/
secretFingerprint: string
/** Salesforce only — the instance URL returned alongside the minted token. */
instanceUrl?: string
/** Zoho Desk only — the Desk REST base derived from the token's api_domain. */
apiDomain?: string
}
interface FailedClientCredentialMint {
/** The error the failed mint threw, re-thrown to callers while memoized. */
error: unknown
secretFingerprint: string
expiresAtMs: number
}
/**
* Per-instance cache of minted client-credential access tokens (Zoom S2S,
* Box CCG, Salesforce client-credentials), keyed by credential id. Entries are
* served while more than {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of
* validity remains, so a hot credential mints roughly once per token TTL
* (~1h for Zoom/Box; Salesforce reports a conservative 10-minute TTL because
* its responses never carry an expiry) per instance.
*
* Every resolution re-reads the credential row (a cheap indexed PK select —
* the mint is the expensive part) and validates the cached entry's secret
* fingerprint against the live ciphertext, so rotating or re-pointing a
* credential takes effect on the next resolution on every instance, and a
* credential that is re-resolved after deletion evicts its own entries. No
* cross-instance lock is needed: mints are stateless and these providers allow
* multiple concurrently valid tokens, so each instance minting its own token
* is correct.
*
* Failed mints are never cached as tokens; instead they are memoized for
* {@link CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS} so a hot workflow on a
* revoked/invalid secret doesn't hammer the provider's token endpoint once
* per block execution.
*
* Both maps are pruned of expired entries on each resolution
* ({@link pruneExpiredClientCredentialCaches}), so their size is bounded by the
* credentials resolved within the last token lifetime — entries for credentials
* that are never resolved again do not accumulate indefinitely.
*/
const clientCredentialTokenCache = new Map<string, CachedClientCredentialToken>()
const clientCredentialMintFailureCache = new Map<string, FailedClientCredentialMint>()
const CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS = 5 * 60 * 1000
const CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS = 30 * 1000
/** Drops fully-expired token and failure entries so the maps stay bounded. */
function pruneExpiredClientCredentialCaches(nowMs: number): void {
for (const [id, entry] of clientCredentialTokenCache) {
if (entry.expiresAtMs <= nowMs) clientCredentialTokenCache.delete(id)
}
for (const [id, entry] of clientCredentialMintFailureCache) {
if (entry.expiresAtMs <= nowMs) clientCredentialMintFailureCache.delete(id)
}
}
/**
* Rotation fingerprint for a stored encrypted secret: the ciphertext prefix
* (IV + first blocks) is unique per encryption, so any re-encrypt — secret
* rotation or re-pointing at a different app — changes it.
*/
function secretFingerprintOf(encryptedServiceAccountKey: string): string {
return encryptedServiceAccountKey.slice(0, 32)
}
/**
* Resolves a client-credential service-account credential to a short-lived
* access token: decrypts the stored client id/secret + org id and mints via
* the provider's registered minter (skipping the connect-time identity
* lookup), read-through the per-instance cache. Wrapped in `coalesceLocally`
* so concurrent block executions on one instance share a single mint.
*/
async function resolveClientCredentialAccountToken(
credentialId: string,
providerId: string
): Promise<ServiceAccountTokenResult> {
return coalesceLocally(`ccsa:${credentialId}`, async () => {
pruneExpiredClientCredentialCaches(Date.now())
const [credentialRow] = await db
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
clientCredentialTokenCache.delete(credentialId)
clientCredentialMintFailureCache.delete(credentialId)
throw new Error('Client-credential service account secret not found')
}
const secretFingerprint = secretFingerprintOf(credentialRow.encryptedServiceAccountKey)
const cached = clientCredentialTokenCache.get(credentialId)
if (
cached &&
cached.secretFingerprint === secretFingerprint &&
cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS
) {
return {
accessToken: cached.accessToken,
instanceUrl: cached.instanceUrl,
apiDomain: cached.apiDomain,
}
}
const failed = clientCredentialMintFailureCache.get(credentialId)
if (
failed &&
failed.secretFingerprint === secretFingerprint &&
Date.now() < failed.expiresAtMs
) {
throw failed.error
}
clientCredentialMintFailureCache.delete(credentialId)
try {
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const blob = parseClientCredentialAccountSecretBlob(decrypted, providerId)
const minter = getClientCredentialAccountMinter(providerId)
if (!minter) {
throw new Error(`No minter registered for service-account provider ${providerId}`)
}
const mint = await minter(
{
clientId: blob.clientId,
clientSecret: blob.clientSecret,
orgId: blob.orgId,
dataCenter: blob.dataCenter,
},
{ skipIdentity: true }
)
clientCredentialTokenCache.set(credentialId, {
accessToken: mint.accessToken,
expiresAtMs: Date.now() + mint.expiresInSeconds * 1000,
secretFingerprint,
instanceUrl: mint.instanceUrl,
apiDomain: mint.apiDomain,
})
return {
accessToken: mint.accessToken,
instanceUrl: mint.instanceUrl,
apiDomain: mint.apiDomain,
}
} catch (error) {
clientCredentialMintFailureCache.set(credentialId, {
error,
secretFingerprint,
expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS,
})
throw error
}
})
}
interface ServiceAccountTokenOptions {
scopes?: string[]
impersonateEmail?: string
}
type ServiceAccountTokenResolver = (
credentialId: string,
options: ServiceAccountTokenOptions
) => Promise<ServiceAccountTokenResult>
/**
* Resolver registry for the bespoke service-account providers. Token-paste
* providers (registered in `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`) resolve
* generically: the stored token IS the access token.
*/
const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record<string, ServiceAccountTokenResolver> = {
[ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
},
[SLACK_CUSTOM_BOT_PROVIDER_ID]: async (credentialId) => {
const botCredential = await getSlackBotCredential(credentialId)
if (!botCredential) {
throw new Error('Slack bot credential not found')
}
return { accessToken: botCredential.botToken }
},
[GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId, { scopes, impersonateEmail }) => {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) }
},
}
/**
* Single dispatch point for turning a `service_account` credential into an
* access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
* `POST /api/auth/oauth/token` route go through here, so a new service-account
* provider is one registry entry and an unknown provider fails loudly instead
* of silently attempting a Google JWT.
*/
export async function resolveServiceAccountToken(
credentialId: string,
providerId: string | null | undefined,
scopes?: string[],
impersonateEmail?: string
): Promise<ServiceAccountTokenResult> {
if (providerId && isTokenServiceAccountProviderId(providerId)) {
const secret = await getTokenServiceAccountSecret(credentialId, providerId)
const descriptorAuthStyle = getTokenServiceAccountDescriptor(providerId)?.authStyle
return {
accessToken: secret.apiToken,
domain: secret.domain,
...(descriptorAuthStyle === 'x-api-token' ? { authStyle: descriptorAuthStyle } : {}),
}
}
if (providerId && isClientCredentialAccountProviderId(providerId)) {
return resolveClientCredentialAccountToken(credentialId, providerId)
}
const resolver =
providerId && Object.hasOwn(SERVICE_ACCOUNT_TOKEN_RESOLVERS, providerId)
? SERVICE_ACCOUNT_TOKEN_RESOLVERS[providerId]
: undefined
if (!resolver) {
throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
}
return resolver(credentialId, { scopes, impersonateEmail })
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
*/
export async function safeAccountInsert(
data: AccountInsertData,
context: { provider: string; identifier?: string }
): Promise<void> {
try {
await db.insert(account).values(data)
logger.info(`Created new ${context.provider} account for user`, { userId: data.userId })
} catch (error: any) {
if (getPostgresErrorCode(error) === '23505') {
logger.error(`Duplicate ${context.provider} account detected, credential already exists`, {
userId: data.userId,
identifier: context.identifier,
})
} else {
throw error
}
}
}
/**
* Get a credential by resolved account ID and verify it belongs to the user.
*/
async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return undefined
}
return {
...credentials[0],
resolvedCredentialId: accountId,
}
}
/**
* Get a credential by ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
return getCredentialByAccountId(requestId, resolved.accountId, userId)
}
interface CoalescedRefreshOptions {
accountId: string
providerId: string
refreshToken: string
/** External provider account id (`account.accountId`), used to scope Slack refreshes per installation. */
providerAccountId?: string | null
requestId?: string
userId?: string
}
/**
* Slack lock budgets sized past `TOKEN_REFRESH_TIMEOUT_MS` (15s) in
* lib/oauth/oauth.ts: installation-keyed locks make every sibling row's request
* a follower of one refresh, so the TTL covers the provider call plus generous
* headroom for the surrounding DB reads and the fan-out write, and followers
* poll for the lock's full lifetime so a slow-but-successful refresh is still
* observed rather than reported as a failure. These budgets are latency knobs,
* not correctness guarantees — chain integrity under lock expiry or unlocked
* concurrent writers is enforced by the version-guarded fan-out
* (`ifChainUnchangedSince` in lib/oauth/slack.ts).
*/
const SLACK_LOCK_TTL_SEC = 30
const SLACK_FOLLOWER_MAX_WAIT_MS = SLACK_LOCK_TTL_SEC * 1000
async function performCoalescedRefresh({
accountId,
providerId,
refreshToken,
providerAccountId,
requestId,
userId,
}: CoalescedRefreshOptions): Promise<string | null> {
/**
* Slack bot tokens are per-installation (team × app): every account row for
* one team holds a copy of the same rotating chain, so refreshes are locked,
* dead-flagged, and written per installation rather than per row.
*/
const slackTeamId = isSlackProvider(providerId) ? extractSlackTeamId(providerAccountId) : null
const scopeKey = slackTeamId ? `slack:${slackTeamId}` : accountId
const logContext = {
...(requestId ? { requestId } : {}),
...(userId ? { userId } : {}),
...(slackTeamId ? { slackTeamId } : {}),
providerId,
accountId,
}
const deadCode = await getRecentTerminalError(scopeKey)
if (deadCode) {
logger.warn('Skipping refresh: credential recently failed', {
...logContext,
errorCode: deadCode,
})
return null
}
const lockKey = `oauth:refresh:${scopeKey}`
const refreshPromise = coalesceLocally(lockKey, () =>
withLeaderLock<string>({
key: lockKey,
// Installation-keyed Slack locks gather followers from every sibling row,
// so their wait and the lock TTL must outlast the 15s provider timeout —
// the 3s/10s defaults would fail followers early and let a second leader
// start a concurrent rotation mid-refresh.
...(slackTeamId ? { maxWaitMs: SLACK_FOLLOWER_MAX_WAIT_MS, ttlSec: SLACK_LOCK_TTL_SEC } : {}),
onLeader: async () => {
try {
let refreshTokenToUse = refreshToken
let slackChainVersion: Date | null = null
if (slackTeamId) {
const freshest = await getFreshestSlackChain(slackTeamId)
if (!freshest) {
throw new Error(
`No refresh-capable account row found for Slack installation ${slackTeamId}`
)
}
slackChainVersion = freshest.chainVersion
if (
freshest.accessToken &&
freshest.accessTokenExpiresAt &&
freshest.accessTokenExpiresAt > new Date()
) {
await fanOutSlackTokenChain(
slackTeamId,
{
accessToken: freshest.accessToken,
refreshToken: freshest.refreshToken,
accessTokenExpiresAt: freshest.accessTokenExpiresAt,
},
{ ifChainUnchangedSince: freshest.chainVersion }
)
logger.info('Reused freshest Slack installation token', logContext)
return freshest.accessToken
}
refreshTokenToUse = freshest.refreshToken
}
const result = await refreshOAuthToken(providerId, refreshTokenToUse)
if (!result.ok) {
logger.error('Failed to refresh token', {
...logContext,
errorCode: result.errorCode,
})
if (result.errorCode && isTerminalRefreshError(result.errorCode)) {
// A refresh that lost a race with a concurrent connect fails with
// a revoked/rotated-out token even though the installation just
// got a live chain — dead-flagging then would take down a healthy
// credential for an hour.
if (
slackChainVersion &&
(await hasSlackChainMoved(slackTeamId!, slackChainVersion))
) {
logger.info('Skipping dead flag: Slack chain moved during refresh', logContext)
} else {
await markCredentialDead(scopeKey, result.errorCode)
}
}
return null
}
const accessTokenExpiresAt = new Date(Date.now() + result.expiresIn * 1000)
if (slackTeamId) {
await fanOutSlackTokenChain(
slackTeamId,
{
accessToken: result.accessToken,
refreshToken: result.refreshToken || refreshTokenToUse,
accessTokenExpiresAt,
},
{ ifChainUnchangedSince: slackChainVersion ?? undefined }
)
} else {
const updateData: Record<string, unknown> = {
accessToken: result.accessToken,
accessTokenExpiresAt,
updatedAt: new Date(),
}
if (result.refreshToken && result.refreshToken !== refreshToken) {
updateData.refreshToken = result.refreshToken
}
if (isMicrosoftProvider(providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, accountId))
}
logger.info('Successfully refreshed access token', logContext)
return result.accessToken
} catch (error) {
logger.error('Refresh failed inside leader path', {
...logContext,
error: toError(error).message,
})
return null
}
},
onFollower: async () => {
try {
const [row] = await db
.select({
accessToken: account.accessToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
})
.from(account)
.where(eq(account.id, accountId))
.limit(1)
if (
row?.accessToken &&
row.accessTokenExpiresAt &&
row.accessTokenExpiresAt > new Date()
) {
logger.info('Got fresh access token from coalesced refresh', logContext)
return row.accessToken
}
return null
} catch (error) {
logger.warn('Follower DB read failed during refresh poll', {
...logContext,
error: toError(error).message,
})
return null
}
},
})
)
try {
return await refreshPromise
} catch (error) {
logger.error('Coalesced refresh did not settle', {
...logContext,
error: toError(error).message,
})
return null
}
}
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
const connections = await db
.select({
id: account.id,
providerAccountId: account.accountId,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
idToken: account.idToken,
scope: account.scope,
updatedAt: account.updatedAt,
})
.from(account)
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
.orderBy(desc(account.updatedAt))
.limit(1)
if (connections.length === 0) {
logger.warn(`No OAuth token found for user ${userId}, provider ${providerId}`)
return null
}
const credential = connections[0]
// Determine whether we should refresh: missing/expired token, or Instagram
// long-lived token nearing expiry (Meta cannot refresh after expiry).
const now = new Date()
const tokenExpiry = credential.accessTokenExpiresAt
const accessTokenNeedsRefresh =
!!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now))
const instagramNeedsProactiveRefresh =
!!credential.refreshToken &&
isInstagramProvider(providerId) &&
shouldProactivelyRefreshInstagramToken({
accessTokenExpiresAt: credential.accessTokenExpiresAt,
updatedAt: credential.updatedAt,
now,
})
if (accessTokenNeedsRefresh || instagramNeedsProactiveRefresh) {
const fresh = await performCoalescedRefresh({
accountId: credential.id,
providerId,
refreshToken: credential.refreshToken!,
providerAccountId: credential.providerAccountId,
userId,
})
if (fresh) return fresh
if (!accessTokenNeedsRefresh && credential.accessToken) {
return credential.accessToken
}
return null
}
if (!credential.accessToken) {
logger.warn(
`Access token is null and no refresh attempted or available for user ${userId}, provider ${providerId}`
)
return null
}
logger.info(`Found valid OAuth token for user ${userId}, provider ${providerId}`)
return credential.accessToken
}
/**
* Resolves a credential to its access token plus provider metadata
* (`cloudId`/`domain`/`instanceUrl`/`authStyle`). Behaves exactly like
* {@link refreshAccessTokenIfNeeded} but returns the full
* {@link ServiceAccountTokenResult} so callers that build provider requests
* directly (e.g. selector routes) can honor non-Bearer auth styles such as
* Pipedrive's `x-api-token`. OAuth credentials resolve with `accessToken`
* only.
*/
export async function resolveCredentialAccessToken(
credentialId: string,
userId: string,
requestId: string,
scopes?: string[],
impersonateEmail?: string
): Promise<ServiceAccountTokenResult | null> {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return null
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
logger.info(`[${requestId}] Using service account token for credential`)
return resolveServiceAccountToken(
resolved.credentialId,
resolved.providerId,
scopes,
impersonateEmail
)
}
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
if (!credential) {
return null
}
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date()
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
// Instagram long-lived tokens can only be refreshed while still valid.
const instagramNeedsProactiveRefresh =
!!credential.refreshToken &&
isInstagramProvider(credential.providerId) &&
shouldProactivelyRefreshInstagramToken({
accessTokenExpiresAt,
updatedAt: credential.updatedAt,
now,
})
const shouldRefresh =
accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh
const accessToken = credential.accessToken
if (shouldRefresh) {
const resolvedCredentialId =
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
const fresh = await performCoalescedRefresh({
accountId: resolvedCredentialId,
providerId: credential.providerId,
refreshToken: credential.refreshToken!,
providerAccountId: credential.accountId,
requestId,
userId: credential.userId,
})
if (fresh) return { accessToken: fresh }
// If refresh was only triggered proactively (Microsoft refresh-token aging /
// Instagram long-lived nearing expiry), the still-valid access token is fine.
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
return { accessToken }
}
return null
}
if (!accessToken) {
// We have no access token and either no refresh token or not eligible to refresh
logger.error(`[${requestId}] Missing access token for credential`)
return null
}
logger.info(`[${requestId}] Access token is valid for credential`)
return { accessToken }
}
/**
* Refreshes an OAuth token if needed based on credential information.
* Also handles service account credentials by generating a JWT-based token.
* Thin string wrapper over {@link resolveCredentialAccessToken}.
* @param credentialId The ID of the credential to check and potentially refresh
* @param userId The user ID who owns the credential (for security verification)
* @param requestId Request ID for log correlation
* @param scopes Optional scopes for service account token generation
* @returns The valid access token or null if refresh fails
*/
export async function refreshAccessTokenIfNeeded(
credentialId: string,
userId: string,
requestId: string,
scopes?: string[],
impersonateEmail?: string
): Promise<string | null> {
const result = await resolveCredentialAccessToken(
credentialId,
userId,
requestId,
scopes,
impersonateEmail
)
return result?.accessToken ?? null
}
/**
* Enhanced version that returns additional information about the refresh operation
*/
export async function refreshTokenIfNeeded(
requestId: string,
credential: any,
credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> {
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date()
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
// Instagram long-lived tokens can only be refreshed while still valid.
const instagramNeedsProactiveRefresh =
!!credential.refreshToken &&
isInstagramProvider(credential.providerId) &&
shouldProactivelyRefreshInstagramToken({
accessTokenExpiresAt,
updatedAt: credential.updatedAt,
now,
})
const shouldRefresh =
accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh
// If token appears valid and present, return it directly
if (!shouldRefresh) {
logger.info(`[${requestId}] Access token is valid`)
return { accessToken: credential.accessToken, refreshed: false }
}
const fresh = await performCoalescedRefresh({
accountId: resolvedCredentialId,
providerId: credential.providerId,
refreshToken: credential.refreshToken!,
providerAccountId: credential.accountId,
requestId,
userId: credential.userId,
})
if (fresh) return { accessToken: fresh, refreshed: true }
if (!accessTokenNeedsRefresh && credential.accessToken) {
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
return { accessToken: credential.accessToken, refreshed: false }
}
throw new Error('Failed to refresh token')
}