@youkno/edge-cli 1.21.339 → 1.21.341
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/README.md +3 -1
- package/dist/commands/login.js +2 -1
- package/dist/commands/shell.js +3 -1
- package/dist/index.js +1 -1
- package/dist/lib/auth.js +85 -10
- package/package.json +1 -1
- package/src/commands/login.ts +2 -1
- package/src/commands/shell.ts +4 -2
- package/src/index.ts +1 -1
- package/src/lib/auth.ts +96 -11
- package/src/lib/types.ts +9 -0
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ Login creates **two** sessions from the one sign-in: a shell session that drives
|
|
|
52
52
|
|
|
53
53
|
Prefer the API-only token anywhere a token leaves the CLI (Postman, scripts, a colleague's terminal): a `--scope shell` token can run any shell command your account is allowed to, against whatever environment it was issued for. If you logged in before this existed, `edge-cli token` will tell you to log in again rather than hand back the shell-capable one.
|
|
54
54
|
|
|
55
|
+
To sign in as a member of staff of a particular company, pass its client ID (pin): `edge-cli --client 101 login`. Both sessions are then exchanged for your row in that company, which is where roles such as shell access live once staff accounts are split per company. Without `--client` you sign in as yourself, as before. Only the flag on the `login` command counts. `CLIENT_ID` in `.edge-api` does not, and the REPL's `/login` ignores `--client`: it re-authenticates whatever identity the current session already is, keeping its company when the stored login is `email#clientId`. So a `--client` given for shop context never switches identity. The company session is stored beside your own login as `email#clientId` (for example `ops@example.com#101`), never over it, and becomes the default. `edge-cli login:use <email>` switches back to yourself. A stored session keeps the identity it was created with, so to change company, log in again with a different `--client`.
|
|
56
|
+
|
|
55
57
|
```bash
|
|
56
58
|
edge-cli login:list # see saved accounts
|
|
57
59
|
edge-cli login:use <email> # switch default account
|
|
@@ -105,7 +107,7 @@ edge-cli product:default --clear
|
|
|
105
107
|
| `--product <name>` | Override product (default from `~/.edge-api`) |
|
|
106
108
|
| `--env <env>` | `prod`, `devel`, or `devlocal` (default: `prod`) |
|
|
107
109
|
| `--base-url <url>` | Override API URL |
|
|
108
|
-
| `--client <clientId>` | Client ID for client-scoped operations |
|
|
110
|
+
| `--client <clientId>` | Client ID for client-scoped operations; with `login`, the company to sign in as staff of |
|
|
109
111
|
| `--config-path <path>` | Additional config file path |
|
|
110
112
|
|
|
111
113
|
## Help
|
package/dist/commands/login.js
CHANGED
|
@@ -13,7 +13,8 @@ function registerLogin(program) {
|
|
|
13
13
|
const parent = this.parent;
|
|
14
14
|
const opts = (parent?.opts() ?? {});
|
|
15
15
|
const cfg = await (0, config_1.resolveEffectiveConfig)(opts);
|
|
16
|
-
|
|
16
|
+
// --client selects the company to sign in as, here and only here: the explicit login.
|
|
17
|
+
const email = await (0, auth_1.login)({ ...cfg, exchangeClientId: opts.clientId?.trim() || undefined });
|
|
17
18
|
process.stdout.write(`Successfully authenticated as ${email}\n`);
|
|
18
19
|
if (this.opts().shell) {
|
|
19
20
|
await (0, shell_1.repl)(cfg, {});
|
package/dist/commands/shell.js
CHANGED
|
@@ -110,7 +110,9 @@ const REPL_COMMANDS = new Map([
|
|
|
110
110
|
// since resolveAccessToken re-reads the token store on every command, the very next line
|
|
111
111
|
// picks up the new session with nothing to invalidate.
|
|
112
112
|
run: async (cfg) => {
|
|
113
|
-
|
|
113
|
+
// Re-authenticates the identity the session already is: a company login (`email#101`)
|
|
114
|
+
// stays that company's. Taken from the stored default, never from a shop-context --client.
|
|
115
|
+
const email = await (0, auth_1.login)({ ...cfg, exchangeClientId: (0, auth_1.defaultLoginClientId)(cfg) });
|
|
114
116
|
process.stdout.write(`Successfully authenticated as ${email}\n`);
|
|
115
117
|
}
|
|
116
118
|
}],
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ program
|
|
|
18
18
|
.option("-p, --product <product>", "Product key (youkno, stash, alleaves, ...)")
|
|
19
19
|
.option("--env <env>", "Environment (devlocal|devel|prod)", "prod")
|
|
20
20
|
.option("--base-url <url>", "Override API base URL")
|
|
21
|
-
.option("--client <clientId>", "Client ID")
|
|
21
|
+
.option("--client <clientId>", "Client ID (with login: the company to sign in as staff of)")
|
|
22
22
|
.option("--config-path <path>", "Additional edge-api style config file")
|
|
23
23
|
.hook("preAction", (command) => {
|
|
24
24
|
const env = command.opts().env;
|
package/dist/lib/auth.js
CHANGED
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.ShellAuthMethodError = void 0;
|
|
7
7
|
exports.listLogins = listLogins;
|
|
8
8
|
exports.setDefaultLogin = setDefaultLogin;
|
|
9
|
+
exports.defaultLoginClientId = defaultLoginClientId;
|
|
9
10
|
exports.logout = logout;
|
|
10
11
|
exports.login = login;
|
|
11
12
|
exports.resolveAccessToken = resolveAccessToken;
|
|
@@ -115,6 +116,24 @@ function setDefaultLogin(cfg, email) {
|
|
|
115
116
|
scope.default = email;
|
|
116
117
|
writeDb(db);
|
|
117
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* The key a login is stored under: the email for the person's own session, `email#clientId` for a
|
|
121
|
+
* session exchanged into a company with `--client`, so one never overwrites the other.
|
|
122
|
+
*/
|
|
123
|
+
function accountKeyFor(email, clientId) {
|
|
124
|
+
return clientId ? `${email}#${clientId}` : email;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The client ID the default stored login was exchanged into, from its `email#clientId` key, or
|
|
128
|
+
* undefined for the person's own login. Lets the REPL's `/login` re-authenticate the same identity
|
|
129
|
+
* it is running as -- taken from the stored session, never from a `--client` given for shop context.
|
|
130
|
+
*/
|
|
131
|
+
function defaultLoginClientId(cfg) {
|
|
132
|
+
const key = getScope(readDb(), cfg).default ?? "";
|
|
133
|
+
const hash = key.lastIndexOf("#");
|
|
134
|
+
const clientId = hash > 0 ? key.slice(hash + 1) : "";
|
|
135
|
+
return /^[A-Za-z0-9_-]+$/.test(clientId) ? clientId : undefined;
|
|
136
|
+
}
|
|
118
137
|
function authStateHeader(cfg) {
|
|
119
138
|
return `${cfg.product}.${cfg.env}`;
|
|
120
139
|
}
|
|
@@ -221,6 +240,36 @@ function asShellAuthMethodError(err, hint) {
|
|
|
221
240
|
const detail = err.problem.detail ?? err.problem.title ?? "Shell access requires a different sign-in method.";
|
|
222
241
|
return new ShellAuthMethodError(`${detail}${hint}`);
|
|
223
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* The server's `errorCode`s for an exchange whose subject cannot be selected. Mirror
|
|
245
|
+
* `ApiErrorCode.NOT_A_MEMBER`, `VULGUS_NOT_SELECTABLE` and `VIRTUAL_CREDENTIAL_REFUSED`.
|
|
246
|
+
*/
|
|
247
|
+
const ERROR_CODE_NOT_A_MEMBER = 403012;
|
|
248
|
+
const ERROR_CODE_VULGUS_NOT_SELECTABLE = 400006;
|
|
249
|
+
const ERROR_CODE_VIRTUAL_CREDENTIAL_REFUSED = 400007;
|
|
250
|
+
/**
|
|
251
|
+
* A `--client` refusal as an error that says what to do, or undefined when this failure is
|
|
252
|
+
* something else. Matched on errorCode, never on the prose.
|
|
253
|
+
*/
|
|
254
|
+
function asCompanySelectionError(err, cfg) {
|
|
255
|
+
if (!(err instanceof AuthEndpointError)) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
switch (err.problem?.errorCode) {
|
|
259
|
+
case ERROR_CODE_NOT_A_MEMBER:
|
|
260
|
+
return new Error(`Login failed: you are not a member of client ${cfg.exchangeClientId} ` +
|
|
261
|
+
`on ${cfg.product}. Check --client, or omit it to sign in as yourself.`, { cause: err });
|
|
262
|
+
case ERROR_CODE_VULGUS_NOT_SELECTABLE:
|
|
263
|
+
return new Error(`Login failed: --client ${cfg.exchangeClientId} is the Vulgus company, ` +
|
|
264
|
+
`which is your own account. Omit --client.`, { cause: err });
|
|
265
|
+
case ERROR_CODE_VIRTUAL_CREDENTIAL_REFUSED:
|
|
266
|
+
return new Error("Login failed: the console handed over a company session's Firebase token. " +
|
|
267
|
+
"It must hand over the person's own Firebase token, not the company session's; " +
|
|
268
|
+
"sign out of the console, sign in again, and retry.", { cause: err });
|
|
269
|
+
default:
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
224
273
|
async function requestTokenEndpoint(cfg, endpoint, payload) {
|
|
225
274
|
const url = `${cfg.baseUrl.replace(/\/$/, "")}/api/v1/auth/${endpoint}`;
|
|
226
275
|
const response = await fetch(url, {
|
|
@@ -240,13 +289,24 @@ async function requestTokenEndpoint(cfg, endpoint, payload) {
|
|
|
240
289
|
}
|
|
241
290
|
return (await response.json());
|
|
242
291
|
}
|
|
243
|
-
|
|
244
|
-
|
|
292
|
+
/**
|
|
293
|
+
* The body of a token exchange. `clientId` is sent only when `--client` was given, so the server
|
|
294
|
+
* makes the person's row in that company the subject (after the virtual-users migration, staff of
|
|
295
|
+
* the Operations company hold SHELL_ACCESS there, not on their own row). Absent, the exchange is
|
|
296
|
+
* exactly what it was before the field existed. Like `scope`, it is sent on exchange only: a refresh
|
|
297
|
+
* re-mints whatever subject the session was created for.
|
|
298
|
+
*/
|
|
299
|
+
function buildExchangePayload(cfg, firebaseToken, scope, deviceId) {
|
|
300
|
+
return {
|
|
245
301
|
firebaseToken,
|
|
246
|
-
deviceId
|
|
302
|
+
deviceId,
|
|
247
303
|
deviceName: DEFAULT_DEVICE_NAME,
|
|
248
|
-
...(scope === null ? {} : { scope })
|
|
249
|
-
|
|
304
|
+
...(scope === null ? {} : { scope }),
|
|
305
|
+
...(cfg.exchangeClientId ? { clientId: cfg.exchangeClientId } : {})
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
async function authExchangeFirebaseToken(cfg, firebaseToken, scope = EXCHANGE_SCOPE_SHELL) {
|
|
309
|
+
const resp = await requestTokenEndpoint(cfg, "exchange", buildExchangePayload(cfg, firebaseToken, scope, authDeviceId()));
|
|
250
310
|
if (!resp?.accessToken) {
|
|
251
311
|
throw new Error("Token exchange did not return accessToken");
|
|
252
312
|
}
|
|
@@ -293,6 +353,10 @@ async function exchangeCallbackToken(cfg, callbackToken) {
|
|
|
293
353
|
if (refusal) {
|
|
294
354
|
throw refusal;
|
|
295
355
|
}
|
|
356
|
+
const companyRefusal = asCompanySelectionError(err, cfg);
|
|
357
|
+
if (companyRefusal) {
|
|
358
|
+
throw companyRefusal;
|
|
359
|
+
}
|
|
296
360
|
const reason = err instanceof Error ? err.message : String(err);
|
|
297
361
|
// The identity hint only fits a 401. On a network error or a 5xx, telling someone to check
|
|
298
362
|
// which account they used is misdirection -- the same species of misleading report this
|
|
@@ -530,10 +594,17 @@ async function login(cfg) {
|
|
|
530
594
|
let firebaseToken;
|
|
531
595
|
if (parsed.firebaseToken) {
|
|
532
596
|
firebaseToken = parsed.firebaseToken;
|
|
533
|
-
tokenResp = await
|
|
597
|
+
tokenResp = await exchangeCallbackToken(cfg, firebaseToken);
|
|
534
598
|
}
|
|
535
599
|
else if (parsed.accessToken && parsed.refreshToken) {
|
|
536
|
-
// Already a first-party pair; nothing to exchange.
|
|
600
|
+
// Already a first-party pair; nothing to exchange -- and so nothing to apply --client to.
|
|
601
|
+
// Refused rather than stored: the pair is for whichever identity the console chose, and
|
|
602
|
+
// saving it under a login that asked for a company would sign in as the wrong one.
|
|
603
|
+
if (cfg.exchangeClientId) {
|
|
604
|
+
throw new Error(`Login failed: --client ${cfg.exchangeClientId} could not be applied because the ` +
|
|
605
|
+
"console returned a finished session instead of a Firebase token to exchange. " +
|
|
606
|
+
"Log in without --client, or from a console that returns a Firebase token.");
|
|
607
|
+
}
|
|
537
608
|
tokenResp = {
|
|
538
609
|
accessToken: parsed.accessToken,
|
|
539
610
|
refreshToken: parsed.refreshToken,
|
|
@@ -550,8 +621,11 @@ async function login(cfg) {
|
|
|
550
621
|
throw new Error("Authentication callback did not include usable token data.");
|
|
551
622
|
}
|
|
552
623
|
const email = parsed.email ?? extractEmailFromToken(tokenResp.accessToken);
|
|
553
|
-
|
|
554
|
-
|
|
624
|
+
// A company session is stored beside the person's own, never over it: `email#clientId`. It
|
|
625
|
+
// becomes the default like any fresh login; `login:use <email>` switches back.
|
|
626
|
+
const accountKey = accountKeyFor(email, cfg.exchangeClientId);
|
|
627
|
+
storeTokens(cfg, accountKey, tokenResp, await exchangeApiSession(cfg, firebaseToken));
|
|
628
|
+
return accountKey;
|
|
555
629
|
}
|
|
556
630
|
/**
|
|
557
631
|
* Exchange the same Firebase token a second time for an API-only session.
|
|
@@ -578,7 +652,8 @@ async function exchangeApiSession(cfg, firebaseToken) {
|
|
|
578
652
|
return resp;
|
|
579
653
|
}
|
|
580
654
|
catch (err) {
|
|
581
|
-
const reason = err
|
|
655
|
+
const reason = asCompanySelectionError(err, cfg)?.message
|
|
656
|
+
?? (err instanceof Error ? err.message : String(err));
|
|
582
657
|
process.stderr.write(`Warning: could not create an API-only session (${reason}).\n` +
|
|
583
658
|
`'edge-cli token' will need --scope shell until the next successful login.\n`);
|
|
584
659
|
return undefined;
|
package/package.json
CHANGED
package/src/commands/login.ts
CHANGED
|
@@ -14,7 +14,8 @@ export function registerLogin(program: Command): void {
|
|
|
14
14
|
const parent = this.parent;
|
|
15
15
|
const opts = (parent?.opts() ?? {}) as CliOptions;
|
|
16
16
|
const cfg = await resolveEffectiveConfig(opts);
|
|
17
|
-
|
|
17
|
+
// --client selects the company to sign in as, here and only here: the explicit login.
|
|
18
|
+
const email = await login({ ...cfg, exchangeClientId: opts.clientId?.trim() || undefined });
|
|
18
19
|
process.stdout.write(`Successfully authenticated as ${email}\n`);
|
|
19
20
|
if (this.opts<{ shell?: boolean }>().shell) {
|
|
20
21
|
await repl(cfg, {});
|
package/src/commands/shell.ts
CHANGED
|
@@ -4,7 +4,7 @@ import readline from "node:readline";
|
|
|
4
4
|
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
|
-
import { login, resolveAuthHeader, resolveAuthHeaderFast } from "../lib/auth";
|
|
7
|
+
import { defaultLoginClientId, login, resolveAuthHeader, resolveAuthHeaderFast } from "../lib/auth";
|
|
8
8
|
import { resolveEffectiveConfig } from "../lib/config";
|
|
9
9
|
import { baseHeaders, checkedText } from "../lib/request";
|
|
10
10
|
import { CliOptions, EffectiveConfig } from "../lib/types";
|
|
@@ -154,7 +154,9 @@ const REPL_COMMANDS = new Map<string, ReplCommand>([
|
|
|
154
154
|
// since resolveAccessToken re-reads the token store on every command, the very next line
|
|
155
155
|
// picks up the new session with nothing to invalidate.
|
|
156
156
|
run: async (cfg) => {
|
|
157
|
-
|
|
157
|
+
// Re-authenticates the identity the session already is: a company login (`email#101`)
|
|
158
|
+
// stays that company's. Taken from the stored default, never from a shop-context --client.
|
|
159
|
+
const email = await login({ ...cfg, exchangeClientId: defaultLoginClientId(cfg) });
|
|
158
160
|
process.stdout.write(`Successfully authenticated as ${email}\n`);
|
|
159
161
|
}
|
|
160
162
|
}],
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ program
|
|
|
21
21
|
.option("-p, --product <product>", "Product key (youkno, stash, alleaves, ...)")
|
|
22
22
|
.option("--env <env>", "Environment (devlocal|devel|prod)", "prod")
|
|
23
23
|
.option("--base-url <url>", "Override API base URL")
|
|
24
|
-
.option("--client <clientId>", "Client ID")
|
|
24
|
+
.option("--client <clientId>", "Client ID (with login: the company to sign in as staff of)")
|
|
25
25
|
.option("--config-path <path>", "Additional edge-api style config file")
|
|
26
26
|
.hook("preAction", (command) => {
|
|
27
27
|
const env = command.opts().env as string;
|
package/src/lib/auth.ts
CHANGED
|
@@ -153,6 +153,26 @@ export function setDefaultLogin(cfg: EffectiveConfig, email: string): void {
|
|
|
153
153
|
writeDb(db);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The key a login is stored under: the email for the person's own session, `email#clientId` for a
|
|
158
|
+
* session exchanged into a company with `--client`, so one never overwrites the other.
|
|
159
|
+
*/
|
|
160
|
+
function accountKeyFor(email: string, clientId: string | undefined): string {
|
|
161
|
+
return clientId ? `${email}#${clientId}` : email;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The client ID the default stored login was exchanged into, from its `email#clientId` key, or
|
|
166
|
+
* undefined for the person's own login. Lets the REPL's `/login` re-authenticate the same identity
|
|
167
|
+
* it is running as -- taken from the stored session, never from a `--client` given for shop context.
|
|
168
|
+
*/
|
|
169
|
+
export function defaultLoginClientId(cfg: EffectiveConfig): string | undefined {
|
|
170
|
+
const key = getScope(readDb(), cfg).default ?? "";
|
|
171
|
+
const hash = key.lastIndexOf("#");
|
|
172
|
+
const clientId = hash > 0 ? key.slice(hash + 1) : "";
|
|
173
|
+
return /^[A-Za-z0-9_-]+$/.test(clientId) ? clientId : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
function authStateHeader(cfg: EffectiveConfig): string {
|
|
157
177
|
return `${cfg.product}.${cfg.env}`;
|
|
158
178
|
}
|
|
@@ -273,6 +293,38 @@ function asShellAuthMethodError(err: unknown, hint: string): ShellAuthMethodErro
|
|
|
273
293
|
return new ShellAuthMethodError(`${detail}${hint}`);
|
|
274
294
|
}
|
|
275
295
|
|
|
296
|
+
/**
|
|
297
|
+
* The server's `errorCode`s for an exchange whose subject cannot be selected. Mirror
|
|
298
|
+
* `ApiErrorCode.NOT_A_MEMBER`, `VULGUS_NOT_SELECTABLE` and `VIRTUAL_CREDENTIAL_REFUSED`.
|
|
299
|
+
*/
|
|
300
|
+
const ERROR_CODE_NOT_A_MEMBER = 403012;
|
|
301
|
+
const ERROR_CODE_VULGUS_NOT_SELECTABLE = 400006;
|
|
302
|
+
const ERROR_CODE_VIRTUAL_CREDENTIAL_REFUSED = 400007;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A `--client` refusal as an error that says what to do, or undefined when this failure is
|
|
306
|
+
* something else. Matched on errorCode, never on the prose.
|
|
307
|
+
*/
|
|
308
|
+
function asCompanySelectionError(err: unknown, cfg: EffectiveConfig): Error | undefined {
|
|
309
|
+
if (!(err instanceof AuthEndpointError)) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
switch (err.problem?.errorCode) {
|
|
313
|
+
case ERROR_CODE_NOT_A_MEMBER:
|
|
314
|
+
return new Error(`Login failed: you are not a member of client ${cfg.exchangeClientId} ` +
|
|
315
|
+
`on ${cfg.product}. Check --client, or omit it to sign in as yourself.`, { cause: err });
|
|
316
|
+
case ERROR_CODE_VULGUS_NOT_SELECTABLE:
|
|
317
|
+
return new Error(`Login failed: --client ${cfg.exchangeClientId} is the Vulgus company, ` +
|
|
318
|
+
`which is your own account. Omit --client.`, { cause: err });
|
|
319
|
+
case ERROR_CODE_VIRTUAL_CREDENTIAL_REFUSED:
|
|
320
|
+
return new Error("Login failed: the console handed over a company session's Firebase token. " +
|
|
321
|
+
"It must hand over the person's own Firebase token, not the company session's; " +
|
|
322
|
+
"sign out of the console, sign in again, and retry.", { cause: err });
|
|
323
|
+
default:
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
276
328
|
async function requestTokenEndpoint(
|
|
277
329
|
cfg: EffectiveConfig,
|
|
278
330
|
endpoint: "exchange" | "refresh" | "logout",
|
|
@@ -304,17 +356,35 @@ async function requestTokenEndpoint(
|
|
|
304
356
|
return (await response.json()) as TokenResponse;
|
|
305
357
|
}
|
|
306
358
|
|
|
359
|
+
/**
|
|
360
|
+
* The body of a token exchange. `clientId` is sent only when `--client` was given, so the server
|
|
361
|
+
* makes the person's row in that company the subject (after the virtual-users migration, staff of
|
|
362
|
+
* the Operations company hold SHELL_ACCESS there, not on their own row). Absent, the exchange is
|
|
363
|
+
* exactly what it was before the field existed. Like `scope`, it is sent on exchange only: a refresh
|
|
364
|
+
* re-mints whatever subject the session was created for.
|
|
365
|
+
*/
|
|
366
|
+
function buildExchangePayload(
|
|
367
|
+
cfg: Pick<EffectiveConfig, "exchangeClientId">,
|
|
368
|
+
firebaseToken: string,
|
|
369
|
+
scope: string | null,
|
|
370
|
+
deviceId: string
|
|
371
|
+
): Record<string, unknown> {
|
|
372
|
+
return {
|
|
373
|
+
firebaseToken,
|
|
374
|
+
deviceId,
|
|
375
|
+
deviceName: DEFAULT_DEVICE_NAME,
|
|
376
|
+
...(scope === null ? {} : { scope }),
|
|
377
|
+
...(cfg.exchangeClientId ? { clientId: cfg.exchangeClientId } : {})
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
307
381
|
async function authExchangeFirebaseToken(
|
|
308
382
|
cfg: EffectiveConfig,
|
|
309
383
|
firebaseToken: string,
|
|
310
384
|
scope: string | null = EXCHANGE_SCOPE_SHELL
|
|
311
385
|
): Promise<TokenResponse> {
|
|
312
|
-
const resp = await requestTokenEndpoint(cfg, "exchange",
|
|
313
|
-
firebaseToken,
|
|
314
|
-
deviceId: authDeviceId(),
|
|
315
|
-
deviceName: DEFAULT_DEVICE_NAME,
|
|
316
|
-
...(scope === null ? {} : { scope })
|
|
317
|
-
});
|
|
386
|
+
const resp = await requestTokenEndpoint(cfg, "exchange",
|
|
387
|
+
buildExchangePayload(cfg, firebaseToken, scope, authDeviceId()));
|
|
318
388
|
if (!resp?.accessToken) {
|
|
319
389
|
throw new Error("Token exchange did not return accessToken");
|
|
320
390
|
}
|
|
@@ -363,6 +433,10 @@ async function exchangeCallbackToken(cfg: EffectiveConfig, callbackToken: string
|
|
|
363
433
|
if (refusal) {
|
|
364
434
|
throw refusal;
|
|
365
435
|
}
|
|
436
|
+
const companyRefusal = asCompanySelectionError(err, cfg);
|
|
437
|
+
if (companyRefusal) {
|
|
438
|
+
throw companyRefusal;
|
|
439
|
+
}
|
|
366
440
|
|
|
367
441
|
const reason = err instanceof Error ? err.message : String(err);
|
|
368
442
|
// The identity hint only fits a 401. On a network error or a 5xx, telling someone to check
|
|
@@ -635,9 +709,16 @@ export async function login(cfg: EffectiveConfig): Promise<string> {
|
|
|
635
709
|
let firebaseToken: string | undefined;
|
|
636
710
|
if (parsed.firebaseToken) {
|
|
637
711
|
firebaseToken = parsed.firebaseToken;
|
|
638
|
-
tokenResp = await
|
|
712
|
+
tokenResp = await exchangeCallbackToken(cfg, firebaseToken);
|
|
639
713
|
} else if (parsed.accessToken && parsed.refreshToken) {
|
|
640
|
-
// Already a first-party pair; nothing to exchange.
|
|
714
|
+
// Already a first-party pair; nothing to exchange -- and so nothing to apply --client to.
|
|
715
|
+
// Refused rather than stored: the pair is for whichever identity the console chose, and
|
|
716
|
+
// saving it under a login that asked for a company would sign in as the wrong one.
|
|
717
|
+
if (cfg.exchangeClientId) {
|
|
718
|
+
throw new Error(`Login failed: --client ${cfg.exchangeClientId} could not be applied because the ` +
|
|
719
|
+
"console returned a finished session instead of a Firebase token to exchange. " +
|
|
720
|
+
"Log in without --client, or from a console that returns a Firebase token.");
|
|
721
|
+
}
|
|
641
722
|
tokenResp = {
|
|
642
723
|
accessToken: parsed.accessToken,
|
|
643
724
|
refreshToken: parsed.refreshToken,
|
|
@@ -655,8 +736,11 @@ export async function login(cfg: EffectiveConfig): Promise<string> {
|
|
|
655
736
|
}
|
|
656
737
|
|
|
657
738
|
const email = parsed.email ?? extractEmailFromToken(tokenResp.accessToken);
|
|
658
|
-
|
|
659
|
-
|
|
739
|
+
// A company session is stored beside the person's own, never over it: `email#clientId`. It
|
|
740
|
+
// becomes the default like any fresh login; `login:use <email>` switches back.
|
|
741
|
+
const accountKey = accountKeyFor(email, cfg.exchangeClientId);
|
|
742
|
+
storeTokens(cfg, accountKey, tokenResp, await exchangeApiSession(cfg, firebaseToken));
|
|
743
|
+
return accountKey;
|
|
660
744
|
}
|
|
661
745
|
|
|
662
746
|
/**
|
|
@@ -688,7 +772,8 @@ async function exchangeApiSession(
|
|
|
688
772
|
}
|
|
689
773
|
return resp;
|
|
690
774
|
} catch (err) {
|
|
691
|
-
const reason = err
|
|
775
|
+
const reason = asCompanySelectionError(err, cfg)?.message
|
|
776
|
+
?? (err instanceof Error ? err.message : String(err));
|
|
692
777
|
process.stderr.write(
|
|
693
778
|
`Warning: could not create an API-only session (${reason}).\n` +
|
|
694
779
|
`'edge-cli token' will need --scope shell until the next successful login.\n`
|
package/src/lib/types.ts
CHANGED
|
@@ -29,5 +29,14 @@ export type EffectiveConfig = {
|
|
|
29
29
|
baseUrl: string;
|
|
30
30
|
consoleBaseUrl?: string;
|
|
31
31
|
clientId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The company to sign in as a member of staff of, sent as `clientId` on the token exchange.
|
|
34
|
+
* Never set by `resolveEffectiveConfig`: only the explicit `login` command fills it, from an
|
|
35
|
+
* explicit `--client`. Not from `CLIENT_ID` in a config file (that exists for client-scoped
|
|
36
|
+
* commands such as `upload-users`), and not for the REPL's `/login`, so a `--client` given for
|
|
37
|
+
* shop context never switches which identity a session signs in as. Unset means the person's
|
|
38
|
+
* own row, as before.
|
|
39
|
+
*/
|
|
40
|
+
exchangeClientId?: string;
|
|
32
41
|
configFiles: string[];
|
|
33
42
|
};
|