Skip to content

Commit d179d2c

Browse files
committed
improvement(org): resolve an account's token once per Home widget read
1 parent baab352 commit d179d2c

3 files changed

Lines changed: 186 additions & 84 deletions

File tree

‎apps/sim/lib/organizations/application/home-widgets.ts‎

Lines changed: 13 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
21
import { createLogger } from '@sim/logger'
32
import { compareStrings } from '@sim/utils/string'
43
import type {
@@ -20,14 +19,14 @@ import {
2019
findCredentialGroupProviderFromProviderId,
2120
} from '@/lib/credential-groups/providers'
2221
import { listViewerOrganizationAccounts } from '@/lib/credential-groups/viewer-accounts'
23-
import {
24-
ManagedOAuthCredentialError,
25-
rejectManagedOAuthToken,
26-
resolveManagedOAuthToken,
27-
} from '@/lib/credentials/managed-oauth'
22+
import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
2823
import { getOwnOrganizationManagedOAuthCredentials } from '@/lib/credentials/organization-managed'
2924
import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability'
30-
import { recordCredentialAccess } from '@/lib/oauth/token-resolution'
25+
import {
26+
type AccountTokenRead,
27+
createAccountTokenRead,
28+
type ProviderTokenInput,
29+
} from '@/lib/organizations/home/account-token'
3130
import {
3231
HOME_CALENDAR_EVENT_LIMIT,
3332
HOME_EMAIL_THREAD_LIMIT,
@@ -71,77 +70,6 @@ export const organizationHomeOperations = {
7170
}),
7271
} as const
7372

74-
interface ProviderTokenInput {
75-
context: OrganizationMembershipContext
76-
credentialId: string
77-
providerId: string
78-
}
79-
80-
function resolveToken({ context, credentialId, providerId }: ProviderTokenInput) {
81-
return resolveManagedOAuthToken({
82-
credentialId,
83-
organizationId: context.organizationId,
84-
expectedProviderId: providerId,
85-
requiredScopes: [],
86-
})
87-
}
88-
89-
function isTokenRejected(error: unknown) {
90-
return error instanceof HomeProviderReadError && error.failure === 'token_rejected'
91-
}
92-
93-
/**
94-
* One read with the member's token, and one more with a fresh token if the first is refused. A
95-
* concurrent read of the same account may already have rotated it, so the fresh token is resolved
96-
* whether or not this call was the one that rejected the old token.
97-
*/
98-
async function readWithToken<T>(
99-
input: ProviderTokenInput,
100-
read: (accessToken: string) => Promise<T[]>
101-
): Promise<T[]> {
102-
const { context, credentialId, providerId } = input
103-
const { accessToken } = await resolveToken(input)
104-
recordCredentialAccess({
105-
actorId: context.userId,
106-
workspaceId: null,
107-
resourceId: credentialId,
108-
providerId,
109-
credentialType: 'oauth',
110-
})
111-
try {
112-
return await read(accessToken)
113-
} catch (error) {
114-
if (!isTokenRejected(error)) throw error
115-
}
116-
const rejected = await rejectManagedOAuthToken({
117-
credentialId,
118-
organizationId: context.organizationId,
119-
expectedProviderId: providerId,
120-
requiredScopes: [],
121-
rejectedAccessToken: accessToken,
122-
})
123-
if (rejected)
124-
recordAudit({
125-
actorId: context.userId,
126-
action: AuditAction.CREDENTIAL_UPDATED,
127-
resourceType: AuditResourceType.CREDENTIAL,
128-
resourceId: credentialId,
129-
description: 'Marked provider-rejected managed OAuth credential as needing reauthorization',
130-
metadata: {
131-
organizationId: context.organizationId,
132-
provider: providerId,
133-
credentialType: 'managed_oauth',
134-
},
135-
})
136-
const retry = await resolveToken(input)
137-
try {
138-
return await read(retry.accessToken)
139-
} catch (error) {
140-
if (isTokenRejected(error)) throw new HomeProviderReadError('needs_reauth', 401)
141-
throw error
142-
}
143-
}
144-
14573
const REAUTH_ERROR_CODES: ReadonlySet<ManagedOAuthCredentialError['code']> = new Set([
14674
'MANAGED_CREDENTIAL_NEEDS_REAUTH',
14775
'MANAGED_CREDENTIAL_REVOKED',
@@ -181,6 +109,7 @@ interface WidgetReadResult<T> {
181109
interface ViewerAccountRead extends ProviderTokenInput {
182110
accountId: string | null
183111
readableAccounts: number
112+
withToken: AccountTokenRead
184113
}
185114

186115
type ViewerAccountReader<T> = (accessToken: string, account: ViewerAccountRead) => Promise<T[]>
@@ -254,16 +183,16 @@ async function readViewerAccounts<T>(
254183
if (!ownedIds.has(credentialId)) return settled('not_offered')
255184
if (!toRead.has(credentialId)) return settled('not_read')
256185
try {
186+
const tokenInput = { context, credentialId, providerId }
257187
const account = {
258-
context,
259-
credentialId,
188+
...tokenInput,
260189
accountId,
261-
providerId,
262190
readableAccounts: toRead.size,
191+
withToken: createAccountTokenRead(tokenInput),
263192
}
264193
return settled(
265194
'ready',
266-
await readWithToken(account, (accessToken) => read(accessToken, account))
195+
await account.withToken((accessToken) => read(accessToken, account))
267196
)
268197
} catch (error) {
269198
return settled(failedState(error, { organizationId, credentialId, providerId }))
@@ -277,7 +206,7 @@ async function readViewerAccounts<T>(
277206
}
278207

279208
interface GmailAccountCursor extends GmailThreadCursor {
280-
account: ProviderTokenInput
209+
account: ViewerAccountRead
281210
}
282211

283212
/**
@@ -298,7 +227,7 @@ async function readRemainingGmailThreads(
298227
const cursor = cursors.get(credentialId)
299228
if (!cursor) return
300229
try {
301-
const threads = await readWithToken(cursor.account, (accessToken) =>
230+
const threads = await cursor.account.withToken((accessToken) =>
302231
readMoreGmailThreads({ accessToken, signal, cursor, count })
303232
)
304233
rows.push(...threads.map((thread) => ({ ...thread, credentialId })))
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const mocks = vi.hoisted(() => ({
4+
resolve: vi.fn(),
5+
reject: vi.fn(),
6+
recordAccess: vi.fn(),
7+
}))
8+
9+
vi.mock('@/lib/credentials/managed-oauth', () => ({
10+
resolveManagedOAuthToken: mocks.resolve,
11+
rejectManagedOAuthToken: mocks.reject,
12+
}))
13+
vi.mock('@/lib/oauth/token-resolution', () => ({ recordCredentialAccess: mocks.recordAccess }))
14+
15+
import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization'
16+
import { createAccountTokenRead } from '@/lib/organizations/home/account-token'
17+
import { HomeProviderReadError } from '@/lib/organizations/home/provider-reads'
18+
19+
const input = {
20+
context: { organizationId: 'org', userId: 'viewer' } as OrganizationMembershipContext,
21+
credentialId: 'credential',
22+
providerId: 'google-email',
23+
}
24+
const refused = () => new HomeProviderReadError('token_rejected', 401)
25+
26+
describe('one request reading an account several times', () => {
27+
beforeEach(() => {
28+
let issued = 0
29+
mocks.resolve.mockReset().mockImplementation(async () => {
30+
issued += 1
31+
return { accessToken: `token-${issued}` }
32+
})
33+
mocks.reject.mockReset().mockResolvedValue(false)
34+
mocks.recordAccess.mockReset()
35+
})
36+
37+
it('resolves and records the token once across reads', async () => {
38+
const withToken = createAccountTokenRead(input)
39+
const used = [await withToken(async (token) => token), await withToken(async (token) => token)]
40+
expect(used).toEqual(['token-1', 'token-1'])
41+
expect(mocks.resolve).toHaveBeenCalledOnce()
42+
expect(mocks.recordAccess).toHaveBeenCalledOnce()
43+
})
44+
45+
it('swaps a refused token mid-request and keeps the fresh one for later reads', async () => {
46+
const withToken = createAccountTokenRead(input)
47+
await withToken(async (token) => token)
48+
const seen: string[] = []
49+
const retried = await withToken(async (token) => {
50+
seen.push(token)
51+
if (token === 'token-1') throw refused()
52+
return token
53+
})
54+
expect(seen).toEqual(['token-1', 'token-2'])
55+
expect(retried).toBe('token-2')
56+
expect(mocks.reject).toHaveBeenCalledWith(
57+
expect.objectContaining({ rejectedAccessToken: 'token-1' })
58+
)
59+
await expect(withToken(async (token) => token)).resolves.toBe('token-2')
60+
expect(mocks.resolve).toHaveBeenCalledTimes(2)
61+
})
62+
63+
it('asks to reconnect when the fresh token is refused too', async () => {
64+
const withToken = createAccountTokenRead(input)
65+
await expect(
66+
withToken(async () => {
67+
throw refused()
68+
})
69+
).rejects.toMatchObject({ failure: 'needs_reauth' })
70+
})
71+
72+
it('does not swap the token for any other failure', async () => {
73+
const withToken = createAccountTokenRead(input)
74+
const failure = new HomeProviderReadError('unavailable', 500)
75+
await expect(
76+
withToken(async () => {
77+
throw failure
78+
})
79+
).rejects.toBe(failure)
80+
expect(mocks.reject).not.toHaveBeenCalled()
81+
await expect(withToken(async (token) => token)).resolves.toBe('token-1')
82+
})
83+
84+
it('resolves a new token for a new request', async () => {
85+
await createAccountTokenRead(input)(async (token) => token)
86+
await expect(createAccountTokenRead(input)(async (token) => token)).resolves.toBe('token-2')
87+
})
88+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2+
import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization'
3+
import { rejectManagedOAuthToken, resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth'
4+
import { recordCredentialAccess } from '@/lib/oauth/token-resolution'
5+
import { HomeProviderReadError } from '@/lib/organizations/home/provider-reads'
6+
7+
export interface ProviderTokenInput {
8+
context: OrganizationMembershipContext
9+
credentialId: string
10+
providerId: string
11+
}
12+
13+
/** Runs a provider read with an account's token, however many reads one widget request needs. */
14+
export type AccountTokenRead = <T>(read: (accessToken: string) => Promise<T>) => Promise<T>
15+
16+
function resolveToken({ context, credentialId, providerId }: ProviderTokenInput) {
17+
return resolveManagedOAuthToken({
18+
credentialId,
19+
organizationId: context.organizationId,
20+
expectedProviderId: providerId,
21+
requiredScopes: [],
22+
})
23+
}
24+
25+
function isTokenRejected(error: unknown) {
26+
return error instanceof HomeProviderReadError && error.failure === 'token_rejected'
27+
}
28+
29+
/**
30+
* One widget request's use of an account's token: resolved and recorded once, and kept until the
31+
* provider refuses it. A refused read runs once more with a fresh token, resolved whether or not
32+
* this call was the one that rejected the old token, since a concurrent read of the same account may
33+
* already have rotated it. Create one per request, so a revoked grant applies on the next request.
34+
*/
35+
export function createAccountTokenRead(input: ProviderTokenInput): AccountTokenRead {
36+
const { context, credentialId, providerId } = input
37+
let currentToken: string | null = null
38+
39+
return async (read) => {
40+
if (currentToken === null) {
41+
currentToken = (await resolveToken(input)).accessToken
42+
recordCredentialAccess({
43+
actorId: context.userId,
44+
workspaceId: null,
45+
resourceId: credentialId,
46+
providerId,
47+
credentialType: 'oauth',
48+
})
49+
}
50+
try {
51+
return await read(currentToken)
52+
} catch (error) {
53+
if (!isTokenRejected(error)) throw error
54+
}
55+
const rejected = await rejectManagedOAuthToken({
56+
credentialId,
57+
organizationId: context.organizationId,
58+
expectedProviderId: providerId,
59+
requiredScopes: [],
60+
rejectedAccessToken: currentToken,
61+
})
62+
if (rejected)
63+
recordAudit({
64+
actorId: context.userId,
65+
action: AuditAction.CREDENTIAL_UPDATED,
66+
resourceType: AuditResourceType.CREDENTIAL,
67+
resourceId: credentialId,
68+
description: 'Marked provider-rejected managed OAuth credential as needing reauthorization',
69+
metadata: {
70+
organizationId: context.organizationId,
71+
provider: providerId,
72+
credentialType: 'managed_oauth',
73+
},
74+
})
75+
currentToken = null
76+
const fresh = await resolveToken(input)
77+
currentToken = fresh.accessToken
78+
try {
79+
return await read(fresh.accessToken)
80+
} catch (error) {
81+
if (isTokenRejected(error)) throw new HomeProviderReadError('needs_reauth', 401)
82+
throw error
83+
}
84+
}
85+
}

0 commit comments

Comments
 (0)