From 7ce12dad5a4f7f1250bed9f569d1749f4cd4d032 Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Mon, 16 Mar 2026 08:29:21 +0100 Subject: [PATCH] feat(auth): support refreshable OAuth config --- .changeset/refreshable-oauth-and-schema.md | 5 + README.md | 46 ++- package.json | 1 + schemas/config.schema.json | 61 ++++ src/api.test.ts | 343 +++++++++++++++++++++ src/api.ts | 55 +++- src/auth.ts | 56 ++-- src/commands/shared.ts | 34 +- src/config.test.ts | 163 +++++++++- src/config.ts | 197 +++++++++++- src/index.ts | 37 ++- src/oauth.ts | 71 +++++ 12 files changed, 1005 insertions(+), 64 deletions(-) create mode 100644 .changeset/refreshable-oauth-and-schema.md create mode 100644 schemas/config.schema.json create mode 100644 src/api.test.ts create mode 100644 src/oauth.ts diff --git a/.changeset/refreshable-oauth-and-schema.md b/.changeset/refreshable-oauth-and-schema.md new file mode 100644 index 0000000..7881d26 --- /dev/null +++ b/.changeset/refreshable-oauth-and-schema.md @@ -0,0 +1,5 @@ +--- +"linear-cli": minor +--- + +Add refreshable OAuth config support, write a versioned `$schema` entry to config.json, and document the nested OAuth config format. diff --git a/README.md b/README.md index b4424d7..5ee5fd2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,11 @@ linear auth login linear auth login --type api --token linear auth login --type oauth --token +# With OAuth refresh token and expiry (optional, enables automatic token refresh) +linear auth login --type oauth --token \ + --refresh-token \ + --expires-at + # Via API token environment variable export LINEAR_API_TOKEN= @@ -70,6 +75,38 @@ You do not need a special `-` argument for stdin. Piped input is detected automa Config is stored in `~/.config/linear-cli/config.json` with `0600` permissions. +### Config format + +```json +{ + "$schema": "https://raw.githubusercontent.com/aliou/linear-cli/v0.2.2/schemas/config.schema.json", + "apiToken": "...", + "oauth": { + "access": "...", + "refresh": "...", + "expiresAt": "2025-01-01T00:00:00.000Z", + "clientId": "...", + "clientSecret": "..." + }, + "defaultTeamKey": "ENG", + "outputFormat": "table" +} +``` + +`apiToken` is used for a regular personal API key. OAuth credentials are stored under `oauth.access`, `oauth.refresh`, and `oauth.expiresAt`. All fields are optional and the file is backward compatible. + +The CLI writes and updates `$schema` automatically, pointing to the schema file for the CLI version used to write the config. + +### Automatic OAuth token refresh + +When a config-stored OAuth token has an `oauth.refresh` value, the CLI will automatically refresh it on a 401 response and retry the request once. + +Refresh credentials are resolved in this order: +1. `LINEAR_CLIENT_ID` + `LINEAR_CLIENT_SECRET` from the environment +2. `oauth.clientId` + `oauth.clientSecret` from `~/.config/linear-cli/config.json` + +> Note: storing `oauth.clientSecret` in the config file is convenient, but it is still a secret. The CLI stores the config with `0600` permissions. + ## Usage ```sh @@ -164,8 +201,15 @@ Stored at `~/.config/linear-cli/config.json`: ```json { + "$schema": "https://raw.githubusercontent.com/aliou/linear-cli/v0.2.2/schemas/config.schema.json", "apiToken": "...", - "accessToken": "...", + "oauth": { + "access": "...", + "refresh": "...", + "expiresAt": "2025-01-01T00:00:00.000Z", + "clientId": "...", + "clientSecret": "..." + }, "defaultTeamKey": "ENG", "outputFormat": "table" } diff --git a/package.json b/package.json index 6395994..ee01726 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "files": [ "src/", + "schemas/", "SKILL.md" ], "scripts": { diff --git a/schemas/config.schema.json b/schemas/config.schema.json new file mode 100644 index 0000000..e070390 --- /dev/null +++ b/schemas/config.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/aliou/linear-cli/v0.2.2/schemas/config.schema.json", + "title": "linear-cli config", + "description": "Configuration for linear-cli.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "format": "uri" + }, + "apiToken": { + "type": "string", + "description": "Linear personal API key." + }, + "oauth": { + "type": "object", + "additionalProperties": false, + "properties": { + "access": { + "type": "string", + "description": "OAuth access token." + }, + "refresh": { + "type": "string", + "description": "OAuth refresh token." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "ISO timestamp for access token expiry." + }, + "clientId": { + "type": "string", + "description": "OAuth client ID used for refresh." + }, + "clientSecret": { + "type": "string", + "description": "OAuth client secret used for refresh." + } + } + }, + "defaultTeamKey": { + "type": "string", + "description": "Default Linear team key, e.g. ENG." + }, + "outputFormat": { + "type": "string", + "enum": ["json", "table"], + "description": "Default output format." + }, + "localConfigPaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional local config file paths to search." + } + } +} diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 0000000..037e6ac --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,343 @@ +/** + * Tests for graphql() OAuth refresh-on-401 behavior. + */ + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { graphql } from "./api.ts"; +import type { ResolvedAuth } from "./config.ts"; +import { loadConfig, saveConfig } from "./config.ts"; + +// ---- helpers ---------------------------------------------------------------- + +function makeJsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const SUCCESS_BODY = { + data: { viewer: { id: "1", name: "Test", email: "t@t.com" } }, +}; + +// ---- test setup ------------------------------------------------------------- + +let tempDir: string; +const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; +const originalClientId = process.env.LINEAR_CLIENT_ID; +const originalClientSecret = process.env.LINEAR_CLIENT_SECRET; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "linear-cli-api-test-")); + process.env.XDG_CONFIG_HOME = tempDir; + process.env.LINEAR_CLIENT_ID = "test-client-id"; + process.env.LINEAR_CLIENT_SECRET = "test-client-secret"; +}); + +afterEach(async () => { + if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + + if (originalClientId === undefined) delete process.env.LINEAR_CLIENT_ID; + else process.env.LINEAR_CLIENT_ID = originalClientId; + + if (originalClientSecret === undefined) + delete process.env.LINEAR_CLIENT_SECRET; + else process.env.LINEAR_CLIENT_SECRET = originalClientSecret; + + await rm(tempDir, { recursive: true, force: true }); + mock.restore(); +}); + +// ---- tests ------------------------------------------------------------------ + +describe("graphql() with config OAuth token + refreshToken on 401", () => { + test("refreshes token, retries, and updates config on 401", async () => { + await saveConfig({ + accessToken: "old-access-token", + refreshToken: "my-refresh-token", + outputFormat: "table", + }); + + const auth: ResolvedAuth = { + header: "Bearer old-access-token", + token: "old-access-token", + source: "config", + kind: "oauth", + refreshToken: "my-refresh-token", + }; + + let callCount = 0; + const fetchMock = mock(async (url: string) => { + if (typeof url === "string" && url.includes("/oauth/token")) { + return makeJsonResponse({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + }); + } + callCount++; + if (callCount === 1) { + return new Response("Unauthorized", { status: 401 }); + } + return makeJsonResponse(SUCCESS_BODY); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + const result = await graphql<(typeof SUCCESS_BODY)["data"]>( + auth, + "query { viewer { id name email } }", + ); + expect(result.viewer.name).toBe("Test"); + expect(callCount).toBe(2); + + const config = await loadConfig(); + expect(config.accessToken).toBe("new-access-token"); + expect(config.refreshToken).toBe("new-refresh-token"); + expect(config.accessTokenExpiresAt).toBeDefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("refreshes using oauth client credentials from config when env vars are absent", async () => { + delete process.env.LINEAR_CLIENT_ID; + delete process.env.LINEAR_CLIENT_SECRET; + + await saveConfig({ + accessToken: "old-access-token", + refreshToken: "my-refresh-token", + oauthClientId: "config-client-id", + oauthClientSecret: "config-client-secret", + outputFormat: "table", + }); + + const auth: ResolvedAuth = { + header: "Bearer old-access-token", + token: "old-access-token", + source: "config", + kind: "oauth", + refreshToken: "my-refresh-token", + }; + + let sawBasicAuth = false; + let callCount = 0; + const fetchMock = mock(async (url: string, init?: RequestInit) => { + if (typeof url === "string" && url.includes("/oauth/token")) { + sawBasicAuth = + init?.headers instanceof Headers + ? init.headers.get("Authorization") === + `Basic ${Buffer.from("config-client-id:config-client-secret").toString("base64")}` + : (init?.headers as Record | undefined) + ?.Authorization === + `Basic ${Buffer.from("config-client-id:config-client-secret").toString("base64")}`; + return makeJsonResponse({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + }); + } + callCount++; + if (callCount === 1) { + return new Response("Unauthorized", { status: 401 }); + } + return makeJsonResponse(SUCCESS_BODY); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + const result = await graphql<(typeof SUCCESS_BODY)["data"]>( + auth, + "query { viewer { id name email } }", + ); + expect(result.viewer.name).toBe("Test"); + expect(sawBasicAuth).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("does not refresh if no refreshToken in auth", async () => { + const auth: ResolvedAuth = { + header: "Bearer no-refresh-token", + token: "no-refresh-token", + source: "config", + kind: "oauth", + }; + + let callCount = 0; + const fetchMock = mock(async () => { + callCount++; + return new Response("Unauthorized", { status: 401 }); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await expect(graphql(auth, "query { viewer { id } }")).rejects.toThrow( + "Invalid API token", + ); + expect(callCount).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("does not refresh env OAuth token on 401", async () => { + const auth: ResolvedAuth = { + header: "Bearer env-oauth-token", + token: "env-oauth-token", + source: "env", + kind: "oauth", + refreshToken: "should-not-use", + }; + + let callCount = 0; + const fetchMock = mock(async () => { + callCount++; + return new Response("Unauthorized", { status: 401 }); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await expect(graphql(auth, "query { viewer { id } }")).rejects.toThrow( + "Invalid API token", + ); + expect(callCount).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("does not refresh config api token on 401", async () => { + const auth: ResolvedAuth = { + header: "api-key", + token: "api-key", + source: "config", + kind: "api", + }; + + let callCount = 0; + const fetchMock = mock(async () => { + callCount++; + return new Response("Unauthorized", { status: 401 }); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await expect(graphql(auth, "query { viewer { id } }")).rejects.toThrow( + "Invalid API token", + ); + expect(callCount).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("does not loop: only retries once after refresh", async () => { + const auth: ResolvedAuth = { + header: "Bearer old-access-token", + token: "old-access-token", + source: "config", + kind: "oauth", + refreshToken: "my-refresh-token", + }; + + let graphqlCallCount = 0; + const fetchMock = mock(async (url: string) => { + if (typeof url === "string" && url.includes("/oauth/token")) { + return makeJsonResponse({ + access_token: "new-access-token", + expires_in: 3600, + }); + } + graphqlCallCount++; + return new Response("Unauthorized", { status: 401 }); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await expect(graphql(auth, "query { viewer { id } }")).rejects.toThrow( + "Invalid API token", + ); + expect(graphqlCallCount).toBe(2); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("persists rotated refresh token from response", async () => { + await saveConfig({ + accessToken: "old-token", + refreshToken: "old-refresh", + outputFormat: "table", + }); + + const auth: ResolvedAuth = { + header: "Bearer old-token", + token: "old-token", + source: "config", + kind: "oauth", + refreshToken: "old-refresh", + }; + + let gqlCalls = 0; + const fetchMock2 = mock(async (url: string) => { + if (typeof url === "string" && url.includes("/oauth/token")) { + return makeJsonResponse({ + access_token: "rotated-access", + refresh_token: "rotated-refresh", + expires_in: 7200, + }); + } + gqlCalls++; + if (gqlCalls === 1) return new Response("Unauthorized", { status: 401 }); + return makeJsonResponse(SUCCESS_BODY); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock2 as unknown as typeof fetch; + + try { + await graphql(auth, "query { viewer { id } }"); + const config = await loadConfig(); + expect(config.accessToken).toBe("rotated-access"); + expect(config.refreshToken).toBe("rotated-refresh"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +describe("graphql() with plain string token", () => { + test("works with a plain string auth header", async () => { + const fetchMock = mock(async () => makeJsonResponse(SUCCESS_BODY)); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + const result = await graphql<(typeof SUCCESS_BODY)["data"]>( + "Bearer string-token", + "query { viewer { id name email } }", + ); + expect(result.viewer.name).toBe("Test"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/src/api.ts b/src/api.ts index 3097f1e..e524b9d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,7 +2,11 @@ * Linear GraphQL API client. */ +import type { ResolvedAuth } from "./config.ts"; import { LINEAR_API_URL } from "./constants.ts"; +import { refreshOAuthToken } from "./oauth.ts"; + +export type { ResolvedAuth }; export interface GraphQLResponse { data?: T; @@ -10,21 +14,56 @@ export interface GraphQLResponse { } /** - * Execute a GraphQL query against the Linear API. + * Resolve an auth value (string or ResolvedAuth) into a header string. */ -export async function graphql( - token: string, +function authHeader(auth: string | ResolvedAuth): string { + return typeof auth === "string" ? auth : auth.header; +} + +/** + * Execute a GraphQL request and return the raw Response. + */ +async function doGraphQL( + authValue: string, query: string, variables?: Record, -): Promise { - const response = await fetch(LINEAR_API_URL, { +): Promise { + return fetch(LINEAR_API_URL, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: token, + Authorization: authValue, }, body: JSON.stringify({ query, variables }), }); +} + +/** + * Execute a GraphQL query against the Linear API. + * + * When passed a ResolvedAuth from config with a refreshToken, a 401 response + * will trigger a single token-refresh attempt followed by a retry. + * Tokens from environment variables are never refreshed automatically. + */ +export async function graphql( + auth: string | ResolvedAuth, + query: string, + variables?: Record, +): Promise { + let currentHeader = authHeader(auth); + let response = await doGraphQL(currentHeader, query, variables); + + // Attempt a token refresh on 401 for config-backed OAuth tokens with a refresh token + if ( + response.status === 401 && + typeof auth !== "string" && + auth.source === "config" && + auth.kind === "oauth" && + auth.refreshToken + ) { + currentHeader = await refreshOAuthToken(auth.refreshToken); + response = await doGraphQL(currentHeader, query, variables); + } if (!response.ok) { if (response.status === 401) { @@ -50,11 +89,11 @@ export async function graphql( * Fetch the authenticated user (viewer) to validate token. */ export async function fetchViewer( - token: string, + auth: string | ResolvedAuth, ): Promise<{ id: string; name: string; email: string }> { const data = await graphql<{ viewer: { id: string; name: string; email: string }; - }>(token, `query { viewer { id name email } }`); + }>(auth, `query { viewer { id name email } }`); return data.viewer; } diff --git a/src/auth.ts b/src/auth.ts index 55b5041..9175a9d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -7,9 +7,13 @@ import { fetchViewer } from "./api.ts"; import { checkConfigPermissions, loadConfig, + resolveAuth, saveConfig, updateConfig, } from "./config.ts"; +import { refreshOAuthToken } from "./oauth.ts"; + +export { refreshOAuthToken }; export interface AuthStatus { authenticated: boolean; @@ -34,20 +38,32 @@ function formatTokenForApi(token: string, kind: TokenKind): string { /** * Perform login: validate token and store in config. + * For OAuth logins, optionally persist refreshToken and accessTokenExpiresAt. */ export async function login( token: string, kind: TokenKind, + options?: { refreshToken?: string; expiresAt?: string }, ): Promise { try { const trimmedToken = token.trim(); const viewer = await fetchViewer(formatTokenForApi(trimmedToken, kind)); - await updateConfig( - kind === "oauth" - ? { accessToken: trimmedToken, apiToken: undefined } - : { apiToken: trimmedToken, accessToken: undefined }, - ); + if (kind === "oauth") { + await updateConfig({ + accessToken: trimmedToken, + apiToken: undefined, + refreshToken: options?.refreshToken, + accessTokenExpiresAt: options?.expiresAt, + }); + } else { + await updateConfig({ + apiToken: trimmedToken, + accessToken: undefined, + refreshToken: undefined, + accessTokenExpiresAt: undefined, + }); + } return { success: true, @@ -63,12 +79,14 @@ export async function login( } /** - * Perform logout: remove token from config. + * Perform logout: remove token and related OAuth fields from config. */ export async function logout(): Promise { const config = await loadConfig(); delete config.apiToken; delete config.accessToken; + delete config.refreshToken; + delete config.accessTokenExpiresAt; delete config.defaultTeamKey; await saveConfig(config); } @@ -79,41 +97,27 @@ export async function logout(): Promise { export async function getAuthStatus(): Promise { await checkConfigPermissions(); - const envOauthToken = process.env.LINEAR_OAUTH_TOKEN; - const envApiToken = process.env.LINEAR_API_TOKEN; - const config = await loadConfig(); - const token = envOauthToken - ? formatTokenForApi(envOauthToken, "oauth") - : envApiToken - ? formatTokenForApi(envApiToken, "api") - : config.accessToken - ? formatTokenForApi(config.accessToken, "oauth") - : config.apiToken - ? formatTokenForApi(config.apiToken, "api") - : undefined; - - if (!token) { + const auth = await resolveAuth(); + + if (!auth) { return { authenticated: false, error: "No API token configured", }; } - const tokenSource = envOauthToken || envApiToken ? "env" : "config"; - try { - const viewer = await fetchViewer(token); - + const viewer = await fetchViewer(auth); return { authenticated: true, name: viewer.name, email: viewer.email, - tokenSource, + tokenSource: auth.source, }; } catch (error) { return { authenticated: false, - tokenSource, + tokenSource: auth.source, error: error instanceof Error ? error.message : "Unknown error", }; } diff --git a/src/commands/shared.ts b/src/commands/shared.ts index 1a17074..2b9fb70 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -1,15 +1,23 @@ +import type { ResolvedAuth } from "../api.ts"; import { graphql } from "../api.ts"; import type { ParsedArgs } from "../args.ts"; import { getBoolean } from "../args.ts"; -import { getApiToken, loadConfig } from "../config.ts"; +import { loadConfig, resolveAuth } from "../config.ts"; -export async function requireToken(): Promise { - const token = await getApiToken(); - if (!token) { +export type { ResolvedAuth }; + +export async function requireAuth(): Promise { + const auth = await resolveAuth(); + if (!auth) { console.error("Error: Not authenticated. Run 'linear auth login' first."); process.exit(1); } - return token; + return auth; +} + +/** @deprecated Use requireAuth() to get the full auth object for refresh support. */ +export async function requireToken(): Promise { + return requireAuth(); } /** @@ -29,7 +37,7 @@ export interface ResolvedUser { * Returns null if not found or throws on ambiguity. */ export async function resolveUser( - token: string, + auth: string | ResolvedAuth, value: string, options?: { requireApp?: boolean; allowMe?: boolean }, ): Promise { @@ -53,7 +61,7 @@ export async function resolveUser( } } `; - const data = await graphql<{ viewer: ResolvedUser }>(token, query); + const data = await graphql<{ viewer: ResolvedUser }>(auth, query); if (requireApp && !data.viewer.app) { throw new Error( `"me" resolves to a regular user, but an agent/app user is required.`, @@ -83,7 +91,7 @@ export async function resolveUser( } } `; - const data = await graphql<{ user: ResolvedUser | null }>(token, query, { + const data = await graphql<{ user: ResolvedUser | null }>(auth, query, { id: value, }); const user = data.user; @@ -121,7 +129,7 @@ export async function resolveUser( `; const data = await graphql<{ users: { nodes: ResolvedUser[] } }>( - token, + auth, query, { filter }, ); @@ -161,13 +169,13 @@ export async function resolveUser( * Resolve an assignee (regular user or "me") */ export async function resolveAssignee( - token: string, + auth: string | ResolvedAuth, value: string, ): Promise { if (value.toLowerCase() === "none") { return null; } - const user = await resolveUser(token, value, { allowMe: true }); + const user = await resolveUser(auth, value, { allowMe: true }); if (!user) { throw new Error(`Assignee "${value}" not found.`); } @@ -178,13 +186,13 @@ export async function resolveAssignee( * Resolve a delegate/agent (must be an app user) */ export async function resolveDelegate( - token: string, + auth: string | ResolvedAuth, value: string, ): Promise { if (value.toLowerCase() === "none") { return null; } - const user = await resolveUser(token, value, { requireApp: true }); + const user = await resolveUser(auth, value, { requireApp: true }); if (!user) { throw new Error( `Agent/delegate "${value}" not found or is not an app user.`, diff --git a/src/config.test.ts b/src/config.test.ts index 37740c7..65da1be 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,57 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getApiToken, saveConfig } from "./config"; +import { + getApiToken, + getConfigPath, + getConfigSchemaUrl, + resolveAuth, + saveConfig, + validateConfig, +} from "./config"; + +describe("validateConfig", () => { + test("preserves refreshToken and accessTokenExpiresAt", () => { + const result = validateConfig({ + accessToken: "tok", + refreshToken: "ref", + accessTokenExpiresAt: "2099-01-01T00:00:00.000Z", + }); + expect(result.refreshToken).toBe("ref"); + expect(result.accessTokenExpiresAt).toBe("2099-01-01T00:00:00.000Z"); + }); + + test("parses nested oauth config shape", () => { + const result = validateConfig({ + apiToken: "api-token", + oauth: { + access: "oauth-access", + refresh: "oauth-refresh", + expiresAt: "2099-01-01T00:00:00.000Z", + clientId: "client-id", + clientSecret: "client-secret", + }, + }); + expect(result.apiToken).toBe("api-token"); + expect(result.accessToken).toBe("oauth-access"); + expect(result.refreshToken).toBe("oauth-refresh"); + expect(result.accessTokenExpiresAt).toBe("2099-01-01T00:00:00.000Z"); + expect(result.oauthClientId).toBe("client-id"); + expect(result.oauthClientSecret).toBe("client-secret"); + }); + + test("strips unknown fields", () => { + const result = validateConfig({ unknownField: "value" }); + expect((result as Record).unknownField).toBeUndefined(); + }); + + test("backward compatible: parses existing configs without new fields", () => { + const result = validateConfig({ apiToken: "abc", outputFormat: "table" }); + expect(result.apiToken).toBe("abc"); + expect(result.refreshToken).toBeUndefined(); + expect(result.accessTokenExpiresAt).toBeUndefined(); + }); +}); describe("getApiToken", () => { let tempDir: string; @@ -70,4 +120,115 @@ describe("getApiToken", () => { await expect(getApiToken()).resolves.toBe("api-token"); }); + + test("reads nested oauth config shape from disk and still supports apiToken", async () => { + const configPath = join(tempDir, "linear-cli", "config.json"); + await Bun.write( + configPath, + JSON.stringify( + { + outputFormat: "table", + oauth: { + access: "oauth-token", + refresh: "refresh-token", + }, + apiToken: "api-token", + }, + null, + 2, + ), + ); + + await expect(getApiToken()).resolves.toBe("Bearer oauth-token"); + }); + + test("saveConfig writes $schema for the current CLI version", async () => { + await saveConfig({ + apiToken: "api-token", + outputFormat: "table", + }); + + const content = await Bun.file(getConfigPath()).text(); + const parsed = JSON.parse(content) as { $schema?: string }; + expect(parsed.$schema).toBe(getConfigSchemaUrl()); + }); +}); + +describe("resolveAuth", () => { + let tempDir: string; + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + const originalApiToken = process.env.LINEAR_API_TOKEN; + const originalOauthToken = process.env.LINEAR_OAUTH_TOKEN; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "linear-cli-test-")); + process.env.XDG_CONFIG_HOME = tempDir; + delete process.env.LINEAR_API_TOKEN; + delete process.env.LINEAR_OAUTH_TOKEN; + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (originalApiToken === undefined) { + delete process.env.LINEAR_API_TOKEN; + } else { + process.env.LINEAR_API_TOKEN = originalApiToken; + } + if (originalOauthToken === undefined) { + delete process.env.LINEAR_OAUTH_TOKEN; + } else { + process.env.LINEAR_OAUTH_TOKEN = originalOauthToken; + } + await rm(tempDir, { recursive: true, force: true }); + }); + + test("env oauth token: source=env, kind=oauth, no refreshToken", async () => { + process.env.LINEAR_OAUTH_TOKEN = "env-oauth"; + const auth = await resolveAuth(); + expect(auth).toBeDefined(); + expect(auth?.source).toBe("env"); + expect(auth?.kind).toBe("oauth"); + expect(auth?.header).toBe("Bearer env-oauth"); + expect(auth?.refreshToken).toBeUndefined(); + }); + + test("env api token: source=env, kind=api", async () => { + process.env.LINEAR_API_TOKEN = "env-api"; + const auth = await resolveAuth(); + expect(auth?.source).toBe("env"); + expect(auth?.kind).toBe("api"); + expect(auth?.header).toBe("env-api"); + }); + + test("config oauth token includes refreshToken and accessTokenExpiresAt", async () => { + await saveConfig({ + accessToken: "cfg-oauth", + refreshToken: "cfg-refresh", + accessTokenExpiresAt: "2099-01-01T00:00:00.000Z", + outputFormat: "table", + }); + const auth = await resolveAuth(); + expect(auth?.source).toBe("config"); + expect(auth?.kind).toBe("oauth"); + expect(auth?.header).toBe("Bearer cfg-oauth"); + expect(auth?.refreshToken).toBe("cfg-refresh"); + expect(auth?.accessTokenExpiresAt).toBe("2099-01-01T00:00:00.000Z"); + }); + + test("config api token: source=config, kind=api, no refreshToken", async () => { + await saveConfig({ apiToken: "cfg-api", outputFormat: "table" }); + const auth = await resolveAuth(); + expect(auth?.source).toBe("config"); + expect(auth?.kind).toBe("api"); + expect(auth?.refreshToken).toBeUndefined(); + }); + + test("returns undefined when no token is configured", async () => { + const auth = await resolveAuth(); + expect(auth).toBeUndefined(); + }); }); diff --git a/src/config.ts b/src/config.ts index 812d158..687609f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,7 @@ import { chmod, mkdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { CONFIG_FILE } from "./constants.ts"; +import { CONFIG_FILE, VERSION } from "./constants.ts"; export interface Config { // Personal API token from Linear settings @@ -15,6 +15,16 @@ export interface Config { // OAuth token from a Linear app accessToken?: string; + // OAuth refresh token (config-stored OAuth only) + refreshToken?: string; + + // ISO timestamp at which the access token expires + accessTokenExpiresAt?: string; + + // OAuth client credentials for token refresh + oauthClientId?: string; + oauthClientSecret?: string; + // Default team key (e.g. "ENG") defaultTeamKey?: string; @@ -25,12 +35,45 @@ export interface Config { localConfigPaths?: string[]; } +export interface ConfigFileShape { + $schema?: string; + apiToken?: string; + oauth?: { + access?: string; + refresh?: string; + expiresAt?: string; + clientId?: string; + clientSecret?: string; + }; + defaultTeamKey?: string; + outputFormat?: "json" | "table"; + localConfigPaths?: string[]; +} + export interface LocalConfig { defaultTeamKey?: string; defaultProject?: string; outputFormat?: "json" | "table"; } +/** + * Rich auth resolution result. + */ +export interface ResolvedAuth { + /** Formatted Authorization header value */ + header: string; + /** Raw token value (without "Bearer " prefix) */ + token: string; + /** Whether token came from env var or config file */ + source: "env" | "config"; + /** Whether this is an oauth or api token */ + kind: "oauth" | "api"; + /** Refresh token (config oauth only) */ + refreshToken?: string; + /** ISO expiry timestamp (config oauth only) */ + accessTokenExpiresAt?: string; +} + const DEFAULT_CONFIG: Config = { outputFormat: "table", }; @@ -57,6 +100,10 @@ export function getConfigPath(): string { return join(getConfigDir(), CONFIG_FILE); } +export function getConfigSchemaUrl(): string { + return `https://raw.githubusercontent.com/aliou/linear-cli/v${VERSION}/schemas/config.schema.json`; +} + /** * Validate config object structure. * Returns a valid Config object, stripping unknown fields. @@ -67,6 +114,10 @@ export function validateConfig(data: unknown): Config { } const obj = data as Record; + const oauth = + obj.oauth && typeof obj.oauth === "object" + ? (obj.oauth as Record) + : undefined; const config: Config = { ...DEFAULT_CONFIG }; if (typeof obj.apiToken === "string") { @@ -75,6 +126,28 @@ export function validateConfig(data: unknown): Config { if (typeof obj.accessToken === "string") { config.accessToken = obj.accessToken; + } else if (typeof oauth?.access === "string") { + config.accessToken = oauth.access; + } + + if (typeof obj.refreshToken === "string") { + config.refreshToken = obj.refreshToken; + } else if (typeof oauth?.refresh === "string") { + config.refreshToken = oauth.refresh; + } + + if (typeof obj.accessTokenExpiresAt === "string") { + config.accessTokenExpiresAt = obj.accessTokenExpiresAt; + } else if (typeof oauth?.expiresAt === "string") { + config.accessTokenExpiresAt = oauth.expiresAt; + } + + if (typeof oauth?.clientId === "string") { + config.oauthClientId = oauth.clientId; + } + + if (typeof oauth?.clientSecret === "string") { + config.oauthClientSecret = oauth.clientSecret; } if (typeof obj.defaultTeamKey === "string") { @@ -136,8 +209,50 @@ export async function saveConfig(config: Config): Promise { // Create directory with restricted permissions (owner only) await mkdir(configDir, { recursive: true, mode: 0o700 }); + const fileShape: ConfigFileShape = { + $schema: getConfigSchemaUrl(), + outputFormat: config.outputFormat, + }; + + if (config.apiToken) { + fileShape.apiToken = config.apiToken; + } + + if ( + config.accessToken || + config.refreshToken || + config.accessTokenExpiresAt || + config.oauthClientId || + config.oauthClientSecret + ) { + fileShape.oauth = {}; + if (config.accessToken) { + fileShape.oauth.access = config.accessToken; + } + if (config.refreshToken) { + fileShape.oauth.refresh = config.refreshToken; + } + if (config.accessTokenExpiresAt) { + fileShape.oauth.expiresAt = config.accessTokenExpiresAt; + } + if (config.oauthClientId) { + fileShape.oauth.clientId = config.oauthClientId; + } + if (config.oauthClientSecret) { + fileShape.oauth.clientSecret = config.oauthClientSecret; + } + } + + if (config.defaultTeamKey) { + fileShape.defaultTeamKey = config.defaultTeamKey; + } + + if (config.localConfigPaths) { + fileShape.localConfigPaths = config.localConfigPaths; + } + // Write config file - const content = `${JSON.stringify(config, null, 2)}\n`; + const content = `${JSON.stringify(fileShape, null, 2)}\n`; await Bun.write(configPath, content); // Set file permissions (owner read/write only) @@ -155,25 +270,91 @@ export async function updateConfig(updates: Partial): Promise { } /** - * Get the auth token, checking env vars first, then config file. + * Resolve auth credentials, checking env vars first then config. + * Precedence: + * 1. LINEAR_OAUTH_TOKEN (env, oauth) + * 2. LINEAR_API_TOKEN (env, api) + * 3. config.accessToken (config, oauth) + * 4. config.apiToken (config, api) */ -export async function getApiToken(): Promise { +export async function resolveAuth(): Promise { const envOauthToken = process.env.LINEAR_OAUTH_TOKEN; if (envOauthToken) { - return `Bearer ${envOauthToken}`; + return { + header: `Bearer ${envOauthToken}`, + token: envOauthToken, + source: "env", + kind: "oauth", + }; } const envApiToken = process.env.LINEAR_API_TOKEN; if (envApiToken) { - return envApiToken; + return { + header: envApiToken, + token: envApiToken, + source: "env", + kind: "api", + }; } const config = await loadConfig(); + if (config.accessToken) { - return `Bearer ${config.accessToken}`; + return { + header: `Bearer ${config.accessToken}`, + token: config.accessToken, + source: "config", + kind: "oauth", + refreshToken: config.refreshToken, + accessTokenExpiresAt: config.accessTokenExpiresAt, + }; + } + + if (config.apiToken) { + return { + header: config.apiToken, + token: config.apiToken, + source: "config", + kind: "api", + }; + } + + return undefined; +} + +/** + * Get the auth token, checking env vars first, then config file. + * Returns the formatted Authorization header value. + */ +export async function getApiToken(): Promise { + const auth = await resolveAuth(); + return auth?.header; +} + +/** + * Resolve OAuth client credentials for token refresh. + * Environment variables take precedence over config file values. + */ +export async function resolveOAuthClientCredentials(): Promise< + { clientId: string; clientSecret: string } | undefined +> { + const clientId = process.env.LINEAR_CLIENT_ID; + const clientSecret = process.env.LINEAR_CLIENT_SECRET; + + if (clientId && clientSecret) { + return { clientId, clientSecret }; } - return config.apiToken; + const config = await loadConfig(); + if (config.oauthClientId && config.oauthClientSecret) { + return { + clientId: config.oauthClientId, + clientSecret: config.oauthClientSecret, + }; + } + + return undefined; } /** diff --git a/src/index.ts b/src/index.ts index 222a4e2..c32abd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -130,10 +130,14 @@ async function main(): Promise { function parseAuthArgs(args: string[]): { token?: string; type?: "api" | "oauth"; + refreshToken?: string; + expiresAt?: string; help: boolean; } { let token: string | undefined; let type: "api" | "oauth" | undefined; + let refreshToken: string | undefined; + let expiresAt: string | undefined; let help = false; for (let i = 0; i < args.length; i++) { @@ -156,10 +160,20 @@ function parseAuthArgs(args: string[]): { if (value === "api" || value === "oauth") { type = value; } + } else if (arg === "--refresh-token" && args[i + 1]) { + refreshToken = args[i + 1]; + i++; + } else if (arg?.startsWith("--refresh-token=")) { + refreshToken = arg.slice(16); + } else if (arg === "--expires-at" && args[i + 1]) { + expiresAt = args[i + 1]; + i++; + } else if (arg?.startsWith("--expires-at=")) { + expiresAt = arg.slice(13); } } - return { token, type, help }; + return { token, type, refreshToken, expiresAt, help }; } async function handleAuth( @@ -177,9 +191,11 @@ Usage: linear auth login [--token ] [--type ] Authenticate with Linear using an API token or an OAuth token. Options: - --token Token value - --type Required with --token or stdin - -h, --help Show this help + --token Token value + --type Required with --token or stdin + --refresh-token OAuth refresh token (only with --type oauth) + --expires-at Access token expiry as ISO timestamp (only with --type oauth) + -h, --help Show this help Interactive login will ask for the token type first. @@ -219,7 +235,14 @@ Get your API token from: } console.log("Validating token..."); - const result = await login(token, kind); + const loginOptions = + kind === "oauth" + ? { + refreshToken: parsed.refreshToken, + expiresAt: parsed.expiresAt, + } + : undefined; + const result = await login(token, kind, loginOptions); if (result.success) { console.log(`Authenticated as: ${result.name}`); @@ -274,7 +297,7 @@ Options: console.log(`Email: ${status.email}`); } console.log( - `Token source: ${status.tokenSource === "env" ? "LINEAR_API_TOKEN env var" : "config file"}`, + `Token source: ${status.tokenSource === "env" ? "environment variable" : "config file"}`, ); } else { console.log("Status: Not authenticated"); @@ -283,7 +306,7 @@ Options: } if (status.tokenSource) { console.log( - `Token source: ${status.tokenSource === "env" ? "LINEAR_API_TOKEN env var" : "config file"}`, + `Token source: ${status.tokenSource === "env" ? "environment variable" : "config file"}`, ); } process.exit(1); diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..dc863b3 --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,71 @@ +/** + * OAuth token refresh utilities for linear-cli. + * Kept in a separate module to avoid circular dependencies between api.ts and auth.ts. + */ + +import { resolveOAuthClientCredentials, updateConfig } from "./config.ts"; +/** + * Exchange a refresh token for a new access token using Linear's OAuth endpoint. + * Uses LINEAR_CLIENT_ID / LINEAR_CLIENT_SECRET from env when present, + * otherwise falls back to oauth.clientId / oauth.clientSecret from config. + * Persists the new tokens to config and returns the new Authorization header value. + */ +export async function refreshOAuthToken( + currentRefreshToken: string, +): Promise { + const credentials = await resolveOAuthClientCredentials(); + + if (!credentials) { + throw new Error( + "OAuth token refresh requires LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET, or oauth.clientId and oauth.clientSecret in config.", + ); + } + + const { clientId, clientSecret } = credentials; + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: currentRefreshToken, + }); + + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString( + "base64", + ); + + const response = await fetch("https://api.linear.app/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicAuth}`, + }, + body: body.toString(), + }); + + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + throw new Error(`OAuth token refresh failed: ${text}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!data.access_token) { + throw new Error("OAuth token refresh response missing access_token"); + } + + const accessTokenExpiresAt = + typeof data.expires_in === "number" + ? new Date(Date.now() + data.expires_in * 1000).toISOString() + : undefined; + + await updateConfig({ + accessToken: data.access_token, + refreshToken: data.refresh_token ?? currentRefreshToken, + accessTokenExpiresAt, + }); + + return `Bearer ${data.access_token}`; +} -- 2.51.2