Skip to content

Commit

Permalink
Merge pull request #852 from chromaui/support-oauth-token
Browse files Browse the repository at this point in the history
Support `projectId` + `userToken` as alternative to `projectToken` for auth
  • Loading branch information
ghengeveld authored Nov 10, 2023
2 parents 20b0b0e + 17a8859 commit f2a52aa
Show file tree
Hide file tree
Showing 9 changed files with 95 additions and 22 deletions.
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)) {
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

0 comments on commit f2a52aa

Please sign in to comment.