@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 +271 -0
- package/dist/auth/constants.d.ts +1 -0
- package/dist/auth/constants.js +4 -0
- package/dist/auth/device-flow.d.ts +41 -0
- package/dist/auth/device-flow.js +122 -0
- package/dist/auth/orchestrator.d.ts +6 -0
- package/dist/auth/orchestrator.js +76 -0
- package/dist/auth/pkce-flow.d.ts +9 -0
- package/dist/auth/pkce-flow.js +40 -0
- package/dist/auth/pkce.d.ts +36 -0
- package/dist/auth/pkce.js +286 -0
- package/dist/auth/providers/azure-devops.d.ts +15 -0
- package/dist/auth/providers/azure-devops.js +130 -0
- package/dist/auth/providers/github.d.ts +6 -0
- package/dist/auth/providers/github.js +38 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +214 -0
- package/dist/commands/update.d.ts +5 -0
- package/dist/commands/update.js +199 -0
- package/dist/config/auth-token-cache.d.ts +10 -0
- package/dist/config/auth-token-cache.js +11 -0
- package/dist/config/loader.d.ts +9 -0
- package/dist/config/loader.js +214 -0
- package/dist/feed/router.d.ts +13 -0
- package/dist/feed/router.js +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +49 -0
- package/dist/npmrc/manager.d.ts +19 -0
- package/dist/npmrc/manager.js +142 -0
- package/dist/nuget/manager.d.ts +40 -0
- package/dist/nuget/manager.js +145 -0
- package/dist/types/config.d.ts +150 -0
- package/dist/types/config.js +63 -0
- package/package.json +41 -0
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,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>;
|