Skip to content

Commit 122126a

Browse files
authored
refactor(cli): add experimental auth flow for login command (#3753)
* refactor(cli): add experimental auth flow for login command * fix: types * fix: remove type reference imports * refactor(mc-scripts): extract callback into separate module * refactor: handle response_mode query * docs: changesets
1 parent 6e1fb48 commit 122126a

File tree

6 files changed

+242
-20
lines changed

6 files changed

+242
-20
lines changed

.changeset/odd-trainers-serve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@commercetools-frontend/mc-scripts': patch
3+
---
4+
5+
Support experimental auth flow with identity for `login` command.

packages/mc-scripts/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,13 @@
8787
"graphql-tag": "^2.12.6",
8888
"html-webpack-plugin": "5.6.0",
8989
"json-loader": "0.5.7",
90+
"jwt-decode": "3.1.2",
9091
"lodash": "4.17.21",
9192
"mini-css-extract-plugin": "2.9.0",
9293
"moment": "^2.29.4",
9394
"moment-locales-webpack-plugin": "1.2.0",
9495
"node-fetch": "2.7.0",
96+
"open": "^10.1.0",
9597
"postcss": "8.4.38",
9698
"postcss-custom-media": "8.0.2",
9799
"postcss-custom-properties": "12.1.4",
Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,39 @@
1+
import crypto from 'node:crypto';
2+
import type { Server } from 'node:http';
3+
import process from 'node:process';
14
import chalk from 'chalk';
25
import prompts from 'prompts';
36
import { processConfig } from '@commercetools-frontend/application-config';
7+
import pkgJson from '../../package.json';
48
import { getAuthToken } from '../utils/auth';
9+
import { createAuthCallbackServer } from '../utils/auth-callback';
510
import CredentialsStorage from '../utils/credentials-storage';
611

712
const credentialsStorage = new CredentialsStorage();
13+
const port = 3001;
14+
const clientIdentifier = `mc-scripts-${pkgJson.version}`;
15+
16+
const startServer = (server: Server) =>
17+
new Promise((resolve, reject) => {
18+
server
19+
.listen(port)
20+
.on('listening', resolve)
21+
.on('error', (error) => {
22+
console.error('Problem starting server', error);
23+
return reject(error);
24+
})
25+
.on('close', () => {
26+
process.exit();
27+
});
28+
});
29+
30+
const generateRandomHash = (length: number = 16) =>
31+
crypto.randomBytes(length).toString('hex');
832

933
async function run() {
34+
const shouldUseExperimentalIdentityAuthFlow =
35+
process.env.ENABLE_EXPERIMENTAL_IDENTITY_AUTH_FLOW === 'true';
36+
1037
const applicationConfig = await processConfig();
1138
const { mcApiUrl } = applicationConfig.env;
1239

@@ -18,27 +45,67 @@ async function run() {
1845
return;
1946
}
2047

21-
console.log(`Enter the login credentials:`);
48+
if (shouldUseExperimentalIdentityAuthFlow) {
49+
const open = await import('open');
2250

23-
const { email } = await prompts({
24-
type: 'text',
25-
name: 'email',
26-
message: 'Email',
27-
});
28-
const { password } = await prompts({
29-
type: 'invisible',
30-
name: 'password',
31-
message: 'Password (hidden)',
32-
});
51+
const state = generateRandomHash();
52+
const nonce = generateRandomHash();
3353

34-
if (!email || !password) {
35-
throw new Error(`Missing email or password values. Aborting.`);
36-
}
54+
const authUrl = new URL('/login/authorize', mcApiUrl);
55+
authUrl.searchParams.set('response_type', 'id_token');
56+
authUrl.searchParams.set('response_mode', 'query');
57+
authUrl.searchParams.set('client_id', `__local:${clientIdentifier}`);
58+
authUrl.searchParams.set(
59+
'scope',
60+
[
61+
'openid',
62+
// 'project_key:??',
63+
].join(' ')
64+
);
65+
authUrl.searchParams.set('state', state);
66+
authUrl.searchParams.set('nonce', nonce);
3767

38-
const credentials = await getAuthToken(mcApiUrl, { email, password });
39-
credentialsStorage.setToken(mcApiUrl, credentials);
68+
const server = createAuthCallbackServer({
69+
clientIdentifier,
70+
state,
71+
nonce,
72+
onSuccess: (tokenContext) => {
73+
credentialsStorage.setToken(mcApiUrl, tokenContext);
4074

41-
console.log(chalk.green(`Login successful.\n`));
75+
console.log();
76+
console.log(chalk.green(`Login successful.`));
77+
console.log();
78+
},
79+
});
80+
await startServer(server);
81+
82+
await open.default(authUrl.toString());
83+
84+
console.log('Waiting for the OIDC authentication to complete...');
85+
} else {
86+
console.log(`Enter the login credentials:`);
87+
88+
const { email } = await prompts({
89+
type: 'text',
90+
name: 'email',
91+
message: 'Email',
92+
});
93+
const { password } = await prompts({
94+
type: 'invisible',
95+
name: 'password',
96+
message: 'Password (hidden)',
97+
});
98+
99+
if (!email || !password) {
100+
throw new Error(`Missing email or password values. Aborting.`);
101+
}
102+
103+
const credentials = await getAuthToken(mcApiUrl, { email, password });
104+
credentialsStorage.setToken(mcApiUrl, credentials);
105+
106+
console.log(chalk.green(`Login successful.`));
107+
console.log();
108+
}
42109
}
43110

44111
export default run;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import http from 'node:http';
2+
import jwtDecode from 'jwt-decode';
3+
import type { TMcCliAuthToken } from '../types';
4+
5+
type TAuthCallbackServerOptions = {
6+
clientIdentifier: string;
7+
state: string;
8+
nonce: string;
9+
onSuccess: (tokenContext: TMcCliAuthToken) => void;
10+
};
11+
type TSessionToken = { exp: number; nonce: string };
12+
13+
function createAuthCallbackServer(options: TAuthCallbackServerOptions) {
14+
const server = http.createServer(async (request, response) => {
15+
try {
16+
if (request.url?.includes(`/${options.clientIdentifier}/oidc/callback`)) {
17+
const incomingUrl = new URL(request.url, 'http://localhost');
18+
const sessionToken = incomingUrl.searchParams.get('sessionToken');
19+
const requestedState = incomingUrl.searchParams.get('state');
20+
21+
if (!sessionToken) {
22+
throw new Error('Invalid authentication flow (missing sessionToken)');
23+
}
24+
const decodedSessionToken = jwtDecode<TSessionToken>(sessionToken);
25+
26+
if (decodedSessionToken?.nonce !== options.nonce) {
27+
throw new Error('Invalid authentication flow (nonce mismatch)');
28+
}
29+
if (requestedState !== options.state) {
30+
throw new Error('Invalid authentication flow (state mismatch)');
31+
}
32+
33+
options.onSuccess({
34+
token: sessionToken,
35+
expiresAt: decodedSessionToken.exp,
36+
});
37+
38+
response.setHeader('content-type', 'text/html');
39+
response.end('Success!');
40+
41+
server.close();
42+
}
43+
} catch (error) {
44+
response.setHeader('content-type', 'text/html');
45+
if (error instanceof Error) {
46+
console.error(error.message);
47+
response.end(error.message);
48+
} else {
49+
console.error(error);
50+
response.end(`Invalid authentication flow.`);
51+
}
52+
53+
server.close();
54+
}
55+
});
56+
57+
return server;
58+
}
59+
60+
export { createAuthCallbackServer };

packages/mc-scripts/src/utils/graphql-requests.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import chalk from 'chalk';
22
import { type DocumentNode, print } from 'graphql';
33
import { ClientError, GraphQLClient, type Variables } from 'graphql-request';
4-
import { GRAPHQL_TARGETS } from '@commercetools-frontend/constants';
4+
import {
5+
GRAPHQL_TARGETS,
6+
SUPPORTED_HEADERS,
7+
} from '@commercetools-frontend/constants';
58
import type {
69
TFetchMyOrganizationsFromCliQuery,
710
TFetchMyOrganizationsFromCliQueryVariables,
@@ -112,18 +115,26 @@ async function requestWithTokenRetry<Data, QueryVariables extends Variables>(
112115
requestOptions: {
113116
variables?: QueryVariables;
114117
mcApiUrl: string;
115-
headers: HeadersInit;
118+
headers: Record<string, string>;
116119
},
117120
retryCount: number = 0
118121
): Promise<Data> {
122+
const shouldUseExperimentalIdentityAuthFlow =
123+
process.env.ENABLE_EXPERIMENTAL_IDENTITY_AUTH_FLOW === 'true';
124+
119125
const token = credentialsStorage.getToken(requestOptions.mcApiUrl);
120126

127+
const tokenHeader: Record<string, string | null> =
128+
shouldUseExperimentalIdentityAuthFlow
129+
? { [SUPPORTED_HEADERS.AUTHORIZATION]: `Bearer ${token}` }
130+
: { 'x-mc-cli-access-token': token };
131+
121132
const client = new GraphQLClient(`${requestOptions.mcApiUrl}/graphql`, {
122133
headers: {
123134
Accept: 'application/json',
124135
'Content-Type': 'application/json',
125136
'x-user-agent': userAgent,
126-
...(token ? { 'x-mc-cli-access-token': token } : {}),
137+
...(token ? tokenHeader : {}),
127138
...requestOptions.headers,
128139
},
129140
});

0 commit comments

Comments
 (0)