@toloka-platform/sdk 0.2.1 → 0.3.1

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/src/cli/auth.ts CHANGED
@@ -1,6 +1,29 @@
1
1
  import { createClient } from "../client";
2
+ import type { ClientConfig } from "../client";
2
3
  import { PotatoApiError } from "../errors";
3
- import { PRODUCTION_BASE_URL, consoleUrlFor, resolveInternalBaseUrl } from "./config";
4
+ import { loginWithBrowser } from "./browser-login";
5
+ import type { BrowserLoginFlow } from "./browser-login";
6
+ import {
7
+ BrowserLoginExpiredError,
8
+ browserSessionFromTokens,
9
+ createBrowserSessionAuth,
10
+ findBrowserLogin,
11
+ isRefreshTokenExpired,
12
+ readBrowserSession,
13
+ removeBrowserSession,
14
+ unixSeconds,
15
+ writeBrowserSession,
16
+ } from "./browser-session";
17
+ import type { BrowserLogin, BrowserSession } from "./browser-session";
18
+ import { PLAIN_STYLE } from "./style";
19
+ import type { Style } from "./style";
20
+ import {
21
+ PRODUCTION_BASE_URL,
22
+ consoleUrlFor,
23
+ registrationUrlFor,
24
+ resolveBaseUrl,
25
+ resolveInternalBaseUrl,
26
+ } from "./config";
4
27
  import {
5
28
  clearStoredCredentials,
6
29
  credentialsPath,
@@ -8,6 +31,7 @@ import {
8
31
  inspectCredentialsFile,
9
32
  maskApiKey,
10
33
  normalizeBaseUrl,
34
+ storedCredentialsPaths,
11
35
  writeStoredCredentials,
12
36
  } from "./credentials";
13
37
  import type {
@@ -16,6 +40,12 @@ import type {
16
40
  FoundCredentials,
17
41
  StoredCredentials,
18
42
  } from "./credentials";
43
+ import {
44
+ browserLoginProviderFor,
45
+ endKeycloakSession,
46
+ KNOWN_BROWSER_LOGIN_PROVIDERS,
47
+ } from "./keycloak";
48
+ import type { BrowserLoginProvider } from "./keycloak";
19
49
  import type { ProjectContext } from "./project-context";
20
50
  import { formatProjectContextLine } from "./project-context";
21
51
  import { assertCli, TolokaCliError } from "./cli-error";
@@ -26,6 +56,7 @@ type ProbeFailure = { readonly kind: ProbeFailureKind; readonly reason: string }
26
56
 
27
57
  const classifyProbeFailure = (error: unknown): ProbeFailure => {
28
58
  const reason = error instanceof Error ? error.message : String(error);
59
+ if (error instanceof BrowserLoginExpiredError) return { kind: "rejected", reason };
29
60
  if (!(error instanceof PotatoApiError) || error.status === undefined)
30
61
  return { kind: "unreachable", reason };
31
62
  return {
@@ -40,18 +71,26 @@ const PROBE_FAILURE_HEADLINE: Record<ProbeFailureKind, string> = {
40
71
  platform: "Platform error",
41
72
  };
42
73
 
43
- const LOGIN_FAILURE_ADVICE: Record<ProbeFailureKind, (baseUrl: string) => string> = {
74
+ const API_KEY_LOGIN_FAILURE_ADVICE: Record<ProbeFailureKind, (baseUrl: string) => string> = {
44
75
  rejected: () => "Check that the key was copied whole and has not been revoked.",
45
76
  unreachable: (baseUrl) =>
46
77
  `${baseUrl} did not answer, so the key was not evaluated. Check network access, VPN, or proxy settings and retry.`,
47
78
  platform: () => "The platform failed before confirming the key. Retry later.",
48
79
  };
49
80
 
81
+ const BROWSER_LOGIN_FAILURE_ADVICE: Record<ProbeFailureKind, (baseUrl: string) => string> = {
82
+ rejected: (baseUrl) =>
83
+ `${baseUrl} did not accept the account you signed in with. Check that it belongs to a Toloka team, or sign in with another account.`,
84
+ unreachable: (baseUrl) =>
85
+ `${baseUrl} did not answer, so the login was not evaluated. Check network access, VPN, or proxy settings and retry.`,
86
+ platform: () => "The platform failed before confirming the login. Retry later.",
87
+ };
88
+
50
89
  const STATUS_FAILURE_ADVICE: Record<ProbeFailureKind, (baseUrl: string) => string> = {
51
- rejected: () => "Run toloka login to store a working key.",
90
+ rejected: () => "Run toloka login to sign in again.",
52
91
  unreachable: (baseUrl) =>
53
- `Check network access to ${baseUrl}; the stored key was not evaluated.`,
54
- platform: () => "The platform failed before confirming the key. Retry later.",
92
+ `Check network access to ${baseUrl}; the stored login was not evaluated.`,
93
+ platform: () => "The platform failed before confirming the login. Retry later.",
55
94
  };
56
95
 
57
96
  export const apiKeyInstructions = (baseUrl: string): string => {
@@ -83,6 +122,15 @@ export type AuthPrompt = (
83
122
  options?: { readonly mask?: boolean },
84
123
  ) => Promise<string>;
85
124
 
125
+ export type AuthChoose = (
126
+ message: string,
127
+ choices: readonly {
128
+ readonly name: string;
129
+ readonly value: string;
130
+ readonly description?: string;
131
+ }[],
132
+ ) => Promise<string>;
133
+
86
134
  export type AuthProbe = {
87
135
  readonly projects: {
88
136
  readonly listPage: (params: { readonly limit: number }) => Promise<{
@@ -90,6 +138,7 @@ export type AuthProbe = {
90
138
  }>;
91
139
  };
92
140
  readonly me?: () => Promise<{
141
+ readonly email?: string | null;
93
142
  readonly internal: boolean;
94
143
  readonly teams?: ReadonlyArray<{
95
144
  readonly id: string;
@@ -99,107 +148,287 @@ export type AuthProbe = {
99
148
  }>;
100
149
  };
101
150
 
151
+ export type LoginMethod = "browser" | "apiKey";
152
+
102
153
  export type LoginOptions = {
103
154
  readonly apiKey?: string;
104
155
  readonly baseUrl?: string;
156
+ readonly browser?: boolean;
105
157
  readonly prompt?: AuthPrompt;
158
+ readonly choose?: AuthChoose;
106
159
  readonly environment?: CliEnvironment;
107
- readonly probe?: (credentials: StoredCredentials) => AuthProbe;
160
+ readonly probe?: (config: ClientConfig) => AuthProbe;
161
+ readonly browserLogin?: BrowserLoginFlow;
162
+ readonly fetch?: typeof globalThis.fetch;
163
+ readonly now?: () => number;
164
+ readonly style?: Style;
108
165
  readonly write?: (text: string) => void;
109
166
  };
110
167
 
111
168
  export type LoginResult = {
112
169
  readonly credentialsFile: string;
113
170
  readonly baseUrl: string;
171
+ readonly method: LoginMethod;
114
172
  readonly output: string;
115
173
  };
116
174
 
117
- export async function login(options: LoginOptions = {}): Promise<LoginResult> {
118
- const environment = options.environment ?? process.env;
119
- const write = options.write ?? (() => undefined);
120
- const baseUrl = normalizeBaseUrl(
121
- options.baseUrl ??
122
- environment.TOLOKA_BASE_URL ??
123
- resolveInternalBaseUrl(environment) ??
124
- PRODUCTION_BASE_URL,
125
- );
126
-
127
- let apiKey = options.apiKey ?? environment.TOLOKA_API_KEY;
128
- if (apiKey === undefined) {
129
- assertCli(
130
- options.prompt,
131
- "Missing API key. Without a terminal, pipe the key to toloka login --api-key-stdin, pass --api-key, or set TOLOKA_API_KEY.",
132
- { hint: { disabled: true } },
133
- );
134
- write(`\n${apiKeyInstructions(baseUrl)}\n`);
135
- apiKey = (await options.prompt("API key: ", { mask: true })).trim();
136
- }
137
- assertCli(
138
- apiKey.length > 0,
139
- "No API key entered.",
140
- { hint: { description: "Re-run toloka login and paste the key at the prompt, or pass --api-key." } },
141
- );
142
- const credentials: StoredCredentials = { baseUrl, apiKey };
143
- const probe: AuthProbe = options.probe?.(credentials) ?? createClient(credentials);
144
-
145
- let reachable: string;
175
+ const verifyAccess = async (
176
+ probe: AuthProbe,
177
+ baseUrl: string,
178
+ advice: Record<ProbeFailureKind, (baseUrl: string) => string>,
179
+ ): Promise<number> => {
146
180
  try {
147
181
  const page = await probe.projects.listPage({ limit: 1 });
148
- reachable = `Authenticated against ${baseUrl}: ${page.pagination.total} project(s) visible.`;
182
+ return page.pagination.total;
149
183
  } catch (error) {
150
184
  const failure = classifyProbeFailure(error);
151
185
  throw new TolokaCliError(
152
186
  [
153
187
  `Could not authenticate against ${baseUrl}: ${failure.reason}`,
154
188
  "",
155
- `Nothing was saved. ${LOGIN_FAILURE_ADVICE[failure.kind](baseUrl)}`,
189
+ `Nothing was saved. ${advice[failure.kind](baseUrl)}`,
156
190
  ].join("\n"),
157
191
  { cause: error, hint: { disabled: true } },
158
192
  );
159
193
  }
194
+ };
195
+
196
+ const registryFor = async (
197
+ environment: CliEnvironment,
198
+ baseUrl: string,
199
+ ): Promise<Pick<StoredCredentials, "current" | "projects">> => {
200
+ const existing = await inspectCredentialsFile(credentialsPath(environment));
201
+ if (typeof existing !== "object") return {};
202
+ if (normalizeBaseUrl(existing.credentials.baseUrl) !== normalizeBaseUrl(baseUrl)) return {};
203
+ return {
204
+ ...(existing.credentials.current === undefined ? {} : { current: existing.credentials.current }),
205
+ ...(existing.credentials.projects === undefined
206
+ ? {}
207
+ : { projects: existing.credentials.projects }),
208
+ };
209
+ };
210
+
211
+ const accountEmail = async (probe: AuthProbe): Promise<string | undefined> => {
212
+ if (probe.me === undefined) return undefined;
213
+ try {
214
+ const email = (await probe.me()).email;
215
+ return typeof email === "string" && email.length > 0 ? email : undefined;
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ };
220
+
221
+ const formatDate = (unixTime: number): string =>
222
+ new Date(unixTime * 1000).toISOString().replace(/\.\d{3}Z$/u, "Z");
223
+
224
+ const projectsVisible = (total: number): string =>
225
+ `${total} ${total === 1 ? "project" : "projects"} visible`;
160
226
 
161
- const existingPath = credentialsPath(environment);
162
- const existing = await inspectCredentialsFile(existingPath);
163
- const merged: StoredCredentials =
164
- typeof existing === "object" &&
165
- normalizeBaseUrl(existing.credentials.baseUrl) === normalizeBaseUrl(baseUrl)
166
- ? {
167
- baseUrl,
168
- apiKey,
169
- ...(existing.credentials.current === undefined
170
- ? {}
171
- : { current: existing.credentials.current }),
172
- ...(existing.credentials.projects === undefined
173
- ? {}
174
- : { projects: existing.credentials.projects }),
175
- }
176
- : credentials;
177
-
178
- const credentialsFile = await writeStoredCredentials(merged, environment);
227
+ const loginWithApiKey = async (
228
+ options: LoginOptions,
229
+ environment: CliEnvironment,
230
+ baseUrl: string,
231
+ write: (text: string) => void,
232
+ ): Promise<LoginResult> => {
233
+ let apiKey = options.apiKey ?? environment.TOLOKA_API_KEY;
234
+ if (apiKey === undefined) {
235
+ assertCli(
236
+ options.prompt,
237
+ "No terminal to type the API key in. Pipe it to toloka login --api-key-stdin, pass --api-key, or set TOLOKA_API_KEY.",
238
+ { hint: { disabled: true } },
239
+ );
240
+ write(`\n${apiKeyInstructions(baseUrl)}\n`);
241
+ apiKey = (await options.prompt("API key: ", { mask: true })).trim();
242
+ }
243
+ assertCli(apiKey.length > 0, "No API key entered.", {
244
+ hint: { description: "Re-run toloka login and paste the key at the prompt, or pass --api-key." },
245
+ });
246
+ const style = options.style ?? PLAIN_STYLE;
247
+ const clientConfig: ClientConfig = { baseUrl, apiKey };
248
+ const probe = options.probe?.(clientConfig) ?? createClient(clientConfig);
249
+ const total = await verifyAccess(probe, baseUrl, API_KEY_LOGIN_FAILURE_ADVICE);
250
+ const registry = await registryFor(environment, baseUrl);
251
+ const credentialsFile = await writeStoredCredentials({ baseUrl, apiKey, ...registry }, environment);
179
252
 
180
253
  return {
181
254
  credentialsFile,
182
255
  baseUrl,
256
+ method: "apiKey",
183
257
  output: [
184
- reachable,
185
- `Saved credentials to ${credentialsFile}.`,
186
- "",
187
- "toloka reads them automatically. --api-key and TOLOKA_API_KEY still win when set.",
258
+ `${style.green("✔")} Authenticated against ${style.bold(baseUrl)} with an API key`,
259
+ style.dim(` ${projectsVisible(total)} · stored in ${credentialsFile}`),
260
+ style.dim(
261
+ ` This API key does not expire. To revoke it, delete it in ${consoleUrlFor(baseUrl) ?? "the Business Console"}.`,
262
+ ),
188
263
  ].join("\n"),
189
264
  };
265
+ };
266
+
267
+ const API_KEY_CHOICE = "api-key";
268
+ const BROWSER_CHOICE = "browser";
269
+
270
+ const signInChoices = (provider: BrowserLoginProvider) => [
271
+ {
272
+ name: "Sign in with your browser",
273
+ value: BROWSER_CHOICE,
274
+ description: `${provider.identityProviders.map((choice) => choice.name).join(", ")}. Lasts up to 14 days.`,
275
+ },
276
+ {
277
+ name: "Use an API key",
278
+ value: API_KEY_CHOICE,
279
+ description:
280
+ "Paste a key from the Business Console. Works until you delete it; made for scripts, CI, and coding agents.",
281
+ },
282
+ ];
283
+
284
+ type SignInChoice =
285
+ | { readonly method: "apiKey" }
286
+ | { readonly method: "browser"; readonly openBrowser: boolean; readonly requireBrowser: boolean };
287
+
288
+ const chooseSignIn = async (
289
+ options: LoginOptions,
290
+ provider: BrowserLoginProvider,
291
+ ): Promise<SignInChoice> => {
292
+ if (options.browser === false) return { method: "browser", openBrowser: false, requireBrowser: false };
293
+ if (options.browser === true || options.choose === undefined) {
294
+ return { method: "browser", openBrowser: true, requireBrowser: options.choose === undefined };
295
+ }
296
+ const choice = await options.choose("How do you want to sign in?", signInChoices(provider));
297
+ return choice === API_KEY_CHOICE
298
+ ? { method: "apiKey" }
299
+ : { method: "browser", openBrowser: true, requireBrowser: false };
300
+ };
301
+
302
+ const loginInBrowser = async (
303
+ options: LoginOptions,
304
+ environment: CliEnvironment,
305
+ baseUrl: string,
306
+ provider: BrowserLoginProvider,
307
+ choice: Extract<SignInChoice, { method: "browser" }>,
308
+ write: (text: string) => void,
309
+ ): Promise<LoginResult> => {
310
+ const tokens = await (options.browserLogin ?? loginWithBrowser)(
311
+ {
312
+ provider,
313
+ openBrowser: choice.openBrowser,
314
+ requireBrowser: choice.requireBrowser,
315
+ registrationUrl: registrationUrlFor(baseUrl),
316
+ ...(options.style === undefined ? {} : { style: options.style }),
317
+ write,
318
+ },
319
+ { environment, ...(options.fetch === undefined ? {} : { fetch: options.fetch }) },
320
+ );
321
+ const session = browserSessionFromTokens(provider, tokens, (options.now ?? unixSeconds)());
322
+ const clientConfig: ClientConfig = { baseUrl, accessToken: async () => session.access_token };
323
+ const probe = options.probe?.(clientConfig) ?? createClient(clientConfig);
324
+ const total = await verifyAccess(probe, baseUrl, BROWSER_LOGIN_FAILURE_ADVICE);
325
+ const who = await accountEmail(probe);
326
+ await writeBrowserSession(session, provider, environment);
327
+ const registry = await registryFor(environment, baseUrl);
328
+ const credentialsFile = await writeStoredCredentials({ baseUrl, ...registry }, environment);
329
+ const style = options.style ?? PLAIN_STYLE;
330
+ const renewal =
331
+ session.refresh_token_expires_at > 0
332
+ ? `session renews automatically until ${formatDate(session.refresh_token_expires_at)}, then run toloka login again`
333
+ : "session renews automatically while the login service allows; when it lapses, run toloka login again";
334
+
335
+ return {
336
+ credentialsFile,
337
+ baseUrl,
338
+ method: "browser",
339
+ output: [
340
+ `${style.green("✔")} Logged in to ${style.bold(baseUrl)}${who === undefined ? "" : ` as ${style.bold(who)}`}`,
341
+ style.dim(` ${projectsVisible(total)} · ${renewal}`),
342
+ ].join("\n"),
343
+ };
344
+ };
345
+
346
+ export async function login(options: LoginOptions = {}): Promise<LoginResult> {
347
+ const environment = options.environment ?? process.env;
348
+ const write = options.write ?? (() => undefined);
349
+ const baseUrl = normalizeBaseUrl(resolveBaseUrl(options, environment));
350
+ const environmentKey = options.browser === undefined ? environment.TOLOKA_API_KEY : undefined;
351
+ if (options.apiKey !== undefined || environmentKey !== undefined) {
352
+ return loginWithApiKey(options, environment, baseUrl, write);
353
+ }
354
+ const provider = browserLoginProviderFor(baseUrl);
355
+ if (provider === undefined) {
356
+ write(`Browser login is not available for ${baseUrl}, so an API key is needed.`);
357
+ return loginWithApiKey(options, environment, baseUrl, write);
358
+ }
359
+ const choice = await chooseSignIn(options, provider);
360
+ return choice.method === "apiKey"
361
+ ? loginWithApiKey(options, environment, baseUrl, write)
362
+ : loginInBrowser(options, environment, baseUrl, provider, choice, write);
190
363
  }
191
364
 
192
- export type LogoutResult = { readonly removed: readonly string[]; readonly output: string };
365
+ export type LogoutOptions = {
366
+ readonly baseUrl?: string;
367
+ readonly fetch?: typeof globalThis.fetch;
368
+ };
369
+
370
+ export type LogoutResult = {
371
+ readonly removed: readonly string[];
372
+ readonly sessionEnded?: boolean;
373
+ readonly output: string;
374
+ };
375
+
376
+ type EndedBrowserLogin = {
377
+ readonly issuer: string;
378
+ readonly ended: boolean;
379
+ readonly path: string | undefined;
380
+ };
193
381
 
194
- export async function logout(environment: CliEnvironment = process.env): Promise<LogoutResult> {
382
+ const endBrowserLogins = async (
383
+ environment: CliEnvironment,
384
+ fetchImplementation: typeof globalThis.fetch | undefined,
385
+ ): Promise<EndedBrowserLogin[]> => {
386
+ const ended: EndedBrowserLogin[] = [];
387
+ for (const provider of KNOWN_BROWSER_LOGIN_PROVIDERS) {
388
+ const session = await readBrowserSession(provider, environment);
389
+ if (session === undefined) continue;
390
+ const ok = await endKeycloakSession(provider, session.refresh_token, fetchImplementation);
391
+ const path = await removeBrowserSession(provider, environment);
392
+ ended.push({ issuer: provider.issuer, ended: ok, path });
393
+ }
394
+ return ended;
395
+ };
396
+
397
+ const browserLogoutLine = (browser: EndedBrowserLogin): string =>
398
+ browser.ended
399
+ ? `Signed out of ${browser.issuer}.`
400
+ : `The login at ${browser.issuer} could not be ended and will expire on its own; the local session was removed.`;
401
+
402
+ export async function logout(
403
+ environment: CliEnvironment = process.env,
404
+ options: LogoutOptions = {},
405
+ ): Promise<LogoutResult> {
406
+ let stored: StoredCredentials | undefined;
407
+ for (const path of storedCredentialsPaths(environment)) {
408
+ const inspected = await inspectCredentialsFile(path);
409
+ if (inspected === "missing") continue;
410
+ stored = typeof inspected === "object" ? inspected.credentials : undefined;
411
+ break;
412
+ }
413
+ const baseUrl = normalizeBaseUrl(resolveBaseUrl(options, environment, stored));
195
414
  const removed = await clearStoredCredentials(environment);
415
+ const browsers = await endBrowserLogins(environment, options.fetch);
416
+ for (const browser of browsers) if (browser.path !== undefined) removed.push(browser.path);
417
+
418
+ if (removed.length === 0) {
419
+ return { removed, output: `No stored credentials found at ${credentialsPath(environment)}.` };
420
+ }
196
421
 
422
+ const apiKeyNote = `This does not revoke the API key. Delete it in ${consoleUrlFor(baseUrl) ?? "the Business Console"} to do that.`;
423
+ const lines = [
424
+ `Removed ${removed.join(", ")}.`,
425
+ ...(stored?.apiKey === undefined ? [] : ["", apiKeyNote]),
426
+ ...browsers.flatMap((browser) => ["", browserLogoutLine(browser)]),
427
+ ];
197
428
  return {
198
429
  removed,
199
- output:
200
- removed.length === 0
201
- ? `No stored credentials found at ${credentialsPath(environment)}.`
202
- : `Removed ${removed.join(", ")}.\n\nThis does not revoke the key. Delete it in console.toloka.ai to do that.`,
430
+ ...(browsers.length === 0 ? {} : { sessionEnded: browsers.every((browser) => browser.ended) }),
431
+ output: lines.join("\n"),
203
432
  };
204
433
  }
205
434
 
@@ -208,9 +437,11 @@ export type AuthStatusOptions = {
208
437
  readonly showToken?: boolean;
209
438
  readonly flagApiKey?: string;
210
439
  readonly flagBaseUrl?: string;
211
- readonly probe?: (credentials: StoredCredentials) => AuthProbe;
440
+ readonly probe?: (config: ClientConfig) => AuthProbe;
212
441
  readonly credentialLookup?: CommandCredentialLookup;
213
442
  readonly project?: ProjectContext;
443
+ readonly fetch?: typeof globalThis.fetch;
444
+ readonly now?: () => number;
214
445
  };
215
446
 
216
447
  export type AuthStatusResult = { readonly authenticated: boolean; readonly output: string };
@@ -221,7 +452,7 @@ const firstDefined = (
221
452
  candidates: ReadonlyArray<ResolvedValue | undefined>,
222
453
  ): ResolvedValue | undefined => candidates.find((candidate) => candidate !== undefined);
223
454
 
224
- const resolveApiKey = (
455
+ const resolveStatusApiKey = (
225
456
  options: AuthStatusOptions,
226
457
  environment: CliEnvironment,
227
458
  found: FoundCredentials | undefined,
@@ -234,7 +465,7 @@ const resolveApiKey = (
234
465
  environment.TOLOKA_API_KEY === undefined
235
466
  ? undefined
236
467
  : { value: environment.TOLOKA_API_KEY, source: "from TOLOKA_API_KEY" },
237
- found === undefined
468
+ found?.credentials.apiKey === undefined
238
469
  ? undefined
239
470
  : { value: found.credentials.apiKey, source: `stored in ${storedPath}` },
240
471
  ]);
@@ -265,12 +496,14 @@ const accountLines = async (probe: AuthProbe): Promise<readonly string[]> => {
265
496
  if (probe.me === undefined) return [];
266
497
  try {
267
498
  const me = await probe.me();
268
- if (me.teams === undefined) return [`Internal: ${me.internal}`];
499
+ const account = typeof me.email === "string" && me.email.length > 0 ? [`Account: ${me.email}`] : [];
500
+ if (me.teams === undefined) return [...account, `Internal: ${me.internal}`];
269
501
  const teams = me.teams.map((team) => {
270
502
  const qualifiers = [...(team.internal ? ["internal"] : []), ...(team.writable ? [] : ["read-only"])];
271
503
  return qualifiers.length === 0 ? team.id : `${team.id} (${qualifiers.join(", ")})`;
272
504
  });
273
505
  return [
506
+ ...account,
274
507
  `Internal: ${me.internal}`,
275
508
  `Teams: ${teams.length === 0 ? "(none)" : teams.join(", ")}`,
276
509
  ];
@@ -279,24 +512,81 @@ const accountLines = async (probe: AuthProbe): Promise<readonly string[]> => {
279
512
  }
280
513
  };
281
514
 
515
+ const describeSession = (session: BrowserSession, now: number): string => {
516
+ if (isRefreshTokenExpired(session, now)) {
517
+ return `expired on ${formatDate(session.refresh_token_expires_at)}; run toloka login`;
518
+ }
519
+ return session.refresh_token_expires_at > 0
520
+ ? `renews automatically; the current refresh window ends ${formatDate(session.refresh_token_expires_at)}`
521
+ : "renews automatically";
522
+ };
523
+
524
+ type BrowserStatus = {
525
+ readonly lines: readonly string[];
526
+ readonly clientConfig?: ClientConfig;
527
+ readonly failure?: string;
528
+ };
529
+
530
+ const browserStatus = async (
531
+ browser: BrowserLogin,
532
+ baseUrl: string,
533
+ environment: CliEnvironment,
534
+ options: AuthStatusOptions,
535
+ now: number,
536
+ ): Promise<BrowserStatus> => {
537
+ const header = `Login: browser (stored in ${browser.path})`;
538
+ const accessToken = createBrowserSessionAuth({
539
+ provider: browser.provider,
540
+ baseUrl,
541
+ environment,
542
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
543
+ ...(options.now === undefined ? {} : { now: options.now }),
544
+ });
545
+ try {
546
+ const token = await accessToken();
547
+ const session = (await readBrowserSession(browser.provider, environment)) ?? browser.session;
548
+ return {
549
+ lines: [
550
+ header,
551
+ `Session: ${describeSession(session, now)}`,
552
+ ...(options.showToken === true ? [`Access token: ${token}`] : []),
553
+ ],
554
+ clientConfig: { baseUrl, accessToken },
555
+ };
556
+ } catch (error) {
557
+ if (error instanceof BrowserLoginExpiredError) {
558
+ return {
559
+ lines: [header, `Session: ${describeSession(browser.session, now)}`],
560
+ failure: STATUS_FAILURE_ADVICE.rejected(baseUrl),
561
+ };
562
+ }
563
+ return {
564
+ lines: [header, "Session: could not be renewed"],
565
+ failure: `Not reachable: ${error instanceof Error ? error.message : String(error)}`,
566
+ };
567
+ }
568
+ };
569
+
282
570
  export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthStatusResult> {
283
571
  const environment = options.environment ?? process.env;
572
+ const now = (options.now ?? unixSeconds)();
284
573
  const credentialLookup =
285
574
  options.credentialLookup ??
286
575
  (await findCommandCredentials(environment, options.flagApiKey ?? environment.TOLOKA_API_KEY));
287
576
  const { found, warning } = credentialLookup;
288
577
  const storedPath = found?.path ?? credentialsPath(environment);
289
- const apiKey = resolveApiKey(options, environment, found, storedPath);
578
+ const apiKey = resolveStatusApiKey(options, environment, found, storedPath);
290
579
  const platform = resolvePlatform(options, environment, found);
580
+ const browser = apiKey === undefined ? await findBrowserLogin(platform.value, environment) : undefined;
291
581
  const projectLine = formatProjectContextLine(options.project, found?.credentials);
292
582
 
293
583
  const lines: string[] = warning === undefined ? [] : [warning, ""];
294
584
 
295
- if (apiKey === undefined) {
585
+ if (apiKey === undefined && browser === undefined) {
296
586
  lines.push(
297
587
  "Not logged in.",
298
588
  "",
299
- "Run toloka login to store an API key for this machine.",
589
+ "Run toloka login to sign in through your browser, or store an API key with toloka login --api-key-stdin.",
300
590
  "",
301
591
  projectLine,
302
592
  );
@@ -304,13 +594,26 @@ export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthS
304
594
  }
305
595
 
306
596
  const { value: baseUrl, source: urlSource } = platform;
307
- lines.push(
308
- `Platform: ${baseUrl} (${urlSource})`,
309
- `API key: ${options.showToken === true ? apiKey.value : maskApiKey(apiKey.value)} (${apiKey.source})`,
310
- projectLine,
311
- );
597
+ lines.push(`Platform: ${baseUrl} (${urlSource})`);
598
+ let clientConfig: ClientConfig;
599
+ if (apiKey !== undefined) {
600
+ lines.push(
601
+ `API key: ${options.showToken === true ? apiKey.value : maskApiKey(apiKey.value)} (${apiKey.source})`,
602
+ );
603
+ clientConfig = { baseUrl, apiKey: apiKey.value };
604
+ } else {
605
+ assertCli(browser, "No login is in effect.", { hint: { description: "Run toloka login." } });
606
+ const status = await browserStatus(browser, baseUrl, environment, options, now);
607
+ lines.push(...status.lines);
608
+ if (status.clientConfig === undefined) {
609
+ lines.push(projectLine, "", status.failure ?? "");
610
+ return { authenticated: false, output: lines.join("\n") };
611
+ }
612
+ clientConfig = status.clientConfig;
613
+ }
614
+ lines.push(projectLine);
312
615
 
313
- if (environment.TOLOKA_API_KEY !== undefined && found !== undefined) {
616
+ if (environment.TOLOKA_API_KEY !== undefined && found?.credentials.apiKey !== undefined) {
314
617
  lines.push("", `TOLOKA_API_KEY is set, so the key stored in ${found.path} is ignored.`);
315
618
  }
316
619
  if (found?.legacy === true) {
@@ -320,9 +623,7 @@ export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthS
320
623
  );
321
624
  }
322
625
 
323
- const probe: AuthProbe =
324
- options.probe?.({ baseUrl, apiKey: apiKey.value }) ??
325
- createClient({ baseUrl, apiKey: apiKey.value });
626
+ const probe: AuthProbe = options.probe?.(clientConfig) ?? createClient(clientConfig);
326
627
  try {
327
628
  const page = await probe.projects.listPage({ limit: 1 });
328
629
  lines.push("", `Reachable: ${page.pagination.total} project(s) visible.`);