fastpass-cli 0.2.0 → 0.2.2
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 +3 -8
- package/package.json +1 -1
- package/src/auth.js +5 -42
- package/src/cli.js +5 -1
- package/src/commands/protect.js +80 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fastpass
|
|
2
2
|
|
|
3
|
-
A CLI for
|
|
3
|
+
A CLI for rapidly configuring [Cloudflare Access](https://www.cloudflare.com/products/zero-trust/access/) — add authentication to internal apps without touching your application code. Creates Access Applications, Identity Providers, and policies via the Cloudflare API.
|
|
4
4
|
|
|
5
5
|
**Requirements:** Node 18+, Cloudflare account with the domain on your account, Cloudflare Zero Trust (Access) enabled.
|
|
6
6
|
|
|
@@ -60,12 +60,9 @@ Options:
|
|
|
60
60
|
|
|
61
61
|
## Credentials
|
|
62
62
|
|
|
63
|
-
fastpass needs Cloudflare API
|
|
63
|
+
fastpass needs a Cloudflare API token via the `CLOUDFLARE_API_TOKEN` environment variable.
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
2. Wrangler OAuth token from `~/.wrangler/config/default.toml` (from `npx wrangler login`)
|
|
67
|
-
|
|
68
|
-
**Recommended:** Use an API token with:
|
|
65
|
+
Create an API token with:
|
|
69
66
|
|
|
70
67
|
- **Access: Organizations, Identity Providers, and Groups** — Edit
|
|
71
68
|
- **Access: Apps and Policies** — Edit
|
|
@@ -80,8 +77,6 @@ export CLOUDFLARE_API_TOKEN="your-token"
|
|
|
80
77
|
export CLOUDFLARE_ACCOUNT_ID="your-account-id"
|
|
81
78
|
```
|
|
82
79
|
|
|
83
|
-
**Note:** Wrangler’s OAuth token typically does not include Access scopes. If `wrangler login` works for Workers but fastpass fails with permission errors, use a dedicated API token with the permissions above.
|
|
84
|
-
|
|
85
80
|
**Logs:** The `logs` and `status` commands fetch access events. If they fail, your token may need **Access: Audit Logs** — Read.
|
|
86
81
|
|
|
87
82
|
## Prerequisites
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -1,36 +1,21 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
1
|
import pc from 'picocolors';
|
|
5
2
|
import { spin } from './ui.js';
|
|
6
3
|
|
|
7
4
|
/**
|
|
8
5
|
* Resolve Cloudflare credentials.
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 2. Wrangler OAuth token from ~/.wrangler/config/default.toml
|
|
13
|
-
*
|
|
14
|
-
* Account ID:
|
|
15
|
-
* 1. CLOUDFLARE_ACCOUNT_ID env var
|
|
16
|
-
* 2. Fetched from /accounts API
|
|
7
|
+
* Token: CLOUDFLARE_API_TOKEN env var
|
|
8
|
+
* Account: CLOUDFLARE_ACCOUNT_ID env var, or fetched from /accounts API
|
|
17
9
|
*/
|
|
18
10
|
export async function getCredentials() {
|
|
19
11
|
const s = spin('Checking Cloudflare credentials');
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (!token) {
|
|
24
|
-
s.text = 'Checking Cloudflare credentials (trying wrangler)';
|
|
25
|
-
token = await tryWranglerToken();
|
|
26
|
-
}
|
|
13
|
+
const token = process.env.CLOUDFLARE_API_TOKEN;
|
|
27
14
|
|
|
28
15
|
if (!token) {
|
|
29
16
|
s.fail('No Cloudflare credentials found');
|
|
30
17
|
console.error('');
|
|
31
|
-
console.error(` ${pc.bold('
|
|
32
|
-
console.error('');
|
|
33
|
-
console.error(` ${pc.cyan('1.')} Set an API token (recommended):`);
|
|
18
|
+
console.error(` ${pc.bold('Set an API token:')}`);
|
|
34
19
|
console.error('');
|
|
35
20
|
console.error(` ${pc.bold('export CLOUDFLARE_API_TOKEN=<your-token>')}`);
|
|
36
21
|
console.error('');
|
|
@@ -39,10 +24,6 @@ export async function getCredentials() {
|
|
|
39
24
|
console.error(` ${pc.dim('•')} Access: Organizations, Identity Providers, and Groups — Edit`);
|
|
40
25
|
console.error(` ${pc.dim('•')} Access: Apps and Policies — Edit`);
|
|
41
26
|
console.error('');
|
|
42
|
-
console.error(` ${pc.cyan('2.')} Log in with wrangler:`);
|
|
43
|
-
console.error('');
|
|
44
|
-
console.error(` ${pc.bold('npx wrangler login')}`);
|
|
45
|
-
console.error('');
|
|
46
27
|
process.exit(1);
|
|
47
28
|
}
|
|
48
29
|
|
|
@@ -54,28 +35,10 @@ export async function getCredentials() {
|
|
|
54
35
|
}
|
|
55
36
|
|
|
56
37
|
s.succeed('Credentials OK');
|
|
38
|
+
console.log('');
|
|
57
39
|
return { token, accountId };
|
|
58
40
|
}
|
|
59
41
|
|
|
60
|
-
async function tryWranglerToken() {
|
|
61
|
-
try {
|
|
62
|
-
const configPath = join(homedir(), '.wrangler', 'config', 'default.toml');
|
|
63
|
-
const content = await readFile(configPath, 'utf-8');
|
|
64
|
-
const match = content.match(/^oauth_token\s*=\s*"(.+)"/m);
|
|
65
|
-
if (match?.[1]) {
|
|
66
|
-
// Check if token is expired
|
|
67
|
-
const expMatch = content.match(/^expiration_time\s*=\s*"(.+)"/m);
|
|
68
|
-
if (expMatch?.[1] && new Date(expMatch[1]) < new Date()) {
|
|
69
|
-
return null; // expired
|
|
70
|
-
}
|
|
71
|
-
return match[1];
|
|
72
|
-
}
|
|
73
|
-
} catch {
|
|
74
|
-
// config file not found — wrangler not logged in
|
|
75
|
-
}
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
42
|
async function fetchAccountId(token) {
|
|
80
43
|
const res = await fetch('https://api.cloudflare.com/client/v4/accounts?per_page=5', {
|
|
81
44
|
headers: { Authorization: `Bearer ${token}` },
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import { Command } from 'commander';
|
|
2
3
|
import pc from 'picocolors';
|
|
3
4
|
import { getCredentials } from './auth.js';
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { version } = require('../package.json');
|
|
4
8
|
import { createApi } from './api.js';
|
|
5
9
|
import { protect } from './commands/protect.js';
|
|
6
10
|
import { list } from './commands/list.js';
|
|
@@ -15,7 +19,7 @@ export function run() {
|
|
|
15
19
|
program
|
|
16
20
|
.name('fastpass-cli')
|
|
17
21
|
.description('Cloudflare Access in 60 seconds.')
|
|
18
|
-
.version(
|
|
22
|
+
.version(version);
|
|
19
23
|
|
|
20
24
|
// Default action (no subcommand) — run the protect wizard
|
|
21
25
|
program
|
package/src/commands/protect.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
|
-
import { input, select } from '@inquirer/prompts';
|
|
2
|
+
import { input, select, confirm } from '@inquirer/prompts';
|
|
3
3
|
import { getTeamName } from '../auth.js';
|
|
4
4
|
import { ensureEmailOtp } from '../idp/email-otp.js';
|
|
5
5
|
import { ensureGitHub } from '../idp/github.js';
|
|
6
6
|
import { ensureGoogle } from '../idp/google.js';
|
|
7
|
-
import { handleApiError } from '../api.js';
|
|
7
|
+
import { handleApiError, ApiError } from '../api.js';
|
|
8
8
|
import { spin, withSpinner } from '../ui.js';
|
|
9
9
|
|
|
10
10
|
const AUTH_CHOICES = {
|
|
@@ -33,6 +33,16 @@ export async function protect(api, opts = {}) {
|
|
|
33
33
|
|
|
34
34
|
// Validate domain exists in CF
|
|
35
35
|
await withSpinner('Verifying domain...', () => validateDomain(api, domain.trim()));
|
|
36
|
+
console.log('');
|
|
37
|
+
|
|
38
|
+
// Check if domain is already protected
|
|
39
|
+
const existing = await checkExistingApp(api, domain.trim());
|
|
40
|
+
if (existing) {
|
|
41
|
+
console.log(`\n ${pc.yellow('This domain is already protected by Access.')}\n`);
|
|
42
|
+
console.log(` Run ${pc.cyan(`fastpass inspect ${domain.trim()}`)} to view its configuration.`);
|
|
43
|
+
console.log(` Run ${pc.cyan(`fastpass remove ${domain.trim()}`)} to remove it first.\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
36
46
|
|
|
37
47
|
// Parse auth methods: comma-separated string from CLI, or single interactive choice
|
|
38
48
|
const authMethods = opts.auth
|
|
@@ -48,9 +58,11 @@ export async function protect(api, opts = {}) {
|
|
|
48
58
|
process.exit(1);
|
|
49
59
|
}
|
|
50
60
|
}
|
|
61
|
+
console.log('');
|
|
51
62
|
|
|
52
63
|
// Resolve who gets access
|
|
53
64
|
const { include, includeType } = await resolveAccess(opts.allow);
|
|
65
|
+
console.log('');
|
|
54
66
|
|
|
55
67
|
// Get team name for OAuth callback URLs
|
|
56
68
|
const teamName = await withSpinner('Fetching team info', () => getTeamName(api));
|
|
@@ -71,6 +83,28 @@ export async function protect(api, opts = {}) {
|
|
|
71
83
|
// Build the policy include rules
|
|
72
84
|
const policyInclude = buildIncludeRules(include, includeType);
|
|
73
85
|
|
|
86
|
+
// Describe access for the summary
|
|
87
|
+
const accessLabel = describeAccess(include, includeType);
|
|
88
|
+
|
|
89
|
+
// Show confirmation summary (skip when all CLI flags provided)
|
|
90
|
+
const allFlagsProvided = opts.domain && opts.auth && opts.allow;
|
|
91
|
+
if (!allFlagsProvided) {
|
|
92
|
+
console.log(` ${pc.bold('Domain:')} ${domain.trim()}`);
|
|
93
|
+
console.log(` ${pc.bold('Login:')} ${authMethods.map((m) => AUTH_CHOICES[m].label.split(' (')[0]).join(', ')}`);
|
|
94
|
+
console.log(` ${pc.bold('Access:')} ${accessLabel}`);
|
|
95
|
+
console.log('');
|
|
96
|
+
|
|
97
|
+
const ok = await confirm({
|
|
98
|
+
message: 'Create this Access application?',
|
|
99
|
+
default: true,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (!ok) {
|
|
103
|
+
console.log(pc.dim(' Cancelled.\n'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
74
108
|
// Create the access application with an inline policy
|
|
75
109
|
const s = spin(`Creating Access application for ${pc.bold(domain.trim())}...`);
|
|
76
110
|
|
|
@@ -91,7 +125,20 @@ export async function protect(api, opts = {}) {
|
|
|
91
125
|
],
|
|
92
126
|
};
|
|
93
127
|
|
|
94
|
-
|
|
128
|
+
let app;
|
|
129
|
+
try {
|
|
130
|
+
const { result } = await api.post('/access/apps', appBody);
|
|
131
|
+
app = result;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
s.fail(`Failed to create Access application for ${pc.bold(domain.trim())}`);
|
|
134
|
+
if (err instanceof ApiError && err.message.includes('application_already_exists')) {
|
|
135
|
+
console.log(`\n ${pc.yellow('This domain is already protected by Access.')}\n`);
|
|
136
|
+
console.log(` Run ${pc.cyan(`fastpass inspect ${domain.trim()}`)} to view its configuration.`);
|
|
137
|
+
console.log(` Run ${pc.cyan(`fastpass remove ${domain.trim()}`)} to remove it first.\n`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
95
142
|
|
|
96
143
|
s.succeed(`Protected ${pc.bold(domain.trim())}`);
|
|
97
144
|
console.log('');
|
|
@@ -182,6 +229,36 @@ export async function resolveAccess(allowFlag) {
|
|
|
182
229
|
}
|
|
183
230
|
}
|
|
184
231
|
|
|
232
|
+
export async function checkExistingApp(api, domain) {
|
|
233
|
+
const s = spin('Checking for existing application...');
|
|
234
|
+
try {
|
|
235
|
+
const { result } = await api.get('/access/apps');
|
|
236
|
+
const match = result?.find(
|
|
237
|
+
(app) => app.type === 'self_hosted' && app.domain === domain,
|
|
238
|
+
);
|
|
239
|
+
s.stop();
|
|
240
|
+
return match || null;
|
|
241
|
+
} catch {
|
|
242
|
+
s.stop();
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function describeAccess(include, includeType) {
|
|
248
|
+
switch (includeType) {
|
|
249
|
+
case 'emails':
|
|
250
|
+
return include.join(', ');
|
|
251
|
+
case 'domain':
|
|
252
|
+
return `*@${include[0]}`;
|
|
253
|
+
case 'github_org':
|
|
254
|
+
return `GitHub org: ${include[0]}`;
|
|
255
|
+
case 'everyone':
|
|
256
|
+
return 'Everyone (any logged-in user)';
|
|
257
|
+
default:
|
|
258
|
+
return 'Everyone';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
185
262
|
export function buildIncludeRules(include, includeType) {
|
|
186
263
|
switch (includeType) {
|
|
187
264
|
case 'emails':
|