Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support projectId + userToken as alternative to projectToken for auth #852

Merged
merged 1 commit into from
Nov 10, 2023
Merged
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
13 changes: 12 additions & 1 deletion node-src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ vi.mock('node-fetch', () => ({

// Authenticate
if (query?.match('CreateAppTokenMutation')) {
return { data: { createAppToken: 'token' } };
return { data: { appToken: 'token' } };
}
if (query?.match('CreateCLITokenMutation')) {
return { data: { cliToken: 'token' } };
}

if (query?.match('AnnounceBuildMutation')) {
Expand Down Expand Up @@ -401,6 +404,14 @@ it('runs in simple situations', async () => {
});
});

it('supports projectId + userToken', async () => {
const ctx = getContext([]);
ctx.env.CHROMATIC_PROJECT_TOKEN = '';
ctx.extraOptions = { projectId: 'project-id', userToken: 'user-token' };
await runAll(ctx);
expect(ctx.exitCode).toBe(1);
});

it('returns 0 with exit-zero-on-changes', async () => {
const ctx = getContext(['--project-token=asdf1234', '--exit-zero-on-changes']);
await runAll(ctx);
Expand Down
4 changes: 2 additions & 2 deletions node-src/io/GraphQLClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ export default class GraphQLClient {
async runQuery<T>(
query: string,
variables: Record<string, any>,
{ headers = {}, retries = 2 } = {}
{ endpoint = this.endpoint, headers = {}, retries = 2 } = {}
): Promise<T> {
return retry(
async (bail) => {
const { data, errors } = await this.client
.fetch(
this.endpoint,
endpoint,
{
body: JSON.stringify({ query, variables }),
headers: { ...this.headers, ...headers },
Expand Down
2 changes: 1 addition & 1 deletion node-src/lib/getOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export default function getOptions({
log.setInteractive(false);
}

if (!options.projectToken) {
if (!options.projectToken && !(options.projectId && options.userToken)) {
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved
throw new Error(missingProjectToken());
}

Expand Down
16 changes: 14 additions & 2 deletions node-src/tasks/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@ import { setAuthorizationToken } from './auth';
describe('setAuthorizationToken', () => {
it('updates the GraphQL client with an app token from the index', async () => {
const client = { runQuery: vi.fn(), setAuthorization: vi.fn() };
client.runQuery.mockReturnValue({ createAppToken: 'token' });
client.runQuery.mockReturnValue({ appToken: 'app-token' });

await setAuthorizationToken({ client, options: { projectToken: 'test' } } as any);
expect(client.setAuthorization).toHaveBeenCalledWith('token');
expect(client.setAuthorization).toHaveBeenCalledWith('app-token');
});

it('supports projectId + userToken', async () => {
const client = { runQuery: vi.fn(), setAuthorization: vi.fn() };
client.runQuery.mockReturnValue({ cliToken: 'cli-token' });

await setAuthorizationToken({
client,
env: { CHROMATIC_INDEX_URL: 'https://index.chromatic.com' },
options: { projectId: 'Project:abc123', userToken: 'user-token' },
} as any);
expect(client.setAuthorization).toHaveBeenCalledWith('cli-token');
});
});
56 changes: 42 additions & 14 deletions node-src/tasks/auth.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,59 @@
import { createTask, transitionTo } from '../lib/tasks';
import { Context } from '../types';
import invalidProjectId from '../ui/messages/errors/invalidProjectId';
import invalidProjectToken from '../ui/messages/errors/invalidProjectToken';
import { authenticated, authenticating, initial } from '../ui/tasks/auth';

const CreateCLITokenMutation = `
mutation CreateCLITokenMutation($projectId: String!) {
cliToken: createCLIToken(projectId: $projectId)
}
`;

// Legacy mutation
const CreateAppTokenMutation = `
mutation CreateAppTokenMutation($projectToken: String!) {
createAppToken(code: $projectToken)
appToken: createAppToken(code: $projectToken)
}
`;

interface CreateAppTokenMutationResult {
createAppToken: string;
}
const getToken = async (ctx: Context) => {
const { projectId, projectToken, userToken } = ctx.options;

export const setAuthorizationToken = async (ctx: Context) => {
const { client, options } = ctx;
const variables = { projectToken: options.projectToken };
if (projectId && userToken) {
const { cliToken } = await ctx.client.runQuery<{ cliToken: string }>(
CreateCLITokenMutation,
{ projectId },
{
endpoint: `${ctx.env.CHROMATIC_INDEX_URL}/api`,
headers: { Authorization: `Bearer ${userToken}` },
}
);
return cliToken;
}

if (projectToken) {
const { appToken } = await ctx.client.runQuery<{ appToken: string }>(CreateAppTokenMutation, {
projectToken,
});
return appToken;
}

// Should never happen since we check for this in getOptions
throw new Error('No projectId or projectToken');
};

export const setAuthorizationToken = async (ctx: Context) => {
try {
const { createAppToken: appToken } = await client.runQuery<CreateAppTokenMutationResult>(
CreateAppTokenMutation,
variables
);
client.setAuthorization(appToken);
const token = await getToken(ctx);
ctx.client.setAuthorization(token);
} catch (errors) {
if (errors[0] && errors[0].message && errors[0].message.match('No app with code')) {
throw new Error(invalidProjectToken(variables));
const message = errors[0]?.message;
if (message?.match('Must login') || message?.match('No Access')) {
throw new Error(invalidProjectId({ projectId: ctx.options.projectId }));
}
if (message?.match('No app with code')) {
throw new Error(invalidProjectToken({ projectToken: ctx.options.projectToken }));
}
throw errors;
}
Expand Down
3 changes: 2 additions & 1 deletion node-src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ export interface Flags {
preserveMissing?: boolean;
}

export interface Options {
export interface Options extends Configuration {
projectToken: string;
userToken?: string;

configFile?: Flags['configFile'];
onlyChanged: boolean | string;
Expand Down
7 changes: 7 additions & 0 deletions node-src/ui/messages/errors/invalidProjectId.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import invalidProjectId from './invalidProjectId';

export default {
title: 'CLI/Messages/Errors',
};

export const InvalidProjectId = () => invalidProjectId({ projectId: '5d67dc0374b2e300209c41e8' });
12 changes: 12 additions & 0 deletions node-src/ui/messages/errors/invalidProjectId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import chalk from 'chalk';
import { dedent } from 'ts-dedent';

import { error, info } from '../../components/icons';
import link from '../../components/link';

export default ({ projectId }: { projectId: string }) =>
dedent(chalk`
${error} Invalid project ID: ${projectId}
You may not sufficient permissions to create builds on this project, or it may not exist.
${info} Read more at ${link('https://www.chromatic.com/docs/setup')}
`);
4 changes: 3 additions & 1 deletion node-src/ui/tasks/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export const authenticating = (ctx: Context) => ({
export const authenticated = (ctx: Context) => ({
status: 'success',
title: `Authenticated with Chromatic${env(ctx.env.CHROMATIC_INDEX_URL)}`,
output: `Using project token '${mask(ctx.options.projectToken)}'`,
output: ctx.options.projectToken
? `Using project token '${mask(ctx.options.projectToken)}'`
: `Using project ID '${ctx.options.projectId}' and user token`,
});

export const invalidToken = (ctx: Context) => ({
Expand Down
Loading