@sanlam-fintech-digital/mfe-platform-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildAuthorizeUrl = buildAuthorizeUrl;
7
+ exports.generateCodeVerifier = generateCodeVerifier;
8
+ exports.generateCodeChallenge = generateCodeChallenge;
9
+ exports.waitForAuthCode = waitForAuthCode;
10
+ exports.findFreePort = findFreePort;
11
+ exports.exchangeCodeForTokenWithEndpoint = exchangeCodeForTokenWithEndpoint;
12
+ const crypto_1 = __importDefault(require("crypto"));
13
+ const http_1 = __importDefault(require("http"));
14
+ const url_1 = require("url");
15
+ function buildAuthorizeUrl(request, codeChallenge, state) {
16
+ const params = new URLSearchParams({
17
+ client_id: request.clientId,
18
+ response_type: 'code',
19
+ redirect_uri: request.redirectUri,
20
+ response_mode: 'query',
21
+ scope: request.scope,
22
+ code_challenge: codeChallenge,
23
+ code_challenge_method: 'S256',
24
+ state,
25
+ });
26
+ return `${request.authorizeEndpoint}?${params.toString()}`;
27
+ }
28
+ function generateCodeVerifier() {
29
+ return base64Url(crypto_1.default.randomBytes(32));
30
+ }
31
+ async function generateCodeChallenge(codeVerifier) {
32
+ const hash = crypto_1.default.createHash('sha256').update(codeVerifier).digest();
33
+ return base64Url(hash);
34
+ }
35
+ async function waitForAuthCode(redirectUri, state, listenPort, expectedPath, timeoutMs = 120000, maxRetries = 3) {
36
+ const redirectUrl = new url_1.URL(redirectUri);
37
+ // If listenPort is not provided, try to extract from redirectUri (legacy behavior).
38
+ // Previously this defaulted to port 80, which requires elevated privileges on most systems.
39
+ // We now require an explicit, non-privileged port (>= 1024) either via listenPort or redirectUri.
40
+ const inferredPort = listenPort ?? (redirectUrl.port ? Number(redirectUrl.port) : undefined);
41
+ const port = inferredPort !== undefined &&
42
+ Number.isFinite(inferredPort) &&
43
+ Number.isInteger(inferredPort) &&
44
+ inferredPort >= 1024 &&
45
+ inferredPort <= 65535
46
+ ? inferredPort
47
+ : undefined;
48
+ if (port === undefined) {
49
+ throw new Error('Unable to determine a valid listen port for PKCE redirect. ' +
50
+ 'Please provide a non-privileged port (>= 1024) via the listenPort parameter or include it in the redirectUri.');
51
+ }
52
+ const path = expectedPath || redirectUrl.pathname;
53
+ // Retry mechanism to handle race condition where port might be taken between
54
+ // findFreePort() returning and server.listen() attempting to bind
55
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
56
+ try {
57
+ return await attemptListenForAuthCode(port, path, state, timeoutMs);
58
+ }
59
+ catch (err) {
60
+ const isAddressInUse = err instanceof Error &&
61
+ (err.message.includes('EADDRINUSE') ||
62
+ err.code === 'EADDRINUSE');
63
+ if (isAddressInUse && attempt < maxRetries) {
64
+ // Exponential backoff: 100ms, 200ms, 400ms
65
+ const delayMs = 100 * Math.pow(2, attempt - 1);
66
+ await new Promise(resolve => setTimeout(resolve, delayMs));
67
+ continue;
68
+ }
69
+ throw err;
70
+ }
71
+ }
72
+ // TypeScript requires a return statement here, but this code is unreachable
73
+ // because the loop either returns successfully or throws an error on the last attempt
74
+ throw new Error('Unexpected: Failed to bind to port after retries');
75
+ }
76
+ function attemptListenForAuthCode(port, path, state, timeoutMs) {
77
+ return new Promise((resolve, reject) => {
78
+ const sockets = new Set();
79
+ const server = http_1.default.createServer((req, res) => {
80
+ try {
81
+ if (!req.url) {
82
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
83
+ res.end('Invalid request');
84
+ return;
85
+ }
86
+ // We construct a dummy base to parse the URL relative to localhost
87
+ const requestUrl = new url_1.URL(req.url, `http://localhost:${port}`);
88
+ if (requestUrl.pathname !== path) {
89
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
90
+ res.end('Not found');
91
+ return;
92
+ }
93
+ const code = requestUrl.searchParams.get('code');
94
+ const returnedState = requestUrl.searchParams.get('state');
95
+ const error = requestUrl.searchParams.get('error');
96
+ const errorDescription = requestUrl.searchParams.get('error_description');
97
+ if (error) {
98
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
99
+ res.end(`Authorization error: ${error}`);
100
+ cleanup();
101
+ reject(new Error(errorDescription || error));
102
+ return;
103
+ }
104
+ // Check state for CSRF protection.
105
+ // The caller (pkce-flow) constructs the state as a JSON string, e.g.:
106
+ // {"id":"pkce-...","port":12345}
107
+ // The provider (or relay service) should send back exactly what we sent.
108
+ // We validate both exact match and JSON structure to help diagnose relay service issues.
109
+ if (!code) {
110
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
111
+ res.end('Missing authorization code');
112
+ cleanup();
113
+ reject(new Error('Missing authorization code in response'));
114
+ return;
115
+ }
116
+ if (!returnedState) {
117
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
118
+ res.end('Missing state parameter');
119
+ cleanup();
120
+ reject(new Error('Missing state parameter in response. This may indicate a relay service issue.'));
121
+ return;
122
+ }
123
+ // First check for exact match (the happy path)
124
+ if (returnedState !== state) {
125
+ // State mismatch - try to provide helpful diagnostics
126
+ let errorMessage = 'State parameter mismatch';
127
+ let errorDetails = '';
128
+ // Try to parse expected state to see its structure
129
+ try {
130
+ JSON.parse(state);
131
+ // Expected state is valid JSON
132
+ // Try to parse returned state
133
+ try {
134
+ JSON.parse(returnedState);
135
+ // Both are valid JSON but don't match - could be modified by relay
136
+ errorDetails = ' (both states are valid JSON but differ - possible relay service modification)';
137
+ }
138
+ catch {
139
+ // Returned state is not valid JSON - likely corrupted by relay
140
+ errorDetails = ' (returned state is not valid JSON - likely corrupted by relay service)';
141
+ }
142
+ }
143
+ catch {
144
+ // Expected state is not JSON - shouldn't happen with current implementation
145
+ errorDetails = ' (unexpected state format)';
146
+ }
147
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
148
+ res.end('Invalid authorization response: state mismatch');
149
+ cleanup();
150
+ reject(new Error(`${errorMessage}${errorDetails}. Expected: ${state}, Received: ${returnedState}`));
151
+ return;
152
+ }
153
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
154
+ res.end('Authorization complete. You can close this window.');
155
+ cleanup();
156
+ resolve(code);
157
+ }
158
+ catch (err) {
159
+ cleanup();
160
+ reject(err instanceof Error ? err : new Error(String(err)));
161
+ }
162
+ });
163
+ server.on('connection', (socket) => {
164
+ sockets.add(socket);
165
+ socket.on('close', () => sockets.delete(socket));
166
+ });
167
+ // Handle server errors, including EADDRINUSE
168
+ server.on('error', (err) => {
169
+ cleanup();
170
+ reject(err);
171
+ });
172
+ const timeout = setTimeout(() => {
173
+ cleanup();
174
+ reject(new Error('Authorization timed out'));
175
+ }, timeoutMs);
176
+ const cleanup = () => {
177
+ clearTimeout(timeout);
178
+ for (const socket of sockets) {
179
+ socket.destroy();
180
+ }
181
+ server.close();
182
+ };
183
+ server.listen(port, 'localhost');
184
+ server.unref();
185
+ });
186
+ }
187
+ /**
188
+ * Finds a free port by binding to port 0 and returning the OS-assigned port.
189
+ *
190
+ * **Race Condition Warning**: There is a small window between when this function
191
+ * closes the temporary server and when the caller attempts to bind to the returned port.
192
+ * During this window, another process could potentially claim the port.
193
+ *
194
+ * To handle this race condition, callers should implement retry logic with exponential
195
+ * backoff when binding fails with EADDRINUSE. The `waitForAuthCode` function already
196
+ * implements this retry mechanism.
197
+ *
198
+ * @returns A promise that resolves to a free port number
199
+ * @throws Error if unable to find a free port within the timeout period
200
+ */
201
+ async function findFreePort() {
202
+ return new Promise((resolve, reject) => {
203
+ const server = http_1.default.createServer();
204
+ let settled = false;
205
+ const timeout = setTimeout(() => {
206
+ if (settled) {
207
+ return;
208
+ }
209
+ settled = true;
210
+ try {
211
+ server.close();
212
+ }
213
+ catch {
214
+ // Ignore errors while closing on timeout
215
+ }
216
+ reject(new Error('Timed out while trying to find a free port'));
217
+ }, 5000);
218
+ const safeResolve = (port) => {
219
+ if (settled) {
220
+ return;
221
+ }
222
+ settled = true;
223
+ clearTimeout(timeout);
224
+ resolve(port);
225
+ };
226
+ const safeReject = (err) => {
227
+ if (settled) {
228
+ return;
229
+ }
230
+ settled = true;
231
+ clearTimeout(timeout);
232
+ reject(err);
233
+ };
234
+ server.on('error', (err) => {
235
+ safeReject(err);
236
+ });
237
+ server.listen(0, () => {
238
+ const address = server.address();
239
+ const port = typeof address === 'object' && address ? address.port : 0;
240
+ try {
241
+ server.close(() => {
242
+ if (port > 0) {
243
+ safeResolve(port);
244
+ }
245
+ else {
246
+ safeReject(new Error('Failed to obtain a free port'));
247
+ }
248
+ });
249
+ }
250
+ catch (err) {
251
+ safeReject(err instanceof Error
252
+ ? err
253
+ : new Error('Failed to close server while finding a free port'));
254
+ }
255
+ });
256
+ });
257
+ }
258
+ async function exchangeCodeForTokenWithEndpoint(tokenEndpoint, clientId, redirectUri, code, codeVerifier, scope) {
259
+ const response = await fetch(tokenEndpoint, {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Content-Type': 'application/x-www-form-urlencoded',
263
+ Accept: 'application/json',
264
+ },
265
+ body: new URLSearchParams({
266
+ client_id: clientId,
267
+ grant_type: 'authorization_code',
268
+ code,
269
+ redirect_uri: redirectUri,
270
+ code_verifier: codeVerifier,
271
+ scope,
272
+ }),
273
+ });
274
+ const data = (await response.json());
275
+ if (!response.ok) {
276
+ throw new Error(`Token request failed: ${data.error || response.statusText} - ${data.error_description || ''}`);
277
+ }
278
+ return data;
279
+ }
280
+ function base64Url(buffer) {
281
+ return buffer
282
+ .toString('base64')
283
+ .replace(/\+/g, '-')
284
+ .replace(/\//g, '_')
285
+ .replace(/=+$/g, '');
286
+ }
@@ -0,0 +1,15 @@
1
+ import { Registry } from '../../types/config.js';
2
+ /**
3
+ * OAuth scope for Azure DevOps device flow authentication
4
+ * Uses the Azure DevOps application ID with .default scope
5
+ */
6
+ export declare const AZURE_DEVOPS_OAUTH_SCOPE = "499b84ac-1321-427f-aa17-267ca6975798/.default";
7
+ /**
8
+ * Authenticate Azure DevOps registries using OAuth device flow
9
+ * Returns map of registry URL → PAT token
10
+ */
11
+ export declare function authenticateAzureDevOps(clientId: string, tenantId: string, registries: Registry[], redirectUri?: string): Promise<Map<string, string>>;
12
+ /**
13
+ * Generate PATs using a provided OAuth token (skips auth flow)
14
+ */
15
+ export declare function authenticateAzureDevOpsWithOAuthToken(oauthToken: string, registries: Registry[]): Promise<Map<string, string>>;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.AZURE_DEVOPS_OAUTH_SCOPE = void 0;
7
+ exports.authenticateAzureDevOps = authenticateAzureDevOps;
8
+ exports.authenticateAzureDevOpsWithOAuthToken = authenticateAzureDevOpsWithOAuthToken;
9
+ const chalk_1 = __importDefault(require("chalk"));
10
+ const pkce_flow_js_1 = require("../pkce-flow.js");
11
+ const constants_1 = require("../constants");
12
+ /**
13
+ * Entra ID (Azure AD) OAuth endpoint templates
14
+ * Tenant ID will be substituted at runtime
15
+ */
16
+ const AUTHORIZE_ENDPOINT_TEMPLATE = 'https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize';
17
+ const TOKEN_ENDPOINT_TEMPLATE = 'https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token';
18
+ /**
19
+ * OAuth scope for Azure DevOps device flow authentication
20
+ * Uses the Azure DevOps application ID with .default scope
21
+ */
22
+ exports.AZURE_DEVOPS_OAUTH_SCOPE = '499b84ac-1321-427f-aa17-267ca6975798/.default';
23
+ /**
24
+ * PAT scope for npm package access (read-only)
25
+ * vso.packaging is the read-only scope for accessing Azure DevOps package feeds
26
+ */
27
+ const PAT_SCOPE = 'vso.packaging';
28
+ /**
29
+ * PAT API endpoint pattern
30
+ */
31
+ const PAT_API_ENDPOINT = 'https://vssps.dev.azure.com/{organization}/_apis/tokens/pats?api-version=7.1-preview.1';
32
+ /**
33
+ * Authenticate Azure DevOps registries using OAuth device flow
34
+ * Returns map of registry URL → PAT token
35
+ */
36
+ async function authenticateAzureDevOps(clientId, tenantId, registries, redirectUri) {
37
+ console.log(chalk_1.default.bold('\nšŸ” Azure DevOps Authentication\n'));
38
+ // Step 1: PKCE auth to get OAuth token
39
+ // If redirectUri is provided (e.g. from CLI or config), use it (Relay mode if non-localhost).
40
+ // Otherwise fallback to legacy fixed port localhost URI.
41
+ const effectiveRedirectUri = redirectUri || constants_1.DEFAULT_REDIRECT_URI;
42
+ const tokenResponse = await (0, pkce_flow_js_1.runPkceFlow)({
43
+ authorizeEndpoint: AUTHORIZE_ENDPOINT_TEMPLATE.replace('{tenantId}', tenantId),
44
+ tokenEndpoint: TOKEN_ENDPOINT_TEMPLATE.replace('{tenantId}', tenantId),
45
+ clientId,
46
+ redirectUri: effectiveRedirectUri,
47
+ scope: exports.AZURE_DEVOPS_OAUTH_SCOPE,
48
+ providerName: 'Azure DevOps',
49
+ });
50
+ if (!tokenResponse.access_token) {
51
+ throw new Error('OAuth access token not returned from Entra ID');
52
+ }
53
+ return buildTokenMap(registries, tokenResponse.access_token);
54
+ }
55
+ /**
56
+ * Generate PATs using a provided OAuth token (skips auth flow)
57
+ */
58
+ async function authenticateAzureDevOpsWithOAuthToken(oauthToken, registries) {
59
+ return buildTokenMap(registries, oauthToken);
60
+ }
61
+ async function buildTokenMap(registries, oauthToken) {
62
+ const organizations = extractOrganizations(registries);
63
+ console.log(chalk_1.default.gray(`\nFound ${organizations.size} unique organization(s): ${Array.from(organizations).join(', ')}\n`));
64
+ const tokenMap = new Map();
65
+ for (const org of organizations) {
66
+ const pat = await generatePat(org, oauthToken);
67
+ for (const registry of registries) {
68
+ const registryOrg = extractOrgFromUrl(registry.url);
69
+ if (registryOrg === org) {
70
+ tokenMap.set(registry.url, pat);
71
+ }
72
+ }
73
+ }
74
+ return tokenMap;
75
+ }
76
+ /**
77
+ * Extract unique organizations from registry URLs
78
+ */
79
+ function extractOrganizations(registries) {
80
+ const orgs = new Set();
81
+ for (const registry of registries) {
82
+ const org = extractOrgFromUrl(registry.url);
83
+ if (org) {
84
+ orgs.add(org);
85
+ }
86
+ }
87
+ return orgs;
88
+ }
89
+ /**
90
+ * Extract organization from Azure DevOps registry URL
91
+ * Format: https://pkgs.dev.azure.com/{organization}/_packaging/...
92
+ */
93
+ function extractOrgFromUrl(url) {
94
+ const match = url.match(/pkgs\.dev\.azure\.com\/([^/]+)\//);
95
+ return match ? match[1] : null;
96
+ }
97
+ /**
98
+ * Generate a Personal Access Token using the OAuth token
99
+ */
100
+ async function generatePat(organization, oauthToken) {
101
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
102
+ const displayName = `mfe-platform-cli-${organization}-${timestamp}`;
103
+ // Set PAT to expire in 1 year
104
+ const validTo = new Date();
105
+ validTo.setFullYear(validTo.getFullYear() + 1);
106
+ const patRequest = {
107
+ displayName,
108
+ scope: PAT_SCOPE,
109
+ validTo: validTo.toISOString(),
110
+ allOrgs: false,
111
+ };
112
+ const endpoint = PAT_API_ENDPOINT.replace('{organization}', organization);
113
+ console.log(chalk_1.default.gray(`Generating PAT for organization: ${organization}...`));
114
+ const response = await fetch(endpoint, {
115
+ method: 'POST',
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ Accept: 'application/json',
119
+ Authorization: `Bearer ${oauthToken}`,
120
+ },
121
+ body: JSON.stringify(patRequest),
122
+ });
123
+ if (!response.ok) {
124
+ const errorText = await response.text();
125
+ throw new Error(`Failed to generate PAT for ${organization}: ${response.status} ${response.statusText}\n${errorText}`);
126
+ }
127
+ const patResponse = (await response.json());
128
+ console.log(chalk_1.default.green(`āœ“ PAT generated: ${displayName}`));
129
+ return patResponse.patToken.token;
130
+ }
@@ -0,0 +1,6 @@
1
+ import { Registry } from '../../types/config.js';
2
+ /**
3
+ * Authenticate GitHub registries using OAuth device flow
4
+ * Returns map of registry URL → access token (same token for all registries in group)
5
+ */
6
+ export declare function authenticateGitHub(clientId: string, registries: Registry[]): Promise<Map<string, string>>;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.authenticateGitHub = authenticateGitHub;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const device_flow_js_1 = require("../device-flow.js");
9
+ /**
10
+ * GitHub OAuth endpoints
11
+ */
12
+ const DEVICE_AUTH_ENDPOINT = 'https://github.com/login/device/code';
13
+ const TOKEN_ENDPOINT = 'https://github.com/login/oauth/access_token';
14
+ /**
15
+ * Required scopes for npm package access (read-only)
16
+ */
17
+ const REQUIRED_SCOPES = 'read:packages';
18
+ /**
19
+ * Authenticate GitHub registries using OAuth device flow
20
+ * Returns map of registry URL → access token (same token for all registries in group)
21
+ */
22
+ async function authenticateGitHub(clientId, registries) {
23
+ console.log(chalk_1.default.bold('\nšŸ” GitHub Authentication\n'));
24
+ // Step 1: Device flow to get access token
25
+ const deviceAuth = await (0, device_flow_js_1.initiateDeviceFlow)(DEVICE_AUTH_ENDPOINT, clientId, REQUIRED_SCOPES);
26
+ (0, device_flow_js_1.displayUserInstructions)(deviceAuth.user_code, deviceAuth.verification_uri, 'GitHub');
27
+ const tokenResponse = await (0, device_flow_js_1.pollForToken)(TOKEN_ENDPOINT, clientId, deviceAuth.device_code, deviceAuth.interval, deviceAuth.expires_in);
28
+ if (!tokenResponse.access_token) {
29
+ throw new Error('GitHub access token not returned from OAuth device flow');
30
+ }
31
+ console.log(chalk_1.default.green('āœ“ GitHub access token acquired\n'));
32
+ // Step 2: Map all registries to the same token
33
+ const tokenMap = new Map();
34
+ for (const registry of registries) {
35
+ tokenMap.set(registry.url, tokenResponse.access_token);
36
+ }
37
+ return tokenMap;
38
+ }
@@ -0,0 +1,10 @@
1
+ import { Command } from 'commander';
2
+ import { ConfigSource } from '../types/config.js';
3
+ /**
4
+ * Get stored config source and auth options for update command
5
+ */
6
+ export declare function getStoredConfigSource(): Promise<ConfigSource | null>;
7
+ /**
8
+ * Create and configure the init command
9
+ */
10
+ export declare function createInitCommand(): Command;