diff --git a/CLAUDE.md b/CLAUDE.md index 5690939..d55c4e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,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 79afcfc..d125b93 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,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`) | 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; diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index ad80a36..cb95001 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -39,7 +39,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(); @@ -65,7 +76,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 771f0be..12712a1 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -10,6 +10,7 @@ import { Login } from './login'; import { Logout } from './logout'; import { loginOptions, statusOptions } from './schema'; import { AuthStatus } from './status'; +import { resolveAuthInfo } from './utils'; interface PollAuthOptions { interval: number; @@ -76,6 +77,7 @@ export function createAuthCli( authResource: IAuthResource, getUpdateInfo?: UpdateInfoProvider, authStorage?: AuthStorage, + envAccessToken?: string, ) { const storage = authStorage ?? defaultStorage; const cli = Cli.create('auth', { @@ -201,24 +203,43 @@ export function createAuthCli( if (!c.agent && !c.formatExplicit) { return renderInteractive( - {}} />, + {}} + />, () => { - const auth = storage.getAuth(); + const info = resolveAuthInfo(envAccessToken, storage); + if (info.authenticated) { + return { + authenticated: true as const, + access_token: info.tokenPreview, + token_type: info.tokenType, + ...(info.source === 'storage' && { + credentials_path: info.credentialsPath, + }), + ...(update && { update }), + }; + } 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 }), }; }, ); } + if (envAccessToken) { + yield { + authenticated: true as const, + access_token: `${envAccessToken.substring(0, 20)}...`, + token_type: 'Bearer', + ...(update && { update }), + }; + return; + } + yield* pollAuthStatus( authResource, storage, diff --git a/packages/cli/src/commands/auth/status.tsx b/packages/cli/src/commands/auth/status.tsx index 028509e..e69f4cd 100644 --- a/packages/cli/src/commands/auth/status.tsx +++ b/packages/cli/src/commands/auth/status.tsx @@ -3,54 +3,52 @@ 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; + envAccessToken?: string; onComplete: () => void; } export const AuthStatus: React.FC = ({ authStorage = defaultStorage, + 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(''); useEffect(() => { - 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]); + }, [onComplete]); if (!checked) { return null; } - if (authenticated) { + const info = resolveAuthInfo(envAccessToken, authStorage); + + if (info.authenticated) { return ( ✓ Authenticated - Access token: {tokenPreview} - - - Token type: {tokenType} + Access token: {info.tokenPreview} - Credentials: {credentialsPath} + Token type: {info.tokenType} + {info.source === 'env' ? ( + + Source: LINK_ACCESS_TOKEN + + ) : ( + + Credentials: {info.credentialsPath} + + )} ); @@ -62,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 }; +} diff --git a/packages/cli/src/utils/__tests__/resource-factory.test.ts b/packages/cli/src/utils/__tests__/resource-factory.test.ts index f7c6785..92d57f5 100644 --- a/packages/cli/src/utils/__tests__/resource-factory.test.ts +++ b/packages/cli/src/utils/__tests__/resource-factory.test.ts @@ -1,12 +1,30 @@ import { + LinkAuthenticationError, PaymentMethodsResource, SpendRequestResource, WebBotAuthResource, } from '@stripe/link-sdk'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { LinkAuthResource } from '../../auth/auth-resource'; +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(); @@ -32,4 +50,53 @@ describe('ResourceFactory', () => { WebBotAuthResource, ); }); + + 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 8ba5b17..2710df8 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, type IWebBotAuthResource, PaymentMethodsResource, ShippingAddressResource, @@ -57,13 +58,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; @@ -75,35 +83,65 @@ 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 = sanitizeResource( + this._authResource = sanitizeResource( 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; }