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 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).
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+ run();
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
+ }