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
4 changes: 4 additions & 0 deletions src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'

vi.mock('../lib/auth.js', () => ({
getApiToken: async () => 'test-token',
// Returning the same token as `getApiToken` means the 401 retry path
// sees "refresh didn't change the token" and surfaces the original
// error. Tests that want to exercise the retry override this.
getApiTokenForceRefresh: async () => 'test-token',
getBaseUrl: async () => 'https://test.outline.com',
}))

Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/auth-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ describe('auth status subcommand', () => {
team: 'Analytics',
baseUrl: 'https://test.outline.com',
source: 'env',
accessTokenExpiresAt: undefined,
hasRefreshToken: false,
})
expect(payload).not.toHaveProperty('name')
expect(payload).not.toHaveProperty('email')
Expand All @@ -168,6 +170,8 @@ describe('auth status subcommand', () => {
team: 'Analytics',
baseUrl: 'https://test.outline.com',
source: 'env',
accessTokenExpiresAt: undefined,
hasRefreshToken: false,
})
})

Expand Down
1 change: 1 addition & 0 deletions src/__tests__/auth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ describe('createOutlineTokenStore', () => {

expect(snapshot).toEqual({
token: LEGACY_CONFIG.api_token,
bundle: { accessToken: LEGACY_CONFIG.api_token },
account: STORED_ACCOUNT,
})
// v2 consulted first (returned null per the beforeEach default),
Expand Down
198 changes: 198 additions & 0 deletions src/__tests__/auth-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { okResponse, STORED_ACCOUNT } from './_fixtures/auth.js'

// Mock the network so the provider's POST is observable.
vi.mock('../transport/fetch-with-retry.js', () => ({ fetchWithRetry: vi.fn() }))
vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() }))

// Skip the migration path — these tests focus on the bundle/refresh wiring,
// not the legacy v1 -> v2 dance which is already covered elsewhere.
vi.mock('../lib/migrate-auth.js', () => ({
runMigrateLegacyAuth: vi.fn(async () => ({
status: 'no-legacy-state' as const,
})),
}))

const configMocks = vi.hoisted(() => ({
getConfig: vi.fn(),
updateConfig: vi.fn(),
}))

vi.mock('../lib/config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/config.js')>()
return {
...actual,
getConfigPath: () => '/tmp/test/outline-cli/config.json',
getConfig: configMocks.getConfig,
updateConfig: configMocks.updateConfig,
}
})

describe('exchangeCode persists the full bundle (refresh + expiry)', () => {
beforeEach(() => {
configMocks.getConfig.mockReset().mockResolvedValue({})
configMocks.updateConfig.mockReset().mockResolvedValue(undefined)
vi.resetModules()
})

afterEach(() => {
vi.clearAllMocks()
})

it('returns accessToken + refreshToken + accessTokenExpiresAt when the token endpoint includes them', async () => {
const { fetchWithRetry } = await import('../transport/fetch-with-retry.js')
vi.mocked(fetchWithRetry).mockResolvedValueOnce(
okResponse({
access_token: 'at-1',
refresh_token: 'rt-1',
expires_in: 3600,
}),
)

const before = Date.now()
const { createOutlineAuthProvider } = await import('../lib/auth-provider.js')
const result = await createOutlineAuthProvider().exchangeCode({
code: 'c',
state: 's',
redirectUri: 'http://localhost:54969/callback',
handshake: {
baseUrl: 'https://wiki.example.com',
clientId: 'cid-xyz',
codeVerifier: 'v',
},
})

expect(result.accessToken).toBe('at-1')
expect(result.refreshToken).toBe('rt-1')
// expires_in=3600 → 1 hour into the future, with a few ms slop.
expect(result.expiresAt).toBeGreaterThanOrEqual(before + 3_600_000)
expect(result.expiresAt).toBeLessThanOrEqual(Date.now() + 3_600_000 + 1000)
})

it('returns just accessToken when the token endpoint omits refresh + expiry (server config)', async () => {
const { fetchWithRetry } = await import('../transport/fetch-with-retry.js')
vi.mocked(fetchWithRetry).mockResolvedValueOnce(okResponse({ access_token: 'at-1' }))

const { createOutlineAuthProvider } = await import('../lib/auth-provider.js')
const result = await createOutlineAuthProvider().exchangeCode({
code: 'c',
state: 's',
redirectUri: 'http://localhost:54969/callback',
handshake: {
baseUrl: 'https://wiki.example.com',
clientId: 'cid-xyz',
codeVerifier: 'v',
},
})

expect(result.accessToken).toBe('at-1')
expect(result.refreshToken).toBeUndefined()
expect(result.expiresAt).toBeUndefined()
})
})

describe('refreshToken on the Outline provider', () => {
beforeEach(() => {
configMocks.getConfig.mockReset().mockResolvedValue({})
configMocks.updateConfig.mockReset().mockResolvedValue(undefined)
vi.resetModules()
})

afterEach(() => {
vi.clearAllMocks()
})

it('POSTs grant_type=refresh_token with stored refresh + clientId, no PKCE verifier', async () => {
const { fetchWithRetry } = await import('../transport/fetch-with-retry.js')
vi.mocked(fetchWithRetry).mockResolvedValueOnce(
okResponse({
access_token: 'new-at',
refresh_token: 'new-rt',
expires_in: 1800,
}),
)

const { createOutlineAuthProvider } = await import('../lib/auth-provider.js')
const result = await createOutlineAuthProvider().refreshToken!({
refreshToken: 'old-rt',
account: STORED_ACCOUNT,
handshake: {},
})

expect(result.accessToken).toBe('new-at')
expect(result.refreshToken).toBe('new-rt')
expect(result.account).toEqual(STORED_ACCOUNT)

const call = vi.mocked(fetchWithRetry).mock.calls[0][0]
expect(call.url).toBe('https://wiki.example.com/oauth/token')
const body = new URLSearchParams(call.options.body as string)
expect(body.get('grant_type')).toBe('refresh_token')
expect(body.get('refresh_token')).toBe('old-rt')
expect(body.get('client_id')).toBe('cid-xyz')
expect(body.has('code_verifier')).toBe(false)
expect(body.has('client_secret')).toBe(false)
})

it('throws when the stored account has no baseUrl or oauthClientId (corrupt record)', async () => {
const { createOutlineAuthProvider } = await import('../lib/auth-provider.js')
await expect(
createOutlineAuthProvider().refreshToken!({
refreshToken: 'rt',
account: { ...STORED_ACCOUNT, oauthClientId: undefined },
handshake: {},
}),
).rejects.toThrow(/baseUrl or oauthClientId/)
})

it('surfaces the server error_description on non-2xx', async () => {
const { fetchWithRetry } = await import('../transport/fetch-with-retry.js')
vi.mocked(fetchWithRetry).mockResolvedValueOnce({
ok: false,
statusText: 'Bad Request',
json: async () => ({ error: 'invalid_grant', error_description: 'token revoked' }),
} as Response)

const { createOutlineAuthProvider } = await import('../lib/auth-provider.js')
await expect(
createOutlineAuthProvider().refreshToken!({
refreshToken: 'rt',
account: STORED_ACCOUNT,
handshake: {},
}),
).rejects.toThrow(/OAuth refresh failed: token revoked/)
})
})

describe('getApiToken integration with refreshAccessToken', () => {
const TEMP_ENV: Record<string, string | undefined> = {}

beforeEach(() => {
configMocks.getConfig.mockReset().mockResolvedValue({})
configMocks.updateConfig.mockReset().mockResolvedValue(undefined)
TEMP_ENV.OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN
delete process.env.OUTLINE_API_TOKEN
vi.resetModules()
})

afterEach(() => {
if (TEMP_ENV.OUTLINE_API_TOKEN !== undefined) {
process.env.OUTLINE_API_TOKEN = TEMP_ENV.OUTLINE_API_TOKEN
}
vi.clearAllMocks()
})

it('env-var token short-circuits and never consults refreshAccessToken', async () => {
process.env.OUTLINE_API_TOKEN = 'env-tok'
const { getApiToken } = await import('../lib/auth.js')
await expect(getApiToken()).resolves.toBe('env-tok')
})

it('maps NOT_AUTHENTICATED from cli-core into NoTokenError (matches the existing UX)', async () => {
// No env var, no stored credentials, no legacy snapshot — refresh
// helper throws NOT_AUTHENTICATED which our adapter collapses to the
// existing "No API token found" error so the user sees a single
// recovery hint instead of two competing codes.
const { getApiToken } = await import('../lib/auth.js')
await expect(getApiToken()).rejects.toThrow('No API token found')
})
})
54 changes: 51 additions & 3 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@
type StatusData = {
email: string
source: 'env' | 'secure-store' | 'config-file'
/** Unix-epoch ms — when the access token expires, if known. */
accessTokenExpiresAt?: number
/** True when a refresh token is stored alongside the access token. */
hasRefreshToken: boolean
}

/**
* Format a Unix-epoch ms expiry into a human-relative window for the
* status output ("in 12m", "in 3h", "in 2d", "expired 5m ago").
*/
function formatExpiresAt(ms: number): string {
const deltaMs = ms - Date.now()
const abs = Math.abs(deltaMs)
const units: Array<[number, string]> = [
[1000 * 60 * 60 * 24, 'd'],
[1000 * 60 * 60, 'h'],
[1000 * 60, 'm'],
[1000, 's'],
]
let value = 0
let unit = 's'
for (const [size, label] of units) {
if (abs >= size) {
value = Math.floor(abs / size)
unit = label
break
}
}
return deltaMs >= 0 ? `in ${value}${unit}` : `expired ${value}${unit} ago`
}

function resolvePreferredCallbackPort(): number {
Expand Down Expand Up @@ -104,7 +133,7 @@
attachStatusCommand<OutlineAccount>(auth, {
store,
description: 'Show current authentication state',
async fetchLive({ token, account }) {
async fetchLive({ token, bundle, account }) {

Check failure on line 136 in src/commands/auth.ts

View workflow job for this annotation

GitHub Actions / SKILL.md Sync

Property 'bundle' does not exist on type '{ account: OutlineAccount; token: string; view: Required<ViewOptions>; flags: Record<string, unknown>; }'.

Check failure on line 136 in src/commands/auth.ts

View workflow job for this annotation

GitHub Actions / Lint

Property 'bundle' does not exist on type '{ account: OutlineAccount; token: string; view: Required<ViewOptions>; flags: Record<string, unknown>; }'.

Check failure on line 136 in src/commands/auth.ts

View workflow job for this annotation

GitHub Actions / Test

Property 'bundle' does not exist on type '{ account: OutlineAccount; token: string; view: Required<ViewOptions>; flags: Record<string, unknown>; }'.
try {
const [{ data: info }, source] = await Promise.all([
apiRequest<AuthInfoResponse>(
Expand All @@ -114,7 +143,12 @@
),
getActiveTokenSource(),
])
statusData = { email: info.user.email, source }
statusData = {
email: info.user.email,
source,
accessTokenExpiresAt: bundle.accessTokenExpiresAt,
hasRefreshToken: Boolean(bundle.refreshToken),
}
return {
...account,
id: info.user.id,
Expand All @@ -133,13 +167,25 @@
},
renderText({ account }) {
if (!statusData) throw new Error('status renderText called before fetchLive')
return [
const lines = [
`${chalk.green('✓')} Authenticated`,
` Team: ${chalk.bold(account.teamName ?? '')}`,
` User: ${account.label} (${statusData.email})`,
` Base URL: ${account.baseUrl}`,
` Token source: ${statusData.source}`,
]
// Only surface refresh metadata when we actually have it — env-var
// tokens and pre-v1.8.0 records won't carry expiry, and a blank
// "Expires: never" line on those is noise.
if (typeof statusData.accessTokenExpiresAt === 'number') {
lines.push(
` Access token expires: ${formatExpiresAt(statusData.accessTokenExpiresAt)}`,
)
}
if (statusData.hasRefreshToken) {
lines.push(' Refresh: enabled (silent re-auth on expiry)')
}
return lines
},
renderJson({ account }) {
if (!statusData) throw new Error('status renderJson called before fetchLive')
Expand All @@ -148,6 +194,8 @@
team: account.teamName,
baseUrl: account.baseUrl,
source: statusData.source,
accessTokenExpiresAt: statusData.accessTokenExpiresAt,
hasRefreshToken: statusData.hasRefreshToken,
}
},
onNotAuthenticated() {
Expand Down
31 changes: 29 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fetchWithRetry } from '../transport/fetch-with-retry.js'
import { getApiToken, getBaseUrl } from './auth.js'
import { TOKEN_ENV_VAR } from './auth-constants.js'
import { getApiToken, getApiTokenForceRefresh, getBaseUrl } from './auth.js'
import { type SpinnerOptions, withSpinner } from './spinner.js'

/**
Expand Down Expand Up @@ -53,7 +54,11 @@ export type ApiRequestOverrides = {
}

/**
* Core API request function without spinner wrapping.
* Core API request function without spinner wrapping. Implements the
* reactive half of OAuth refresh: on a 401 with a stored token (i.e. not
* the env-var path, and not a caller-supplied override), force a single
* refresh + retry. A second 401 after the refresh propagates the error
* untouched — at that point the user really has to re-authenticate.
*/
async function rawApiRequest<T>(
path: string,
Expand All @@ -77,6 +82,28 @@ async function rawApiRequest<T>(
},
})

if (res.status === 401 && !overrides.token && !process.env[TOKEN_ENV_VAR]?.trim()) {
const refreshed = await getApiTokenForceRefresh(true).catch(() => null)
if (refreshed && refreshed !== resolvedToken) {
const retry = await fetchWithRetry({
url: `${resolvedBaseUrl}/api/${path}`,
options: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${refreshed}`,
},
body: JSON.stringify(body),
},
})
return finalizeResponse<T>(retry)
}
}

return finalizeResponse<T>(res)
}

async function finalizeResponse<T>(res: Response): Promise<PaginatedResult<T>> {
if (!res.ok) {
let message = `API error: ${res.status} ${res.statusText}`
try {
Expand Down
Loading
Loading