Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
37 changes: 37 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,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();
});
});
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
15 changes: 13 additions & 2 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -64,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(
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 @@ -9,11 +9,13 @@ 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,
getUpdateInfo?: UpdateInfoProvider,
authStorage?: AuthStorage,
envAccessToken?: string,
) {
const storage = authStorage ?? defaultStorage;
const cli = Cli.create('auth', {
Expand Down Expand Up @@ -122,24 +124,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;
}

for await (const result of pollUntil({
fn: async () => {
// If there's a pending device auth, try one poll to see if the user approved.
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
38 changes: 38 additions & 0 deletions packages/cli/src/commands/auth/utils.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
76 changes: 72 additions & 4 deletions packages/cli/src/utils/__tests__/resource-factory.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -14,12 +34,60 @@ describe('ResourceFactory', () => {
expect(factory.createPaymentMethodsResource()).toBe(
factory.createPaymentMethodsResource(),
);
expect(factory.createAuthResource()).toBeInstanceOf(LinkAuthResource);
expect(factory.createSpendRequestResource()).toBeInstanceOf(
SpendRequestResource,
);
expect(factory.createPaymentMethodsResource()).toBeInstanceOf(
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');
});
});
});
Loading