Skip to content

Commit d6edc92

Browse files
fix(credentials): serialize enrollment readiness
1 parent c7f6f7e commit d6edc92

5 files changed

Lines changed: 139 additions & 15 deletions

File tree

apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,16 @@ describe('credential group OAuth callback', () => {
9797
expect(mismatchedResponse.status).toBe(400)
9898
expect(mocks.completeOAuth).not.toHaveBeenCalled()
9999
})
100+
101+
it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => {
102+
mocks.authenticate.mockResolvedValue(null)
103+
104+
const response = await GET(request('state=state-1&code=code-1'), context)
105+
106+
expect(response.status).toBe(307)
107+
expect(response.headers.get('location')).toBe(
108+
'/credential-groups/enroll/invitation-token?oauth=unavailable'
109+
)
110+
expect(mocks.completeOAuth).not.toHaveBeenCalled()
111+
})
100112
})

apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,9 @@ export const GET = withRouteHandler(
5454

5555
const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
5656
if (!principal) {
57-
return NextResponse.json(
58-
{ error: 'Invitation is invalid or expired.' },
59-
{ status: 404, headers: { 'Cache-Control': 'no-store' } }
60-
)
57+
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
58+
oauth: 'unavailable',
59+
})
6160
}
6261

6362
try {

apps/sim/ee/credential-groups/components/credential-group-detail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ function getEnrollmentStatus(
5757
const needsReauthorization = enrollment.connections.some(
5858
(connection) => connection.status === 'needs_reauth'
5959
)
60-
if (enrollment.expired) return { label: 'Expired', invalid: true }
6160
if (needsReauthorization) return { label: 'Reconnect needed', invalid: false }
6261
const connectedProviders = new Set(
6362
enrollment.connections
@@ -71,6 +70,7 @@ function getEnrollmentStatus(
7170
return { label: 'Connected', invalid: false }
7271
}
7372
if (enrollment.status === 'completed') return { label: 'In progress', invalid: false }
73+
if (enrollment.expired) return { label: 'Expired', invalid: true }
7474
if (enrollment.status === 'in_progress') return { label: 'In progress', invalid: false }
7575
return { label: 'Invited', invalid: false }
7676
}

apps/sim/lib/credential-groups/enrollments.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,64 @@ describe('completeCredentialGroupEnrollment', () => {
263263
expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1)
264264
expect(dbChainMockFns.update).not.toHaveBeenCalled()
265265
})
266+
267+
it('refuses completion when a connection needs reauthorization under the row locks', async () => {
268+
queueTableRows(schemaMock.credentialGroupEnrollment, [
269+
{
270+
enrollment: { ...ENROLLMENT, status: 'in_progress' },
271+
groupId: 'group-1',
272+
groupName: 'Group',
273+
groupStatus: 'active',
274+
options: [
275+
{
276+
id: 'option-1',
277+
provider: 'gmail',
278+
label: 'Gmail',
279+
required: true,
280+
status: 'active',
281+
},
282+
],
283+
workspaceId: 'workspace-1',
284+
workspaceName: 'Workspace',
285+
workspaceOwnerId: 'owner-1',
286+
inviterName: 'Inviter',
287+
},
288+
])
289+
queueTableRows(schemaMock.credentialGroupEnrollment, [
290+
{
291+
status: 'in_progress',
292+
invitationTokenHash: ENROLLMENT.invitationTokenHash,
293+
invitationExpiresAt: ENROLLMENT.invitationExpiresAt,
294+
},
295+
])
296+
queueTableRows(schemaMock.credentialGroup, [
297+
{
298+
status: 'active',
299+
options: [
300+
{
301+
id: 'option-1',
302+
provider: 'gmail',
303+
label: 'Gmail',
304+
required: true,
305+
status: 'active',
306+
},
307+
],
308+
},
309+
])
310+
queueTableRows(schemaMock.credential, [
311+
{
312+
optionId: 'option-1',
313+
status: 'needs_reauth',
314+
scopeVersion: 1,
315+
authorizationAppId: 'google:client',
316+
grantedScopes: ['scope'],
317+
grantedAt: new Date('2026-08-11T12:05:00.000Z'),
318+
},
319+
])
320+
321+
await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(false)
322+
323+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
324+
expect(adapter.getPolicy).not.toHaveBeenCalled()
325+
})
266326
})

apps/sim/lib/credential-groups/enrollments.ts

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -801,18 +801,9 @@ async function completeResolvedCredentialGroupEnrollment(
801801
row: NonNullable<Awaited<ReturnType<typeof resolvePublicEnrollmentRowByIdentity>>>,
802802
identity: PublicCredentialGroupEnrollmentIdentity
803803
): Promise<boolean | null> {
804-
const enrollment = await buildPublicCredentialGroupEnrollment(row)
805-
const activeOptions = enrollment.options.filter((option) => option.status === 'active')
806-
const allConnected =
807-
activeOptions.length > 0 &&
808-
activeOptions.every(
809-
(option) => option.connections.length === 1 && option.connections[0]?.status === 'connected'
810-
)
811-
if (!allConnected) return false
812-
813-
const now = new Date()
814804
return db.transaction(async (tx) => {
815805
await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id)
806+
const now = new Date()
816807
const [current] = await tx
817808
.select({
818809
status: credentialGroupEnrollment.status,
@@ -832,6 +823,68 @@ async function completeResolvedCredentialGroupEnrollment(
832823
return null
833824
}
834825

826+
const [group] = await tx
827+
.select({
828+
status: credentialGroup.status,
829+
options: credentialGroup.options,
830+
})
831+
.from(credentialGroup)
832+
.where(
833+
and(
834+
eq(credentialGroup.id, identity.credentialGroupId),
835+
eq(credentialGroup.workspaceId, identity.workspaceId)
836+
)
837+
)
838+
.limit(1)
839+
.for('update')
840+
if (!group || group.status !== 'active') return null
841+
842+
const activeOptions = group.options.filter((option) => option.status === 'active')
843+
if (activeOptions.length === 0) return false
844+
const connections = await tx
845+
.select({
846+
optionId: credential.credentialGroupOptionId,
847+
status: credential.managedOauthStatus,
848+
scopeVersion: credential.managedOauthScopeVersion,
849+
authorizationAppId: credential.authorizationAppId,
850+
grantedScopes: credential.grantedScopes,
851+
grantedAt: credential.grantedAt,
852+
})
853+
.from(credential)
854+
.where(
855+
and(
856+
eq(credential.type, 'managed_oauth'),
857+
eq(credential.credentialGroupEnrollmentId, row.enrollment.id)
858+
)
859+
)
860+
.for('update')
861+
862+
for (const option of activeOptions) {
863+
if (!isCredentialGroupProvider(option.provider)) {
864+
throw new Error(`Unsupported Credential Group provider: ${option.provider}`)
865+
}
866+
const matchingConnections = connections.filter(
867+
(connection) => connection.optionId === option.id
868+
)
869+
if (matchingConnections.length !== 1) return false
870+
const [connection] = matchingConnections
871+
if (!connection || connection.status !== 'active' || !connection.grantedAt) return false
872+
873+
const adapter = getCredentialGroupProviderAdapter(option.provider)
874+
const policy = await adapter.getPolicy(option, {
875+
workspaceId: identity.workspaceId,
876+
credentialGroupId: identity.credentialGroupId,
877+
executor: tx,
878+
})
879+
if (
880+
connection.authorizationAppId !== policy.authorizationAppId ||
881+
connection.scopeVersion !== policy.scopeVersion ||
882+
!adapter.hasRequiredScopes(connection.grantedScopes ?? [], policy.requiredScopes)
883+
) {
884+
return false
885+
}
886+
}
887+
835888
const [completed] = await tx
836889
.update(credentialGroupEnrollment)
837890
.set({ status: 'completed', completedAt: now, updatedAt: now })

0 commit comments

Comments
 (0)