Skip to content

Commit f3e1460

Browse files
authored
feat(auth): introduces auth commands (#1002)
Summary I moved `login` and `logout` commands to the `auth` namespace while keeping top-level shortcuts. New command to print token introduced. ### Files created: - **`src/commands/auth/login.ts`** - The main login command (renamed to `AuthLoginCommand`) - **`src/commands/auth/logout.ts`** - The main logout command (renamed to `AuthLogoutCommand`) - **`src/commands/auth/token.ts`** - The token command ### Files modified: - **`src/commands/login.ts`** - Now a simple shortcut that extends `AuthLoginCommand` - **`src/commands/logout.ts`** - Now a simple shortcut that extends `AuthLogoutCommand` - **`src/commands/auth/_index.ts`** - Added login and logout subcommands - **`src/commands/_register.ts`** - Added `AuthIndexCommand` import ### Usage: ```bash # Auth namespace commands apify auth login # Login via auth namespace apify auth logout # Logout via auth namespace apify auth token # Print current token # Top-level shortcuts (still work) apify login # Shortcut for auth login apify logout # Shortcut for auth logout ``` closes #898
1 parent 15dc5f3 commit f3e1460

11 files changed

Lines changed: 311 additions & 234 deletions

File tree

docs/reference.md

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,21 @@ Use these commands to manage your Apify account authentication, access tokens, a
8585

8686
<!-- prettier-ignore-start -->
8787
<!-- auth-commands-start -->
88-
##### `apify login`
88+
##### `apify auth`
89+
90+
```sh
91+
DESCRIPTION
92+
Manages authentication for Apify CLI.
93+
94+
SUBCOMMANDS
95+
auth login Authenticates your Apify account and saves credentials
96+
to '~/.apify/auth.json'.
97+
auth logout Removes authentication by deleting your API token and
98+
account information from '~/.apify/auth.json'.
99+
auth token Prints the current API token for the Apify CLI.
100+
```
101+
102+
##### `apify auth login` / `apify login`
89103

90104
```sh
91105
DESCRIPTION
@@ -96,15 +110,15 @@ DESCRIPTION
96110
Run 'apify logout' to remove authentication.
97111

98112
USAGE
99-
$ apify login [-m console|manual] [-t <value>]
113+
$ apify auth login [-m console|manual] [-t <value>]
100114

101115
FLAGS
102116
-m, --method=<option> Method of logging in to Apify
103117
<options: console|manual>
104118
-t, --token=<value> Apify API token
105119
```
106120

107-
##### `apify logout`
121+
##### `apify auth logout` / `apify logout`
108122

109123
```sh
110124
DESCRIPTION
@@ -113,7 +127,17 @@ DESCRIPTION
113127
Run 'apify login' to authenticate again.
114128

115129
USAGE
116-
$ apify logout
130+
$ apify auth logout
131+
```
132+
133+
##### `apify auth token`
134+
135+
```sh
136+
DESCRIPTION
137+
Prints the current API token for the Apify CLI.
138+
139+
USAGE
140+
$ apify auth token
117141
```
118142

119143
##### `apify info`
@@ -249,16 +273,19 @@ DESCRIPTION
249273
WARNING: Overwrites existing 'storage' directory.
250274

251275
USAGE
252-
$ apify init [actorName] [-y]
276+
$ apify init [actorName] [--dockerfile <value>] [-y]
253277

254278
ARGUMENTS
255279
actorName Name of the Actor. If not provided, you will be prompted
256280
for it.
257281

258282
FLAGS
259-
-y, --yes Automatic yes to prompts; assume "yes" as answer to all
260-
prompts. Note that in some cases, the command may still ask for
261-
confirmation.
283+
--dockerfile=<value> Path to a Dockerfile to use for
284+
the Actor (e.g., "./Dockerfile" or
285+
"./docker/Dockerfile").
286+
-y, --yes Automatic yes to prompts;
287+
assume "yes" as answer to all prompts. Note that in some
288+
cases, the command may still ask for confirmation.
262289
```
263290
264291
##### `apify run`

scripts/generate-cli-docs.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ process.env.APIFY_CLI_MAX_LINE_WIDTH = '80';
88
const categories: Record<string, CommandsInCategory[]> = {
99
'auth': [
1010
//
11-
{ command: Commands.login },
12-
{ command: Commands.logout },
11+
{ command: Commands.auth },
12+
{ command: Commands.authLogin, aliases: [Commands.login] },
13+
{ command: Commands.authLogout, aliases: [Commands.logout] },
14+
{ command: Commands.authToken },
1315
{ command: Commands.info },
1416
{ command: Commands.secrets },
1517
{ command: Commands.secretsAdd },

src/commands/_register.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ActorGetValueCommand } from './actor/get-value.js';
88
import { ActorPushDataCommand } from './actor/push-data.js';
99
import { ActorSetValueCommand } from './actor/set-value.js';
1010
import { ActorsIndexCommand } from './actors/_index.js';
11+
import { AuthIndexCommand } from './auth/_index.js';
1112
import { BuildsIndexCommand } from './builds/_index.js';
1213
import { TopLevelCallCommand } from './call.js';
1314
import { InstallCommand } from './cli-management/install.js';
@@ -36,6 +37,7 @@ export const apifyCommands = [
3637
// namespaces
3738
ActorIndexCommand,
3839
ActorsIndexCommand,
40+
AuthIndexCommand,
3941
BuildsIndexCommand,
4042
DatasetsIndexCommand,
4143
KeyValueStoresIndexCommand,

src/commands/auth/_index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
2+
import { AuthLoginCommand } from './login.js';
3+
import { AuthLogoutCommand } from './logout.js';
4+
import { AuthTokenCommand } from './token.js';
5+
6+
export class AuthIndexCommand extends ApifyCommand<typeof AuthIndexCommand> {
7+
static override name = 'auth' as const;
8+
9+
static override description = 'Manages authentication for Apify CLI.';
10+
11+
static override subcommands = [AuthLoginCommand, AuthLogoutCommand, AuthTokenCommand];
12+
13+
async run() {
14+
this.printHelp();
15+
}
16+
}

src/commands/auth/login.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import type { Server } from 'node:http';
2+
import type { AddressInfo } from 'node:net';
3+
4+
import chalk from 'chalk';
5+
import computerName from 'computer-name';
6+
import cors from 'cors';
7+
import express from 'express';
8+
import open from 'open';
9+
10+
import { cryptoRandomObjectId } from '@apify/utilities';
11+
12+
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
13+
import { Flags } from '../../lib/command-framework/flags.js';
14+
import { AUTH_FILE_PATH } from '../../lib/consts.js';
15+
import { updateUserId } from '../../lib/hooks/telemetry/useTelemetryState.js';
16+
import { useMaskedInput } from '../../lib/hooks/user-confirmations/useMaskedInput.js';
17+
import { useSelectFromList } from '../../lib/hooks/user-confirmations/useSelectFromList.js';
18+
import { error, info, success } from '../../lib/outputs.js';
19+
import { getLocalUserInfo, getLoggedClient, tildify } from '../../lib/utils.js';
20+
21+
const CONSOLE_BASE_URL = 'https://console.apify.com/settings/integrations';
22+
// const CONSOLE_BASE_URL = 'http://localhost:3000/settings/integrations';
23+
const CONSOLE_URL_ORIGIN = new URL(CONSOLE_BASE_URL).origin;
24+
25+
const API_BASE_URL = CONSOLE_BASE_URL.includes('localhost') ? 'http://localhost:3333' : undefined;
26+
27+
// Not really checked right now, but it might come useful if we ever need to do some breaking changes
28+
const API_VERSION = 'v1';
29+
30+
const tryToLogin = async (token: string) => {
31+
const isUserLogged = await getLoggedClient(token, API_BASE_URL);
32+
const userInfo = await getLocalUserInfo();
33+
34+
if (isUserLogged) {
35+
await updateUserId(userInfo.id!);
36+
37+
success({
38+
message: `You are logged in to Apify as ${userInfo.username || userInfo.id}. ${chalk.gray(`Your token is stored at ${AUTH_FILE_PATH()}.`)}`,
39+
});
40+
} else {
41+
error({
42+
message: 'Login to Apify failed, the provided API token is not valid.',
43+
});
44+
}
45+
return isUserLogged;
46+
};
47+
48+
export class AuthLoginCommand extends ApifyCommand<typeof AuthLoginCommand> {
49+
static override name = 'login' as const;
50+
51+
static override description =
52+
`Authenticates your Apify account and saves credentials to '${tildify(AUTH_FILE_PATH())}'.\n` +
53+
`All other commands use these stored credentials.\n\n` +
54+
`Run 'apify logout' to remove authentication.`;
55+
56+
static override flags = {
57+
token: Flags.string({
58+
char: 't',
59+
description: 'Apify API token',
60+
required: false,
61+
}),
62+
method: Flags.string({
63+
char: 'm',
64+
description: 'Method of logging in to Apify',
65+
choices: ['console', 'manual'],
66+
required: false,
67+
}),
68+
};
69+
70+
async run() {
71+
const { token, method } = this.flags;
72+
73+
if (token) {
74+
await tryToLogin(token);
75+
return;
76+
}
77+
78+
let selectedMethod = method;
79+
80+
if (!method) {
81+
const answer = await useSelectFromList({
82+
message: 'Choose how you want to log in to Apify',
83+
choices: [
84+
{
85+
value: 'console',
86+
name: 'Through Apify Console in your default browser',
87+
short: 'Through Apify Console',
88+
},
89+
{
90+
value: 'manual',
91+
name: 'Enter API token manually',
92+
short: 'Manually',
93+
},
94+
] as const,
95+
loop: true,
96+
});
97+
98+
selectedMethod = answer;
99+
}
100+
101+
if (selectedMethod === 'console') {
102+
let server: Server;
103+
const app = express();
104+
105+
// To send requests from browser to localhost, CORS has to be configured properly
106+
app.use(
107+
cors({
108+
origin: CONSOLE_URL_ORIGIN,
109+
allowedHeaders: ['Content-Type', 'Authorization'],
110+
}),
111+
);
112+
113+
// Turn off keepalive, otherwise closing the server when command is finished is lagging
114+
app.use((_, res, next) => {
115+
res.set('Connection', 'close');
116+
next();
117+
});
118+
119+
app.use(express.json());
120+
121+
// Basic authorization via a random token, which is passed to the Apify Console,
122+
// and that sends it back via the `token` query param, or `Authorization` header
123+
const authToken = cryptoRandomObjectId();
124+
app.use((req, res, next) => {
125+
let { token: serverToken } = req.query;
126+
if (!serverToken) {
127+
const authorizationHeader = req.get('Authorization');
128+
if (authorizationHeader) {
129+
const [schema, tokenFromHeader, ...extra] = authorizationHeader.trim().split(/\s+/);
130+
if (schema.toLowerCase() === 'bearer' && tokenFromHeader && extra.length === 0) {
131+
serverToken = tokenFromHeader;
132+
}
133+
}
134+
}
135+
136+
if (serverToken !== authToken) {
137+
res.status(401);
138+
res.send('Authorization failed');
139+
} else {
140+
next();
141+
}
142+
});
143+
144+
const apiRouter = express.Router();
145+
app.use(`/api/${API_VERSION}`, apiRouter);
146+
147+
apiRouter.post('/login-token', async (req, res) => {
148+
try {
149+
if (req.body.apiToken) {
150+
await tryToLogin(req.body.apiToken);
151+
} else {
152+
throw new Error('Request did not contain API token');
153+
}
154+
res.end();
155+
} catch (err) {
156+
const errorMessage = `Login to Apify failed with error: ${(err as Error).message}`;
157+
error({ message: errorMessage });
158+
res.status(500);
159+
res.send(errorMessage);
160+
}
161+
server.close();
162+
});
163+
164+
apiRouter.post('/exit', (req, res) => {
165+
if (req.body.isWindowClosed) {
166+
error({
167+
message: 'Login to Apify failed, the console window was closed.',
168+
});
169+
} else if (req.body.actionCanceled) {
170+
error({
171+
message: 'Login to Apify failed, the action was canceled in the Apify Console.',
172+
});
173+
} else {
174+
error({ message: 'Login to Apify failed.' });
175+
}
176+
177+
res.end();
178+
server.close();
179+
});
180+
181+
// Listening on port 0 will assign a random available port
182+
server = app.listen(0);
183+
const { port } = server.address() as AddressInfo;
184+
185+
const consoleUrl = new URL(CONSOLE_BASE_URL);
186+
consoleUrl.searchParams.set('localCliCommand', 'login');
187+
consoleUrl.searchParams.set('localCliPort', `${port}`);
188+
consoleUrl.searchParams.set('localCliToken', authToken);
189+
consoleUrl.searchParams.set('localCliApiVersion', API_VERSION);
190+
try {
191+
consoleUrl.searchParams.set('localCliComputerName', encodeURIComponent(computerName()));
192+
} catch {
193+
// Ignore errors from fetching computer name as it's not critical
194+
}
195+
196+
info({ message: `Opening Apify Console at "${consoleUrl.href}"...` });
197+
await open(consoleUrl.href);
198+
} else {
199+
console.log(
200+
'Enter your Apify API token. You can find it at https://console.apify.com/settings/integrations',
201+
);
202+
203+
const tokenAnswer = await useMaskedInput({ message: 'token:' });
204+
await tryToLogin(tokenAnswer);
205+
}
206+
}
207+
}

src/commands/auth/logout.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
2+
import { AUTH_FILE_PATH } from '../../lib/consts.js';
3+
import { rimrafPromised } from '../../lib/files.js';
4+
import { updateUserId } from '../../lib/hooks/telemetry/useTelemetryState.js';
5+
import { success } from '../../lib/outputs.js';
6+
import { tildify } from '../../lib/utils.js';
7+
8+
export class AuthLogoutCommand extends ApifyCommand<typeof AuthLogoutCommand> {
9+
static override name = 'logout' as const;
10+
11+
static override description =
12+
`Removes authentication by deleting your API token and account information from '${tildify(AUTH_FILE_PATH())}'.\n` +
13+
`Run 'apify login' to authenticate again.`;
14+
15+
async run() {
16+
await rimrafPromised(AUTH_FILE_PATH());
17+
18+
await updateUserId(null);
19+
20+
success({ message: 'You are logged out from your Apify account.' });
21+
}
22+
}

src/commands/auth/token.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
2+
import { simpleLog } from '../../lib/outputs.js';
3+
import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js';
4+
5+
export class AuthTokenCommand extends ApifyCommand<typeof AuthTokenCommand> {
6+
static override name = 'token' as const;
7+
8+
static override description = 'Prints the current API token for the Apify CLI.';
9+
10+
async run() {
11+
await getLoggedClientOrThrow();
12+
const userInfo = await getLocalUserInfo();
13+
14+
if (userInfo.token) {
15+
simpleLog({ message: userInfo.token, stdout: true });
16+
}
17+
}
18+
}

0 commit comments

Comments
 (0)