fix: removed multiple account oauth functionality, updated better-auth to acknowledge security vulnerability

This commit is contained in:
Waleed Latif
2025-03-14 16:07:34 -07:00
parent afb8c9ea26
commit 4deef0b769
12 changed files with 206 additions and 69 deletions
+46 -13
View File
@@ -5,13 +5,14 @@ import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { OAuthService } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
import { account, user } from '@/db/schema'
const logger = createLogger('OAuthConnectionsAPI')
interface GoogleIdToken {
email?: string
sub?: string
name?: string
}
// Valid OAuth providers
@@ -36,30 +37,61 @@ export async function GET(request: NextRequest) {
// Get all accounts for this user
const accounts = await db.select().from(account).where(eq(account.userId, session.user.id))
// Get the user's email for fallback
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const userEmail = userRecord.length > 0 ? userRecord[0].email : null
// Process accounts to determine connections
const connections: any[] = []
accounts.forEach((acc) => {
for (const acc of accounts) {
// Extract the base provider and feature type from providerId (e.g., 'google-email' -> 'google', 'email')
const [provider, featureType = 'default'] = acc.providerId.split('-')
if (provider && VALID_PROVIDERS.includes(provider)) {
// Get the account name (try to get email for Google accounts)
let name = acc.accountId
if (provider === 'google' && acc.idToken) {
// Try multiple methods to get a user-friendly display name
let displayName = ''
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
if (decoded.email) {
name = decoded.email
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (error) {
logger.warn(`[${requestId}] Error decoding Google ID token`, { accountId: acc.id })
logger.warn(`[${requestId}] Error decoding ID token`, { accountId: acc.id })
}
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && provider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
// Method 3: Use the user's email from our database
if (!displayName && userEmail) {
displayName = userEmail
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${provider})`
}
// Find existing connection for this provider and feature type
// Create a unique connection key that includes the full provider ID
const connectionKey = acc.providerId
// Find existing connection for this specific provider ID
const existingConnection = connections.find(
(conn) => conn.provider === provider && conn.featureType === featureType
(conn) => conn.provider === connectionKey
)
if (existingConnection) {
@@ -67,12 +99,13 @@ export async function GET(request: NextRequest) {
existingConnection.accounts = existingConnection.accounts || []
existingConnection.accounts.push({
id: acc.id,
name,
name: displayName,
})
} else {
// Create new connection
connections.push({
provider: provider as OAuthService,
provider: connectionKey,
baseProvider: provider,
featureType,
isConnected: true,
scopes: acc.scope ? acc.scope.split(' ') : [],
@@ -80,13 +113,13 @@ export async function GET(request: NextRequest) {
accounts: [
{
id: acc.id,
name,
name: displayName,
},
],
})
}
}
})
}
return NextResponse.json({ connections }, { status: 200 })
} catch (error) {
+39 -7
View File
@@ -6,13 +6,14 @@ import { createLogger } from '@/lib/logs/console-logger'
import { parseProvider } from '@/lib/oauth'
import { OAuthService } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
import { account, user } from '@/db/schema'
const logger = createLogger('OAuthCredentialsAPI')
interface GoogleIdToken {
email?: string
sub?: string
name?: string
}
/**
@@ -55,22 +56,53 @@ export async function GET(request: NextRequest) {
// Extract the feature type from providerId (e.g., 'google-default' -> 'default')
const [_, featureType = 'default'] = acc.providerId.split('-')
// For Google accounts, try to get the email from the ID token
let name = acc.accountId
if (baseProvider === 'google' && acc.idToken) {
// Try multiple methods to get a user-friendly display name
let displayName = ''
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
if (decoded.email) {
name = decoded.email
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (error) {
logger.warn(`[${requestId}] Error decoding Google ID token`, { accountId: acc.id })
logger.warn(`[${requestId}] Error decoding ID token`, { accountId: acc.id })
}
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
// Method 3: Try to get the user's email from our database
if (!displayName) {
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, acc.userId))
.limit(1)
if (userRecord.length > 0) {
displayName = userRecord[0].email
}
} catch (error) {
logger.warn(`[${requestId}] Error fetching user email`, { userId: acc.userId })
}
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
}
return {
id: acc.id,
name,
name: displayName,
provider,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
+2 -2
View File
@@ -505,7 +505,7 @@ export function FileSelector({
</CommandGroup>
)}
{/* Add another account option */}
{/* Add another account option
{credentials.length > 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
@@ -514,7 +514,7 @@ export function FileSelector({
</div>
</CommandItem>
</CommandGroup>
)}
)} */}
</CommandList>
</Command>
</PopoverContent>
+2 -2
View File
@@ -412,7 +412,7 @@ export function FolderSelector({
)}
{/* Add another account option */}
{credentials.length > 0 && (
{/* {credentials.length > 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
@@ -420,7 +420,7 @@ export function FolderSelector({
</div>
</CommandItem>
</CommandGroup>
)}
)} */}
</CommandList>
</Command>
</PopoverContent>
@@ -128,8 +128,12 @@ export function OAuthRequiredModal({
// Close the modal
onClose()
// Begin OAuth flow with the appropriate provider
await client.signIn.oauth2({
logger.info('Linking OAuth2:', {
providerId,
requiredScopes,
})
await client.oauth2.link({
providerId,
callbackURL: window.location.href,
})
-1
View File
@@ -6,5 +6,4 @@ export const client = createAuthClient({
})
export const { useSession } = client
// Export commonly used hooks and methods
export const { signIn, signUp, signOut } = client
+15
View File
@@ -27,6 +27,13 @@ export const auth = betterAuth({
maxAge: 5 * 60, // Cache duration (5 minutes)
},
},
account: {
accountLinking: {
enabled: true,
allowDifferentEmails: true,
trustedProviders: ["google", "github", "email-password"],
},
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
@@ -120,6 +127,7 @@ export const auth = betterAuth({
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: ['user:email', 'repo'],
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/github-repo`,
},
{
providerId: 'github-workflow',
@@ -131,6 +139,7 @@ export const auth = betterAuth({
userInfoUrl: 'https://api.github.com/user',
scopes: ['workflow', 'repo'],
prompt: 'consent',
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/github-workflow`,
},
// Google providers for different purposes
@@ -148,6 +157,7 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/gmail.labels',
],
prompt: 'consent',
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/google-email`,
},
{
providerId: 'google-calendar',
@@ -161,6 +171,7 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/calendar',
],
prompt: 'consent',
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/google-calendar`,
},
{
providerId: 'google-drive',
@@ -175,6 +186,7 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/google-drive`,
},
{
providerId: 'google-docs',
@@ -189,6 +201,7 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/google-docs`,
},
{
providerId: 'google-sheets',
@@ -203,6 +216,7 @@ export const auth = betterAuth({
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/google-sheets`,
},
// Supabase provider
@@ -217,6 +231,7 @@ export const auth = betterAuth({
scopes: ['database.read', 'database.write', 'projects.read'],
responseType: 'code',
pkce: true,
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/supabase`,
},
// X provider
@@ -13,7 +13,6 @@ import {
} from '@/components/ui/command'
import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { client } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import {
Credential,
@@ -106,7 +105,7 @@ export function CredentialSelector({
} finally {
setIsLoading(false)
}
}, [provider, onChange, selectedId])
}, [provider, onChange, selectedId, getProviderId])
// Fetch credentials on initial mount and when dependencies change
useEffect(() => {
@@ -154,21 +153,6 @@ export function CredentialSelector({
setOpen(false)
}
// Handle direct OAuth flow
const handleDirectOAuth = async () => {
try {
const providerId = getProviderId()
// Begin OAuth flow with the appropriate provider
await client.signIn.oauth2({
providerId,
callbackURL: window.location.href,
})
} catch (error) {
logger.error('OAuth login error:', { error })
}
}
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -72,12 +72,13 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
// Update services with connection status and account info
const updatedServices = serviceDefinitions.map((service) => {
// Find matching connection
// Find matching connection - now we can do an exact match on providerId
const connection = connections.find((conn: any) => {
const [provider, featureType] = conn.provider.split('-')
return service.providerId.startsWith(provider)
})
// Exact match on providerId is the most reliable
return conn.provider === service.providerId;
});
// If we found an exact match, use it
if (connection) {
return {
...service,
@@ -87,7 +88,31 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
}
}
return service
// If no exact match, check if any connection has all the required scopes
const connectionWithScopes = connections.find((conn: any) => {
// Only consider connections from the same base provider
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
return false;
}
// Check if all required scopes for this service are included in the connection
if (conn.scopes && service.scopes) {
return service.scopes.every(scope => conn.scopes.includes(scope));
}
return false;
});
if (connectionWithScopes) {
return {
...service,
isConnected: connectionWithScopes.accounts?.length > 0,
accounts: connectionWithScopes.accounts || [],
lastConnected: connectionWithScopes.lastConnected,
}
}
return service;
})
setServices(updatedServices)
@@ -184,14 +209,19 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
saveToStorage<string[]>('pending_oauth_scopes', service.scopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', service.providerId)
logger.info('Connecting service:', {
serviceId: service.id,
providerId: service.providerId,
scopes: service.scopes,
})
// Begin OAuth flow with the appropriate provider
await client.signIn.oauth2({
await client.oauth2.link({
providerId: service.providerId,
callbackURL: window.location.href,
})
} catch (error) {
logger.error('OAuth login error:', { error })
logger.error('OAuth connection error:', { error })
setIsConnecting(null)
}
}
@@ -359,7 +389,7 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
</Button>
</div>
))}
<Button
{/* <Button
variant="outline"
size="sm"
className="w-full mt-2"
@@ -377,7 +407,7 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
Connect Another Account
</>
)}
</Button>
</Button> */}
</div>
)}
</div>
+13 -4
View File
@@ -1,9 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSessionCookie } from 'better-auth'
export async function middleware(request: NextRequest) {
// Check if the path is exactly /w
if (request.nextUrl.pathname === '/w') {
// Check if the path is exactly /w
if (request.nextUrl.pathname === '/w') {
return NextResponse.redirect(new URL('/w/1', request.url))
}
@@ -12,8 +11,18 @@ export async function middleware(request: NextRequest) {
return NextResponse.next()
}
const cookieHeader = request.headers.get("cookie");
const cookies = cookieHeader?.split("; ").reduce((acc, cookie) => {
const [key, value] = cookie.split("=");
acc.set(key, value);
return acc;
}, new Map());
const sessionCookie =
cookies?.get("better-auth.session_token") ||
cookies?.get("__Secure-better-auth.session_token");
// Existing auth check for protected routes
const sessionCookie = getSessionCookie(request)
if (!sessionCookie) {
return NextResponse.redirect(new URL('/login', request.url))
}
+41 -10
View File
@@ -32,7 +32,7 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@vercel/og": "^0.6.5",
"@webcontainer/api": "^1.5.1-internal.9",
"better-auth": "^1.1.18",
"better-auth": "^1.2.4",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -597,7 +597,9 @@
}
},
"node_modules/@better-fetch/fetch": {
"version": "1.1.12"
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.15.tgz",
"integrity": "sha512-0Bl8YYj1f8qCTNHeSn5+1DWv2hy7rLBrQ8rS8Y9XYloiwZEfc3k4yspIG0llRxafxqhGCwlGRg+F8q1HZRCMXA=="
},
"node_modules/@cerebras/cerebras_cloud_sdk": {
"version": "1.23.0",
@@ -3939,29 +3941,34 @@
"license": "MIT"
},
"node_modules/better-auth": {
"version": "1.1.18",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.2.4.tgz",
"integrity": "sha512-/ZK2jbUjm8JwdeCLFrUWUBmexPyI9PkaLVXWLWtN60sMDHTY8B5G72wcHglo1QMFBaw4G0qFkP5ayl9k6XfDaA==",
"dependencies": {
"@better-auth/utils": "0.2.3",
"@better-fetch/fetch": "1.1.12",
"@better-fetch/fetch": "^1.1.15",
"@noble/ciphers": "^0.6.0",
"@noble/hashes": "^1.6.1",
"@simplewebauthn/browser": "^13.0.0",
"@simplewebauthn/server": "^13.0.0",
"better-call": "0.3.3",
"better-call": "^1.0.3",
"defu": "^6.1.4",
"jose": "^5.9.6",
"kysely": "^0.27.4",
"kysely": "^0.27.6",
"nanostores": "^0.11.3",
"valibot": "1.0.0-beta.15",
"zod": "^3.24.1"
}
},
"node_modules/better-call": {
"version": "0.3.3",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/better-call/-/better-call-1.0.4.tgz",
"integrity": "sha512-NdAihYdkS0IOz1mtz8mw1gWacCxR9r921U8YqB+VB6++rt8edMG13vVL16Y4TBL4XkjMK/DUewEsOOFkw9LJYQ==",
"dependencies": {
"@better-fetch/fetch": "^1.1.4",
"rou3": "^0.5.1",
"uncrypto": "^0.1.3",
"zod": "^3.24.1"
"set-cookie-parser": "^2.7.1",
"uncrypto": "^0.1.3"
}
},
"node_modules/binary-extensions": {
@@ -7294,7 +7301,9 @@
}
},
"node_modules/kysely": {
"version": "0.27.5",
"version": "0.27.6",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz",
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -9008,6 +9017,8 @@
},
"node_modules/rou3": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.5.1.tgz",
"integrity": "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
@@ -9150,6 +9161,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.33.5",
"hasInstallScript": true,
@@ -9985,6 +10002,20 @@
"node": ">=10.12.0"
}
},
"node_modules/valibot": {
"version": "1.0.0-beta.15",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.15.tgz",
"integrity": "sha512-BKy8XosZkDHWmYC+cJG74LBzP++Gfntwi33pP3D3RKztz2XV9jmFWnkOi21GoqARP8wAWARwhV6eTr1JcWzjGw==",
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"dev": true,
+1 -1
View File
@@ -44,7 +44,7 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@vercel/og": "^0.6.5",
"@webcontainer/api": "^1.5.1-internal.9",
"better-auth": "^1.1.18",
"better-auth": "^1.2.4",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",