Skip to content

Commit 7e41aa3

Browse files
committed
fix: tighten OAuth refresh validation
Refresh doctor probes online, fail closed on partial OAuth metadata, and cover resource-aware shared client creation.
1 parent 83a6b32 commit 7e41aa3

8 files changed

Lines changed: 101 additions & 14 deletions

File tree

src/commands/doctor.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ describe('doctor command', () => {
114114
expect(consoleSpy).toHaveBeenCalledWith(
115115
expect.stringContaining('PASS Authenticated as person@example.com via secure-store'),
116116
)
117+
expect(mockProbeApiToken).toHaveBeenCalledWith({ refresh: true })
117118
expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('test_token_123456789', {
118119
baseUrl: 'https://comms.staging.todoist.com',
119120
})
@@ -236,6 +237,7 @@ describe('doctor command', () => {
236237
const program = createProgram()
237238
await program.parseAsync(['node', 'tdc', 'doctor', '--offline'])
238239

240+
expect(mockProbeApiToken).toHaveBeenCalledWith({ refresh: false })
239241
expect(consoleSpy).toHaveBeenCalledWith(
240242
expect.stringContaining(
241243
'SKIP Auth validation skipped (--offline); credentials found via secure-store',

src/commands/doctor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ async function checkAuthentication(offline: boolean): Promise<DoctorCheck> {
245245
let metadata: Awaited<ReturnType<typeof probeApiToken>>['metadata']
246246

247247
try {
248-
const probe = await probeApiToken()
248+
const probe = await probeApiToken({ refresh: !offline })
249249
token = probe.token
250250
metadata = probe.metadata
251251
} catch (error) {

src/lib/api.test.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ vi.mock('@doist/comms-sdk', () => {
3535
vi.mock('./auth.js', () => ({
3636
getApiTokenSnapshot: vi.fn().mockResolvedValue({
3737
token: 'test-token',
38-
account: { id: '42', label: 'Ada', authMode: 'read-write', authScope: '' },
38+
account: {
39+
id: '42',
40+
label: 'Ada',
41+
authMode: 'read-write',
42+
authResource: 'https://comms.staging.todoist.com',
43+
authScope: '',
44+
},
3945
}),
4046
getAuthMetadata: vi.fn().mockResolvedValue({ authMode: 'full' }),
4147
}))
@@ -59,12 +65,18 @@ vi.mock('./progress.js', () => ({
5965
getProgressTracker: () => ({ isEnabled: () => false, emitApiCall: vi.fn() }),
6066
}))
6167

62-
const { clearWorkspaceUserCache, createWrappedCommsClient, getWorkspaceUsers } =
63-
await import('./api.js')
68+
const {
69+
clearApiClientCache,
70+
clearWorkspaceUserCache,
71+
createWrappedCommsClient,
72+
getCommsClient,
73+
getWorkspaceUsers,
74+
} = await import('./api.js')
6475

6576
describe('getWorkspaceUsers', () => {
6677
beforeEach(() => {
6778
getWorkspaceUsersMock.mockClear()
79+
clearApiClientCache()
6880
clearWorkspaceUserCache()
6981
})
7082

@@ -109,6 +121,7 @@ describe('getWorkspaceUsers', () => {
109121

110122
describe('wrapResult — central 403 translation', () => {
111123
beforeEach(() => {
124+
clearApiClientCache()
112125
sdkMocks.createClient.mockReset()
113126
sdkMocks.deleteChannel.mockReset()
114127
sdkMocks.uploadAttachment.mockReset()
@@ -123,6 +136,14 @@ describe('wrapResult — central 403 translation', () => {
123136
})
124137
})
125138

139+
it('uses the active OAuth account resource when creating the shared SDK client', async () => {
140+
await getCommsClient()
141+
142+
expect(sdkMocks.createClient).toHaveBeenCalledWith('test-token', {
143+
baseUrl: 'https://comms.staging.todoist.com',
144+
})
145+
})
146+
126147
it('translates a plain 403 into a FORBIDDEN CliError', async () => {
127148
sdkMocks.deleteChannel.mockRejectedValueOnce(
128149
new CommsRequestError('Request failed with status 403', 403, {}),

src/lib/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ function analyzeAndEmitApiResponse(
247247

248248
let apiClient: CommsApi | null = null
249249

250+
export function clearApiClientCache(): void {
251+
apiClient = null
252+
}
253+
250254
export function createWrappedCommsClient(
251255
token: string,
252256
options: { baseUrl?: string } = {},

src/lib/auth-provider.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,40 @@ describe('createCommsAuthProvider', () => {
275275
expect(exchange.refreshToken).toBe('refresh_rotated')
276276
})
277277

278+
it('keeps stored account scope metadata when a refresh response omits scope', async () => {
279+
const fetchImpl = vi.fn().mockResolvedValue(
280+
jsonResponse({
281+
access_token: 'tdc_fresh',
282+
expires_in: 3600,
283+
}),
284+
)
285+
286+
const exchange = await createCommsAuthProvider(fetchImpl).refreshToken!({
287+
refreshToken: 'refresh_old',
288+
handshake: {
289+
oauthClientId: 'tdd_123',
290+
accountId: '42',
291+
accountLabel: 'Ada',
292+
authScope: READ_WRITE_SCOPES.join(' '),
293+
authBaseUrl: 'https://todoist.com',
294+
authorizationUrl: 'https://todoist.com/oauth/authorize',
295+
tokenUrl: 'https://todoist.com/oauth/access_token',
296+
registrationUrl: 'https://todoist.com/oauth/register',
297+
resource: 'https://comms.todoist.com',
298+
},
299+
})
300+
301+
expect(exchange.account).toEqual({
302+
id: '42',
303+
label: 'Ada',
304+
authMode: 'read-write',
305+
authScope: READ_WRITE_SCOPES.join(' '),
306+
oauthClientId: 'tdd_123',
307+
authBaseUrl: 'https://todoist.com',
308+
authResource: 'https://comms.todoist.com',
309+
})
310+
})
311+
278312
it('translates invalid_grant refresh failures into AUTH_REFRESH_EXPIRED', async () => {
279313
const fetchImpl = vi.fn().mockResolvedValue(
280314
jsonResponse(

src/lib/auth-provider.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,14 @@ export function createCommsAuthProvider(
322322
}
323323

324324
export function getCommsOAuthRefreshHandshake(account: CommsAccount): Record<string, unknown> {
325-
const config = getCommsOAuthConfigForAccount(account)
325+
if (!account.oauthClientId || !account.authBaseUrl || !account.authResource) {
326+
throw new CliError(
327+
'NO_TOKEN',
328+
'Stored OAuth token cannot be refreshed because its client metadata is missing.',
329+
['Run: tdc auth login'],
330+
)
331+
}
332+
const config = buildCommsOAuthConfig(account.authBaseUrl, account.authResource)
326333
return {
327334
...config,
328335
oauthClientId: account.oauthClientId,
@@ -337,13 +344,6 @@ function getCommsOAuthConfig(): CommsOAuthConfig {
337344
return buildCommsOAuthConfig(getTodoistAuthBaseUrl(resource), resource)
338345
}
339346

340-
function getCommsOAuthConfigForAccount(account: CommsAccount): CommsOAuthConfig {
341-
if (account.authBaseUrl && account.authResource) {
342-
return buildCommsOAuthConfig(account.authBaseUrl, account.authResource)
343-
}
344-
return getCommsOAuthConfig()
345-
}
346-
347347
function buildCommsOAuthConfig(authBaseUrl: string, resource: string): CommsOAuthConfig {
348348
return {
349349
authBaseUrl,

src/lib/auth.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,28 @@ describe('auth shims over the cli-core keyring store', () => {
178178
)
179179
})
180180

181+
it('getApiToken rejects partial OAuth client metadata instead of defaulting refresh target', async () => {
182+
mocks.activeBundleMock.mockResolvedValue({
183+
account: {
184+
...STORED_ACCOUNT,
185+
oauthClientId: 'tdd_123',
186+
},
187+
bundle: {
188+
accessToken: 'tdc_old',
189+
refreshToken: 'rt_old',
190+
accessTokenExpiresAt: Date.now() - 1000,
191+
},
192+
})
193+
194+
await expect(getApiToken()).rejects.toMatchObject({
195+
code: 'NO_TOKEN',
196+
message:
197+
'Stored OAuth token cannot be refreshed because its client metadata is missing.',
198+
})
199+
200+
expect(mocks.refreshAccessTokenMock).not.toHaveBeenCalled()
201+
})
202+
181203
it('getApiToken uses a legacy OAuth token until it actually expires', async () => {
182204
mocks.activeBundleMock.mockResolvedValue({
183205
account: STORED_ACCOUNT,

src/lib/auth.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export type AuthProbeResult = {
4646
metadata: AuthProbeMetadata
4747
}
4848

49+
export type AuthProbeOptions = {
50+
refresh?: boolean
51+
}
52+
4953
export type ActiveAuthSnapshot = {
5054
token: string
5155
account: CommsAccount
@@ -76,8 +80,8 @@ export async function getApiTokenSnapshot(ref?: string): Promise<ActiveAuthSnaps
7680
}
7781

7882
/** Token + metadata in one round-trip for `tdc config view` / `tdc doctor`. */
79-
export async function probeApiToken(): Promise<AuthProbeResult> {
80-
const snapshot = await getActiveSnapshot({ refresh: false })
83+
export async function probeApiToken(options: AuthProbeOptions = {}): Promise<AuthProbeResult> {
84+
const snapshot = await getActiveSnapshot({ refresh: options.refresh === true })
8185
const source = await getActiveTokenSource()
8286
return {
8387
token: snapshot.token,

0 commit comments

Comments
 (0)