1+ import { db } from '@sim/db'
2+ import { pendingCredentialDraft , user } from '@sim/db/schema'
13import { createLogger } from '@sim/logger'
4+ import { generateId } from '@sim/utils/id'
5+ import { and , eq , lt } from 'drizzle-orm'
26import { type NextRequest , NextResponse } from 'next/server'
37import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
48import { parseRequest } from '@/lib/api/server'
59import { auth , getSession } from '@/lib/auth/auth'
610import { getBaseUrl } from '@/lib/core/utils/urls'
711import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+ import { getAllOAuthServices } from '@/lib/oauth/utils'
13+ import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
814
915const logger = createLogger ( 'OAuth2Authorize' )
1016
1117export const dynamic = 'force-dynamic'
1218
19+ const DRAFT_TTL_MS = 15 * 60 * 1000
20+
21+ /**
22+ * Creates the pending credential draft at click time so its TTL starts when the
23+ * user actually initiates the connect. Better Auth's `account.create.after` hook
24+ * consumes this draft to materialize the real credential after the OAuth
25+ * callback; starting the clock here guarantees the draft outlives the (≤5 min)
26+ * OAuth round-trip rather than expiring mid-flow and silently producing no
27+ * credential.
28+ */
29+ async function createConnectDraft ( params : {
30+ userId : string
31+ workspaceId : string
32+ providerId : string
33+ } ) : Promise < void > {
34+ const { userId, workspaceId, providerId } = params
35+
36+ const service = getAllOAuthServices ( ) . find ( ( s ) => s . providerId === providerId )
37+ const serviceName = service ?. name ?? providerId
38+
39+ let displayName = serviceName
40+ try {
41+ const [ row ] = await db . select ( { name : user . name } ) . from ( user ) . where ( eq ( user . id , userId ) )
42+ if ( row ?. name ) {
43+ displayName = `${ row . name } 's ${ serviceName } `
44+ }
45+ } catch {
46+ // Fall back to service name only
47+ }
48+
49+ const now = new Date ( )
50+ const expiresAt = new Date ( now . getTime ( ) + DRAFT_TTL_MS )
51+ await db
52+ . delete ( pendingCredentialDraft )
53+ . where (
54+ and ( eq ( pendingCredentialDraft . userId , userId ) , lt ( pendingCredentialDraft . expiresAt , now ) )
55+ )
56+ await db
57+ . insert ( pendingCredentialDraft )
58+ . values ( {
59+ id : generateId ( ) ,
60+ userId,
61+ workspaceId,
62+ providerId,
63+ displayName,
64+ expiresAt,
65+ createdAt : now ,
66+ } )
67+ . onConflictDoUpdate ( {
68+ target : [
69+ pendingCredentialDraft . userId ,
70+ pendingCredentialDraft . providerId ,
71+ pendingCredentialDraft . workspaceId ,
72+ ] ,
73+ set : { displayName, expiresAt, createdAt : now } ,
74+ } )
75+
76+ logger . info ( 'Created OAuth connect credential draft' , { userId, workspaceId, providerId } )
77+ }
78+
1379/**
1480 * Browser-initiated entrypoint for linking a generic OAuth2 account.
1581 */
@@ -22,16 +88,32 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2288 loginUrl . searchParams . set ( 'callbackUrl' , request . nextUrl . pathname + request . nextUrl . search )
2389 return NextResponse . redirect ( loginUrl . toString ( ) )
2490 }
91+ const userId = session . user . id
2592
2693 const parsed = await parseRequest ( authorizeOAuth2Contract , request , { } )
2794 if ( ! parsed . success ) return parsed . response
28- const { providerId, callbackURL : requestedCallback } = parsed . data . query
95+ const { providerId, workspaceId , callbackURL : requestedCallback } = parsed . data . query
2996
3097 const callbackURL = requestedCallback ?. startsWith ( `${ baseUrl } /` )
3198 ? requestedCallback
3299 : `${ baseUrl } /workspace`
33100
34101 try {
102+ const access = await checkWorkspaceAccess ( workspaceId , userId )
103+ if ( ! access . canWrite ) {
104+ logger . warn ( 'Workspace write access denied for OAuth2 authorize' , {
105+ userId,
106+ workspaceId,
107+ providerId,
108+ } )
109+ return NextResponse . redirect ( `${ baseUrl } /workspace?error=workspace_access_denied` )
110+ }
111+
112+ // Create the draft before initiating the link so it is guaranteed to exist
113+ // (and freshly clocked) when the OAuth callback's `account.create.after`
114+ // hook runs. If this throws, we never start the OAuth flow.
115+ await createConnectDraft ( { userId, workspaceId, providerId } )
116+
35117 const linkResponse = await auth . api . oAuth2LinkAccount ( {
36118 body : { providerId, callbackURL } ,
37119 headers : request . headers ,
0 commit comments