Skip to content

Commit 7deafc3

Browse files
committed
fix(auth): refresh token before token view
1 parent 54fbf7c commit 7deafc3

3 files changed

Lines changed: 117 additions & 23 deletions

File tree

src/commands/auth/auth.test.ts

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ import { attachLoginCommand } from '@doist/cli-core/auth'
8888
import { CommsRequestError, type User } from '@doist/comms-sdk'
8989
import { createWrappedCommsClient } from '../../lib/api.js'
9090
import { type CommsAccount, type CommsTokenStore } from '../../lib/auth-provider.js'
91-
import { getApiTokenSnapshot, TOKEN_ENV_VAR } from '../../lib/auth.js'
91+
import { getApiTokenSnapshot, NoTokenError, TOKEN_ENV_VAR } from '../../lib/auth.js'
9292
import { getConfig, updateConfig } from '../../lib/config.js'
9393
import { resetGlobalArgs } from '../../lib/global-args.js'
9494
import { registerAuthCommand } from './index.js'
@@ -284,30 +284,81 @@ describe('auth command', () => {
284284
vi.unstubAllEnvs()
285285
})
286286

287-
it('prints exactly the stored token to stdout with no envelope (pipe-safe)', async () => {
287+
it('prints exactly the current token snapshot to stdout with no envelope (pipe-safe)', async () => {
288288
vi.stubEnv(TOKEN_ENV_VAR, '')
289-
storeMocks.active.mockResolvedValue(STORED_SNAPSHOT)
289+
mockGetApiTokenSnapshot.mockResolvedValue(STORED_SNAPSHOT)
290290

291291
await createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view'])
292292

293+
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith(undefined)
293294
expect(stdoutPayload()).toBe('tk_stored_1234567890')
294295
expect(consoleSpy).not.toHaveBeenCalled()
295296
})
296297

298+
it('adds a trailing newline only when stdout is a TTY', async () => {
299+
vi.stubEnv(TOKEN_ENV_VAR, '')
300+
mockGetApiTokenSnapshot.mockResolvedValue(STORED_SNAPSHOT)
301+
const originalIsTTY = process.stdout.isTTY
302+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
303+
304+
try {
305+
await createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view'])
306+
} finally {
307+
Object.defineProperty(process.stdout, 'isTTY', {
308+
value: originalIsTTY,
309+
configurable: true,
310+
})
311+
}
312+
313+
expect(stdoutPayload()).toBe('tk_stored_1234567890\n')
314+
})
315+
316+
it('refreshes an expired OAuth token before printing', async () => {
317+
vi.stubEnv(TOKEN_ENV_VAR, '')
318+
const oauthAccount: CommsAccount = {
319+
...STORED_ACCOUNT,
320+
oauthClientId: 'tdd_123',
321+
authBaseUrl: 'https://todoist.com',
322+
authResource: 'https://comms.todoist.com',
323+
}
324+
mockGetApiTokenSnapshot.mockResolvedValue({
325+
token: 'tk_refreshed_1234567890',
326+
account: oauthAccount,
327+
})
328+
329+
await createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view'])
330+
331+
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith(undefined)
332+
expect(stdoutPayload()).toBe('tk_refreshed_1234567890')
333+
})
334+
335+
it('prints manual tokens returned by the token snapshot path', async () => {
336+
vi.stubEnv(TOKEN_ENV_VAR, '')
337+
mockGetApiTokenSnapshot.mockResolvedValue({
338+
token: 'manual_token_1234567890',
339+
account: { id: '', label: '', authMode: 'unknown', authScope: '' },
340+
})
341+
342+
await createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view'])
343+
344+
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith(undefined)
345+
expect(stdoutPayload()).toBe('manual_token_1234567890')
346+
})
347+
297348
it('refuses to print when the env var is set so the CLI does not disclose an unmanaged token', async () => {
298349
vi.stubEnv(TOKEN_ENV_VAR, 'env_token_supplied_externally')
299350

300351
await expect(
301352
createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view']),
302353
).rejects.toHaveProperty('code', 'TOKEN_FROM_ENV')
303354

304-
expect(storeMocks.active).not.toHaveBeenCalled()
355+
expect(mockGetApiTokenSnapshot).not.toHaveBeenCalled()
305356
expect(stdoutPayload()).toBe('')
306357
})
307358

308359
it('throws NOT_AUTHENTICATED when no token is stored', async () => {
309360
vi.stubEnv(TOKEN_ENV_VAR, '')
310-
storeMocks.active.mockResolvedValue(null)
361+
mockGetApiTokenSnapshot.mockRejectedValue(new NoTokenError())
311362

312363
await expect(
313364
createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view']),
@@ -316,9 +367,9 @@ describe('auth command', () => {
316367
expect(stdoutPayload()).toBe('')
317368
})
318369

319-
it('matches per-command --user against the stored account by id', async () => {
370+
it('passes per-command --user through to the token snapshot path', async () => {
320371
vi.stubEnv(TOKEN_ENV_VAR, '')
321-
storeMocks.active.mockResolvedValue(STORED_SNAPSHOT)
372+
mockGetApiTokenSnapshot.mockResolvedValue(STORED_SNAPSHOT)
322373

323374
await createProgram().parseAsync([
324375
'node',
@@ -330,13 +381,13 @@ describe('auth command', () => {
330381
'1',
331382
])
332383

333-
expect(storeMocks.active).toHaveBeenCalledWith('1')
384+
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith('1')
334385
expect(stdoutPayload()).toBe('tk_stored_1234567890')
335386
})
336387

337388
it('rejects per-command --user with ACCOUNT_NOT_FOUND when the ref does not match', async () => {
338389
vi.stubEnv(TOKEN_ENV_VAR, '')
339-
storeMocks.active.mockResolvedValue(null)
390+
mockGetApiTokenSnapshot.mockRejectedValue(new NoTokenError())
340391

341392
await expect(
342393
createProgram().parseAsync([
@@ -350,6 +401,7 @@ describe('auth command', () => {
350401
]),
351402
).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND')
352403

404+
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith('999')
353405
expect(stdoutPayload()).toBe('')
354406
})
355407
})
@@ -373,16 +425,15 @@ describe('auth command', () => {
373425
vi.unstubAllEnvs()
374426
})
375427

376-
it('threads `tdc --user <ref> auth token view` into store.active', async () => {
428+
it('threads `tdc --user <ref> auth token view` into token refresh', async () => {
377429
vi.stubEnv(TOKEN_ENV_VAR, '')
378-
storeMocks.list.mockResolvedValue(STORED_RECORDS)
379-
storeMocks.active.mockResolvedValue(STORED_SNAPSHOT)
430+
mockGetApiTokenSnapshot.mockResolvedValue(STORED_SNAPSHOT)
380431
process.argv = ['node', 'tdc', '--user', '1', 'auth', 'token', 'view']
381432
resetGlobalArgs()
382433

383434
await createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view'])
384435

385-
expect(storeMocks.active).toHaveBeenCalledWith('1')
436+
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith('1')
386437
expect(writeSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('')).toBe(
387438
'tk_stored_1234567890',
388439
)

src/commands/auth/index.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
import { attachTokenViewCommand } from '@doist/cli-core/auth'
21
import { Command } from 'commander'
32
import { createCommsTokenStore } from '../../lib/auth-provider.js'
4-
import { TOKEN_ENV_VAR } from '../../lib/auth.js'
53
import { getRequestedUserRef } from '../../lib/global-args.js'
64
import { attachCommsLoginCommand } from './login.js'
75
import { attachCommsLogoutCommand } from './logout.js'
86
import { attachCommsStatusCommand } from './status.js'
97
import { withUserRefAware } from './store-wrap.js'
8+
import { attachCommsTokenViewCommand } from './token-view.js'
109
import { loginWithToken } from './token.js'
1110

1211
export function registerAuthCommand(program: Command): void {
1312
const auth = program.command('auth').description('Manage authentication')
1413

1514
const store = createCommsTokenStore()
16-
const refAware = withUserRefAware(store, getRequestedUserRef())
15+
const requestedRef = getRequestedUserRef()
16+
const refAware = withUserRefAware(store, requestedRef)
1717

1818
attachCommsLoginCommand(auth, store)
1919
attachCommsLogoutCommand(auth, refAware)
@@ -28,11 +28,5 @@ export function registerAuthCommand(program: Command): void {
2828
.description('Save API token for CLI authentication (or use a subcommand: `view`)')
2929
.action(() => loginWithToken())
3030

31-
attachTokenViewCommand(tokenCmd, {
32-
name: 'view',
33-
store: refAware,
34-
envVarName: TOKEN_ENV_VAR,
35-
description:
36-
'Print the stored API token for the active user (or --user <ref>) to stdout for use in scripts',
37-
})
31+
attachCommsTokenViewCommand(tokenCmd, requestedRef)
3832
}

src/commands/auth/token-view.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Command } from 'commander'
2+
import { getApiTokenSnapshot, NoTokenError, TOKEN_ENV_VAR } from '../../lib/auth.js'
3+
import { CliError } from '../../lib/errors.js'
4+
5+
type TokenViewOptions = {
6+
user?: string
7+
}
8+
9+
const USER_FLAG_DESCRIPTION = 'Target a specific stored account'
10+
11+
export function attachCommsTokenViewCommand(
12+
parent: Command,
13+
requestedRef: string | undefined,
14+
): Command {
15+
return parent
16+
.command('view')
17+
.description(
18+
'Print the stored API token for the active user (or --user <ref>) to stdout for use in scripts',
19+
)
20+
.option('--user <ref>', USER_FLAG_DESCRIPTION)
21+
.action((options: TokenViewOptions) => viewToken(options.user ?? requestedRef))
22+
}
23+
24+
async function viewToken(ref: string | undefined): Promise<void> {
25+
if (process.env[TOKEN_ENV_VAR]) {
26+
throw new CliError(
27+
'TOKEN_FROM_ENV',
28+
`Refusing to print: token is being read from $${TOKEN_ENV_VAR}, not the saved store.`,
29+
[
30+
`Unset ${TOKEN_ENV_VAR} to view the stored token.`,
31+
'The env var takes precedence over saved tokens; printing it would disclose a secret the CLI did not manage.',
32+
],
33+
)
34+
}
35+
36+
try {
37+
const snapshot = await getApiTokenSnapshot(ref)
38+
process.stdout.write(snapshot.token)
39+
if (process.stdout.isTTY) process.stdout.write('\n')
40+
} catch (error) {
41+
if (error instanceof NoTokenError) {
42+
if (ref !== undefined) {
43+
throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`)
44+
}
45+
throw new CliError('NOT_AUTHENTICATED', 'Not signed in.')
46+
}
47+
throw error
48+
}
49+
}

0 commit comments

Comments
 (0)