fix(creds): glitch allowing multiple credentials in an integration (#2282)

This commit is contained in:
Vikhyath Mondreti
2025-12-09 21:07:14 -08:00
committed by GitHub
parent 0083c89fa5
commit f421f27d3f
8 changed files with 7911 additions and 19 deletions

View File

@@ -7,6 +7,42 @@ import { refreshOAuthToken } from '@/lib/oauth/oauth'
const logger = createLogger('OAuthUtilsAPI')
interface AccountInsertData {
id: string
userId: string
providerId: string
accountId: string
accessToken: string
scope: string
createdAt: Date
updatedAt: Date
refreshToken?: string
idToken?: string
}
/**
* 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 (error?.code === '23505') {
logger.error(`Duplicate ${context.provider} account detected, credential already exists`, {
userId: data.userId,
identifier: context.identifier,
})
} else {
throw error
}
}
}
/**
* Get the user ID based on either a session or a workflow ID
*/

View File

@@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
const logger = createLogger('ShopifyStore')
@@ -66,14 +67,20 @@ export async function GET(request: NextRequest) {
await db.update(account).set(accountData).where(eq(account.id, existing.id))
logger.info('Updated existing Shopify account', { accountId: existing.id })
} else {
await db.insert(account).values({
id: `shopify_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'shopify',
...accountData,
createdAt: now,
})
logger.info('Created new Shopify account for user', { userId: session.user.id })
await safeAccountInsert(
{
id: `shopify_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'shopify',
accountId: accountData.accountId,
accessToken: accountData.accessToken,
scope: accountData.scope,
idToken: accountData.idToken,
createdAt: now,
updatedAt: now,
},
{ provider: 'Shopify', identifier: shopDomain }
)
}
const returnUrl = request.cookies.get('shopify_return_url')?.value

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { createLogger } from '@/lib/logs/console/logger'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
import { db } from '@/../../packages/db'
import { account } from '@/../../packages/db/schema'
@@ -67,16 +68,19 @@ export async function POST(request: NextRequest) {
})
.where(eq(account.id, existing.id))
} else {
await db.insert(account).values({
id: `trello_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'trello',
accountId: trelloUser.id,
accessToken: token,
scope: 'read,write',
createdAt: now,
updatedAt: now,
})
await safeAccountInsert(
{
id: `trello_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'trello',
accountId: trelloUser.id,
accessToken: token,
scope: 'read,write',
createdAt: now,
updatedAt: now,
},
{ provider: 'Trello', identifier: trelloUser.id }
)
}
return NextResponse.json({ success: true })

View File

@@ -13,7 +13,7 @@ import {
oneTimeToken,
organization,
} from 'better-auth/plugins'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { headers } from 'next/headers'
import Stripe from 'stripe'
import {
@@ -100,6 +100,44 @@ export const auth = betterAuth({
},
account: {
create: {
before: async (account) => {
const existing = await db.query.account.findFirst({
where: and(
eq(schema.account.userId, account.userId),
eq(schema.account.providerId, account.providerId),
eq(schema.account.accountId, account.accountId)
),
})
if (existing) {
logger.warn(
'[databaseHooks.account.create.before] Duplicate account detected, updating existing',
{
existingId: existing.id,
userId: account.userId,
providerId: account.providerId,
accountId: account.accountId,
}
)
await db
.update(schema.account)
.set({
accessToken: account.accessToken,
refreshToken: account.refreshToken,
idToken: account.idToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: account.scope,
updatedAt: new Date(),
})
.where(eq(schema.account.id, existing.id))
return false
}
return { data: account }
},
after: async (account) => {
// Salesforce doesn't return expires_in in its token response (unlike other OAuth providers).
// We set a default 2-hour expiration so token refresh logic works correctly.

View File

@@ -0,0 +1,9 @@
DELETE FROM account a
USING account b
WHERE a.user_id = b.user_id
AND a.provider_id = b.provider_id
AND a.account_id = b.account_id
AND a.id <> b.id
AND a.updated_at < b.updated_at;
--> statement-breakpoint
CREATE UNIQUE INDEX "account_user_provider_account_unique" ON "account" USING btree ("user_id","provider_id","account_id");

File diff suppressed because it is too large Load Diff

View File

@@ -834,6 +834,13 @@
"when": 1765271011445,
"tag": "0119_far_lethal_legion",
"breakpoints": true
},
{
"idx": 120,
"version": "7",
"when": 1765339999291,
"tag": "0120_illegal_moon_knight",
"breakpoints": true
}
]
}

View File

@@ -88,6 +88,11 @@ export const account = pgTable(
table.accountId,
table.providerId
),
uniqueUserProviderAccount: uniqueIndex('account_user_provider_account_unique').on(
table.userId,
table.providerId,
table.accountId
),
})
)