fastpass-cli 0.1.2 → 0.2.0
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 +93 -91
- package/package.json +11 -1
- package/src/auth.js +40 -20
- package/src/cli.js +2 -2
- package/src/commands/inspect.js +12 -11
- package/src/commands/list.js +5 -1
- package/src/commands/logs.js +4 -2
- package/src/commands/protect.js +32 -21
- package/src/commands/remove.js +6 -3
- package/src/commands/status.js +13 -13
- package/src/idp/email-otp.js +6 -4
- package/src/idp/github.js +8 -3
- package/src/idp/google.js +8 -3
- package/src/ui.js +32 -0
package/README.md
CHANGED
|
@@ -1,148 +1,150 @@
|
|
|
1
1
|
# fastpass
|
|
2
2
|
|
|
3
|
-
Cloudflare Access
|
|
3
|
+
A CLI for rapdily 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
|
|
|
7
|
-
|
|
8
|
-
|------------------------------------|--------------------------------|----------------------|
|
|
9
|
-
| A login page on my app | Self-hosted Access Application | `protect` |
|
|
10
|
-
| GitHub/Google login | Identity Provider (IdP) | `--auth github` |
|
|
11
|
-
| "Only my team can access this" | Access Policy (Allow rule) | `--allow` |
|
|
12
|
-
| "Only my GitHub org" | GitHub Organization rule | `--allow "org:name"` |
|
|
13
|
-
| Email-based login, no passwords | One-Time PIN (OTP) | `--auth email` |
|
|
14
|
-
| The wall that checks your identity | Cloudflare Access | all of it |
|
|
15
|
-
|
|
16
|
-
## Quickstart
|
|
7
|
+
## Installation
|
|
17
8
|
|
|
18
9
|
```sh
|
|
19
10
|
npx fastpass-cli
|
|
20
11
|
```
|
|
21
12
|
|
|
22
|
-
|
|
13
|
+
No global install. Use `npx` to run the latest version.
|
|
23
14
|
|
|
24
|
-
##
|
|
15
|
+
## Quick start
|
|
25
16
|
|
|
26
17
|
```sh
|
|
27
|
-
#
|
|
28
|
-
npx fastpass-cli
|
|
18
|
+
# Interactive wizard (prompts for domain, auth method, and access rules)
|
|
19
|
+
npx fastpass-cli
|
|
29
20
|
|
|
30
|
-
#
|
|
31
|
-
npx fastpass-cli protect staging.myapp.com --auth
|
|
21
|
+
# Or one-liner
|
|
22
|
+
npx fastpass-cli protect staging.myapp.com --auth email --allow "me@example.com"
|
|
23
|
+
```
|
|
32
24
|
|
|
33
|
-
|
|
34
|
-
npx fastpass-cli protect staging.myapp.com --auth github --allow "org:my-github-org"
|
|
25
|
+
## Commands
|
|
35
26
|
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
| Command | Description |
|
|
28
|
+
|---------|-------------|
|
|
29
|
+
| `protect [domain]` | Create an Access Application. Default command when no subcommand is given. |
|
|
30
|
+
| `list` | List all protected domains (self-hosted Access apps). |
|
|
31
|
+
| `remove [domain]` | Delete Access protection from a domain. |
|
|
32
|
+
| `status` | Overview of team, apps, IdPs, and recent access activity. |
|
|
33
|
+
| `logs [domain]` | Recent access events (default: last 25). |
|
|
34
|
+
| `inspect [domain]` | Detailed configuration for an Access Application. |
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
npx fastpass-cli list
|
|
36
|
+
## Protect options
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
```
|
|
39
|
+
protect [domain] [options]
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
Options:
|
|
42
|
+
--auth <method> email | github | google (comma-separated for multiple)
|
|
43
|
+
--allow <rule> Access rule (see below)
|
|
44
|
+
```
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
npx fastpass-cli logs
|
|
46
|
+
### `--allow` rule syntax
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
| Rule | Meaning | Example |
|
|
49
|
+
|------|---------|---------|
|
|
50
|
+
| Email address(es) | Specific users (comma-separated) | `me@example.com` or `a@b.com,b@b.com` |
|
|
51
|
+
| `*@domain.com` | Anyone with that email domain | `*@company.com` |
|
|
52
|
+
| `org:name` | Members of a GitHub organization | `org:my-org` |
|
|
53
|
+
| `everyone` | Any authenticated user | `everyone` |
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
npx fastpass-cli logs --since 2025-01-15
|
|
55
|
+
### Auth methods
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
- **email** — One-time PIN (OTP) sent to email. No external setup; built into Cloudflare.
|
|
58
|
+
- **github** — OAuth. You create an app at [GitHub Developer Settings](https://github.com/settings/developers) and provide Client ID + Secret when prompted.
|
|
59
|
+
- **google** — OAuth. Same flow via Google Cloud Console.
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
npx fastpass-cli inspect
|
|
62
|
-
```
|
|
61
|
+
## Credentials
|
|
63
62
|
|
|
64
|
-
|
|
63
|
+
fastpass needs Cloudflare API access. It checks, in order:
|
|
65
64
|
|
|
66
|
-
|
|
65
|
+
1. `CLOUDFLARE_API_TOKEN` environment variable
|
|
66
|
+
2. Wrangler OAuth token from `~/.wrangler/config/default.toml` (from `npx wrangler login`)
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
**Recommended:** Use an API token with:
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
- **Access: Organizations, Identity Providers, and Groups** — Edit
|
|
71
|
+
- **Access: Apps and Policies** — Edit
|
|
72
|
+
- **Zone: Zone** — Read (for domain validation)
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
Create tokens at [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens).
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
```sh
|
|
77
|
+
export CLOUDFLARE_API_TOKEN="your-token"
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
# Optional, if you have multiple accounts:
|
|
80
|
+
export CLOUDFLARE_ACCOUNT_ID="your-account-id"
|
|
81
|
+
```
|
|
79
82
|
|
|
80
|
-
|
|
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.
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
2. Create a token with these permissions:
|
|
84
|
-
- **Access: Organizations, Identity Providers, and Groups** — Edit
|
|
85
|
-
- **Access: Apps and Policies** — Edit
|
|
86
|
-
- **Zone: Zone** — Read
|
|
87
|
-
3. Set the env var:
|
|
85
|
+
**Logs:** The `logs` and `status` commands fetch access events. If they fail, your token may need **Access: Audit Logs** — Read.
|
|
88
86
|
|
|
89
|
-
|
|
90
|
-
export CLOUDFLARE_API_TOKEN="your-token"
|
|
91
|
-
```
|
|
87
|
+
## Prerequisites
|
|
92
88
|
|
|
93
|
-
|
|
89
|
+
1. **Cloudflare Access enabled** — In [Zero Trust](https://one.dash.cloudflare.com), select the Free plan and set a team name (e.g. `myteam` → `myteam.cloudflareaccess.com`). This is a one-time setup per account.
|
|
94
90
|
|
|
95
|
-
|
|
91
|
+
2. **Domain in Cloudflare** — The domain you protect must exist as a zone in your Cloudflare account. fastpass validates this before creating the Access Application.
|
|
96
92
|
|
|
97
|
-
|
|
93
|
+
## Example usage
|
|
98
94
|
|
|
99
95
|
```sh
|
|
100
|
-
|
|
101
|
-
npx fastpass-cli
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
> **Note:** Wrangler's default OAuth token does not include Access scopes. If you use `wrangler login` for deploying Workers but get permission errors from fastpass, you'll need a dedicated API token (Option A). The wrangler OAuth scopes cover Workers, KV, D1, Pages, etc. — but not Cloudflare Access.
|
|
96
|
+
# Email OTP, only you
|
|
97
|
+
npx fastpass-cli protect staging.myapp.com --auth email --allow "me@gmail.com"
|
|
105
98
|
|
|
106
|
-
|
|
99
|
+
# GitHub, anyone at your company
|
|
100
|
+
npx fastpass-cli protect staging.myapp.com --auth github --allow "*@company.com"
|
|
107
101
|
|
|
108
|
-
|
|
102
|
+
# GitHub, restrict to a specific org
|
|
103
|
+
npx fastpass-cli protect staging.myapp.com --auth github --allow "org:my-github-org"
|
|
109
104
|
|
|
110
|
-
|
|
105
|
+
# Google, any logged-in user
|
|
106
|
+
npx fastpass-cli protect admin.myapp.com --auth google --allow "everyone"
|
|
111
107
|
|
|
112
|
-
|
|
108
|
+
# Multiple login methods (email + GitHub), shows Cloudflare's provider picker
|
|
109
|
+
npx fastpass-cli protect staging.myapp.com --auth email,github --allow "*@company.com"
|
|
113
110
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
# List apps, view status, inspect config
|
|
112
|
+
npx fastpass-cli list
|
|
113
|
+
npx fastpass-cli status
|
|
114
|
+
npx fastpass-cli inspect staging.myapp.com
|
|
117
115
|
|
|
118
|
-
|
|
116
|
+
# Logs
|
|
117
|
+
npx fastpass-cli logs
|
|
118
|
+
npx fastpass-cli logs staging.myapp.com --limit 10
|
|
119
|
+
npx fastpass-cli logs --since 2025-01-15
|
|
119
120
|
|
|
120
|
-
|
|
121
|
+
# Remove protection
|
|
122
|
+
npx fastpass-cli remove staging.myapp.com
|
|
123
|
+
```
|
|
121
124
|
|
|
122
125
|
## How it works
|
|
123
126
|
|
|
124
|
-
|
|
127
|
+
fastpass calls the Cloudflare API to:
|
|
125
128
|
|
|
126
|
-
1.
|
|
127
|
-
2.
|
|
128
|
-
3.
|
|
129
|
-
4.
|
|
129
|
+
1. Check that the domain exists in your account (zone lookup).
|
|
130
|
+
2. Create or reuse an Identity Provider (email OTP, GitHub, or Google).
|
|
131
|
+
3. Create an Access Application (self-hosted) with an allow policy.
|
|
132
|
+
4. Visitors hit your domain and see Cloudflare’s login page before access.
|
|
130
133
|
|
|
131
|
-
All of this maps to Cloudflare
|
|
134
|
+
All of this maps to Cloudflare Zero Trust Access. fastpass automates the setup; advanced configuration is done in the [Zero Trust dashboard](https://one.dash.cloudflare.com).
|
|
132
135
|
|
|
133
136
|
## FAQ
|
|
134
137
|
|
|
135
|
-
**
|
|
136
|
-
|
|
138
|
+
**Cost?** Cloudflare Access is free for up to 50 users.
|
|
139
|
+
|
|
140
|
+
**Existing IdPs?** fastpass reuses Identity Providers already configured in your account (e.g. Email Login, GitHub, Google).
|
|
141
|
+
|
|
142
|
+
**Existing Access setup?** If you already have Access apps and IdPs, fastpass will use them where applicable. It does not modify existing apps; `protect` creates new ones.
|
|
137
143
|
|
|
138
|
-
**
|
|
139
|
-
Run `protect` again on the same domain with a different `--auth` flag, or configure additional IdPs in the CF dashboard.
|
|
144
|
+
**Multiple auth methods per app?** Use `--auth email,github` to wire multiple IdPs in one command. When multiple methods are set, visitors see Cloudflare's provider picker instead of auto-redirecting.
|
|
140
145
|
|
|
141
|
-
**
|
|
142
|
-
fastpass detects existing identity providers and reuses them.
|
|
146
|
+
**Need to change policies or users?** Use the [Zero Trust dashboard](https://one.dash.cloudflare.com).
|
|
143
147
|
|
|
144
|
-
|
|
145
|
-
Run `npx fastpass-cli status` for an overview, `npx fastpass-cli logs` for recent events, or `npx fastpass-cli inspect <domain>` for detailed app config.
|
|
148
|
+
## License
|
|
146
149
|
|
|
147
|
-
|
|
148
|
-
Use the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com).
|
|
150
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastpass-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Cloudflare Access in 60 seconds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
"bugs": {
|
|
30
30
|
"url": "https://github.com/corywilkerson/fastpass/issues"
|
|
31
31
|
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest",
|
|
35
|
+
"test:coverage": "vitest run --coverage"
|
|
36
|
+
},
|
|
32
37
|
"engines": {
|
|
33
38
|
"node": ">=18"
|
|
34
39
|
},
|
|
@@ -36,6 +41,11 @@
|
|
|
36
41
|
"@inquirer/prompts": "^7.0.0",
|
|
37
42
|
"commander": "^13.0.0",
|
|
38
43
|
"open": "^10.0.0",
|
|
44
|
+
"ora": "^9.3.0",
|
|
39
45
|
"picocolors": "^1.1.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
49
|
+
"vitest": "^4.0.18"
|
|
40
50
|
}
|
|
41
51
|
}
|
package/src/auth.js
CHANGED
|
@@ -1,57 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import pc from 'picocolors';
|
|
5
|
+
import { spin } from './ui.js';
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Resolve Cloudflare credentials.
|
|
6
9
|
*
|
|
7
10
|
* Priority:
|
|
8
11
|
* 1. CLOUDFLARE_API_TOKEN env var
|
|
9
|
-
* 2.
|
|
12
|
+
* 2. Wrangler OAuth token from ~/.wrangler/config/default.toml
|
|
10
13
|
*
|
|
11
14
|
* Account ID:
|
|
12
15
|
* 1. CLOUDFLARE_ACCOUNT_ID env var
|
|
13
16
|
* 2. Fetched from /accounts API
|
|
14
17
|
*/
|
|
15
18
|
export async function getCredentials() {
|
|
19
|
+
const s = spin('Checking Cloudflare credentials');
|
|
20
|
+
|
|
16
21
|
let token = process.env.CLOUDFLARE_API_TOKEN;
|
|
17
22
|
|
|
18
23
|
if (!token) {
|
|
19
|
-
|
|
24
|
+
s.text = 'Checking Cloudflare credentials (trying wrangler)';
|
|
25
|
+
token = await tryWranglerToken();
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
if (!token) {
|
|
23
|
-
|
|
24
|
-
console.error('
|
|
25
|
-
console.error(` ${pc.bold('
|
|
26
|
-
console.error(
|
|
27
|
-
console.error(`
|
|
28
|
-
console.error(
|
|
29
|
-
console.error(`
|
|
29
|
+
s.fail('No Cloudflare credentials found');
|
|
30
|
+
console.error('');
|
|
31
|
+
console.error(` ${pc.bold('How to fix — pick one:')}`);
|
|
32
|
+
console.error('');
|
|
33
|
+
console.error(` ${pc.cyan('1.')} Set an API token (recommended):`);
|
|
34
|
+
console.error('');
|
|
35
|
+
console.error(` ${pc.bold('export CLOUDFLARE_API_TOKEN=<your-token>')}`);
|
|
36
|
+
console.error('');
|
|
37
|
+
console.error(` Create one at: ${pc.dim('https://dash.cloudflare.com/profile/api-tokens')}`);
|
|
38
|
+
console.error(' Required permissions:');
|
|
39
|
+
console.error(` ${pc.dim('•')} Access: Organizations, Identity Providers, and Groups — Edit`);
|
|
40
|
+
console.error(` ${pc.dim('•')} Access: Apps and Policies — Edit`);
|
|
41
|
+
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('');
|
|
30
46
|
process.exit(1);
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
let accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
34
50
|
|
|
35
51
|
if (!accountId) {
|
|
52
|
+
s.text = 'Fetching account info';
|
|
36
53
|
accountId = await fetchAccountId(token);
|
|
37
54
|
}
|
|
38
55
|
|
|
56
|
+
s.succeed('Credentials OK');
|
|
39
57
|
return { token, accountId };
|
|
40
58
|
}
|
|
41
59
|
|
|
42
|
-
function tryWranglerToken() {
|
|
60
|
+
async function tryWranglerToken() {
|
|
43
61
|
try {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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];
|
|
52
72
|
}
|
|
53
73
|
} catch {
|
|
54
|
-
//
|
|
74
|
+
// config file not found — wrangler not logged in
|
|
55
75
|
}
|
|
56
76
|
return null;
|
|
57
77
|
}
|
package/src/cli.js
CHANGED
|
@@ -15,13 +15,13 @@ export function run() {
|
|
|
15
15
|
program
|
|
16
16
|
.name('fastpass-cli')
|
|
17
17
|
.description('Cloudflare Access in 60 seconds.')
|
|
18
|
-
.version('0.
|
|
18
|
+
.version('0.2.0');
|
|
19
19
|
|
|
20
20
|
// Default action (no subcommand) — run the protect wizard
|
|
21
21
|
program
|
|
22
22
|
.command('protect [domain]', { isDefault: true })
|
|
23
23
|
.description('Protect a domain with Cloudflare Access')
|
|
24
|
-
.option('--auth <method>', 'Auth method: email, github,
|
|
24
|
+
.option('--auth <method>', 'Auth method(s): email, github, google (comma-separated for multiple)')
|
|
25
25
|
.option('--allow <rule>', 'Who can access: email, *@domain.com, or "everyone"')
|
|
26
26
|
.action(async (domain, opts) => {
|
|
27
27
|
printBanner();
|
package/src/commands/inspect.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { select } from '@inquirer/prompts';
|
|
3
3
|
import { handleApiError } from '../api.js';
|
|
4
|
+
import { withSpinner, heading } from '../ui.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Inspect a specific Access application's detailed configuration.
|
|
7
8
|
*/
|
|
8
9
|
export async function inspect(api, opts = {}) {
|
|
9
10
|
try {
|
|
10
|
-
const [{ result: apps }, { result: idps }] = await
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const [{ result: apps }, { result: idps }] = await withSpinner(
|
|
12
|
+
'Loading application details',
|
|
13
|
+
() => Promise.all([
|
|
14
|
+
api.get('/access/apps'),
|
|
15
|
+
api.get('/access/identity_providers'),
|
|
16
|
+
]),
|
|
17
|
+
);
|
|
14
18
|
|
|
15
19
|
const selfHosted = apps?.filter((a) => a.type === 'self_hosted') || [];
|
|
16
20
|
|
|
@@ -44,16 +48,14 @@ export async function inspect(api, opts = {}) {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
// --- App details ---
|
|
47
|
-
|
|
48
|
-
console.log(pc.dim(` ${'─'.repeat(50)}`));
|
|
51
|
+
heading('Application');
|
|
49
52
|
console.log(` Domain: ${target.domain || 'n/a'}`);
|
|
50
53
|
console.log(` Type: ${target.type || 'n/a'}`);
|
|
51
54
|
console.log(` Session duration: ${target.session_duration || 'default'}`);
|
|
52
55
|
console.log(` App ID: ${pc.dim(target.id)}`);
|
|
53
56
|
|
|
54
57
|
// --- Allowed IdPs ---
|
|
55
|
-
|
|
56
|
-
console.log(pc.dim(` ${'─'.repeat(50)}`));
|
|
58
|
+
heading('Identity Providers');
|
|
57
59
|
if (target.allowed_idps?.length) {
|
|
58
60
|
for (const idpId of target.allowed_idps) {
|
|
59
61
|
const idp = idpMap.get(idpId);
|
|
@@ -68,8 +70,7 @@ export async function inspect(api, opts = {}) {
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
// --- Policies ---
|
|
71
|
-
|
|
72
|
-
console.log(pc.dim(` ${'─'.repeat(50)}`));
|
|
73
|
+
heading('Policies');
|
|
73
74
|
if (target.policies?.length) {
|
|
74
75
|
for (const policy of target.policies) {
|
|
75
76
|
console.log(` ${pc.cyan(policy.name || 'Unnamed')} — ${policy.decision || 'n/a'}`);
|
|
@@ -89,7 +90,7 @@ export async function inspect(api, opts = {}) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
function describeRule(rule) {
|
|
93
|
+
export function describeRule(rule) {
|
|
93
94
|
if (rule.email?.email) {
|
|
94
95
|
return rule.email.email;
|
|
95
96
|
}
|
package/src/commands/list.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { handleApiError } from '../api.js';
|
|
3
|
+
import { withSpinner } from '../ui.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* List all Access applications in the account.
|
|
6
7
|
*/
|
|
7
8
|
export async function list(api) {
|
|
8
9
|
try {
|
|
9
|
-
const { result } = await
|
|
10
|
+
const { result } = await withSpinner(
|
|
11
|
+
'Fetching protected domains',
|
|
12
|
+
() => api.get('/access/apps'),
|
|
13
|
+
);
|
|
10
14
|
|
|
11
15
|
if (!result?.length) {
|
|
12
16
|
console.log(pc.dim('\n No Access applications found.\n'));
|
package/src/commands/logs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { handleApiError } from '../api.js';
|
|
3
|
+
import { withSpinner } from '../ui.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Show recent access log events, optionally filtered by domain.
|
|
@@ -20,8 +21,9 @@ export async function logs(api, opts = {}) {
|
|
|
20
21
|
sinceParam = `&since=${parsed.toISOString()}`;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
const { result } = await
|
|
24
|
-
|
|
24
|
+
const { result } = await withSpinner(
|
|
25
|
+
'Fetching access events',
|
|
26
|
+
() => api.get(`/access/logs/access_requests?limit=${limit}&direction=desc${sinceParam}`),
|
|
25
27
|
);
|
|
26
28
|
|
|
27
29
|
if (!result?.length) {
|
package/src/commands/protect.js
CHANGED
|
@@ -5,6 +5,7 @@ import { ensureEmailOtp } from '../idp/email-otp.js';
|
|
|
5
5
|
import { ensureGitHub } from '../idp/github.js';
|
|
6
6
|
import { ensureGoogle } from '../idp/google.js';
|
|
7
7
|
import { handleApiError } from '../api.js';
|
|
8
|
+
import { spin, withSpinner } from '../ui.js';
|
|
8
9
|
|
|
9
10
|
const AUTH_CHOICES = {
|
|
10
11
|
email: { label: 'Email code (easiest, no setup)', setup: ensureEmailOtp },
|
|
@@ -31,46 +32,55 @@ export async function protect(api, opts = {}) {
|
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
// Validate domain exists in CF
|
|
34
|
-
await validateDomain(api, domain.trim());
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
await withSpinner('Verifying domain...', () => validateDomain(api, domain.trim()));
|
|
36
|
+
|
|
37
|
+
// Parse auth methods: comma-separated string from CLI, or single interactive choice
|
|
38
|
+
const authMethods = opts.auth
|
|
39
|
+
? opts.auth.split(',').map((m) => m.trim())
|
|
40
|
+
: [await select({
|
|
41
|
+
message: 'How should people log in?',
|
|
42
|
+
choices: Object.entries(AUTH_CHOICES).map(([value, { label }]) => ({ value, name: label })),
|
|
43
|
+
})];
|
|
44
|
+
|
|
45
|
+
for (const method of authMethods) {
|
|
46
|
+
if (!AUTH_CHOICES[method]) {
|
|
47
|
+
console.error(pc.red(`Unknown auth method: ${method}. Use: email, github, or google`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
// Resolve who gets access
|
|
47
53
|
const { include, includeType } = await resolveAccess(opts.allow);
|
|
48
54
|
|
|
49
55
|
// Get team name for OAuth callback URLs
|
|
50
|
-
const teamName = await getTeamName(api);
|
|
51
|
-
if (!teamName &&
|
|
56
|
+
const teamName = await withSpinner('Fetching team info', () => getTeamName(api));
|
|
57
|
+
if (!teamName && authMethods.some((m) => m !== 'email')) {
|
|
52
58
|
console.error(pc.red('Could not determine your Access team name.'));
|
|
53
59
|
console.error('Make sure Access is enabled in your Cloudflare dashboard.\n');
|
|
54
60
|
process.exit(1);
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
// Set up
|
|
63
|
+
// Set up identity providers
|
|
58
64
|
console.log('');
|
|
59
|
-
const
|
|
65
|
+
const idpResults = [];
|
|
66
|
+
for (const method of authMethods) {
|
|
67
|
+
const idp = await AUTH_CHOICES[method].setup(api, teamName);
|
|
68
|
+
idpResults.push(idp);
|
|
69
|
+
}
|
|
60
70
|
|
|
61
71
|
// Build the policy include rules
|
|
62
72
|
const policyInclude = buildIncludeRules(include, includeType);
|
|
63
73
|
|
|
64
74
|
// Create the access application with an inline policy
|
|
65
|
-
|
|
75
|
+
const s = spin(`Creating Access application for ${pc.bold(domain.trim())}...`);
|
|
66
76
|
|
|
67
77
|
const appBody = {
|
|
68
78
|
name: domain.trim(),
|
|
69
79
|
domain: domain.trim(),
|
|
70
80
|
type: 'self_hosted',
|
|
71
81
|
session_duration: '24h',
|
|
72
|
-
allowed_idps:
|
|
73
|
-
auto_redirect_to_identity:
|
|
82
|
+
allowed_idps: idpResults.map((idp) => idp.id),
|
|
83
|
+
auto_redirect_to_identity: idpResults.length === 1,
|
|
74
84
|
policies: [
|
|
75
85
|
{
|
|
76
86
|
name: `Allow — ${domain.trim()}`,
|
|
@@ -83,7 +93,8 @@ export async function protect(api, opts = {}) {
|
|
|
83
93
|
|
|
84
94
|
const { result: app } = await api.post('/access/apps', appBody);
|
|
85
95
|
|
|
86
|
-
|
|
96
|
+
s.succeed(`Protected ${pc.bold(domain.trim())}`);
|
|
97
|
+
console.log('');
|
|
87
98
|
console.log(` ${pc.bold('Your app is protected!')} Try visiting:`);
|
|
88
99
|
console.log(` ${pc.cyan(`https://${domain.trim()}`)}\n`);
|
|
89
100
|
console.log(` Manage it: ${pc.dim('https://one.dash.cloudflare.com')}`);
|
|
@@ -95,7 +106,7 @@ export async function protect(api, opts = {}) {
|
|
|
95
106
|
}
|
|
96
107
|
}
|
|
97
108
|
|
|
98
|
-
async function validateDomain(api, domain) {
|
|
109
|
+
export async function validateDomain(api, domain) {
|
|
99
110
|
// Extract the root domain for zone lookup
|
|
100
111
|
const parts = domain.split('.');
|
|
101
112
|
const rootDomain = parts.slice(-2).join('.');
|
|
@@ -113,7 +124,7 @@ async function validateDomain(api, domain) {
|
|
|
113
124
|
}
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
async function resolveAccess(allowFlag) {
|
|
127
|
+
export async function resolveAccess(allowFlag) {
|
|
117
128
|
// If --allow flag was passed, parse it
|
|
118
129
|
if (allowFlag) {
|
|
119
130
|
if (allowFlag.startsWith('*@')) {
|
|
@@ -171,7 +182,7 @@ async function resolveAccess(allowFlag) {
|
|
|
171
182
|
}
|
|
172
183
|
}
|
|
173
184
|
|
|
174
|
-
function buildIncludeRules(include, includeType) {
|
|
185
|
+
export function buildIncludeRules(include, includeType) {
|
|
175
186
|
switch (includeType) {
|
|
176
187
|
case 'emails':
|
|
177
188
|
// CF API expects one rule per email: [{ email: { email: "a@b.com" } }, ...]
|
package/src/commands/remove.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { select, confirm } from '@inquirer/prompts';
|
|
3
3
|
import { handleApiError } from '../api.js';
|
|
4
|
+
import { spin, withSpinner } from '../ui.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Remove Access protection from a domain.
|
|
7
8
|
*/
|
|
8
9
|
export async function remove(api, opts = {}) {
|
|
9
10
|
try {
|
|
11
|
+
const s = spin('Fetching applications...');
|
|
10
12
|
const { result } = await api.get('/access/apps');
|
|
11
13
|
const apps = result?.filter((app) => app.type === 'self_hosted') || [];
|
|
14
|
+
s.stop();
|
|
12
15
|
|
|
13
16
|
if (!apps.length) {
|
|
14
17
|
console.log(pc.dim('\n No Access applications to remove.\n'));
|
|
@@ -44,9 +47,9 @@ export async function remove(api, opts = {}) {
|
|
|
44
47
|
return;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
await api.delete(`/access/apps/${target.id}`);
|
|
49
|
-
console.log(
|
|
50
|
+
const label = target.domain || target.name;
|
|
51
|
+
await withSpinner(`Removing ${label}`, () => api.delete(`/access/apps/${target.id}`));
|
|
52
|
+
console.log('');
|
|
50
53
|
} catch (err) {
|
|
51
54
|
handleApiError(err);
|
|
52
55
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { handleApiError } from '../api.js';
|
|
3
|
+
import { withSpinner, heading } from '../ui.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Status dashboard — quick snapshot of Access configuration and recent activity.
|
|
6
7
|
*/
|
|
7
8
|
export async function status(api) {
|
|
8
9
|
try {
|
|
9
|
-
const [{ result: org }, { result: apps }, { result: idps }] = await
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
const [{ result: org }, { result: apps }, { result: idps }] = await withSpinner(
|
|
11
|
+
'Loading Access configuration',
|
|
12
|
+
() => Promise.all([
|
|
13
|
+
api.get('/access/organizations'),
|
|
14
|
+
api.get('/access/apps'),
|
|
15
|
+
api.get('/access/identity_providers'),
|
|
16
|
+
]),
|
|
17
|
+
);
|
|
14
18
|
|
|
15
19
|
// --- Team info ---
|
|
16
20
|
const teamName = org?.auth_domain?.replace('.cloudflareaccess.com', '') || 'unknown';
|
|
17
|
-
|
|
18
|
-
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
21
|
+
heading('Team');
|
|
19
22
|
console.log(` Auth domain: ${org?.auth_domain || 'n/a'}`);
|
|
20
23
|
console.log(` Team name: ${teamName}`);
|
|
21
24
|
|
|
22
25
|
// --- Protected apps ---
|
|
23
26
|
const selfHosted = apps?.filter((a) => a.type === 'self_hosted') || [];
|
|
24
|
-
|
|
25
|
-
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
27
|
+
heading(`Protected Apps (${selfHosted.length})`);
|
|
26
28
|
if (selfHosted.length) {
|
|
27
29
|
for (const app of selfHosted) {
|
|
28
30
|
console.log(` ${app.domain || app.name}`);
|
|
@@ -32,8 +34,7 @@ export async function status(api) {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
// --- Identity providers ---
|
|
35
|
-
|
|
36
|
-
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
37
|
+
heading(`Identity Providers (${idps?.length || 0})`);
|
|
37
38
|
if (idps?.length) {
|
|
38
39
|
const maxName = Math.max(...idps.map((p) => (p.name || '').length), 4);
|
|
39
40
|
console.log(` ${pc.dim('Name'.padEnd(maxName + 2))}${pc.dim('Type')}`);
|
|
@@ -45,8 +46,7 @@ export async function status(api) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
// --- Recent activity (graceful fallback) ---
|
|
48
|
-
|
|
49
|
-
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
49
|
+
heading('Recent Activity');
|
|
50
50
|
try {
|
|
51
51
|
const { result: logs } = await api.get('/access/logs/access_requests?limit=50&direction=desc');
|
|
52
52
|
if (logs?.length) {
|
package/src/idp/email-otp.js
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { spin } from '../ui.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Ensure the One-Time PIN (email code) identity provider exists.
|
|
5
5
|
* Returns the IdP object.
|
|
6
6
|
*/
|
|
7
7
|
export async function ensureEmailOtp(api) {
|
|
8
|
+
const s = spin('Checking for Email Login');
|
|
9
|
+
|
|
8
10
|
const { result } = await api.get('/access/identity_providers');
|
|
9
11
|
const existing = result?.find((idp) => idp.type === 'onetimepin');
|
|
10
12
|
|
|
11
13
|
if (existing) {
|
|
12
|
-
|
|
14
|
+
s.succeed('Email Login already configured');
|
|
13
15
|
return existing;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
s.text = 'Creating Email Login';
|
|
17
19
|
const { result: created } = await api.post('/access/identity_providers', {
|
|
18
20
|
type: 'onetimepin',
|
|
19
21
|
name: 'Email Login',
|
|
20
22
|
config: {},
|
|
21
23
|
});
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
s.succeed('Email Login enabled');
|
|
24
26
|
return created;
|
|
25
27
|
}
|
package/src/idp/github.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { input, confirm } from '@inquirer/prompts';
|
|
3
3
|
import open from 'open';
|
|
4
|
+
import { spin } from '../ui.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Ensure a GitHub OAuth identity provider exists.
|
|
7
8
|
* Guides the user through creating a GitHub OAuth app if needed.
|
|
8
9
|
*/
|
|
9
10
|
export async function ensureGitHub(api, teamName) {
|
|
11
|
+
const s = spin('Checking for GitHub login');
|
|
12
|
+
|
|
10
13
|
const { result } = await api.get('/access/identity_providers');
|
|
11
14
|
const existing = result?.find((idp) => idp.type === 'github');
|
|
12
15
|
|
|
13
16
|
if (existing) {
|
|
14
|
-
|
|
17
|
+
s.succeed('GitHub login already configured');
|
|
15
18
|
return existing;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
s.stop();
|
|
22
|
+
|
|
18
23
|
const callbackUrl = `https://${teamName}.cloudflareaccess.com/cdn-cgi/access/callback`;
|
|
19
24
|
const homepageUrl = `https://${teamName}.cloudflareaccess.com`;
|
|
20
25
|
|
|
@@ -49,7 +54,7 @@ export async function ensureGitHub(api, teamName) {
|
|
|
49
54
|
validate: (v) => v.trim().length > 0 || 'Required',
|
|
50
55
|
});
|
|
51
56
|
|
|
52
|
-
|
|
57
|
+
const s2 = spin('Creating GitHub identity provider...');
|
|
53
58
|
const { result: created } = await api.post('/access/identity_providers', {
|
|
54
59
|
type: 'github',
|
|
55
60
|
name: 'GitHub',
|
|
@@ -59,6 +64,6 @@ export async function ensureGitHub(api, teamName) {
|
|
|
59
64
|
},
|
|
60
65
|
});
|
|
61
66
|
|
|
62
|
-
|
|
67
|
+
s2.succeed('GitHub login enabled');
|
|
63
68
|
return created;
|
|
64
69
|
}
|
package/src/idp/google.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { input, confirm } from '@inquirer/prompts';
|
|
3
3
|
import open from 'open';
|
|
4
|
+
import { spin } from '../ui.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Ensure a Google OAuth identity provider exists.
|
|
7
8
|
* Guides the user through creating a Google OAuth client if needed.
|
|
8
9
|
*/
|
|
9
10
|
export async function ensureGoogle(api, teamName) {
|
|
11
|
+
const s = spin('Checking for Google login');
|
|
12
|
+
|
|
10
13
|
const { result } = await api.get('/access/identity_providers');
|
|
11
14
|
const existing = result?.find((idp) => idp.type === 'google');
|
|
12
15
|
|
|
13
16
|
if (existing) {
|
|
14
|
-
|
|
17
|
+
s.succeed('Google login already configured');
|
|
15
18
|
return existing;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
s.stop();
|
|
22
|
+
|
|
18
23
|
const callbackUrl = `https://${teamName}.cloudflareaccess.com/cdn-cgi/access/callback`;
|
|
19
24
|
|
|
20
25
|
console.log('');
|
|
@@ -47,7 +52,7 @@ export async function ensureGoogle(api, teamName) {
|
|
|
47
52
|
validate: (v) => v.trim().length > 0 || 'Required',
|
|
48
53
|
});
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
const s2 = spin('Creating Google identity provider...');
|
|
51
56
|
const { result: created } = await api.post('/access/identity_providers', {
|
|
52
57
|
type: 'google',
|
|
53
58
|
name: 'Google',
|
|
@@ -57,6 +62,6 @@ export async function ensureGoogle(api, teamName) {
|
|
|
57
62
|
},
|
|
58
63
|
});
|
|
59
64
|
|
|
60
|
-
|
|
65
|
+
s2.succeed('Google login enabled');
|
|
61
66
|
return created;
|
|
62
67
|
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Start an ora spinner with consistent styling.
|
|
6
|
+
*/
|
|
7
|
+
export function spin(text) {
|
|
8
|
+
return ora({ text, indent: 2, color: 'cyan' }).start();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wrap an async operation with an auto-succeed/fail spinner.
|
|
13
|
+
*/
|
|
14
|
+
export async function withSpinner(text, fn, successText) {
|
|
15
|
+
const s = spin(text);
|
|
16
|
+
try {
|
|
17
|
+
const result = await fn(s);
|
|
18
|
+
s.succeed(successText || text);
|
|
19
|
+
return result;
|
|
20
|
+
} catch (err) {
|
|
21
|
+
s.fail(text);
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Print a bold section heading with a separator line.
|
|
28
|
+
*/
|
|
29
|
+
export function heading(text) {
|
|
30
|
+
console.log(`\n ${pc.bold(text)}`);
|
|
31
|
+
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
32
|
+
}
|