fastpass-cli 0.1.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/LICENSE +21 -0
- package/README.md +148 -0
- package/bin/fastpass.js +3 -0
- package/package.json +41 -0
- package/skill/fastpass-skill.md +92 -0
- package/src/api.js +63 -0
- package/src/auth.js +93 -0
- package/src/cli.js +87 -0
- package/src/commands/inspect.js +106 -0
- package/src/commands/list.js +48 -0
- package/src/commands/logs.js +77 -0
- package/src/commands/protect.js +188 -0
- package/src/commands/remove.js +53 -0
- package/src/commands/status.js +71 -0
- package/src/idp/email-otp.js +25 -0
- package/src/idp/github.js +64 -0
- package/src/idp/google.js +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Cory Wilkerson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# fastpass
|
|
2
|
+
|
|
3
|
+
Cloudflare Access in 60 seconds. No enterprise jargon.
|
|
4
|
+
|
|
5
|
+
## What you want → What it does
|
|
6
|
+
|
|
7
|
+
| You want... | Cloudflare calls it... | fastpass handles it |
|
|
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
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx fastpass
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That's it. The interactive wizard walks you through everything.
|
|
23
|
+
|
|
24
|
+
## One-liners
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
# Protect with email login (zero config)
|
|
28
|
+
npx fastpass protect staging.myapp.com --auth email --allow "me@gmail.com"
|
|
29
|
+
|
|
30
|
+
# Protect with GitHub login, allow anyone at your company
|
|
31
|
+
npx fastpass protect staging.myapp.com --auth github --allow "*@company.com"
|
|
32
|
+
|
|
33
|
+
# Protect with GitHub login, restrict to a GitHub org
|
|
34
|
+
npx fastpass protect staging.myapp.com --auth github --allow "org:my-github-org"
|
|
35
|
+
|
|
36
|
+
# Protect with Google login, allow everyone (just require login)
|
|
37
|
+
npx fastpass protect admin.myapp.com --auth google --allow "everyone"
|
|
38
|
+
|
|
39
|
+
# List protected domains
|
|
40
|
+
npx fastpass list
|
|
41
|
+
|
|
42
|
+
# Remove protection
|
|
43
|
+
npx fastpass remove staging.myapp.com
|
|
44
|
+
|
|
45
|
+
# Overview dashboard — team, apps, IdPs, recent activity
|
|
46
|
+
npx fastpass status
|
|
47
|
+
|
|
48
|
+
# Recent access events (last 25)
|
|
49
|
+
npx fastpass logs
|
|
50
|
+
|
|
51
|
+
# Filter events to one domain, last 10
|
|
52
|
+
npx fastpass logs staging.myapp.com --limit 10
|
|
53
|
+
|
|
54
|
+
# Events since a specific date
|
|
55
|
+
npx fastpass logs --since 2025-01-15
|
|
56
|
+
|
|
57
|
+
# Detailed config for a specific app
|
|
58
|
+
npx fastpass inspect staging.myapp.com
|
|
59
|
+
|
|
60
|
+
# Interactive app picker
|
|
61
|
+
npx fastpass inspect
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Prerequisites
|
|
65
|
+
|
|
66
|
+
### 1. Enable Cloudflare Access (one-time)
|
|
67
|
+
|
|
68
|
+
Before fastpass can do anything, Access needs to be turned on for your account:
|
|
69
|
+
|
|
70
|
+
1. Go to [https://one.dash.cloudflare.com](https://one.dash.cloudflare.com) (Zero Trust dashboard)
|
|
71
|
+
2. You'll be prompted to select the **Free** plan ($0) — confirm it
|
|
72
|
+
3. Pick a **team name** (e.g. `myteam`) — this becomes `myteam.cloudflareaccess.com`, the login domain for all your protected apps
|
|
73
|
+
|
|
74
|
+
This only needs to happen once per account.
|
|
75
|
+
|
|
76
|
+
### 2. API credentials
|
|
77
|
+
|
|
78
|
+
You need a Cloudflare API token. Pick one:
|
|
79
|
+
|
|
80
|
+
### Option A: API Token (recommended)
|
|
81
|
+
|
|
82
|
+
1. Go to https://dash.cloudflare.com/profile/api-tokens
|
|
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:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
export CLOUDFLARE_API_TOKEN="your-token"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Optionally set `CLOUDFLARE_ACCOUNT_ID` if you have multiple accounts.
|
|
94
|
+
|
|
95
|
+
### Option B: Wrangler login
|
|
96
|
+
|
|
97
|
+
If you already use wrangler:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
npx wrangler login
|
|
101
|
+
npx fastpass
|
|
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.
|
|
105
|
+
|
|
106
|
+
## Auth methods
|
|
107
|
+
|
|
108
|
+
### Email code (OTP)
|
|
109
|
+
|
|
110
|
+
The easiest option. No external setup required. Users get a one-time code sent to their email.
|
|
111
|
+
|
|
112
|
+
### GitHub
|
|
113
|
+
|
|
114
|
+
fastpass walks you through creating a GitHub OAuth app. You'll need to:
|
|
115
|
+
1. Create an OAuth app at https://github.com/settings/developers
|
|
116
|
+
2. Paste the Client ID and Secret when prompted
|
|
117
|
+
|
|
118
|
+
### Google
|
|
119
|
+
|
|
120
|
+
Same as GitHub — fastpass guides you through the Google Cloud Console OAuth setup.
|
|
121
|
+
|
|
122
|
+
## How it works
|
|
123
|
+
|
|
124
|
+
Under the hood, fastpass calls the Cloudflare API to:
|
|
125
|
+
|
|
126
|
+
1. **Validate** your domain exists in your CF account
|
|
127
|
+
2. **Create an Identity Provider** (email OTP, GitHub, or Google OAuth)
|
|
128
|
+
3. **Create an Access Application** on your domain with an allow policy
|
|
129
|
+
4. Your domain now shows a login page before granting access
|
|
130
|
+
|
|
131
|
+
All of this maps to Cloudflare's [Zero Trust Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) product — fastpass just removes the jargon and manual steps.
|
|
132
|
+
|
|
133
|
+
## FAQ
|
|
134
|
+
|
|
135
|
+
**Does this cost money?**
|
|
136
|
+
Cloudflare Access is free for up to 50 users.
|
|
137
|
+
|
|
138
|
+
**Can I use multiple auth methods?**
|
|
139
|
+
Run `protect` again on the same domain with a different `--auth` flag, or configure additional IdPs in the CF dashboard.
|
|
140
|
+
|
|
141
|
+
**What if I already have Access set up?**
|
|
142
|
+
fastpass detects existing identity providers and reuses them.
|
|
143
|
+
|
|
144
|
+
**How do I see what's going on?**
|
|
145
|
+
Run `npx fastpass status` for an overview, `npx fastpass logs` for recent events, or `npx fastpass inspect <domain>` for detailed app config.
|
|
146
|
+
|
|
147
|
+
**How do I manage users/policies after setup?**
|
|
148
|
+
Use the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com).
|
package/bin/fastpass.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fastpass-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cloudflare Access in 60 seconds. No enterprise jargon.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fastpass": "./bin/fastpass.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"skill/"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"cloudflare",
|
|
16
|
+
"access",
|
|
17
|
+
"auth",
|
|
18
|
+
"login",
|
|
19
|
+
"zero-trust",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"author": "Cory Wilkerson",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/corywilkerson/fastpass.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/corywilkerson/fastpass#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/corywilkerson/fastpass/issues"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@inquirer/prompts": "^7.0.0",
|
|
37
|
+
"commander": "^13.0.0",
|
|
38
|
+
"open": "^10.0.0",
|
|
39
|
+
"picocolors": "^1.1.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fastpass
|
|
3
|
+
description: Protect a domain with Cloudflare Access — interactive or one-liner
|
|
4
|
+
user_invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# fastpass: Cloudflare Access for Humans
|
|
8
|
+
|
|
9
|
+
You are helping the user set up Cloudflare Access on their domain using the `fastpass` CLI tool.
|
|
10
|
+
|
|
11
|
+
## What you can do
|
|
12
|
+
|
|
13
|
+
- **Protect a domain** with a login page (email code, GitHub, or Google)
|
|
14
|
+
- **List** currently protected domains
|
|
15
|
+
- **Remove** protection from a domain
|
|
16
|
+
- **Status** — overview dashboard of team, apps, IdPs, and recent activity
|
|
17
|
+
- **Logs** — view recent access events, optionally filtered by domain
|
|
18
|
+
- **Inspect** — deep dive into a specific app's configuration and policies
|
|
19
|
+
|
|
20
|
+
## How to use
|
|
21
|
+
|
|
22
|
+
### Interactive mode
|
|
23
|
+
Run the wizard and let the user answer prompts:
|
|
24
|
+
```bash
|
|
25
|
+
npx fastpass
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### One-liner mode
|
|
29
|
+
If the user has already told you the domain, auth method, and who should have access:
|
|
30
|
+
```bash
|
|
31
|
+
npx fastpass protect <domain> --auth <email|github|google> --allow "<rule>"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Allow rules:
|
|
35
|
+
- `"me@email.com"` — specific email(s), comma-separated
|
|
36
|
+
- `"*@company.com"` — anyone with that email domain
|
|
37
|
+
- `"org:my-github-org"` — members of a GitHub organization (use with `--auth github`)
|
|
38
|
+
- `"everyone"` — just require login, allow all
|
|
39
|
+
|
|
40
|
+
### List protected domains
|
|
41
|
+
```bash
|
|
42
|
+
npx fastpass list
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Remove protection
|
|
46
|
+
```bash
|
|
47
|
+
npx fastpass remove <domain>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Status dashboard
|
|
51
|
+
```bash
|
|
52
|
+
npx fastpass status
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### View recent access logs
|
|
56
|
+
```bash
|
|
57
|
+
npx fastpass logs [domain] --limit 25 --since 2025-01-15
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
- `[domain]` — optional, filter events to a specific domain
|
|
62
|
+
- `--limit <n>` — number of events (default 25)
|
|
63
|
+
- `--since <date>` — only events after this date (ISO 8601)
|
|
64
|
+
|
|
65
|
+
### Inspect app configuration
|
|
66
|
+
```bash
|
|
67
|
+
npx fastpass inspect [domain]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If no domain is provided, shows an interactive picker. Displays: domain, type, session duration, allowed identity providers (resolved to names), and policy rules translated to plain English.
|
|
71
|
+
|
|
72
|
+
## Prerequisites
|
|
73
|
+
|
|
74
|
+
The user needs Cloudflare credentials. Check if they have either:
|
|
75
|
+
1. `CLOUDFLARE_API_TOKEN` env var set, OR
|
|
76
|
+
2. `npx wrangler login` already done
|
|
77
|
+
|
|
78
|
+
If not, help them create an API token at https://dash.cloudflare.com/profile/api-tokens with:
|
|
79
|
+
- Access: Organizations, Identity Providers, and Groups — Edit
|
|
80
|
+
- Access: Apps and Policies — Edit
|
|
81
|
+
- Zone: Zone — Read
|
|
82
|
+
|
|
83
|
+
## Conversational flow
|
|
84
|
+
|
|
85
|
+
When the user asks to protect a domain, gather these three things:
|
|
86
|
+
1. **Domain** — what domain to protect (must be in their CF account)
|
|
87
|
+
2. **Auth method** — email (easiest), github, or google
|
|
88
|
+
3. **Access rule** — who should be allowed in
|
|
89
|
+
|
|
90
|
+
Then construct and run the appropriate `npx fastpass` command.
|
|
91
|
+
|
|
92
|
+
If the user is unsure, recommend **email** auth — it requires zero external setup.
|
package/src/api.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
|
|
3
|
+
const BASE = 'https://api.cloudflare.com/client/v4';
|
|
4
|
+
|
|
5
|
+
export function createApi({ token, accountId }) {
|
|
6
|
+
const headers = {
|
|
7
|
+
Authorization: `Bearer ${token}`,
|
|
8
|
+
'Content-Type': 'application/json',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const accountPath = `/accounts/${accountId}`;
|
|
12
|
+
|
|
13
|
+
async function request(method, path, body) {
|
|
14
|
+
// Paths starting with / but not /accounts or /zones get the account prefix
|
|
15
|
+
const fullPath = path.startsWith('/zones') || path.startsWith('/accounts')
|
|
16
|
+
? path
|
|
17
|
+
: `${accountPath}${path}`;
|
|
18
|
+
|
|
19
|
+
const url = `${BASE}${fullPath}`;
|
|
20
|
+
|
|
21
|
+
const opts = { method, headers };
|
|
22
|
+
if (body) opts.body = JSON.stringify(body);
|
|
23
|
+
|
|
24
|
+
const res = await fetch(url, opts);
|
|
25
|
+
const json = await res.json();
|
|
26
|
+
|
|
27
|
+
if (!json.success) {
|
|
28
|
+
const errors = json.errors?.map((e) => e.message).join(', ') || 'Unknown error';
|
|
29
|
+
throw new ApiError(errors, json.errors, res.status);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return json;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
get: (path) => request('GET', path),
|
|
37
|
+
post: (path, body) => request('POST', path, body),
|
|
38
|
+
put: (path, body) => request('PUT', path, body),
|
|
39
|
+
delete: (path) => request('DELETE', path),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ApiError extends Error {
|
|
44
|
+
constructor(message, errors, status) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'ApiError';
|
|
47
|
+
this.errors = errors;
|
|
48
|
+
this.status = status;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function handleApiError(err) {
|
|
53
|
+
if (err instanceof ApiError) {
|
|
54
|
+
console.error(pc.red(`\nCloudflare API error: ${err.message}`));
|
|
55
|
+
if (err.status === 403) {
|
|
56
|
+
console.error(pc.yellow('Your API token may not have the required permissions.'));
|
|
57
|
+
console.error('Needed scopes: Access: Organizations + Identity Providers + Apps and Policies (Edit)\n');
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
console.error(pc.red(`\nError: ${err.message}\n`));
|
|
61
|
+
}
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve Cloudflare credentials.
|
|
6
|
+
*
|
|
7
|
+
* Priority:
|
|
8
|
+
* 1. CLOUDFLARE_API_TOKEN env var
|
|
9
|
+
* 2. `wrangler auth token` fallback (OAuth token)
|
|
10
|
+
*
|
|
11
|
+
* Account ID:
|
|
12
|
+
* 1. CLOUDFLARE_ACCOUNT_ID env var
|
|
13
|
+
* 2. Fetched from /accounts API
|
|
14
|
+
*/
|
|
15
|
+
export async function getCredentials() {
|
|
16
|
+
let token = process.env.CLOUDFLARE_API_TOKEN;
|
|
17
|
+
|
|
18
|
+
if (!token) {
|
|
19
|
+
token = tryWranglerToken();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!token) {
|
|
23
|
+
console.error(pc.red('\nCould not find Cloudflare credentials.\n'));
|
|
24
|
+
console.error('Set one of the following:\n');
|
|
25
|
+
console.error(` ${pc.bold('CLOUDFLARE_API_TOKEN')} — API token (recommended)`);
|
|
26
|
+
console.error(` Create one at https://dash.cloudflare.com/profile/api-tokens`);
|
|
27
|
+
console.error(` Needs ${pc.cyan('Access: Organizations, Identity Providers, and Groups — Edit')}`);
|
|
28
|
+
console.error(` and ${pc.cyan('Access: Apps and Policies — Edit')}\n`);
|
|
29
|
+
console.error(` Or install ${pc.bold('wrangler')} and run ${pc.cyan('npx wrangler login')} first.\n`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
34
|
+
|
|
35
|
+
if (!accountId) {
|
|
36
|
+
accountId = await fetchAccountId(token);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { token, accountId };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tryWranglerToken() {
|
|
43
|
+
try {
|
|
44
|
+
const result = execSync('npx wrangler --version 2>/dev/null && npx wrangler auth token 2>/dev/null', {
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
timeout: 15000,
|
|
47
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
48
|
+
});
|
|
49
|
+
const trimmed = result.trim();
|
|
50
|
+
if (trimmed && !trimmed.includes('Error') && !trimmed.includes('error')) {
|
|
51
|
+
return trimmed;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// wrangler not available or not logged in
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function fetchAccountId(token) {
|
|
60
|
+
const res = await fetch('https://api.cloudflare.com/client/v4/accounts?per_page=5', {
|
|
61
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
62
|
+
});
|
|
63
|
+
const json = await res.json();
|
|
64
|
+
|
|
65
|
+
if (!json.success || !json.result?.length) {
|
|
66
|
+
console.error(pc.red('Could not determine your Cloudflare account ID.'));
|
|
67
|
+
console.error(`Set ${pc.bold('CLOUDFLARE_ACCOUNT_ID')} in your environment.\n`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (json.result.length === 1) {
|
|
72
|
+
return json.result[0].id;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Multiple accounts — pick the first, but warn
|
|
76
|
+
console.warn(pc.yellow(`Multiple Cloudflare accounts found. Using: ${json.result[0].name}`));
|
|
77
|
+
console.warn(`Set ${pc.bold('CLOUDFLARE_ACCOUNT_ID')} to choose a specific account.\n`);
|
|
78
|
+
return json.result[0].id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch the Access organization team name (needed for IdP callback URLs).
|
|
83
|
+
*/
|
|
84
|
+
export async function getTeamName(api) {
|
|
85
|
+
const { result } = await api.get('/access/organizations');
|
|
86
|
+
|
|
87
|
+
if (result?.auth_domain) {
|
|
88
|
+
// auth_domain looks like "myteam.cloudflareaccess.com"
|
|
89
|
+
return result.auth_domain.replace('.cloudflareaccess.com', '');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { getCredentials } from './auth.js';
|
|
4
|
+
import { createApi } from './api.js';
|
|
5
|
+
import { protect } from './commands/protect.js';
|
|
6
|
+
import { list } from './commands/list.js';
|
|
7
|
+
import { remove } from './commands/remove.js';
|
|
8
|
+
import { status } from './commands/status.js';
|
|
9
|
+
import { logs } from './commands/logs.js';
|
|
10
|
+
import { inspect } from './commands/inspect.js';
|
|
11
|
+
|
|
12
|
+
export function run() {
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('fastpass')
|
|
17
|
+
.description('Cloudflare Access in 60 seconds. No enterprise jargon.')
|
|
18
|
+
.version('0.1.0');
|
|
19
|
+
|
|
20
|
+
// Default action (no subcommand) — run the protect wizard
|
|
21
|
+
program
|
|
22
|
+
.command('protect [domain]', { isDefault: true })
|
|
23
|
+
.description('Protect a domain with Cloudflare Access')
|
|
24
|
+
.option('--auth <method>', 'Auth method: email, github, or google')
|
|
25
|
+
.option('--allow <rule>', 'Who can access: email, *@domain.com, or "everyone"')
|
|
26
|
+
.action(async (domain, opts) => {
|
|
27
|
+
printBanner();
|
|
28
|
+
const creds = await getCredentials();
|
|
29
|
+
const api = createApi(creds);
|
|
30
|
+
await protect(api, { domain, ...opts });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command('list')
|
|
35
|
+
.description('List protected domains')
|
|
36
|
+
.action(async () => {
|
|
37
|
+
const creds = await getCredentials();
|
|
38
|
+
const api = createApi(creds);
|
|
39
|
+
await list(api);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
program
|
|
43
|
+
.command('remove [domain]')
|
|
44
|
+
.description('Remove protection from a domain')
|
|
45
|
+
.action(async (domain) => {
|
|
46
|
+
const creds = await getCredentials();
|
|
47
|
+
const api = createApi(creds);
|
|
48
|
+
await remove(api, { domain });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.command('status')
|
|
53
|
+
.description('Show Access overview: team, apps, IdPs, and recent activity')
|
|
54
|
+
.action(async () => {
|
|
55
|
+
const creds = await getCredentials();
|
|
56
|
+
const api = createApi(creds);
|
|
57
|
+
await status(api);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
program
|
|
61
|
+
.command('logs [domain]')
|
|
62
|
+
.description('Show recent access events')
|
|
63
|
+
.option('--limit <n>', 'Number of events to show', '25')
|
|
64
|
+
.option('--since <date>', 'Only show events after this date (ISO 8601)')
|
|
65
|
+
.action(async (domain, opts) => {
|
|
66
|
+
const creds = await getCredentials();
|
|
67
|
+
const api = createApi(creds);
|
|
68
|
+
await logs(api, { domain, limit: parseInt(opts.limit, 10), since: opts.since });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
program
|
|
72
|
+
.command('inspect [domain]')
|
|
73
|
+
.description('Show detailed configuration for an Access application')
|
|
74
|
+
.action(async (domain) => {
|
|
75
|
+
const creds = await getCredentials();
|
|
76
|
+
const api = createApi(creds);
|
|
77
|
+
await inspect(api, { domain });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
program.parse();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printBanner() {
|
|
84
|
+
console.log('');
|
|
85
|
+
console.log(` ${pc.bold('fastpass')}: protect your app in 60 seconds`);
|
|
86
|
+
console.log('');
|
|
87
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { select } from '@inquirer/prompts';
|
|
3
|
+
import { handleApiError } from '../api.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Inspect a specific Access application's detailed configuration.
|
|
7
|
+
*/
|
|
8
|
+
export async function inspect(api, opts = {}) {
|
|
9
|
+
try {
|
|
10
|
+
const [{ result: apps }, { result: idps }] = await Promise.all([
|
|
11
|
+
api.get('/access/apps'),
|
|
12
|
+
api.get('/access/identity_providers'),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const selfHosted = apps?.filter((a) => a.type === 'self_hosted') || [];
|
|
16
|
+
|
|
17
|
+
if (!selfHosted.length) {
|
|
18
|
+
console.log(pc.dim('\n No Access applications found.\n'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Build IdP lookup map
|
|
23
|
+
const idpMap = new Map();
|
|
24
|
+
for (const idp of idps || []) {
|
|
25
|
+
idpMap.set(idp.id, idp);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let target;
|
|
29
|
+
|
|
30
|
+
if (opts.domain) {
|
|
31
|
+
target = selfHosted.find((app) => app.domain === opts.domain);
|
|
32
|
+
if (!target) {
|
|
33
|
+
console.error(pc.red(`\n No Access application found for domain: ${opts.domain}\n`));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
target = await select({
|
|
38
|
+
message: 'Which application do you want to inspect?',
|
|
39
|
+
choices: selfHosted.map((app) => ({
|
|
40
|
+
value: app,
|
|
41
|
+
name: `${app.domain || app.name} (${app.id})`,
|
|
42
|
+
})),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- App details ---
|
|
47
|
+
console.log(`\n ${pc.bold('Application')}`);
|
|
48
|
+
console.log(pc.dim(` ${'─'.repeat(50)}`));
|
|
49
|
+
console.log(` Domain: ${target.domain || 'n/a'}`);
|
|
50
|
+
console.log(` Type: ${target.type || 'n/a'}`);
|
|
51
|
+
console.log(` Session duration: ${target.session_duration || 'default'}`);
|
|
52
|
+
console.log(` App ID: ${pc.dim(target.id)}`);
|
|
53
|
+
|
|
54
|
+
// --- Allowed IdPs ---
|
|
55
|
+
console.log(`\n ${pc.bold('Identity Providers')}`);
|
|
56
|
+
console.log(pc.dim(` ${'─'.repeat(50)}`));
|
|
57
|
+
if (target.allowed_idps?.length) {
|
|
58
|
+
for (const idpId of target.allowed_idps) {
|
|
59
|
+
const idp = idpMap.get(idpId);
|
|
60
|
+
if (idp) {
|
|
61
|
+
console.log(` ${idp.name} (${idp.type})`);
|
|
62
|
+
} else {
|
|
63
|
+
console.log(` ${pc.dim(idpId)} (unknown)`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
console.log(pc.dim(' Any provider'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Policies ---
|
|
71
|
+
console.log(`\n ${pc.bold('Policies')}`);
|
|
72
|
+
console.log(pc.dim(` ${'─'.repeat(50)}`));
|
|
73
|
+
if (target.policies?.length) {
|
|
74
|
+
for (const policy of target.policies) {
|
|
75
|
+
console.log(` ${pc.cyan(policy.name || 'Unnamed')} — ${policy.decision || 'n/a'}`);
|
|
76
|
+
if (policy.include?.length) {
|
|
77
|
+
for (const rule of policy.include) {
|
|
78
|
+
console.log(` ${describeRule(rule)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
console.log(pc.dim(' No policies configured'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
handleApiError(err);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function describeRule(rule) {
|
|
93
|
+
if (rule.email?.email) {
|
|
94
|
+
return rule.email.email;
|
|
95
|
+
}
|
|
96
|
+
if (rule.email_domain?.domain) {
|
|
97
|
+
return `Anyone with an @${rule.email_domain.domain} email`;
|
|
98
|
+
}
|
|
99
|
+
if (rule['github-organization']?.name) {
|
|
100
|
+
return `Members of GitHub org "${rule['github-organization'].name}"`;
|
|
101
|
+
}
|
|
102
|
+
if ('everyone' in rule) {
|
|
103
|
+
return 'Everyone (any logged-in user)';
|
|
104
|
+
}
|
|
105
|
+
return JSON.stringify(rule);
|
|
106
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { handleApiError } from '../api.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* List all Access applications in the account.
|
|
6
|
+
*/
|
|
7
|
+
export async function list(api) {
|
|
8
|
+
try {
|
|
9
|
+
const { result } = await api.get('/access/apps');
|
|
10
|
+
|
|
11
|
+
if (!result?.length) {
|
|
12
|
+
console.log(pc.dim('\n No Access applications found.\n'));
|
|
13
|
+
console.log(` Run ${pc.cyan('fastpass protect <domain>')} to get started.\n`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Filter to self-hosted apps (the ones fastpass creates)
|
|
18
|
+
const apps = result.filter((app) => app.type === 'self_hosted');
|
|
19
|
+
|
|
20
|
+
if (!apps.length) {
|
|
21
|
+
console.log(pc.dim('\n No self-hosted Access applications found.\n'));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log(`\n ${pc.bold('Protected domains')}\n`);
|
|
26
|
+
|
|
27
|
+
const maxDomain = Math.max(...apps.map((a) => (a.domain || '').length), 6);
|
|
28
|
+
|
|
29
|
+
console.log(
|
|
30
|
+
` ${pc.dim('Domain'.padEnd(maxDomain + 2))}${pc.dim('Auth'.padEnd(18))}${pc.dim('Session')}`,
|
|
31
|
+
);
|
|
32
|
+
console.log(pc.dim(` ${'─'.repeat(maxDomain + 2)}${'─'.repeat(18)}${'─'.repeat(10)}`));
|
|
33
|
+
|
|
34
|
+
for (const app of apps) {
|
|
35
|
+
const domain = (app.domain || 'n/a').padEnd(maxDomain + 2);
|
|
36
|
+
const idpNames = app.allowed_idps?.length
|
|
37
|
+
? `${app.allowed_idps.length} provider(s)`
|
|
38
|
+
: 'any';
|
|
39
|
+
const session = app.session_duration || 'default';
|
|
40
|
+
|
|
41
|
+
console.log(` ${domain}${idpNames.padEnd(18)}${session}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log(`\n ${pc.dim(`${apps.length} application(s)`)}\n`);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
handleApiError(err);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { handleApiError } from '../api.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Show recent access log events, optionally filtered by domain.
|
|
6
|
+
*/
|
|
7
|
+
export async function logs(api, opts = {}) {
|
|
8
|
+
try {
|
|
9
|
+
const limit = opts.limit || 25;
|
|
10
|
+
|
|
11
|
+
// Validate --since if provided
|
|
12
|
+
let sinceParam = '';
|
|
13
|
+
if (opts.since) {
|
|
14
|
+
const parsed = new Date(opts.since);
|
|
15
|
+
if (isNaN(parsed.getTime())) {
|
|
16
|
+
console.error(pc.red(`\n Invalid date: ${opts.since}`));
|
|
17
|
+
console.error(' Use ISO 8601 format, e.g. 2025-01-15 or 2025-01-15T00:00:00Z\n');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
sinceParam = `&since=${parsed.toISOString()}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { result } = await api.get(
|
|
24
|
+
`/access/logs/access_requests?limit=${limit}&direction=desc${sinceParam}`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (!result?.length) {
|
|
28
|
+
console.log(pc.dim('\n No access events found.\n'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Client-side domain filter
|
|
33
|
+
const domain = opts.domain;
|
|
34
|
+
const events = domain
|
|
35
|
+
? result.filter((e) => e.app_domain === domain)
|
|
36
|
+
: result;
|
|
37
|
+
|
|
38
|
+
if (!events.length) {
|
|
39
|
+
console.log(pc.dim(`\n No events found for domain: ${domain}\n`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(`\n ${pc.bold('Recent Access Events')}${domain ? ` — ${domain}` : ''}\n`);
|
|
44
|
+
|
|
45
|
+
const colTime = Math.max(...events.map((e) => formatTime(e.created_at).length), 4);
|
|
46
|
+
const colEmail = Math.max(...events.map((e) => (e.user_email || '').length), 5);
|
|
47
|
+
const colDomain = Math.max(...events.map((e) => (e.app_domain || '').length), 6);
|
|
48
|
+
|
|
49
|
+
console.log(
|
|
50
|
+
` ${pc.dim('Time'.padEnd(colTime + 2))}${pc.dim('Email'.padEnd(colEmail + 2))}${pc.dim('Domain'.padEnd(colDomain + 2))}${pc.dim('OK'.padEnd(5))}${pc.dim('IP')}`,
|
|
51
|
+
);
|
|
52
|
+
console.log(
|
|
53
|
+
pc.dim(` ${'─'.repeat(colTime + 2)}${'─'.repeat(colEmail + 2)}${'─'.repeat(colDomain + 2)}${'─'.repeat(5)}${'─'.repeat(15)}`),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
for (const e of events) {
|
|
57
|
+
const time = formatTime(e.created_at).padEnd(colTime + 2);
|
|
58
|
+
const email = (e.user_email || 'n/a').padEnd(colEmail + 2);
|
|
59
|
+
const dom = (e.app_domain || 'n/a').padEnd(colDomain + 2);
|
|
60
|
+
const ok = (e.allowed ? '✓' : '✗').padEnd(5);
|
|
61
|
+
const ip = e.ip_address || 'n/a';
|
|
62
|
+
|
|
63
|
+
const line = ` ${time}${email}${dom}${ok}${ip}`;
|
|
64
|
+
console.log(e.allowed ? line : pc.red(line));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(`\n ${pc.dim(`${events.length} event(s)`)}\n`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
handleApiError(err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatTime(iso) {
|
|
74
|
+
if (!iso) return 'n/a';
|
|
75
|
+
const d = new Date(iso);
|
|
76
|
+
return d.toLocaleString();
|
|
77
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { input, select } from '@inquirer/prompts';
|
|
3
|
+
import { getTeamName } from '../auth.js';
|
|
4
|
+
import { ensureEmailOtp } from '../idp/email-otp.js';
|
|
5
|
+
import { ensureGitHub } from '../idp/github.js';
|
|
6
|
+
import { ensureGoogle } from '../idp/google.js';
|
|
7
|
+
import { handleApiError } from '../api.js';
|
|
8
|
+
|
|
9
|
+
const AUTH_CHOICES = {
|
|
10
|
+
email: { label: 'Email code (easiest, no setup)', setup: ensureEmailOtp },
|
|
11
|
+
github: { label: 'GitHub', setup: ensureGitHub },
|
|
12
|
+
google: { label: 'Google', setup: ensureGoogle },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const ACCESS_CHOICES = {
|
|
16
|
+
me: 'Just me (enter your email)',
|
|
17
|
+
domain: 'Anyone with a specific email domain (@company.com)',
|
|
18
|
+
github_org: 'Members of a GitHub organization',
|
|
19
|
+
emails: 'Specific email addresses',
|
|
20
|
+
everyone: 'Everyone (just require login)',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Main protect command — interactive wizard or one-liner.
|
|
25
|
+
*/
|
|
26
|
+
export async function protect(api, opts = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const domain = opts.domain || await input({
|
|
29
|
+
message: 'What domain do you want to protect?',
|
|
30
|
+
validate: (v) => v.trim().includes('.') || 'Enter a valid domain (e.g. app.example.com)',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Validate domain exists in CF
|
|
34
|
+
await validateDomain(api, domain.trim());
|
|
35
|
+
|
|
36
|
+
const authMethod = opts.auth || await select({
|
|
37
|
+
message: 'How should people log in?',
|
|
38
|
+
choices: Object.entries(AUTH_CHOICES).map(([value, { label }]) => ({ value, name: label })),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!AUTH_CHOICES[authMethod]) {
|
|
42
|
+
console.error(pc.red(`Unknown auth method: ${authMethod}. Use: email, github, or google`));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve who gets access
|
|
47
|
+
const { include, includeType } = await resolveAccess(opts.allow);
|
|
48
|
+
|
|
49
|
+
// Get team name for OAuth callback URLs
|
|
50
|
+
const teamName = await getTeamName(api);
|
|
51
|
+
if (!teamName && authMethod !== 'email') {
|
|
52
|
+
console.error(pc.red('Could not determine your Access team name.'));
|
|
53
|
+
console.error('Make sure Access is enabled in your Cloudflare dashboard.\n');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Set up the identity provider
|
|
58
|
+
console.log('');
|
|
59
|
+
const idp = await AUTH_CHOICES[authMethod].setup(api, teamName);
|
|
60
|
+
|
|
61
|
+
// Build the policy include rules
|
|
62
|
+
const policyInclude = buildIncludeRules(include, includeType);
|
|
63
|
+
|
|
64
|
+
// Create the access application with an inline policy
|
|
65
|
+
console.log(` Protecting ${pc.bold(domain.trim())}...`);
|
|
66
|
+
|
|
67
|
+
const appBody = {
|
|
68
|
+
name: domain.trim(),
|
|
69
|
+
domain: domain.trim(),
|
|
70
|
+
type: 'self_hosted',
|
|
71
|
+
session_duration: '24h',
|
|
72
|
+
allowed_idps: [idp.id],
|
|
73
|
+
auto_redirect_to_identity: true,
|
|
74
|
+
policies: [
|
|
75
|
+
{
|
|
76
|
+
name: `Allow — ${domain.trim()}`,
|
|
77
|
+
decision: 'allow',
|
|
78
|
+
include: policyInclude,
|
|
79
|
+
precedence: 1,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const { result: app } = await api.post('/access/apps', appBody);
|
|
85
|
+
|
|
86
|
+
console.log(pc.green(' Done!\n'));
|
|
87
|
+
console.log(` ${pc.bold('Your app is protected!')} Try visiting:`);
|
|
88
|
+
console.log(` ${pc.cyan(`https://${domain.trim()}`)}\n`);
|
|
89
|
+
console.log(` Manage it: ${pc.dim('https://one.dash.cloudflare.com')}`);
|
|
90
|
+
console.log(` App ID: ${pc.dim(app.id)}\n`);
|
|
91
|
+
|
|
92
|
+
return app;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
handleApiError(err);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function validateDomain(api, domain) {
|
|
99
|
+
// Extract the root domain for zone lookup
|
|
100
|
+
const parts = domain.split('.');
|
|
101
|
+
const rootDomain = parts.slice(-2).join('.');
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const { result } = await api.get(`/zones?name=${rootDomain}`);
|
|
105
|
+
if (!result?.length) {
|
|
106
|
+
console.error(pc.red(`\nDomain "${rootDomain}" not found in your Cloudflare account.`));
|
|
107
|
+
console.error('Make sure the domain is added to your Cloudflare dashboard.\n');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(pc.red(`\nCould not verify domain: ${err.message}\n`));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function resolveAccess(allowFlag) {
|
|
117
|
+
// If --allow flag was passed, parse it
|
|
118
|
+
if (allowFlag) {
|
|
119
|
+
if (allowFlag.startsWith('*@')) {
|
|
120
|
+
return { include: [allowFlag.slice(2)], includeType: 'domain' };
|
|
121
|
+
}
|
|
122
|
+
if (allowFlag.startsWith('org:')) {
|
|
123
|
+
return { include: [allowFlag.slice(4)], includeType: 'github_org' };
|
|
124
|
+
}
|
|
125
|
+
if (allowFlag === 'everyone') {
|
|
126
|
+
return { include: [], includeType: 'everyone' };
|
|
127
|
+
}
|
|
128
|
+
// Treat as comma-separated email list
|
|
129
|
+
return { include: allowFlag.split(',').map((e) => e.trim()), includeType: 'emails' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Interactive
|
|
133
|
+
const accessType = await select({
|
|
134
|
+
message: 'Who should have access?',
|
|
135
|
+
choices: Object.entries(ACCESS_CHOICES).map(([value, name]) => ({ value, name })),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
switch (accessType) {
|
|
139
|
+
case 'me': {
|
|
140
|
+
const email = await input({
|
|
141
|
+
message: 'Your email address:',
|
|
142
|
+
validate: (v) => v.includes('@') || 'Enter a valid email',
|
|
143
|
+
});
|
|
144
|
+
return { include: [email.trim()], includeType: 'emails' };
|
|
145
|
+
}
|
|
146
|
+
case 'domain': {
|
|
147
|
+
const domain = await input({
|
|
148
|
+
message: 'Email domain (e.g. company.com):',
|
|
149
|
+
validate: (v) => v.includes('.') || 'Enter a valid domain',
|
|
150
|
+
});
|
|
151
|
+
return { include: [domain.trim()], includeType: 'domain' };
|
|
152
|
+
}
|
|
153
|
+
case 'github_org': {
|
|
154
|
+
const org = await input({
|
|
155
|
+
message: 'GitHub organization name:',
|
|
156
|
+
validate: (v) => v.trim().length > 0 || 'Enter a GitHub org name',
|
|
157
|
+
});
|
|
158
|
+
return { include: [org.trim()], includeType: 'github_org' };
|
|
159
|
+
}
|
|
160
|
+
case 'emails': {
|
|
161
|
+
const emails = await input({
|
|
162
|
+
message: 'Email addresses (comma-separated):',
|
|
163
|
+
validate: (v) => v.includes('@') || 'Enter at least one email',
|
|
164
|
+
});
|
|
165
|
+
return { include: emails.split(',').map((e) => e.trim()), includeType: 'emails' };
|
|
166
|
+
}
|
|
167
|
+
case 'everyone':
|
|
168
|
+
return { include: [], includeType: 'everyone' };
|
|
169
|
+
default:
|
|
170
|
+
return { include: [], includeType: 'everyone' };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildIncludeRules(include, includeType) {
|
|
175
|
+
switch (includeType) {
|
|
176
|
+
case 'emails':
|
|
177
|
+
// CF API expects one rule per email: [{ email: { email: "a@b.com" } }, ...]
|
|
178
|
+
return include.map((email) => ({ email: { email } }));
|
|
179
|
+
case 'domain':
|
|
180
|
+
return [{ email_domain: { domain: include[0] } }];
|
|
181
|
+
case 'github_org':
|
|
182
|
+
return [{ 'github-organization': { name: include[0] } }];
|
|
183
|
+
case 'everyone':
|
|
184
|
+
return [{ everyone: {} }];
|
|
185
|
+
default:
|
|
186
|
+
return [{ everyone: {} }];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { select, confirm } from '@inquirer/prompts';
|
|
3
|
+
import { handleApiError } from '../api.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Remove Access protection from a domain.
|
|
7
|
+
*/
|
|
8
|
+
export async function remove(api, opts = {}) {
|
|
9
|
+
try {
|
|
10
|
+
const { result } = await api.get('/access/apps');
|
|
11
|
+
const apps = result?.filter((app) => app.type === 'self_hosted') || [];
|
|
12
|
+
|
|
13
|
+
if (!apps.length) {
|
|
14
|
+
console.log(pc.dim('\n No Access applications to remove.\n'));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let target;
|
|
19
|
+
|
|
20
|
+
if (opts.domain) {
|
|
21
|
+
target = apps.find((app) => app.domain === opts.domain);
|
|
22
|
+
if (!target) {
|
|
23
|
+
console.error(pc.red(`\n No Access application found for domain: ${opts.domain}\n`));
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
// Interactive selection
|
|
28
|
+
target = await select({
|
|
29
|
+
message: 'Which application do you want to remove?',
|
|
30
|
+
choices: apps.map((app) => ({
|
|
31
|
+
value: app,
|
|
32
|
+
name: `${app.domain || app.name} (${app.id})`,
|
|
33
|
+
})),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ok = await confirm({
|
|
38
|
+
message: `Remove Access protection from ${pc.bold(target.domain || target.name)}?`,
|
|
39
|
+
default: false,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!ok) {
|
|
43
|
+
console.log(pc.dim(' Cancelled.\n'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(` Removing ${pc.bold(target.domain || target.name)}...`);
|
|
48
|
+
await api.delete(`/access/apps/${target.id}`);
|
|
49
|
+
console.log(pc.green(' Removed.\n'));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
handleApiError(err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { handleApiError } from '../api.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Status dashboard — quick snapshot of Access configuration and recent activity.
|
|
6
|
+
*/
|
|
7
|
+
export async function status(api) {
|
|
8
|
+
try {
|
|
9
|
+
const [{ result: org }, { result: apps }, { result: idps }] = await Promise.all([
|
|
10
|
+
api.get('/access/organizations'),
|
|
11
|
+
api.get('/access/apps'),
|
|
12
|
+
api.get('/access/identity_providers'),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// --- Team info ---
|
|
16
|
+
const teamName = org?.auth_domain?.replace('.cloudflareaccess.com', '') || 'unknown';
|
|
17
|
+
console.log(`\n ${pc.bold('Team')}`);
|
|
18
|
+
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
19
|
+
console.log(` Auth domain: ${org?.auth_domain || 'n/a'}`);
|
|
20
|
+
console.log(` Team name: ${teamName}`);
|
|
21
|
+
|
|
22
|
+
// --- Protected apps ---
|
|
23
|
+
const selfHosted = apps?.filter((a) => a.type === 'self_hosted') || [];
|
|
24
|
+
console.log(`\n ${pc.bold('Protected Apps')} (${selfHosted.length})`);
|
|
25
|
+
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
26
|
+
if (selfHosted.length) {
|
|
27
|
+
for (const app of selfHosted) {
|
|
28
|
+
console.log(` ${app.domain || app.name}`);
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
console.log(pc.dim(' None'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- Identity providers ---
|
|
35
|
+
console.log(`\n ${pc.bold('Identity Providers')} (${idps?.length || 0})`);
|
|
36
|
+
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
37
|
+
if (idps?.length) {
|
|
38
|
+
const maxName = Math.max(...idps.map((p) => (p.name || '').length), 4);
|
|
39
|
+
console.log(` ${pc.dim('Name'.padEnd(maxName + 2))}${pc.dim('Type')}`);
|
|
40
|
+
for (const idp of idps) {
|
|
41
|
+
console.log(` ${(idp.name || 'n/a').padEnd(maxName + 2)}${idp.type || 'n/a'}`);
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
console.log(pc.dim(' None'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Recent activity (graceful fallback) ---
|
|
48
|
+
console.log(`\n ${pc.bold('Recent Activity')}`);
|
|
49
|
+
console.log(pc.dim(` ${'─'.repeat(40)}`));
|
|
50
|
+
try {
|
|
51
|
+
const { result: logs } = await api.get('/access/logs/access_requests?limit=50&direction=desc');
|
|
52
|
+
if (logs?.length) {
|
|
53
|
+
let allowed = 0;
|
|
54
|
+
let denied = 0;
|
|
55
|
+
for (const entry of logs) {
|
|
56
|
+
if (entry.allowed) allowed++;
|
|
57
|
+
else denied++;
|
|
58
|
+
}
|
|
59
|
+
console.log(` Allowed: ${pc.green(String(allowed))} Denied: ${pc.red(String(denied))} (last ${logs.length} events)`);
|
|
60
|
+
} else {
|
|
61
|
+
console.log(pc.dim(' No recent events'));
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
console.log(pc.dim(' Unable to fetch logs (token may lack Access: Audit Logs permission)'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('');
|
|
68
|
+
} catch (err) {
|
|
69
|
+
handleApiError(err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ensure the One-Time PIN (email code) identity provider exists.
|
|
5
|
+
* Returns the IdP object.
|
|
6
|
+
*/
|
|
7
|
+
export async function ensureEmailOtp(api) {
|
|
8
|
+
const { result } = await api.get('/access/identity_providers');
|
|
9
|
+
const existing = result?.find((idp) => idp.type === 'onetimepin');
|
|
10
|
+
|
|
11
|
+
if (existing) {
|
|
12
|
+
console.log(pc.green(' Email Login already configured.'));
|
|
13
|
+
return existing;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log(' Setting up Email Login...');
|
|
17
|
+
const { result: created } = await api.post('/access/identity_providers', {
|
|
18
|
+
type: 'onetimepin',
|
|
19
|
+
name: 'Email Login',
|
|
20
|
+
config: {},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
console.log(pc.green(' Email Login enabled.'));
|
|
24
|
+
return created;
|
|
25
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { input, confirm } from '@inquirer/prompts';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ensure a GitHub OAuth identity provider exists.
|
|
7
|
+
* Guides the user through creating a GitHub OAuth app if needed.
|
|
8
|
+
*/
|
|
9
|
+
export async function ensureGitHub(api, teamName) {
|
|
10
|
+
const { result } = await api.get('/access/identity_providers');
|
|
11
|
+
const existing = result?.find((idp) => idp.type === 'github');
|
|
12
|
+
|
|
13
|
+
if (existing) {
|
|
14
|
+
console.log(pc.green(' GitHub login already configured.'));
|
|
15
|
+
return existing;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const callbackUrl = `https://${teamName}.cloudflareaccess.com/cdn-cgi/access/callback`;
|
|
19
|
+
const homepageUrl = `https://${teamName}.cloudflareaccess.com`;
|
|
20
|
+
|
|
21
|
+
console.log('');
|
|
22
|
+
console.log(pc.bold(' GitHub OAuth Setup'));
|
|
23
|
+
console.log(pc.dim(' You need to create a GitHub OAuth app. It takes about 30 seconds.\n'));
|
|
24
|
+
|
|
25
|
+
console.log(` 1. Go to ${pc.cyan('https://github.com/settings/developers')}`);
|
|
26
|
+
console.log(' 2. Click "New OAuth App"');
|
|
27
|
+
console.log(` 3. Application name: ${pc.bold('Cloudflare Access')}`);
|
|
28
|
+
console.log(` 4. Homepage URL: ${pc.bold(homepageUrl)}`);
|
|
29
|
+
console.log(` 5. Callback URL: ${pc.bold(callbackUrl)}`);
|
|
30
|
+
console.log(' 6. Click "Register application"');
|
|
31
|
+
console.log(' 7. Generate a client secret\n');
|
|
32
|
+
|
|
33
|
+
const shouldOpen = await confirm({
|
|
34
|
+
message: 'Open GitHub settings in your browser?',
|
|
35
|
+
default: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (shouldOpen) {
|
|
39
|
+
await open('https://github.com/settings/developers');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const clientId = await input({
|
|
43
|
+
message: 'GitHub Client ID:',
|
|
44
|
+
validate: (v) => v.trim().length > 0 || 'Required',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const clientSecret = await input({
|
|
48
|
+
message: 'GitHub Client Secret:',
|
|
49
|
+
validate: (v) => v.trim().length > 0 || 'Required',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
console.log(' Creating GitHub identity provider...');
|
|
53
|
+
const { result: created } = await api.post('/access/identity_providers', {
|
|
54
|
+
type: 'github',
|
|
55
|
+
name: 'GitHub',
|
|
56
|
+
config: {
|
|
57
|
+
client_id: clientId.trim(),
|
|
58
|
+
client_secret: clientSecret.trim(),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
console.log(pc.green(' GitHub login enabled.'));
|
|
63
|
+
return created;
|
|
64
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { input, confirm } from '@inquirer/prompts';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ensure a Google OAuth identity provider exists.
|
|
7
|
+
* Guides the user through creating a Google OAuth client if needed.
|
|
8
|
+
*/
|
|
9
|
+
export async function ensureGoogle(api, teamName) {
|
|
10
|
+
const { result } = await api.get('/access/identity_providers');
|
|
11
|
+
const existing = result?.find((idp) => idp.type === 'google');
|
|
12
|
+
|
|
13
|
+
if (existing) {
|
|
14
|
+
console.log(pc.green(' Google login already configured.'));
|
|
15
|
+
return existing;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const callbackUrl = `https://${teamName}.cloudflareaccess.com/cdn-cgi/access/callback`;
|
|
19
|
+
|
|
20
|
+
console.log('');
|
|
21
|
+
console.log(pc.bold(' Google OAuth Setup'));
|
|
22
|
+
console.log(pc.dim(' You need to create a Google OAuth client. It takes about a minute.\n'));
|
|
23
|
+
|
|
24
|
+
console.log(` 1. Go to ${pc.cyan('https://console.cloud.google.com/apis/credentials')}`);
|
|
25
|
+
console.log(' 2. Click "Create Credentials" → "OAuth client ID"');
|
|
26
|
+
console.log(' 3. Application type: "Web application"');
|
|
27
|
+
console.log(` 4. Name: ${pc.bold('Cloudflare Access')}`);
|
|
28
|
+
console.log(` 5. Authorized redirect URI: ${pc.bold(callbackUrl)}`);
|
|
29
|
+
console.log(' 6. Click "Create"\n');
|
|
30
|
+
|
|
31
|
+
const shouldOpen = await confirm({
|
|
32
|
+
message: 'Open Google Cloud Console in your browser?',
|
|
33
|
+
default: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (shouldOpen) {
|
|
37
|
+
await open('https://console.cloud.google.com/apis/credentials');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const clientId = await input({
|
|
41
|
+
message: 'Google Client ID:',
|
|
42
|
+
validate: (v) => v.trim().length > 0 || 'Required',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const clientSecret = await input({
|
|
46
|
+
message: 'Google Client Secret:',
|
|
47
|
+
validate: (v) => v.trim().length > 0 || 'Required',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
console.log(' Creating Google identity provider...');
|
|
51
|
+
const { result: created } = await api.post('/access/identity_providers', {
|
|
52
|
+
type: 'google',
|
|
53
|
+
name: 'Google',
|
|
54
|
+
config: {
|
|
55
|
+
client_id: clientId.trim(),
|
|
56
|
+
client_secret: clientSecret.trim(),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
console.log(pc.green(' Google login enabled.'));
|
|
61
|
+
return created;
|
|
62
|
+
}
|