Skip to content
Merged
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/auth/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,45 @@ 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();
});
});
7 changes: 7 additions & 0 deletions packages/cli/src/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -65,7 +76,9 @@ 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(
Expand Down
41 changes: 31 additions & 10 deletions packages/cli/src/commands/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +77,7 @@ export function createAuthCli(
authResource: IAuthResource,
getUpdateInfo?: UpdateInfoProvider,
authStorage?: AuthStorage,
envAccessToken?: string,
) {
const storage = authStorage ?? defaultStorage;
const cli = Cli.create('auth', {
Expand Down Expand Up @@ -201,24 +203,43 @@ export function createAuthCli(

if (!c.agent && !c.formatExplicit) {
return renderInteractive(
<AuthStatus authStorage={storage} onComplete={() => {}} />,
<AuthStatus
authStorage={storage}
envAccessToken={envAccessToken}
onComplete={() => {}}
/>,
() => {
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,
Expand Down
40 changes: 19 additions & 21 deletions packages/cli/src/commands/auth/status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthStatusProps> = ({
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 (
<Box flexDirection="column">
<Text color="green">✓ Authenticated</Text>
<Box flexDirection="column" marginTop={1} paddingX={2}>
<Text>
Access token: <Text bold>{tokenPreview}</Text>
</Text>
<Text>
Token type: <Text bold>{tokenType}</Text>
Access token: <Text bold>{info.tokenPreview}</Text>
</Text>
<Text>
Credentials: <Text bold>{credentialsPath}</Text>
Token type: <Text bold>{info.tokenType}</Text>
</Text>
{info.source === 'env' ? (
<Text>
Source: <Text bold>LINK_ACCESS_TOKEN</Text>
</Text>
) : (
<Text>
Credentials: <Text bold>{info.credentialsPath}</Text>
</Text>
)}
</Box>
</Box>
);
Expand All @@ -62,7 +60,7 @@ export const AuthStatus: React.FC<AuthStatusProps> = ({
<Text dimColor>Run "link-cli auth login" to authenticate</Text>
<Box marginTop={1} paddingX={2}>
<Text>
Credentials: <Text bold>{credentialsPath}</Text>
Credentials: <Text bold>{info.credentialsPath}</Text>
</Text>
</Box>
</Box>
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/commands/auth/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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 };
}
69 changes: 68 additions & 1 deletion packages/cli/src/utils/__tests__/resource-factory.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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');
});
});
});
Loading
Loading