diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 9d33aa9..e35f00d 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -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', })) diff --git a/src/__tests__/auth-command.test.ts b/src/__tests__/auth-command.test.ts index 9c87d11..92a30e1 100644 --- a/src/__tests__/auth-command.test.ts +++ b/src/__tests__/auth-command.test.ts @@ -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') @@ -168,6 +170,8 @@ describe('auth status subcommand', () => { team: 'Analytics', baseUrl: 'https://test.outline.com', source: 'env', + accessTokenExpiresAt: undefined, + hasRefreshToken: false, }) }) diff --git a/src/__tests__/auth-provider.test.ts b/src/__tests__/auth-provider.test.ts index 2f666f4..9a1773b 100644 --- a/src/__tests__/auth-provider.test.ts +++ b/src/__tests__/auth-provider.test.ts @@ -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), diff --git a/src/__tests__/auth-refresh.test.ts b/src/__tests__/auth-refresh.test.ts new file mode 100644 index 0000000..5064f83 --- /dev/null +++ b/src/__tests__/auth-refresh.test.ts @@ -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() + 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 = {} + + 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') + }) +}) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index a2dc509..b9636b3 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -23,6 +23,35 @@ const DEFAULT_OAUTH_CALLBACK_PORT = 54969 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 { @@ -104,7 +133,7 @@ export function registerAuthCommand(program: Command): void { attachStatusCommand(auth, { store, description: 'Show current authentication state', - async fetchLive({ token, account }) { + async fetchLive({ token, bundle, account }) { try { const [{ data: info }, source] = await Promise.all([ apiRequest( @@ -114,7 +143,12 @@ export function registerAuthCommand(program: Command): void { ), 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, @@ -133,13 +167,25 @@ export function registerAuthCommand(program: Command): void { }, 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') @@ -148,6 +194,8 @@ export function registerAuthCommand(program: Command): void { team: account.teamName, baseUrl: account.baseUrl, source: statusData.source, + accessTokenExpiresAt: statusData.accessTokenExpiresAt, + hasRefreshToken: statusData.hasRefreshToken, } }, onNotAuthenticated() { diff --git a/src/lib/api.ts b/src/lib/api.ts index 833f2a4..adc9a0a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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' /** @@ -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( path: string, @@ -77,6 +82,28 @@ async function rawApiRequest( }, }) + 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(retry) + } + } + + return finalizeResponse(res) +} + +async function finalizeResponse(res: Response): Promise> { if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index 24045ad..61937ec 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -4,9 +4,12 @@ import { type AuthProvider, createKeyringTokenStore, deriveChallenge, + type ExchangeResult, generateVerifier, type KeyringTokenStore, type MigrateAuthResult, + type RefreshInput, + type TokenBundle, } from '@doist/cli-core/auth' import { fetchWithRetry } from '../transport/fetch-with-retry.js' import { apiRequest } from './api.js' @@ -120,6 +123,8 @@ export function createOutlineAuthProvider(): AuthProvider { const json = (await res.json().catch(() => ({}))) as { access_token?: string + refresh_token?: string + expires_in?: number error?: string error_description?: string message?: string @@ -135,7 +140,80 @@ export function createOutlineAuthProvider(): AuthProvider { throw new Error('OAuth token exchange did not return an access token.') } - return { accessToken: json.access_token } + return { + accessToken: json.access_token, + refreshToken: json.refresh_token, + expiresAt: + typeof json.expires_in === 'number' + ? Date.now() + json.expires_in * 1000 + : undefined, + } + }, + + async refreshToken( + input: RefreshInput, + ): Promise> { + // At refresh time the user is long past the authorize step, so + // there is no PKCE handshake. We rebuild what we need from the + // stored account: Outline OAuth refresh on a public client takes + // `grant_type=refresh_token` + `refresh_token` + `client_id`, + // no client_secret, no code_verifier. + const baseUrl = input.account.baseUrl?.replace(/\/$/, '') + const clientId = input.account.oauthClientId + if (!baseUrl || !clientId) { + throw new Error( + 'Cannot refresh: stored account is missing baseUrl or oauthClientId. Run: ol auth login', + ) + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: input.refreshToken, + client_id: clientId, + }) + + const res = await fetchWithRetry({ + url: `${baseUrl}/oauth/token`, + options: { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }, + }) + + const json = (await res.json().catch(() => ({}))) as { + access_token?: string + refresh_token?: string + expires_in?: number + error?: string + error_description?: string + message?: string + } + + if (!res.ok) { + const message = + json.error_description || json.message || json.error || res.statusText + throw new Error(`OAuth refresh failed: ${message}`) + } + if (!json.access_token) { + throw new Error('OAuth refresh did not return an access token.') + } + + return { + accessToken: json.access_token, + // Some OAuth servers rotate the refresh token on each refresh, + // others reuse it. Persist whatever comes back; the caller's + // `refreshAccessToken` helper preserves the existing refresh + // when the server doesn't return one. + refreshToken: json.refresh_token, + expiresAt: + typeof json.expires_in === 'number' + ? Date.now() + json.expires_in * 1000 + : undefined, + // Pass the account through so the helper doesn't have to + // re-derive it (PKCE provider does the same). + account: input.account, + } }, async validateToken({ token, handshake }) { @@ -189,6 +267,7 @@ function migrationIsConclusive(result: MigrateAuthResult): boole */ async function readLegacyTokenSnapshot(): Promise<{ token: string + bundle: TokenBundle account: OutlineAccount } | null> { const config = await getConfig() @@ -196,6 +275,7 @@ async function readLegacyTokenSnapshot(): Promise<{ if (!token) return null return { token, + bundle: { accessToken: token }, account: makeOutlineAccount({ id: config.auth_user_id ?? '', label: config.auth_user_name ?? '', @@ -280,6 +360,7 @@ export function createOutlineTokenStore(): OutlineTokenStore { if (envToken) { return { token: envToken, + bundle: { accessToken: envToken }, account: makeOutlineAccount({ id: '', label: '', @@ -307,6 +388,16 @@ export function createOutlineTokenStore(): OutlineTokenStore { await dischargeLegacyState().catch(() => undefined) } }, + async setBundle(account: OutlineAccount, bundle: TokenBundle) { + await ensureMigrated() + // Delegate to the inner keyring store, which always exposes + // setBundle. The non-null assertion is safe because + // `createKeyringTokenStore` guarantees it. + await inner.setBundle!(account, bundle) + if (await migrationIsInconclusive()) { + await dischargeLegacyState().catch(() => undefined) + } + }, async clear(ref?: AccountRef) { await ensureMigrated() await inner.clear(ref) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6a40df0..cbbc55e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,13 +1,14 @@ -import { SecureStoreUnavailableError } from '@doist/cli-core/auth' +import { refreshAccessToken, SecureStoreUnavailableError } from '@doist/cli-core/auth' import { TOKEN_ENV_VAR } from './auth-constants.js' import { + createOutlineAuthProvider, createOutlineTokenStore, getActiveTokenSource, type OutlineTokenStore, } from './auth-provider.js' -import { getConfig } from './config.js' -import { CliError } from './errors.js' -import { DEFAULT_BASE_URL } from './outline-account.js' +import { getConfig, getConfigPath } from './config.js' +import { BaseCliError, CliError } from './errors.js' +import { DEFAULT_BASE_URL, type OutlineAccount } from './outline-account.js' import { getDefaultUserRecord } from './user-records.js' export { SecureStoreUnavailableError, getActiveTokenSource, TOKEN_ENV_VAR } @@ -25,9 +26,10 @@ export class NoTokenError extends CliError { } /** - * Module-level token-store singleton. Built lazily on first call; reused - * across every `apiRequest` so the request hot path doesn't reconstruct - * the keyring + user-record adapters per POST. + * Module-level token-store + provider singletons. Built lazily on first + * call; reused across every `apiRequest` so the request hot path doesn't + * reconstruct the keyring + user-record adapters (or the provider's resolver + * closures) per POST. */ let storeSingleton: OutlineTokenStore | undefined function tokenStore(): OutlineTokenStore { @@ -35,19 +37,68 @@ function tokenStore(): OutlineTokenStore { return storeSingleton } +let providerSingleton: ReturnType | undefined +function authProvider(): ReturnType { + if (!providerSingleton) providerSingleton = createOutlineAuthProvider() + return providerSingleton +} + /** - * Read the active token. Hot path: when `OUTLINE_API_TOKEN` is set we - * return it directly without consulting the token store, since - * `apiRequest` already resolves the base URL separately — going through - * `store.active()` here would trigger a redundant `getBaseUrl()` lookup - * per request just to synthesise an account we don't need. + * Read the active token, refreshing it silently when the stored access + * token has expired (or is within the 60s skew window). The OAuth refresh + * grant runs against Outline's `/oauth/token` endpoint and the new bundle + * is persisted back to the keyring + user record. + * + * Env-token short-circuit: when `OUTLINE_API_TOKEN` is set we return it + * directly without consulting the token store. The env token is + * user-managed; refresh is meaningless and would burn a request. + * + * `AUTH_REFRESH_UNAVAILABLE` (e.g. v1.7.0 record with no refresh token + * stored) and `AUTH_REFRESH_EXPIRED` (refresh token itself expired/revoked) + * are translated to `NoTokenError` so the user sees the existing + * "run: ol auth login" hint instead of an unfamiliar code. */ export async function getApiToken(): Promise { const envToken = process.env[TOKEN_ENV_VAR]?.trim() if (envToken) return envToken - const snapshot = await tokenStore().active() - if (!snapshot?.token) throw new NoTokenError() - return snapshot.token + return getApiTokenForceRefresh(false) +} + +/** + * Internal: shared between proactive `getApiToken()` and the reactive + * 401-retry path. `force` skips the expiry-window check and refreshes + * immediately (used after the server has rejected the current token). + */ +export async function getApiTokenForceRefresh(force: boolean): Promise { + try { + const refreshed = await refreshAccessToken({ + store: tokenStore(), + provider: authProvider(), + force, + // Sidecar O_EXCL lock so two parallel `ol` invocations don't + // both POST refresh and race Outline's refresh-token rotation. + // `getConfigPath()` is already an absolute, expanded path + // (cli-core does not interpret `~`). + lockPath: `${getConfigPath()}.refresh.lock`, + }) + return refreshed.token + } catch (error) { + // Refresh helper surfaces typed codes we want to collapse to the + // existing "no token" UX. Anything else (network errors during + // refresh, store write failures) propagates with its original code. + // `BaseCliError` catches both cli-core-thrown errors (the refresh + // path) and ol-cli's own `CliError` subclass. + if (error instanceof BaseCliError) { + if ( + error.code === 'NOT_AUTHENTICATED' || + error.code === 'AUTH_REFRESH_UNAVAILABLE' || + error.code === 'AUTH_REFRESH_EXPIRED' + ) { + throw new NoTokenError() + } + } + throw error + } } /** diff --git a/src/lib/config.ts b/src/lib/config.ts index 46ce074..71a7cd6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -9,9 +9,12 @@ import { const APP_NAME = 'outline-cli' /** - * One row of the `users[]` array. `id` is the Outline user UUID. `token` is - * a plaintext fallback persisted only when the OS keyring is unavailable at - * write time (WSL, headless Linux, missing native binary). + * One row of the `users[]` array. `id` is the Outline user UUID. `token` and + * `refresh_token` are plaintext fallbacks persisted only when the OS keyring + * is unavailable at write time (WSL, headless Linux, missing native binary). + * `access_token_expires_at` / `refresh_token_expires_at` carry expiry + * metadata for the silent-refresh path; they're plain timestamps, never + * secrets, so they always live on the record (not the keyring). */ export type StoredUser = { id: string @@ -20,6 +23,11 @@ export type StoredUser = { oauth_client_id?: string team_name?: string token?: string + refresh_token?: string + /** Unix-epoch ms. */ + access_token_expires_at?: number + /** Unix-epoch ms. */ + refresh_token_expires_at?: number } export type Config = CoreConfig & { diff --git a/src/lib/user-records.ts b/src/lib/user-records.ts index 4e7f7c9..1bbba98 100644 --- a/src/lib/user-records.ts +++ b/src/lib/user-records.ts @@ -64,16 +64,25 @@ function toRecord(user: StoredUser): UserRecord { oauthClientId: user.oauth_client_id, teamName: user.team_name, }) - const trimmed = user.token?.trim() + const trimmedAccess = user.token?.trim() + const trimmedRefresh = user.refresh_token?.trim() const record: UserRecord = { account } - if (trimmed) record.fallbackToken = trimmed + if (trimmedAccess) record.fallbackToken = trimmedAccess + if (trimmedRefresh) record.fallbackRefreshToken = trimmedRefresh + if (typeof user.access_token_expires_at === 'number') { + record.accessTokenExpiresAt = user.access_token_expires_at + } + if (typeof user.refresh_token_expires_at === 'number') { + record.refreshTokenExpiresAt = user.refresh_token_expires_at + } return record } function fromRecord(record: UserRecord): StoredUser { // Replace, don't merge: an absent `fallbackToken` strips the plaintext // slot so it can't shadow a fresh keyring-backed write. cli-core contract. - const trimmed = record.fallbackToken?.trim() + const trimmedAccess = record.fallbackToken?.trim() + const trimmedRefresh = record.fallbackRefreshToken?.trim() const next: StoredUser = { id: record.account.id, name: record.account.label, @@ -81,6 +90,13 @@ function fromRecord(record: UserRecord): StoredUser { if (record.account.baseUrl) next.base_url = record.account.baseUrl if (record.account.oauthClientId) next.oauth_client_id = record.account.oauthClientId if (record.account.teamName) next.team_name = record.account.teamName - if (trimmed && trimmed.length > 0) next.token = trimmed + if (trimmedAccess && trimmedAccess.length > 0) next.token = trimmedAccess + if (trimmedRefresh && trimmedRefresh.length > 0) next.refresh_token = trimmedRefresh + if (typeof record.accessTokenExpiresAt === 'number') { + next.access_token_expires_at = record.accessTokenExpiresAt + } + if (typeof record.refreshTokenExpiresAt === 'number') { + next.refresh_token_expires_at = record.refreshTokenExpiresAt + } return next }