From 053cd700bbe9e88f2d7347c6bcc05c403a6ee641 Mon Sep 17 00:00:00 2001 From: System Administrator Date: Wed, 20 May 2026 09:51:29 -0400 Subject: [PATCH 1/6] feat: add noRefresh option to createAccessTokenProvider Committed-By-Agent: claude --- .../cli/src/auth/__tests__/session.test.ts | 37 +++++++++++++++++++ packages/cli/src/auth/session.ts | 7 ++++ 2 files changed, 44 insertions(+) diff --git a/packages/cli/src/auth/__tests__/session.test.ts b/packages/cli/src/auth/__tests__/session.test.ts index 54ac172..02d0332 100644 --- a/packages/cli/src/auth/__tests__/session.test.ts +++ b/packages/cli/src/auth/__tests__/session.test.ts @@ -86,4 +86,41 @@ describe('createAccessTokenProvider', () => { expect(token).toBe('at_refreshed'); expect(repo.refreshToken).toHaveBeenCalledWith('rt_123'); }); + + it('throws when noRefresh is true and token is expired', async () => { + const storage = new MemoryStorage({ + access_token: 'at_old', + refresh_token: 'rt_123', + expires_in: 0, + token_type: 'Bearer', + }); + storage.setAuth({ + access_token: 'at_old', + refresh_token: 'rt_123', + expires_in: 0, + token_type: 'Bearer', + expires_at: Date.now() + 30_000, + }); + const repo = createMockAuthRepo(); + const provider = createAccessTokenProvider(repo, storage, { noRefresh: true }); + + await expect(provider()).rejects.toThrow(LinkAuthenticationError); + expect(repo.refreshToken).not.toHaveBeenCalled(); + }); + + it('throws when noRefresh is true and forceRefresh is requested', async () => { + const storage = new MemoryStorage({ + access_token: 'at_cached', + refresh_token: 'rt_123', + expires_in: 3600, + token_type: 'Bearer', + }); + const repo = createMockAuthRepo(); + const provider = createAccessTokenProvider(repo, storage, { noRefresh: true }); + + await expect(provider({ forceRefresh: true })).rejects.toThrow( + LinkAuthenticationError, + ); + expect(repo.refreshToken).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/auth/session.ts b/packages/cli/src/auth/session.ts index 08b4fc5..266abf3 100644 --- a/packages/cli/src/auth/session.ts +++ b/packages/cli/src/auth/session.ts @@ -11,6 +11,7 @@ const EXPIRY_BUFFER_MS = 60_000; export function createAccessTokenProvider( authResource: IAuthResource, authStorage: AuthStorage = storage, + options: { noRefresh?: boolean } = {}, ): AccessTokenProvider { return async ({ forceRefresh } = {}) => { const auth = authStorage.getAuth(); @@ -28,6 +29,12 @@ export function createAccessTokenProvider( return auth.access_token; } + if (options.noRefresh) { + throw new LinkAuthenticationError( + 'Access token expired. Re-authenticate with "link-cli auth login".', + ); + } + const refreshed = await authResource.refreshToken(auth.refresh_token); authStorage.setAuth(refreshed); return refreshed.access_token; From c55ffbedafc1a00aad1c4f04995725a6157d33f1 Mon Sep 17 00:00:00 2001 From: System Administrator Date: Wed, 20 May 2026 09:58:36 -0400 Subject: [PATCH 2/6] feat: add env-based token provider to ResourceFactory Committed-By-Agent: claude --- .../utils/__tests__/resource-factory.test.ts | 76 ++++++++++++++++++- packages/cli/src/utils/resource-factory.ts | 48 ++++++++++-- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/__tests__/resource-factory.test.ts b/packages/cli/src/utils/__tests__/resource-factory.test.ts index 95dc57c..b75ea83 100644 --- a/packages/cli/src/utils/__tests__/resource-factory.test.ts +++ b/packages/cli/src/utils/__tests__/resource-factory.test.ts @@ -1,8 +1,28 @@ -import { PaymentMethodsResource, SpendRequestResource } from '@stripe/link-sdk'; -import { describe, expect, it } from 'vitest'; -import { LinkAuthResource } from '../../auth/auth-resource'; +import { + LinkAuthenticationError, + PaymentMethodsResource, + SpendRequestResource, +} from '@stripe/link-sdk'; +import { describe, expect, it, vi } from 'vitest'; +import type { IAuthResource } from '../../auth/types'; import { ResourceFactory } from '../resource-factory'; +function createMockAuthResource( + refreshResult = { + access_token: 'at_refreshed', + refresh_token: 'rt_refreshed', + expires_in: 3600, + token_type: 'Bearer', + }, +): IAuthResource { + return { + initiateDeviceAuth: vi.fn(), + pollDeviceAuth: vi.fn(), + refreshToken: vi.fn(async () => refreshResult), + revokeToken: vi.fn(async () => {}), + }; +} + describe('ResourceFactory', () => { it('caches resource instances', () => { const factory = new ResourceFactory(); @@ -14,7 +34,6 @@ describe('ResourceFactory', () => { expect(factory.createPaymentMethodsResource()).toBe( factory.createPaymentMethodsResource(), ); - expect(factory.createAuthResource()).toBeInstanceOf(LinkAuthResource); expect(factory.createSpendRequestResource()).toBeInstanceOf( SpendRequestResource, ); @@ -22,4 +41,53 @@ describe('ResourceFactory', () => { PaymentMethodsResource, ); }); + + describe('env-based token provider', () => { + it('returns LINK_ACCESS_TOKEN directly', async () => { + const factory = new ResourceFactory({ envAccessToken: 'at_env' }); + const provider = factory.getAccessTokenProvider(); + + expect(await provider()).toBe('at_env'); + }); + + it('throws on forceRefresh when LINK_REFRESH_TOKEN is not set', async () => { + const factory = new ResourceFactory({ envAccessToken: 'at_env' }); + const provider = factory.getAccessTokenProvider(); + + await expect(provider({ forceRefresh: true })).rejects.toThrow( + LinkAuthenticationError, + ); + }); + + it('throws on forceRefresh when LINK_NO_REFRESH is set', async () => { + const mockAuth = createMockAuthResource(); + const factory = new ResourceFactory({ + envAccessToken: 'at_env', + envRefreshToken: 'rt_env', + noRefresh: true, + authResource: mockAuth, + }); + const provider = factory.getAccessTokenProvider(); + + await expect(provider({ forceRefresh: true })).rejects.toThrow( + LinkAuthenticationError, + ); + expect(mockAuth.refreshToken).not.toHaveBeenCalled(); + }); + + it('refreshes using LINK_REFRESH_TOKEN on forceRefresh', async () => { + const mockAuth = createMockAuthResource(); + const factory = new ResourceFactory({ + envAccessToken: 'at_env', + envRefreshToken: 'rt_env', + authResource: mockAuth, + }); + const provider = factory.getAccessTokenProvider(); + + const token = await provider({ forceRefresh: true }); + + expect(token).toBe('at_refreshed'); + expect(mockAuth.refreshToken).toHaveBeenCalledWith('rt_env'); + }); + }); }); diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index e77f165..22f5424 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -4,6 +4,7 @@ import { type IShippingAddressResource, type ISpendRequestResource, type IUserInfoResource, + LinkAuthenticationError, PaymentMethodsResource, ShippingAddressResource, SpendRequestResource, @@ -55,13 +56,20 @@ interface ResourceFactoryOptions { verbose?: boolean; defaultHeaders?: Record; authStorage?: AuthStorage; + envAccessToken?: string; + envRefreshToken?: string; + noRefresh?: boolean; + authResource?: IAuthResource; } export class ResourceFactory { private readonly verbose: boolean; private readonly defaultHeaders?: Record; private readonly authStorage?: AuthStorage; - private authResource?: IAuthResource; + private readonly envAccessToken?: string; + private readonly envRefreshToken?: string; + private readonly noRefresh: boolean; + private _authResource?: IAuthResource; private accessTokenProvider?: ReturnType; private spendRequestResource?: ISpendRequestResource; private paymentMethodsResource?: IPaymentMethodsResource; @@ -72,33 +80,63 @@ export class ResourceFactory { this.verbose = options.verbose ?? false; this.defaultHeaders = options.defaultHeaders; this.authStorage = options.authStorage; + this.envAccessToken = options.envAccessToken; + this.envRefreshToken = options.envRefreshToken; + this.noRefresh = options.noRefresh ?? false; + this._authResource = options.authResource; } createAuthResource(): IAuthResource { - if (this.authResource) { - return this.authResource; + if (this._authResource) { + return this._authResource; } - this.authResource = new LinkAuthResource({ + this._authResource = new LinkAuthResource({ verbose: this.verbose, defaultHeaders: this.defaultHeaders, }); - return this.authResource; + return this._authResource; } getAuthStorage(): AuthStorage | undefined { return this.authStorage; } + getAccessTokenProvider(): ReturnType { + return this.createSdkAccessTokenProvider(); + } + private createSdkAccessTokenProvider() { if (this.accessTokenProvider) { return this.accessTokenProvider; } + if (this.envAccessToken) { + const envAccessToken = this.envAccessToken; + const envRefreshToken = this.envRefreshToken; + const noRefresh = this.noRefresh; + const factory = this; + + this.accessTokenProvider = async ({ forceRefresh } = {}) => { + if (forceRefresh) { + if (noRefresh || !envRefreshToken) { + throw new LinkAuthenticationError( + 'Access token expired. Update LINK_ACCESS_TOKEN and retry.', + ); + } + const refreshed = await factory.createAuthResource().refreshToken(envRefreshToken); + return refreshed.access_token; + } + return envAccessToken; + }; + return this.accessTokenProvider; + } + this.accessTokenProvider = createAccessTokenProvider( this.createAuthResource(), this.authStorage, + { noRefresh: this.noRefresh }, ); return this.accessTokenProvider; } From 84b90f40e5ac3a6f4f8b7a66c6844ba3c2c1fe00 Mon Sep 17 00:00:00 2001 From: System Administrator Date: Wed, 20 May 2026 10:04:40 -0400 Subject: [PATCH 3/6] feat: read LINK_ACCESS_TOKEN, LINK_REFRESH_TOKEN, LINK_NO_REFRESH from env Committed-By-Agent: claude --- packages/cli/src/cli.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 665e049..58cf0d0 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -38,7 +38,18 @@ const authStorage: AuthStorage = credentialFilePath ? new Storage({ configPath: credentialFilePath }) : storage; -const factory = new ResourceFactory({ verbose, defaultHeaders, authStorage }); +const envAccessToken = process.env.LINK_ACCESS_TOKEN; +const envRefreshToken = process.env.LINK_REFRESH_TOKEN; +const noRefresh = Boolean(process.env.LINK_NO_REFRESH); + +const factory = new ResourceFactory({ + verbose, + defaultHeaders, + authStorage, + envAccessToken, + envRefreshToken, + noRefresh, +}); const authRepo = factory.createAuthResource(); const spendRequestRepo = factory.createSpendRequestResource(); From 5688e2ab49a4028f32a255b85b432b4246c04f8d Mon Sep 17 00:00:00 2001 From: System Administrator Date: Wed, 20 May 2026 10:05:41 -0400 Subject: [PATCH 4/6] docs: add LINK_ACCESS_TOKEN, LINK_REFRESH_TOKEN, LINK_NO_REFRESH to env vars docs Committed-By-Agent: claude --- CLAUDE.md | 3 +++ README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2b01a5d..9149940 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,9 @@ JSON output mode (`--format json`) is **not** affected — `JSON.stringify` enco | Variable | Effect | |----------|--------| | `LINK_AUTH_FILE` | Same as `--auth` — override the auth credential file path (flag takes precedence) | +| `LINK_ACCESS_TOKEN` | Use this access token directly, bypassing auth storage | +| `LINK_REFRESH_TOKEN` | Refresh token to use when `LINK_ACCESS_TOKEN` is expired | +| `LINK_NO_REFRESH` | When set, never auto-refresh the access token — error instead | | `LINK_API_BASE_URL` | Override API base URL | | `LINK_AUTH_BASE_URL` | Override auth base URL | | `LINK_HTTP_PROXY` | Route all SDK requests through an HTTP proxy (requires `undici` installed) | diff --git a/README.md b/README.md index 3edbe85..dbf9cd2 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,9 @@ link-cli mpp decode \ | Variable | Effect | |----------|--------| | `LINK_AUTH_FILE` | Same as `--auth` — override the auth credential file path (flag takes precedence) | +| `LINK_ACCESS_TOKEN` | Use this access token directly, bypassing auth storage | +| `LINK_REFRESH_TOKEN` | Refresh token to use when `LINK_ACCESS_TOKEN` is expired | +| `LINK_NO_REFRESH` | When set, never auto-refresh the access token — error instead | | `LINK_API_BASE_URL` | Override the API base URL | | `LINK_AUTH_BASE_URL` | Override the auth base URL | | `LINK_HTTP_PROXY` | Route all requests through an HTTP proxy (requires `undici`) | From 3dd6cd0942840268e23f5f2305f8e157f7480e84 Mon Sep 17 00:00:00 2001 From: System Administrator Date: Wed, 20 May 2026 10:24:33 -0400 Subject: [PATCH 5/6] fix: auth status shows LINK_ACCESS_TOKEN state instead of stored credentials Committed-By-Agent: claude --- packages/cli/src/cli.tsx | 2 +- packages/cli/src/commands/auth/index.tsx | 25 +++++++++++++++- packages/cli/src/commands/auth/status.tsx | 36 ++++++++++++++++------- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 58cf0d0..dd024cc 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -75,7 +75,7 @@ if (!isAgent && process.stdout.isTTY) { } } -cli.command(createAuthCli(authRepo, getUpdateInfo, authStorage)); +cli.command(createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken)); cli.command(createSpendRequestCli(spendRequestRepo, authStorage)); cli.command( createPaymentMethodsCli( diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 7dae3f9..7de5f4d 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -14,6 +14,7 @@ export function createAuthCli( authResource: IAuthResource, getUpdateInfo?: UpdateInfoProvider, authStorage?: AuthStorage, + envAccessToken?: string, ) { const storage = authStorage ?? defaultStorage; const cli = Cli.create('auth', { @@ -122,8 +123,20 @@ export function createAuthCli( if (!c.agent && !c.formatExplicit) { return renderInteractive( - {}} />, + {}} + />, () => { + if (envAccessToken) { + return { + authenticated: true, + access_token: `${envAccessToken.substring(0, 20)}...`, + token_type: 'Bearer', + ...(update && { update }), + }; + } const auth = storage.getAuth(); return { authenticated: !!auth, @@ -140,6 +153,16 @@ export function createAuthCli( ); } + if (envAccessToken) { + yield { + authenticated: true as const, + access_token: `${envAccessToken.substring(0, 20)}...`, + token_type: 'Bearer', + ...(update && { update }), + }; + return; + } + for await (const result of pollUntil({ fn: async () => { // If there's a pending device auth, try one poll to see if the user approved. diff --git a/packages/cli/src/commands/auth/status.tsx b/packages/cli/src/commands/auth/status.tsx index 028509e..a9b70ce 100644 --- a/packages/cli/src/commands/auth/status.tsx +++ b/packages/cli/src/commands/auth/status.tsx @@ -6,11 +6,13 @@ import { DISPLAY_DELAY_MS } from '../../utils/constants'; interface AuthStatusProps { authStorage?: AuthStorage; + envAccessToken?: string; onComplete: () => void; } export const AuthStatus: React.FC = ({ authStorage = defaultStorage, + envAccessToken, onComplete, }) => { const storage = authStorage; @@ -19,19 +21,27 @@ export const AuthStatus: React.FC = ({ const [tokenPreview, setTokenPreview] = useState(''); const [tokenType, setTokenType] = useState(''); const [credentialsPath, setCredentialsPath] = useState(''); + const [fromEnv, setFromEnv] = useState(false); useEffect(() => { - const auth = storage.getAuth(); - const credentialsPath = storage.getPath(); - if (auth) { + if (envAccessToken) { setAuthenticated(true); - setTokenPreview(`${auth.access_token.substring(0, 20)}...`); - setTokenType(auth.token_type); + setTokenPreview(`${envAccessToken.substring(0, 20)}...`); + setTokenType('Bearer'); + setFromEnv(true); + } else { + const auth = storage.getAuth(); + const credentialsPath = storage.getPath(); + if (auth) { + setAuthenticated(true); + setTokenPreview(`${auth.access_token.substring(0, 20)}...`); + setTokenType(auth.token_type); + } + setCredentialsPath(credentialsPath); } - setCredentialsPath(credentialsPath); setChecked(true); setTimeout(onComplete, DISPLAY_DELAY_MS); - }, [onComplete, storage]); + }, [onComplete, storage, envAccessToken]); if (!checked) { return null; @@ -48,9 +58,15 @@ export const AuthStatus: React.FC = ({ Token type: {tokenType} - - Credentials: {credentialsPath} - + {fromEnv ? ( + + Source: LINK_ACCESS_TOKEN + + ) : ( + + Credentials: {credentialsPath} + + )} ); From bb07dcf9d0ee13bbb02310b9d403a39d0c7c7a4a Mon Sep 17 00:00:00 2001 From: System Administrator Date: Wed, 20 May 2026 10:45:31 -0400 Subject: [PATCH 6/6] refactor: extract resolveAuthInfo helper, simplify auth status component Committed-By-Agent: claude --- packages/cli/src/commands/auth/index.tsx | 24 +++++++------- packages/cli/src/commands/auth/status.tsx | 38 ++++++----------------- packages/cli/src/commands/auth/utils.ts | 38 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 41 deletions(-) create mode 100644 packages/cli/src/commands/auth/utils.ts diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 7de5f4d..935484d 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -9,6 +9,7 @@ import { Login } from './login'; import { Logout } from './logout'; import { loginOptions, statusOptions } from './schema'; import { AuthStatus } from './status'; +import { resolveAuthInfo } from './utils'; export function createAuthCli( authResource: IAuthResource, @@ -129,24 +130,21 @@ export function createAuthCli( onComplete={() => {}} />, () => { - if (envAccessToken) { + const info = resolveAuthInfo(envAccessToken, storage); + if (info.authenticated) { return { - authenticated: true, - access_token: `${envAccessToken.substring(0, 20)}...`, - token_type: 'Bearer', + authenticated: true as const, + access_token: info.tokenPreview, + token_type: info.tokenType, + ...(info.source === 'storage' && { + credentials_path: info.credentialsPath, + }), ...(update && { update }), }; } - const auth = storage.getAuth(); return { - authenticated: !!auth, - ...(auth - ? { - access_token: `${auth.access_token.substring(0, 20)}...`, - token_type: auth.token_type, - } - : {}), - credentials_path: storage.getPath(), + authenticated: false as const, + credentials_path: info.credentialsPath, ...(update && { update }), }; }, diff --git a/packages/cli/src/commands/auth/status.tsx b/packages/cli/src/commands/auth/status.tsx index a9b70ce..e69f4cd 100644 --- a/packages/cli/src/commands/auth/status.tsx +++ b/packages/cli/src/commands/auth/status.tsx @@ -3,6 +3,7 @@ import { Box, Text } from 'ink'; import type React from 'react'; import { useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { resolveAuthInfo } from './utils'; interface AuthStatusProps { authStorage?: AuthStorage; @@ -15,56 +16,37 @@ export const AuthStatus: React.FC = ({ envAccessToken, onComplete, }) => { - const storage = authStorage; const [checked, setChecked] = useState(false); - const [authenticated, setAuthenticated] = useState(false); - const [tokenPreview, setTokenPreview] = useState(''); - const [tokenType, setTokenType] = useState(''); - const [credentialsPath, setCredentialsPath] = useState(''); - const [fromEnv, setFromEnv] = useState(false); useEffect(() => { - if (envAccessToken) { - setAuthenticated(true); - setTokenPreview(`${envAccessToken.substring(0, 20)}...`); - setTokenType('Bearer'); - setFromEnv(true); - } else { - const auth = storage.getAuth(); - const credentialsPath = storage.getPath(); - if (auth) { - setAuthenticated(true); - setTokenPreview(`${auth.access_token.substring(0, 20)}...`); - setTokenType(auth.token_type); - } - setCredentialsPath(credentialsPath); - } setChecked(true); setTimeout(onComplete, DISPLAY_DELAY_MS); - }, [onComplete, storage, envAccessToken]); + }, [onComplete]); if (!checked) { return null; } - if (authenticated) { + const info = resolveAuthInfo(envAccessToken, authStorage); + + if (info.authenticated) { return ( ✓ Authenticated - Access token: {tokenPreview} + Access token: {info.tokenPreview} - Token type: {tokenType} + Token type: {info.tokenType} - {fromEnv ? ( + {info.source === 'env' ? ( Source: LINK_ACCESS_TOKEN ) : ( - Credentials: {credentialsPath} + Credentials: {info.credentialsPath} )} @@ -78,7 +60,7 @@ export const AuthStatus: React.FC = ({ Run "link-cli auth login" to authenticate - Credentials: {credentialsPath} + Credentials: {info.credentialsPath} diff --git a/packages/cli/src/commands/auth/utils.ts b/packages/cli/src/commands/auth/utils.ts new file mode 100644 index 0000000..c86b2bd --- /dev/null +++ b/packages/cli/src/commands/auth/utils.ts @@ -0,0 +1,38 @@ +import type { AuthStorage } from '@stripe/link-sdk'; + +export type AuthInfo = + | { authenticated: true; source: 'env'; tokenPreview: string; tokenType: string } + | { + authenticated: true; + source: 'storage'; + tokenPreview: string; + tokenType: string; + credentialsPath: string; + } + | { authenticated: false; source: 'storage'; credentialsPath: string }; + +export function resolveAuthInfo( + envAccessToken: string | undefined, + authStorage: AuthStorage, +): AuthInfo { + if (envAccessToken) { + return { + authenticated: true, + source: 'env', + tokenPreview: `${envAccessToken.substring(0, 20)}...`, + tokenType: 'Bearer', + }; + } + const auth = authStorage.getAuth(); + const credentialsPath = authStorage.getPath(); + if (auth) { + return { + authenticated: true, + source: 'storage', + tokenPreview: `${auth.access_token.substring(0, 20)}...`, + tokenType: auth.token_type, + credentialsPath, + }; + } + return { authenticated: false, source: 'storage', credentialsPath }; +}