@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.
package/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # Sanlam Platform CLI
2
+
3
+ A bootstrapping and orchestration CLI for the Sanlam Fintech Digital platform.
4
+
5
+ ## Overview
6
+
7
+ **@sanlam-fintech-digital/mfe-platform-cli** initializes new developer machines by:
8
+
9
+ - Configuring `.npmrc` with scoped, private npm registries
10
+ - Authenticating registries using OAuth device flow (Azure DevOps, GitHub)
11
+ - Automatically generating Personal Access Tokens for Azure DevOps (one per organization)
12
+ - Providing pass-through access to internal CLI tools (future capability)
13
+
14
+ ## Installation
15
+
16
+ ### Global Install
17
+
18
+ ```bash
19
+ npm install -g @sanlam-fintech-digital/mfe-platform-cli
20
+ ```
21
+
22
+ ### One-off Execution
23
+
24
+ ```bash
25
+ npx @sanlam-fintech-digital/mfe-platform-cli init --config <source>
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Initialize Your Machine
31
+
32
+ The `init` command sets up your local npm registry configuration with automatic OAuth authentication:
33
+
34
+ ```bash
35
+ # From remote URL
36
+ sft-mfe-platform init --config https://example.com/registry-config.json
37
+
38
+ # From local file
39
+ sft-mfe-platform init --config ./registry-config.json
40
+
41
+ # Dry-run to preview changes
42
+ sft-mfe-platform init --config ./registry-config.json --dry-run
43
+ ```
44
+
45
+ This will:
46
+
47
+ 1. Load registry configuration from URL or file
48
+ 2. Store the config source for later updates
49
+ 3. Authenticate via OAuth (GitHub device flow, Azure DevOps PKCE)
50
+ 4. Backup your existing `.npmrc` file (just before modifying)
51
+ 5. Update `.npmrc` with registry URLs and authenticated tokens
52
+
53
+ ### Update Registry Configuration
54
+
55
+ After initial setup, update your registry configuration:
56
+
57
+ ```bash
58
+ # Uses the stored config source and auth options from init
59
+ sft-mfe-platform update
60
+
61
+ # Or specify a different source
62
+ sft-mfe-platform update --config https://example.com/registry-config.json
63
+
64
+ # Override stored auth options if needed
65
+ sft-mfe-platform update --config-client-id <new-client-id>
66
+ ```
67
+
68
+ **Note**: The `update` command automatically reuses the config source URL and authentication options (if any) from your initial `init` command. You only need to provide `--config-auth-*` options if you want to override the stored authentication settings.
69
+
70
+ ## NuGet Feed Support
71
+
72
+ The CLI supports configuring NuGet feeds alongside npm registries using a unified configuration.
73
+
74
+ ### Configuration Example
75
+
76
+ ```json
77
+ {
78
+ "authGroups": [
79
+ {
80
+ "provider": "azure-devops",
81
+ "clientId": "your-client-id",
82
+ "tenantId": "your-tenant-id",
83
+ "registries": [
84
+ {
85
+ "scope": "@myorg",
86
+ "url": "https://pkgs.dev.azure.com/myorg/_packaging/feed/npm/registry/",
87
+ "feedType": "npm"
88
+ },
89
+ {
90
+ "scope": "MyOrgNuGet",
91
+ "url": "https://pkgs.dev.azure.com/myorg/_packaging/feed/nuget/v3/index.json",
92
+ "feedType": "nuget"
93
+ }
94
+ ]
95
+ }
96
+ ]
97
+ }
98
+ ```
99
+
100
+ ### Feed Types
101
+
102
+ - **npm** (default): npm registries configured in `~/.npmrc`
103
+ - **nuget**: NuGet feeds configured in platform-specific nuget.config:
104
+ - Windows: `%AppData%\NuGet\NuGet.Config`
105
+ - macOS/Linux: `~/.config/NuGet/NuGet.Config`
106
+
107
+ ### Authentication
108
+
109
+ **Azure DevOps**: Same PAT works for both npm and NuGet feeds (`vso.packaging` scope)
110
+
111
+ **GitHub**: OAuth device flow token works for both npm and NuGet feeds (`read:packages` scope)
112
+
113
+ ### Backward Compatibility
114
+
115
+ Existing configurations work unchanged. The `feedType` field is optional and defaults to `"npm"`.
116
+
117
+ ### Authenticated Config URL
118
+
119
+ If the config URL requires authentication, use these options:
120
+
121
+ ```bash
122
+ ## Device flow (when using /devicecode)
123
+ sft-mfe-platform init \
124
+ --config https://example.com/registry-config.json \
125
+ --config-auth-endpoint https://login.microsoft.com/<tenantId>/oauth2/v2.0/devicecode \
126
+ --config-token-endpoint https://login.microsoft.com/<tenantId>/oauth2/v2.0/token \
127
+ --config-client-id <client-id> \
128
+ --config-token-kind access \
129
+ --config-auth-scope <scope>
130
+
131
+ ## PKCE (when using /authorize)
132
+ sft-mfe-platform init \
133
+ --config https://example.com/registry-config.json \
134
+ --config-auth-endpoint https://login.microsoft.com/<tenantId>/oauth2/v2.0/authorize \
135
+ --config-token-endpoint https://login.microsoft.com/<tenantId>/oauth2/v2.0/token \
136
+ --config-client-id <client-id> \
137
+ --config-token-kind access \
138
+ --config-auth-scope <scope>
139
+ ```
140
+
141
+ > **Authentication options are automatically stored**: When you provide `--config-auth-*` options during `init`, they are saved to `~/.sft-mfe-platform/config-source.json` and automatically reused by the `update` command. You don't need to provide them again unless you want to override them.
142
+ >
143
+ > If `--config-token-endpoint` is omitted, it is derived from the auth endpoint when possible (e.g., replacing `/devicecode` or `/authorize` with `/token`, GitHub `/device/code` → `/oauth/access_token`).
144
+ > The default token kind is `access` for all providers, unless overridden.
145
+ > If `--config-auth-endpoint` points to GitHub, the default scope is `repo read:packages` unless overridden.
146
+ > If `--config-auth-endpoint` points to Entra ID (login.microsoftonline.com), the default scope is `499b84ac-1321-427f-aa17-267ca6975798/.default` unless overridden.
147
+ > Use `--config-token-kind id` to send the ID token as the bearer token instead of the access token.
148
+
149
+ ## Configuration Format
150
+
151
+ The registry configuration file (JSON) defines authentication groups and optional public registries:
152
+
153
+ ```json
154
+ {
155
+ "authGroups": [
156
+ {
157
+ "provider": "github",
158
+ "clientId": "Iv1.1234567890abcdef",
159
+ "registries": [
160
+ {
161
+ "scope": "@github-project",
162
+ "url": "https://npm.pkg.github.com"
163
+ }
164
+ ]
165
+ },
166
+ {
167
+ "provider": "azure-devops",
168
+ "clientId": "your-azure-client-id",
169
+ "tenantId": "your-azure-tenant-id",
170
+ "registries": [
171
+ {
172
+ "scope": "@some-scope",
173
+ "url": "https://pkgs.dev.azure.com/my-org/_packaging/my-feed/npm/registry/"
174
+ },
175
+ {
176
+ "scope": "@other-scope",
177
+ "url": "https://pkgs.dev.azure.com/my-org/_packaging/other-feed/npm/registry/"
178
+ }
179
+ ]
180
+ }
181
+ ],
182
+ "registries": [
183
+ {
184
+ "scope": "@public-scope",
185
+ "url": "https://registry.npmjs.org"
186
+ }
187
+ ]
188
+ }
189
+ ```
190
+
191
+ ### Configuration Structure
192
+
193
+ #### `authGroups` (optional)
194
+
195
+ Array of authentication groups, each with:
196
+
197
+ - **provider**: Registry provider type (`"azure-devops"` or `"github"`)
198
+ - **clientId**: OAuth client ID for the provider
199
+ - **tenantId**: Azure AD tenant ID (required for `azure-devops` provider only)
200
+ - **registries**: Array of registries sharing this authentication
201
+ - **scope**: npm scope (e.g., `@your-org`)
202
+ - **url**: Registry URL (Azure DevOps Artifacts or GitHub Packages)
203
+
204
+ **Important Limitations**:
205
+
206
+ - **GitHub**: Only one GitHub auth group is allowed per configuration (GitHub uses a single `_authToken` for `npm.pkg.github.com`)
207
+ - **Azure DevOps**: Multiple auth groups supported; one PAT generated per organization
208
+
209
+ #### `registries` (optional)
210
+
211
+ Array of public registries that do not require authentication:
212
+
213
+ - **scope**: npm scope
214
+ - **url**: Registry URL
215
+
216
+ ## Authentication
217
+
218
+ Authentication is handled automatically during `init` using OAuth 2.0 (GitHub device flow, Azure DevOps PKCE):
219
+
220
+ ### GitHub Authentication
221
+
222
+ 1. User runs `sft-mfe-platform init`
223
+ 2. CLI displays a device code and verification URL
224
+ 3. User signs in and enters the device code
225
+ 4. CLI receives access token and stores in `.npmrc`
226
+ 5. All GitHub registries in the auth group use the same token
227
+
228
+ **Permissions**: Read-only access to GitHub Packages (`read:packages`)
229
+
230
+ **Limitation**: Only one GitHub auth group is allowed per configuration because GitHub uses a single authentication token for `npm.pkg.github.com`. All GitHub package registries must be grouped together.
231
+
232
+ ### Azure DevOps Authentication
233
+
234
+ 1. User runs `sft-mfe-platform init`
235
+ 2. CLI opens or prints a browser URL for PKCE login
236
+ 3. User signs in via Entra ID and the browser redirects to the registered `redirectUri`
237
+ 4. CLI exchanges the authorization code for an access token
238
+ 5. CLI generates a Personal Access Token (PAT) for each organization
239
+ 6. PAT is stored in `.npmrc` for each registry
240
+
241
+ **Details**:
242
+
243
+ - Uses Entra ID (Azure AD) OAuth 2.0 Authorization Code with PKCE
244
+ - One PAT is generated per Azure DevOps organization
245
+ - PAT display name format: `mfe-platform-cli-{organization}-{timestamp}` (visible in Azure DevOps token management)
246
+ - PAT expires after 1 year; re-run `sft-mfe-platform init` to refresh
247
+ - Permissions: Read-only access to package feeds (`vso.packaging`)
248
+
249
+ ### Dynamic Port & Relay Support
250
+
251
+ By default, the CLI listens on port `53682` for the PKCE callback (`http://localhost:53682/callback`).
252
+
253
+ To support environments where fixed ports are problematic or to use a public relay service:
254
+
255
+ 1. **CLI Option**: Use `--config-redirect-uri <url>`
256
+
257
+ ```bash
258
+ sft-mfe-platform init --config ... --config-redirect-uri https://relay.example.com/callback
259
+ ```
260
+
261
+ 2. **Configuration**: Add `redirectUri` to your Azure DevOps auth group in the config JSON.
262
+
263
+ **How it works**:
264
+
265
+ - If a custom redirect URI is provided (non-localhost), the CLI finds a random available local port.
266
+ - It sends the port in the `state` parameter as a JSON string (e.g. `{"id":"...","port":12345}`).
267
+ - The relay service is expected to parse this JSON state to extract the port and redirect the callback to `http://localhost:<port>/callback`.
268
+
269
+ ## License
270
+
271
+ ISC
@@ -0,0 +1 @@
1
+ export declare const DEFAULT_REDIRECT_URI = "http://localhost:53682/callback";
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_REDIRECT_URI = void 0;
4
+ exports.DEFAULT_REDIRECT_URI = 'http://localhost:53682/callback';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Device authorization response from OAuth provider
3
+ */
4
+ export interface DeviceAuthResponse {
5
+ device_code: string;
6
+ user_code: string;
7
+ verification_uri: string;
8
+ expires_in: number;
9
+ interval: number;
10
+ message?: string;
11
+ }
12
+ /**
13
+ * Token response from OAuth provider
14
+ */
15
+ export interface TokenResponse {
16
+ access_token: string;
17
+ token_type: string;
18
+ expires_in?: number;
19
+ refresh_token?: string;
20
+ id_token?: string;
21
+ scope: string;
22
+ }
23
+ /**
24
+ * Token error response during polling
25
+ */
26
+ export interface TokenErrorResponse {
27
+ error: 'authorization_pending' | 'slow_down' | 'expired_token' | 'access_denied' | string;
28
+ error_description?: string;
29
+ }
30
+ /**
31
+ * Initiate device authorization flow
32
+ */
33
+ export declare function initiateDeviceFlow(endpoint: string, clientId: string, scope: string): Promise<DeviceAuthResponse>;
34
+ /**
35
+ * Display user instructions for device flow authentication
36
+ */
37
+ export declare function displayUserInstructions(userCode: string, verificationUri: string, providerName: string): void;
38
+ /**
39
+ * Poll for access token after device authorization
40
+ */
41
+ export declare function pollForToken(tokenEndpoint: string, clientId: string, deviceCode: string, interval: number, expiresIn: number): Promise<TokenResponse>;
@@ -0,0 +1,122 @@
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.initiateDeviceFlow = initiateDeviceFlow;
7
+ exports.displayUserInstructions = displayUserInstructions;
8
+ exports.pollForToken = pollForToken;
9
+ const chalk_1 = __importDefault(require("chalk"));
10
+ const ora_1 = __importDefault(require("ora"));
11
+ /**
12
+ * Initiate device authorization flow
13
+ */
14
+ async function initiateDeviceFlow(endpoint, clientId, scope) {
15
+ const response = await fetch(endpoint, {
16
+ method: 'POST',
17
+ headers: {
18
+ "Content-Type": "application/x-www-form-urlencoded",
19
+ Accept: 'application/json',
20
+ },
21
+ body: new URLSearchParams({
22
+ client_id: clientId,
23
+ scope,
24
+ }),
25
+ });
26
+ if (!response.ok) {
27
+ const errorText = await response.text();
28
+ throw new Error(`Device authorization failed: ${response.status} ${response.statusText} - ${errorText}`);
29
+ }
30
+ const data = (await response.json());
31
+ return data;
32
+ }
33
+ /**
34
+ * Display user instructions for device flow authentication
35
+ */
36
+ function displayUserInstructions(userCode, verificationUri, providerName) {
37
+ console.log('\n' + chalk_1.default.bold.cyan(`${providerName} Authentication Required`));
38
+ console.log(chalk_1.default.gray('─'.repeat(50)));
39
+ console.log(`\n${chalk_1.default.bold('Please visit:')} ${chalk_1.default.green.underline(verificationUri)}\n` +
40
+ `${chalk_1.default.bold('Enter code:')} ${chalk_1.default.yellow.bold(userCode)}\n`);
41
+ console.log(chalk_1.default.gray('Waiting for authentication to complete...'));
42
+ }
43
+ /**
44
+ * Poll for access token after device authorization
45
+ */
46
+ async function pollForToken(tokenEndpoint, clientId, deviceCode, interval, expiresIn) {
47
+ const startTime = Date.now();
48
+ let currentInterval = interval * 1000; // Convert to milliseconds
49
+ const spinner = (0, ora_1.default)('Waiting for authorization...').start();
50
+ try {
51
+ while (true) {
52
+ await sleep(currentInterval);
53
+ // Check timeout
54
+ if ((Date.now() - startTime) / 1000 > expiresIn) {
55
+ spinner.fail('Device authorization expired');
56
+ throw new Error('Device authorization expired. Please try again.');
57
+ }
58
+ const response = await fetch(tokenEndpoint, {
59
+ method: 'POST',
60
+ headers: {
61
+ "Content-Type": "application/x-www-form-urlencoded",
62
+ Accept: 'application/json',
63
+ },
64
+ body: new URLSearchParams({
65
+ client_id: clientId,
66
+ device_code: deviceCode,
67
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
68
+ }),
69
+ });
70
+ // Parse response - Entra ID returns 400 for authorization_pending
71
+ const data = (await response.json());
72
+ // Check if response is a success with access_token
73
+ if ('access_token' in data && data.access_token) {
74
+ spinner.succeed('Authorization successful');
75
+ return data;
76
+ }
77
+ // Handle OAuth error responses (may have non-200 status codes)
78
+ if ('error' in data) {
79
+ const error = data;
80
+ if (error.error === 'authorization_pending') {
81
+ // Keep polling
82
+ continue;
83
+ }
84
+ else if (error.error === 'slow_down') {
85
+ // Increase polling interval
86
+ currentInterval += 5000; // Add 5 seconds
87
+ spinner.text = 'Slowing down polling...';
88
+ continue;
89
+ }
90
+ else if (error.error === 'expired_token') {
91
+ spinner.fail('Device code expired');
92
+ throw new Error('Device code expired. Please try again.');
93
+ }
94
+ else if (error.error === 'access_denied') {
95
+ spinner.fail('Authorization denied');
96
+ throw new Error('User denied authorization');
97
+ }
98
+ else {
99
+ spinner.fail('Token request failed');
100
+ throw new Error(`Token request failed: ${error.error} - ${error.error_description || ''}`);
101
+ }
102
+ }
103
+ // Unexpected response format
104
+ if (!response.ok) {
105
+ spinner.fail('Token request failed');
106
+ throw new Error(`Token request failed: ${response.status} ${response.statusText}`);
107
+ }
108
+ }
109
+ }
110
+ catch (error) {
111
+ if (spinner.isSpinning) {
112
+ spinner.fail('Authorization failed');
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+ /**
118
+ * Sleep utility
119
+ */
120
+ function sleep(ms) {
121
+ return new Promise((resolve) => setTimeout(resolve, ms));
122
+ }
@@ -0,0 +1,6 @@
1
+ import { AuthGroup } from '../types/config.js';
2
+ /**
3
+ * Orchestrate authentication across all auth groups
4
+ * Returns a map of registry URL → token for writing to .npmrc
5
+ */
6
+ export declare function orchestrateAuthentication(authGroups: AuthGroup[] | undefined, redirectUri?: string): Promise<Map<string, string>>;
@@ -0,0 +1,76 @@
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.orchestrateAuthentication = orchestrateAuthentication;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const azure_devops_js_1 = require("./providers/azure-devops.js");
9
+ const github_js_1 = require("./providers/github.js");
10
+ const auth_token_cache_js_1 = require("../config/auth-token-cache.js");
11
+ /**
12
+ * Orchestrate authentication across all auth groups
13
+ * Returns a map of registry URL → token for writing to .npmrc
14
+ */
15
+ async function orchestrateAuthentication(authGroups, redirectUri) {
16
+ const tokenMap = new Map();
17
+ if (!authGroups || authGroups.length === 0) {
18
+ console.log(chalk_1.default.gray('No authentication groups configured'));
19
+ return tokenMap;
20
+ }
21
+ console.log(chalk_1.default.bold(`\nAuthenticating ${authGroups.length} auth group(s)...\n`));
22
+ for (let i = 0; i < authGroups.length; i++) {
23
+ const group = authGroups[i];
24
+ console.log(chalk_1.default.cyan(`[${i + 1}/${authGroups.length}] Authenticating ${group.provider} (${group.registries.length} ${group.registries.length === 1 ? 'registry' : 'registries'})`));
25
+ try {
26
+ let groupTokens;
27
+ switch (group.provider) {
28
+ case 'azure-devops':
29
+ // Use command-line redirectUri if provided, otherwise fall back to group config or undefined
30
+ // Priority: CLI > Config Group > Default
31
+ const finalRedirectUri = redirectUri || group.redirectUri;
32
+ groupTokens = await authenticateAzureDevOpsWithCache(group.clientId, group.tenantId, group.registries, finalRedirectUri);
33
+ break;
34
+ case 'github':
35
+ groupTokens = await authenticateGitHubWithCache(group.clientId, group.registries);
36
+ break;
37
+ default:
38
+ throw new Error(`Unsupported provider: ${group.provider}`);
39
+ }
40
+ // Merge tokens into main map
41
+ for (const [url, token] of groupTokens) {
42
+ tokenMap.set(url, token);
43
+ }
44
+ console.log(chalk_1.default.green(`āœ“ ${group.provider} authentication complete\n`));
45
+ }
46
+ catch (error) {
47
+ console.error(chalk_1.default.red(`āœ— Failed to authenticate ${group.provider}: ${error instanceof Error ? error.message : String(error)}\n`));
48
+ throw error;
49
+ }
50
+ }
51
+ console.log(chalk_1.default.green.bold(`\nāœ“ All authentications complete (${tokenMap.size} ${tokenMap.size === 1 ? 'token' : 'tokens'} acquired)\n`));
52
+ return tokenMap;
53
+ }
54
+ async function authenticateGitHubWithCache(clientId, registries) {
55
+ const cached = (0, auth_token_cache_js_1.getCachedConfigAuthToken)();
56
+ if (cached && cached.providerHint === 'github' && cached.clientId === clientId && cached.accessToken) {
57
+ const tokenMap = new Map();
58
+ for (const registry of registries) {
59
+ tokenMap.set(registry.url, cached.accessToken);
60
+ }
61
+ return tokenMap;
62
+ }
63
+ return (0, github_js_1.authenticateGitHub)(clientId, registries);
64
+ }
65
+ async function authenticateAzureDevOpsWithCache(clientId, tenantId, registries, redirectUri) {
66
+ const cached = (0, auth_token_cache_js_1.getCachedConfigAuthToken)();
67
+ if (cached
68
+ && cached.providerHint === 'entra'
69
+ && cached.clientId === clientId
70
+ && cached.tenantId === tenantId
71
+ && cached.accessToken
72
+ && cached.scopes?.includes(azure_devops_js_1.AZURE_DEVOPS_OAUTH_SCOPE)) {
73
+ return (0, azure_devops_js_1.authenticateAzureDevOpsWithOAuthToken)(cached.accessToken, registries);
74
+ }
75
+ return (0, azure_devops_js_1.authenticateAzureDevOps)(clientId, tenantId, registries, redirectUri);
76
+ }
@@ -0,0 +1,9 @@
1
+ export interface PkceFlowOptions {
2
+ authorizeEndpoint: string;
3
+ tokenEndpoint: string;
4
+ clientId: string;
5
+ redirectUri: string;
6
+ scope: string;
7
+ providerName: string;
8
+ }
9
+ export declare function runPkceFlow(options: PkceFlowOptions): Promise<import("./pkce").PkceTokenResponse>;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runPkceFlow = runPkceFlow;
4
+ const device_flow_1 = require("./device-flow");
5
+ const pkce_1 = require("./pkce");
6
+ async function runPkceFlow(options) {
7
+ const redirectUrl = new URL(options.redirectUri);
8
+ const isLocalhost = redirectUrl.hostname === 'localhost' || redirectUrl.hostname === '127.0.0.1';
9
+ let listenPort;
10
+ let expectedPath;
11
+ // Use an object for state to allow flexibility (e.g. adding port)
12
+ const stateObj = {
13
+ id: `pkce-${Date.now()}`
14
+ };
15
+ if (isLocalhost) {
16
+ // If port is explicitly provided in redirectUri, use it
17
+ // Otherwise, find a free unprivileged port to avoid requiring admin privileges
18
+ listenPort = Number(redirectUrl.port) || await (0, pkce_1.findFreePort)();
19
+ }
20
+ else {
21
+ // Relay mode: find a dynamic port
22
+ listenPort = await (0, pkce_1.findFreePort)();
23
+ // Add port to state so relay knows where to redirect
24
+ stateObj.port = listenPort;
25
+ // We expect the relay to redirect to http://localhost:port/callback
26
+ expectedPath = '/callback';
27
+ }
28
+ const state = JSON.stringify(stateObj);
29
+ const codeVerifier = (0, pkce_1.generateCodeVerifier)();
30
+ const codeChallenge = await (0, pkce_1.generateCodeChallenge)(codeVerifier);
31
+ const authorizeUrl = (0, pkce_1.buildAuthorizeUrl)({
32
+ authorizeEndpoint: options.authorizeEndpoint,
33
+ clientId: options.clientId,
34
+ redirectUri: options.redirectUri,
35
+ scope: options.scope,
36
+ }, codeChallenge, state);
37
+ (0, device_flow_1.displayUserInstructions)('Open your browser', authorizeUrl, options.providerName);
38
+ const authCode = await (0, pkce_1.waitForAuthCode)(options.redirectUri, state, listenPort, expectedPath);
39
+ return (0, pkce_1.exchangeCodeForTokenWithEndpoint)(options.tokenEndpoint, options.clientId, options.redirectUri, authCode, codeVerifier, options.scope);
40
+ }
@@ -0,0 +1,36 @@
1
+ export interface PkceRequest {
2
+ authorizeEndpoint: string;
3
+ clientId: string;
4
+ redirectUri: string;
5
+ scope: string;
6
+ }
7
+ export interface PkceTokenResponse {
8
+ access_token?: string;
9
+ id_token?: string;
10
+ token_type?: string;
11
+ expires_in?: number;
12
+ refresh_token?: string;
13
+ scope?: string;
14
+ error?: string;
15
+ error_description?: string;
16
+ }
17
+ export declare function buildAuthorizeUrl(request: PkceRequest, codeChallenge: string, state: string): string;
18
+ export declare function generateCodeVerifier(): string;
19
+ export declare function generateCodeChallenge(codeVerifier: string): Promise<string>;
20
+ export declare function waitForAuthCode(redirectUri: string, state: string, listenPort?: number, expectedPath?: string, timeoutMs?: number, maxRetries?: number): Promise<string>;
21
+ /**
22
+ * Finds a free port by binding to port 0 and returning the OS-assigned port.
23
+ *
24
+ * **Race Condition Warning**: There is a small window between when this function
25
+ * closes the temporary server and when the caller attempts to bind to the returned port.
26
+ * During this window, another process could potentially claim the port.
27
+ *
28
+ * To handle this race condition, callers should implement retry logic with exponential
29
+ * backoff when binding fails with EADDRINUSE. The `waitForAuthCode` function already
30
+ * implements this retry mechanism.
31
+ *
32
+ * @returns A promise that resolves to a free port number
33
+ * @throws Error if unable to find a free port within the timeout period
34
+ */
35
+ export declare function findFreePort(): Promise<number>;
36
+ export declare function exchangeCodeForTokenWithEndpoint(tokenEndpoint: string, clientId: string, redirectUri: string, code: string, codeVerifier: string, scope: string): Promise<PkceTokenResponse>;