Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ td auth login

This opens your browser to authenticate with Todoist. Once approved, the token is saved automatically.

For a read-only OAuth token (scope `data:read`), run:

```bash
td auth login --read-only
```

In read-only mode, commands that change Todoist data (create/update/delete/complete/move/archive, etc.) are blocked by the CLI.

### Alternative methods

**Manual token:** Get your API token from [Todoist Settings > Integrations > Developer](https://todoist.com/app/settings/integrations/developer):
Expand All @@ -46,11 +54,19 @@ td auth token "your-token"
export TODOIST_API_TOKEN="your-token"
```

Note: externally provided tokens (`TODOIST_API_TOKEN` or `td auth token`) are treated as unknown scope and assumed write-capable. The CLI cannot currently auto-detect OAuth scope for these tokens.

### Auth commands

```bash
td auth status # check if authenticated
td auth logout # remove saved token
td auth status # check if authenticated + mode (read-only/read-write/unknown)
td auth logout # remove saved token and auth metadata
```

To switch back to normal write access, re-run:

```bash
td auth login
```

## Usage
Expand Down
54 changes: 48 additions & 6 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/auth.js', () => ({
saveApiToken: vi.fn(),
clearApiToken: vi.fn(),
getAuthMetadata: vi.fn(),
}))

// Mock the api module
Expand Down Expand Up @@ -54,7 +55,8 @@ import { createInterface, type Interface } from 'node:readline'
import open from 'open'
import { registerAuthCommand } from '../commands/auth.js'
import { getApi } from '../lib/api/core.js'
import { clearApiToken, saveApiToken } from '../lib/auth.js'
import { clearApiToken, getAuthMetadata, saveApiToken } from '../lib/auth.js'
import { buildAuthorizationUrl, exchangeCodeForToken } from '../lib/oauth.js'
import { startCallbackServer } from '../lib/oauth-server.js'
import { exchangeCodeForToken } from '../lib/oauth.js'
import { createMockApi } from './helpers/mock-api.js'
Expand All @@ -63,8 +65,10 @@ const mockCreateInterface = vi.mocked(createInterface)

const mockSaveApiToken = vi.mocked(saveApiToken)
const mockClearApiToken = vi.mocked(clearApiToken)
const mockGetAuthMetadata = vi.mocked(getAuthMetadata)
const mockGetApi = vi.mocked(getApi)
const mockStartCallbackServer = vi.mocked(startCallbackServer)
const mockBuildAuthorizationUrl = vi.mocked(buildAuthorizationUrl)
const mockExchangeCodeForToken = vi.mocked(exchangeCodeForToken)
const mockOpen = vi.mocked(open)

Expand Down Expand Up @@ -96,7 +100,7 @@ describe('auth command', () => {

await program.parseAsync(['node', 'td', 'auth', 'token', token])

expect(mockSaveApiToken).toHaveBeenCalledWith(token)
expect(mockSaveApiToken).toHaveBeenCalledWith(token, { authMode: 'unknown' })
expect(consoleSpy).toHaveBeenCalledWith('✓', 'API token saved successfully!')
expect(consoleSpy).toHaveBeenCalledWith(
'Token saved to ~/.config/todoist-cli/config.json',
Expand All @@ -113,7 +117,7 @@ describe('auth command', () => {
program.parseAsync(['node', 'td', 'auth', 'token', token]),
).rejects.toThrow('Permission denied')

expect(mockSaveApiToken).toHaveBeenCalledWith(token)
expect(mockSaveApiToken).toHaveBeenCalledWith(token, { authMode: 'unknown' })
})

it('trims whitespace from token', async () => {
Expand All @@ -125,7 +129,7 @@ describe('auth command', () => {

await program.parseAsync(['node', 'td', 'auth', 'token', tokenWithWhitespace])

expect(mockSaveApiToken).toHaveBeenCalledWith(expectedToken)
expect(mockSaveApiToken).toHaveBeenCalledWith(expectedToken, { authMode: 'unknown' })
})

it('prompts interactively when no token argument given', async () => {
Expand All @@ -145,7 +149,9 @@ describe('auth command', () => {

expect(mockRl.question).toHaveBeenCalled()
expect(mockRl.close).toHaveBeenCalled()
expect(mockSaveApiToken).toHaveBeenCalledWith('interactive_token_456')
expect(mockSaveApiToken).toHaveBeenCalledWith('interactive_token_456', {
authMode: 'unknown',
})
writeSpy.mockRestore()
})

Expand Down Expand Up @@ -190,10 +196,40 @@ describe('auth command', () => {
expect(mockOpen).toHaveBeenCalledWith('https://todoist.com/oauth/authorize?test=1')
expect(mockStartCallbackServer).toHaveBeenCalledWith('test_state')
expect(mockExchangeCodeForToken).toHaveBeenCalledWith(authCode, 'test_code_verifier')
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken)
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken, {
authMode: 'read-write',
authScope: 'data:read_write,data:delete,project:delete',
})
expect(consoleSpy).toHaveBeenCalledWith('✓', 'Successfully logged in!')
})

it('requests data:read scope when --read-only is set', async () => {
const program = createProgram()
const authCode = 'oauth_auth_code_123'
const accessToken = 'oauth_access_token_456'

mockStartCallbackServer.mockReturnValue({
promise: Promise.resolve(authCode),
cleanup: vi.fn(),
})
mockExchangeCodeForToken.mockResolvedValue(accessToken)
mockSaveApiToken.mockResolvedValue(undefined)
mockOpen.mockResolvedValue({} as Awaited<ReturnType<typeof open>>)

await program.parseAsync(['node', 'td', 'auth', 'login', '--read-only'])

expect(mockBuildAuthorizationUrl).toHaveBeenCalledWith(
'test_code_challenge',
'test_state',
{ readOnly: true },
)
expect(mockOpen).toHaveBeenCalledWith('https://todoist.com/oauth/authorize?test=1')
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken, {
authMode: 'read-only',
authScope: 'data:read',
})
})

it('handles OAuth callback server error', async () => {
const program = createProgram()
const mockCleanup = vi.fn()
Expand Down Expand Up @@ -256,6 +292,11 @@ describe('auth command', () => {
const mockUser = { email: 'test@example.com', fullName: 'Test User' }
const mockApi = createMockApi({ getUser: vi.fn().mockResolvedValue(mockUser) })
mockGetApi.mockResolvedValue(mockApi)
mockGetAuthMetadata.mockResolvedValue({
authMode: 'read-only',
authScope: 'data:read',
source: 'config',
})

await program.parseAsync(['node', 'td', 'auth', 'status'])

Expand All @@ -264,6 +305,7 @@ describe('auth command', () => {
expect(consoleSpy).toHaveBeenCalledWith('✓', 'Authenticated')
expect(consoleSpy).toHaveBeenCalledWith(' Email: test@example.com')
expect(consoleSpy).toHaveBeenCalledWith(' Name: Test User')
expect(consoleSpy).toHaveBeenCalledWith(' Mode: read-only (OAuth scope data:read)')
})

it('shows not authenticated when no token', async () => {
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { buildAuthorizationUrl } from '../lib/oauth.js'

describe('buildAuthorizationUrl', () => {
it('uses read-only scope when requested', () => {
const url = buildAuthorizationUrl('challenge', 'state', { readOnly: true })
const params = new URL(url).searchParams

expect(params.get('scope')).toBe('data:read')
})
})
44 changes: 44 additions & 0 deletions src/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'

vi.mock('../lib/auth.js', () => ({
getAuthMetadata: vi.fn(),
}))

import { getAuthMetadata } from '../lib/auth.js'
import {
ensureWriteAllowed,
isMutatingApiMethod,
isMutatingSyncPayload,
READ_ONLY_ERROR_MESSAGE,
} from '../lib/permissions.js'

const mockGetAuthMetadata = vi.mocked(getAuthMetadata)

describe('permissions', () => {
it('blocks writes in read-only mode', async () => {
mockGetAuthMetadata.mockResolvedValue({
authMode: 'read-only',
authScope: 'data:read',
source: 'config',
})

await expect(ensureWriteAllowed()).rejects.toThrow(READ_ONLY_ERROR_MESSAGE)
})

it('allows writes when mode is unknown', async () => {
mockGetAuthMetadata.mockResolvedValue({
authMode: 'unknown',
source: 'env',
})

await expect(ensureWriteAllowed()).resolves.toBeUndefined()
})

it('identifies mutating methods and sync payloads', () => {
expect(isMutatingApiMethod('addTask')).toBe(true)
expect(isMutatingApiMethod('getTasks')).toBe(false)
expect(isMutatingApiMethod('brandNewApiMethod')).toBe(true)
expect(isMutatingSyncPayload([{ commands: [{ type: 'task_add' }] }])).toBe(true)
expect(isMutatingSyncPayload([{ resourceTypes: ['items'], syncToken: '*' }])).toBe(false)
})
})
32 changes: 26 additions & 6 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import chalk from 'chalk'
import { Command } from 'commander'
import open from 'open'
import { getApi } from '../lib/api/core.js'
import { clearApiToken, saveApiToken } from '../lib/auth.js'
import { clearApiToken, getAuthMetadata, saveApiToken } from '../lib/auth.js'
import { buildAuthorizationUrl, exchangeCodeForToken } from '../lib/oauth.js'
import { startCallbackServer } from '../lib/oauth-server.js'
import { buildAuthorizationUrl, exchangeCodeForToken } from '../lib/oauth.js'
import { generateCodeChallenge, generateCodeVerifier, generateState } from '../lib/pkce.js'
Expand Down Expand Up @@ -39,19 +40,21 @@ async function loginWithToken(token?: string): Promise<void> {
return
}
}
await saveApiToken(token.trim())
await saveApiToken(token.trim(), { authMode: 'unknown' })
console.log(chalk.green('✓'), 'API token saved successfully!')
console.log(chalk.dim('Token saved to ~/.config/todoist-cli/config.json'))
}

async function loginWithOAuth(): Promise<void> {
async function loginWithOAuth(options: { readOnly?: boolean }): Promise<void> {
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()

console.log('Opening browser for Todoist authorization...')

const authUrl = buildAuthorizationUrl(codeChallenge, state)
const authUrl = buildAuthorizationUrl(codeChallenge, state, {
readOnly: options.readOnly,
})
const { promise: callbackPromise, cleanup } = startCallbackServer(state)

try {
Expand All @@ -62,7 +65,12 @@ async function loginWithOAuth(): Promise<void> {
console.log(chalk.dim('Exchanging code for token...'))

const accessToken = await exchangeCodeForToken(code, codeVerifier)
await saveApiToken(accessToken)
await saveApiToken(accessToken, {
authMode: options.readOnly ? 'read-only' : 'read-write',
authScope: options.readOnly
? 'data:read'
: 'data:read_write,data:delete,project:delete',
})

console.log(chalk.green('✓'), 'Successfully logged in!')
console.log(chalk.dim('Token saved to ~/.config/todoist-cli/config.json'))
Expand All @@ -76,9 +84,18 @@ async function showStatus(): Promise<void> {
try {
const api = await getApi()
const user = await api.getUser()
const metadata = await getAuthMetadata()
const modeLabel =
metadata.authMode === 'read-only'
? 'read-only (OAuth scope data:read)'
: metadata.authMode === 'read-write'
? 'read-write'
: 'unknown (manual token or env var; assuming write access)'

console.log(chalk.green('✓'), 'Authenticated')
console.log(` Email: ${user.email}`)
console.log(` Name: ${user.fullName}`)
console.log(` Mode: ${modeLabel}`)
} catch {
console.log(chalk.yellow('Not authenticated'))
console.log(chalk.dim('Run `td auth login` or `td auth token <token>` to authenticate'))
Expand All @@ -94,7 +111,10 @@ async function logout(): Promise<void> {
export function registerAuthCommand(program: Command): void {
const auth = program.command('auth').description('Manage authentication')

auth.command('login').description('Authenticate with Todoist via OAuth').action(loginWithOAuth)
auth.command('login')
.description('Authenticate with Todoist via OAuth')
.option('--read-only', 'Authenticate with read-only scope (data:read)')
.action(loginWithOAuth)

auth.command('token [token]')
.description('Save API token to config file (manual authentication)')
Expand Down
Loading