ts / src / auth.ts
549 words
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
 * Device login flow and API key management. The device endpoints work
 * anonymously; the API key management endpoints require an account JWT
 * (API keys cannot manage API keys).
 */
import type { Client } from './client.js';
import { isApiError } from './errors.js';
import * as http from './http.js';
import type { Body, Res } from './types.js';

/** POST /auth/device/start — Start a device login. Anonymous allowed. */
export const deviceStart = (client: Client) => http.post(client, '/auth/device/start');

/** POST /auth/device/poll — Poll a device login. Anonymous allowed. */
export const devicePoll = (client: Client, deviceCode: string) =>
  http.post(client, '/auth/device/poll', { body: { device_code: deviceCode } });

/** POST /auth/device/complete — Complete a device login. Anonymous allowed. */
export const deviceComplete = (client: Client, deviceCode: string, confirmationCode: string) =>
  http.post(client, '/auth/device/complete', {
    body: { device_code: deviceCode, confirmation_code: confirmationCode },
  });

/** GET /auth/api-keys — List API keys (account JWT only). */
export const listApiKeys = (client: Client) => http.get(client, '/auth/api-keys');

/** POST /auth/api-keys — Create an API key (account JWT only). */
export const createApiKey = (client: Client, body: Body<'/auth/api-keys', 'post'>) =>
  http.post(client, '/auth/api-keys', { body });

/** POST /auth/api-keys/{id}/revoke — Revoke an API key (account JWT only). */
export const revokeApiKey = (client: Client, id: string) =>
  http.post(client, '/auth/api-keys/{id}/revoke', { params: { id } });

export type DeviceStart = Res<'/auth/device/start', 'post'>;

export type DeviceLoginOptions = Readonly<{
  /** Called with the start response so a person can approve in a browser; default prints instructions. */
  onPrompt?: (start: DeviceStart) => void | Promise<void>;
  /** Returns the confirmation code shown after approval; default reads a line from stdin. */
  confirm?: (start: DeviceStart) => string | Promise<string>;
  /** Poll interval; defaults to the server-suggested `interval` (seconds). */
  intervalMs?: number;
  /** Overall deadline; defaults to the server's `expires_in`. */
  timeoutMs?: number;
}>;

const defaultPrompt = (start: DeviceStart): void => {
  console.log(`To sign in, open: ${start.verification_uri_complete ?? start.verification_uri}`);
  console.log(`Code: ${start.user_code}`);
};

const defaultConfirm = async (): Promise<string> => {
  const { createInterface } = await import('node:readline/promises');
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    return (await rl.question('Enter the confirmation code shown after approving: ')).trim();
  } finally {
    rl.close();
  }
};

/**
 * Run the whole device login flow: start, prompt, poll until approved,
 * complete. Returns the completion body; feed its `access_token` to
 * `createClient` as `apiKey`. Throws a timeout error when the login is not
 * approved in time.
 */
export const deviceLogin = async (client: Client, options: DeviceLoginOptions = {}) => {
  const start = await deviceStart(client);
  const { onPrompt = defaultPrompt, confirm = defaultConfirm } = options;
  const intervalMs = options.intervalMs ?? 1000 * (start.interval ?? 5);
  const timeoutMs = options.timeoutMs ?? 1000 * (start.expires_in ?? 600);
  // While the user hasn't approved, the API answers 400 authorization-pending;
  // 429 slow-down asks for a longer interval (pollUntil's backoff grows it).
  // Anything else (401 expired, 403 denied) is terminal and rethrown.
  const poll = async (): Promise<{ approved: boolean }> => {
    try {
      return await devicePoll(client, start.device_code);
    } catch (e) {
      if (isApiError(e) && (e.status === 400 || e.status === 429)) return { approved: false };
      throw e;
    }
  };
  await onPrompt(start);
  await http.pollUntil(poll, (r) => r.approved, {
    intervalMs,
    timeoutMs,
    message: 'Device login timed out before approval',
    sleep: client.sleep,
  });
  return deviceComplete(client, start.device_code, await confirm(start));
};

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/ts.git