@aderis-erp/cli 1.1.0 → 1.1.2
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/dist/commands/access-groups.d.ts +8 -0
- package/dist/commands/access-groups.js +56 -8
- package/dist/commands/account-transaction-allocations.d.ts +8 -0
- package/dist/commands/account-transaction-allocations.js +52 -5
- package/dist/commands/account-transactions.d.ts +8 -0
- package/dist/commands/account-transactions.js +55 -7
- package/dist/commands/accounts.d.ts +8 -0
- package/dist/commands/accounts.js +52 -5
- package/dist/commands/agent-platform.d.ts +8 -0
- package/dist/commands/agent-platform.js +52 -5
- package/dist/commands/agent-runs.d.ts +8 -0
- package/dist/commands/agent-runs.js +52 -5
- package/dist/commands/analytics.d.ts +8 -0
- package/dist/commands/analytics.js +52 -5
- package/dist/commands/api-keys.d.ts +8 -0
- package/dist/commands/api-keys.js +52 -5
- package/dist/commands/async-jobs.d.ts +8 -0
- package/dist/commands/async-jobs.js +52 -5
- package/dist/commands/audit-log.d.ts +8 -0
- package/dist/commands/audit-log.js +52 -5
- package/dist/commands/bank-accounts.d.ts +8 -0
- package/dist/commands/bank-accounts.js +52 -5
- package/dist/commands/bank-integrations.d.ts +8 -0
- package/dist/commands/bank-integrations.js +52 -5
- package/dist/commands/business-partners.d.ts +9 -0
- package/dist/commands/business-partners.js +66 -9
- package/dist/commands/chat.d.ts +8 -0
- package/dist/commands/chat.js +52 -5
- package/dist/commands/credit-card-accounts.d.ts +8 -0
- package/dist/commands/credit-card-accounts.js +52 -5
- package/dist/commands/delivery-notes.d.ts +8 -0
- package/dist/commands/delivery-notes.js +52 -5
- package/dist/commands/dunning-configs.d.ts +8 -0
- package/dist/commands/dunning-configs.js +52 -5
- package/dist/commands/dunning-reminders.d.ts +8 -0
- package/dist/commands/dunning-reminders.js +52 -5
- package/dist/commands/dunning-runs.d.ts +8 -0
- package/dist/commands/dunning-runs.js +52 -5
- package/dist/commands/email-transactions.d.ts +8 -0
- package/dist/commands/email-transactions.js +52 -5
- package/dist/commands/employees.d.ts +8 -0
- package/dist/commands/employees.js +52 -5
- package/dist/commands/entity-configurations.d.ts +8 -0
- package/dist/commands/entity-configurations.js +52 -5
- package/dist/commands/event-processor-mappings.d.ts +8 -0
- package/dist/commands/event-processor-mappings.js +52 -5
- package/dist/commands/files.d.ts +8 -0
- package/dist/commands/files.js +53 -6
- package/dist/commands/health.d.ts +8 -0
- package/dist/commands/health.js +52 -5
- package/dist/commands/imports.d.ts +8 -0
- package/dist/commands/imports.js +53 -8
- package/dist/commands/inbox.d.ts +8 -0
- package/dist/commands/inbox.js +53 -7
- package/dist/commands/integration-identities.d.ts +8 -0
- package/dist/commands/integration-identities.js +52 -5
- package/dist/commands/integrations.d.ts +8 -0
- package/dist/commands/integrations.js +52 -5
- package/dist/commands/item-categories.d.ts +8 -0
- package/dist/commands/item-categories.js +52 -5
- package/dist/commands/items.d.ts +8 -0
- package/dist/commands/items.js +52 -5
- package/dist/commands/journal-lines.d.ts +8 -0
- package/dist/commands/journal-lines.js +52 -5
- package/dist/commands/journals.d.ts +8 -0
- package/dist/commands/journals.js +52 -5
- package/dist/commands/ledgers.d.ts +8 -0
- package/dist/commands/ledgers.js +57 -9
- package/dist/commands/my-account.d.ts +8 -0
- package/dist/commands/my-account.js +52 -5
- package/dist/commands/office-locations.d.ts +8 -0
- package/dist/commands/office-locations.js +52 -5
- package/dist/commands/payment-methods.d.ts +8 -0
- package/dist/commands/payment-methods.js +52 -5
- package/dist/commands/payment-runs.d.ts +8 -0
- package/dist/commands/payment-runs.js +54 -6
- package/dist/commands/payment-terms.d.ts +8 -0
- package/dist/commands/payment-terms.js +52 -5
- package/dist/commands/payments.d.ts +8 -0
- package/dist/commands/payments.js +52 -5
- package/dist/commands/payroll-runs.d.ts +8 -0
- package/dist/commands/payroll-runs.js +52 -5
- package/dist/commands/permissions.d.ts +8 -0
- package/dist/commands/permissions.js +52 -5
- package/dist/commands/price-lists.d.ts +8 -0
- package/dist/commands/price-lists.js +52 -5
- package/dist/commands/processor-mappings.d.ts +8 -0
- package/dist/commands/processor-mappings.js +52 -5
- package/dist/commands/processors.d.ts +8 -0
- package/dist/commands/processors.js +52 -5
- package/dist/commands/purchase-order-types.d.ts +8 -0
- package/dist/commands/purchase-order-types.js +52 -5
- package/dist/commands/purchase-orders.d.ts +8 -0
- package/dist/commands/purchase-orders.js +52 -5
- package/dist/commands/reports.d.ts +8 -0
- package/dist/commands/reports.js +52 -5
- package/dist/commands/roles.d.ts +8 -0
- package/dist/commands/roles.js +52 -5
- package/dist/commands/salary-types.d.ts +8 -0
- package/dist/commands/salary-types.js +52 -5
- package/dist/commands/sales-invoices.d.ts +10 -0
- package/dist/commands/sales-invoices.js +62 -6
- package/dist/commands/sales-orders.d.ts +8 -0
- package/dist/commands/sales-orders.js +52 -5
- package/dist/commands/saved-views.d.ts +8 -0
- package/dist/commands/saved-views.js +52 -5
- package/dist/commands/scheduled-processor-mappings.d.ts +8 -0
- package/dist/commands/scheduled-processor-mappings.js +52 -5
- package/dist/commands/schedules.d.ts +8 -0
- package/dist/commands/schedules.js +52 -5
- package/dist/commands/support/end.d.ts +9 -0
- package/dist/commands/support/end.js +29 -0
- package/dist/commands/support/start.d.ts +14 -0
- package/dist/commands/support/start.js +50 -0
- package/dist/commands/support/status.d.ts +10 -0
- package/dist/commands/support/status.js +45 -0
- package/dist/commands/support/tenants.d.ts +10 -0
- package/dist/commands/support/tenants.js +39 -0
- package/dist/commands/tags.d.ts +8 -0
- package/dist/commands/tags.js +56 -8
- package/dist/commands/tax-codes.d.ts +8 -0
- package/dist/commands/tax-codes.js +52 -5
- package/dist/commands/tax-matrix.d.ts +8 -0
- package/dist/commands/tax-matrix.js +52 -5
- package/dist/commands/templates.d.ts +8 -0
- package/dist/commands/templates.js +52 -5
- package/dist/commands/tenants.d.ts +8 -0
- package/dist/commands/tenants.js +52 -5
- package/dist/commands/tools.d.ts +8 -0
- package/dist/commands/tools.js +52 -5
- package/dist/commands/users.d.ts +8 -0
- package/dist/commands/users.js +52 -5
- package/dist/commands/vat-declarations.d.ts +8 -0
- package/dist/commands/vat-declarations.js +52 -5
- package/dist/commands/vendor-invoices.d.ts +9 -0
- package/dist/commands/vendor-invoices.js +60 -8
- package/dist/commands/webhooks.d.ts +8 -0
- package/dist/commands/webhooks.js +52 -5
- package/dist/commands/workflows.d.ts +8 -0
- package/dist/commands/workflows.js +52 -5
- package/dist/lib/auth/constants.d.ts +12 -0
- package/dist/lib/auth/constants.js +12 -0
- package/dist/lib/auth/index.d.ts +11 -1
- package/dist/lib/auth/index.js +90 -2
- package/dist/lib/auth/keycloak-provider.d.ts +22 -0
- package/dist/lib/auth/keycloak-provider.js +108 -3
- package/dist/lib/config.d.ts +18 -0
- package/dist/lib/config.js +10 -0
- package/oclif.manifest.json +640 -216
- package/package.json +4 -1
- package/dist/commands/tag-configurations.d.ts +0 -39
- package/dist/commands/tag-configurations.js +0 -1155
|
@@ -39,6 +39,7 @@ export default class Workflows extends Command {
|
|
|
39
39
|
query: Flags.string({ description: 'Additional query params as JSON' }),
|
|
40
40
|
fields: Flags.string({ description: 'Comma-separated list of fields to return, applied by the API (e.g. --fields=id,name,status)' }),
|
|
41
41
|
sanitize: Flags.boolean({ description: 'Remove null values, audit fields, and duplicate UUIDs from output' }),
|
|
42
|
+
meta: Flags.boolean({ description: 'Keep the API envelope in JSON output: data plus page, totals and sums' }),
|
|
42
43
|
'show-http-request': Flags.boolean({ description: 'Show HTTP request details (method, URL, body)' }),
|
|
43
44
|
};
|
|
44
45
|
async run() {
|
|
@@ -331,9 +332,15 @@ export default class Workflows extends Command {
|
|
|
331
332
|
}
|
|
332
333
|
catch { /* keep as string */ }
|
|
333
334
|
}
|
|
334
|
-
//
|
|
335
|
+
// A paginated answer is { data, meta, links, totals }. The records are
|
|
336
|
+
// unwrapped for output, but what the API said about the page is kept:
|
|
337
|
+
// dropping it silently let a script read one page as the whole result
|
|
338
|
+
// (AderisERP/product#61).
|
|
339
|
+
let envelope;
|
|
335
340
|
if (output && typeof output === 'object' && !Array.isArray(output) && 'data' in output && Array.isArray(output.data)) {
|
|
336
|
-
|
|
341
|
+
const page = output;
|
|
342
|
+
envelope = page;
|
|
343
|
+
output = page.data;
|
|
337
344
|
}
|
|
338
345
|
// For table/csv on non-array responses, auto-extract the primary array
|
|
339
346
|
// e.g., { payments: [...], total: 3 } → extract payments array
|
|
@@ -350,12 +357,24 @@ export default class Workflows extends Command {
|
|
|
350
357
|
output = this.sanitizeOutput(output);
|
|
351
358
|
}
|
|
352
359
|
const fieldList = flags.fields?.split(',').map(f => f.trim());
|
|
353
|
-
if (
|
|
360
|
+
if (envelope)
|
|
361
|
+
this.warnShortPage(envelope, flags.limit);
|
|
362
|
+
if (flags.csv || flags.output === 'csv') {
|
|
354
363
|
this.printCsv(output, fieldList);
|
|
355
|
-
|
|
364
|
+
}
|
|
365
|
+
else if (flags.table || flags.output === 'table') {
|
|
356
366
|
this.printTable(output, fieldList);
|
|
357
|
-
|
|
367
|
+
if (envelope)
|
|
368
|
+
this.printPageSummary(envelope);
|
|
369
|
+
}
|
|
370
|
+
else if (envelope && flags.meta) {
|
|
371
|
+
// The whole envelope, for a caller that asked for it; the default stays
|
|
372
|
+
// a bare array so existing scripts are unaffected.
|
|
373
|
+
this.log(JSON.stringify({ ...envelope, data: output }, null, 2));
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
358
376
|
this.log(JSON.stringify(output, null, 2));
|
|
377
|
+
}
|
|
359
378
|
}
|
|
360
379
|
catch (error) {
|
|
361
380
|
if (error instanceof ApiError) {
|
|
@@ -984,6 +1003,34 @@ export default class Workflows extends Command {
|
|
|
984
1003
|
return `\$${op}`;
|
|
985
1004
|
return opRe.test(rest) ? `\$${op}:${this.parseFilterValue(rest)}` : `\$${op}:${rest}`;
|
|
986
1005
|
}
|
|
1006
|
+
/** The page line under a table: which page, how many, and any sums the API returned. */
|
|
1007
|
+
printPageSummary(envelope) {
|
|
1008
|
+
const meta = envelope.meta;
|
|
1009
|
+
if (meta && typeof meta.currentPage === 'number' && typeof meta.totalPages === 'number') {
|
|
1010
|
+
const shown = Array.isArray(envelope.data) ? envelope.data.length : 0;
|
|
1011
|
+
this.log(`
|
|
1012
|
+
Page ${meta.currentPage}/${meta.totalPages} — ${shown} of ${meta.totalItems ?? '?'} records`);
|
|
1013
|
+
}
|
|
1014
|
+
for (const total of envelope.totals ?? []) {
|
|
1015
|
+
const { currency, ...sums } = total;
|
|
1016
|
+
const parts = Object.entries(sums).map(([column, value]) => `${column} ${value}`).join(', ');
|
|
1017
|
+
this.log(`Total ${currency ?? ''}: ${parts}`.trim());
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* The API caps how many records a page may hold. Asking for more and getting
|
|
1022
|
+
* fewer looks like "that is everything" unless it is said out loud.
|
|
1023
|
+
*/
|
|
1024
|
+
warnShortPage(envelope, requestedLimit) {
|
|
1025
|
+
const asked = requestedLimit === undefined ? undefined : Number(requestedLimit);
|
|
1026
|
+
const perPage = envelope.meta?.itemsPerPage;
|
|
1027
|
+
if (asked === undefined || Number.isNaN(asked) || typeof perPage !== 'number' || perPage >= asked)
|
|
1028
|
+
return;
|
|
1029
|
+
const pages = envelope.meta?.totalPages;
|
|
1030
|
+
const more = typeof pages === 'number' && pages > 1 ? ` Use --page, or the export action for every page.` : '';
|
|
1031
|
+
process.stderr.write(`! Asked for ${asked} records per page; the API returned at most ${perPage}.${more}
|
|
1032
|
+
`);
|
|
1033
|
+
}
|
|
987
1034
|
sanitizeOutput(data) {
|
|
988
1035
|
const auditFields = ['_type', 'tenantId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'];
|
|
989
1036
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -9,3 +9,15 @@ export declare const FORM_POST_HEADERS: {
|
|
|
9
9
|
readonly Accept: "application/json";
|
|
10
10
|
readonly 'Content-Type': "application/x-www-form-urlencoded";
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Acting as Aderis Support. The realm mints a second login for the shared
|
|
14
|
+
* support account through a custom grant, taking the operator's own access
|
|
15
|
+
* token as the Bearer. The support token names the customer in `tenantId` and
|
|
16
|
+
* the operator in `impersonatorId`; the backend records that pair on every
|
|
17
|
+
* write (backend/docs/concepts/2026-09-23-support-impersonator-audit-attribution.md).
|
|
18
|
+
*/
|
|
19
|
+
export declare const SUPPORT_SESSION_GRANT_TYPE = "urn:aderis:params:oauth:grant-type:support-session";
|
|
20
|
+
/** Public client of the support session; like the CLI client it holds no secret. */
|
|
21
|
+
export declare const SUPPORT_CLIENT_ID = "aderis-support-session";
|
|
22
|
+
/** Keycloak ends a support session after this long however active it is. */
|
|
23
|
+
export declare const SUPPORT_SESSION_MAX_LIFESPAN_S: number;
|
|
@@ -9,3 +9,15 @@ export const FORM_POST_HEADERS = {
|
|
|
9
9
|
Accept: 'application/json',
|
|
10
10
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Acting as Aderis Support. The realm mints a second login for the shared
|
|
14
|
+
* support account through a custom grant, taking the operator's own access
|
|
15
|
+
* token as the Bearer. The support token names the customer in `tenantId` and
|
|
16
|
+
* the operator in `impersonatorId`; the backend records that pair on every
|
|
17
|
+
* write (backend/docs/concepts/2026-09-23-support-impersonator-audit-attribution.md).
|
|
18
|
+
*/
|
|
19
|
+
export const SUPPORT_SESSION_GRANT_TYPE = 'urn:aderis:params:oauth:grant-type:support-session';
|
|
20
|
+
/** Public client of the support session; like the CLI client it holds no secret. */
|
|
21
|
+
export const SUPPORT_CLIENT_ID = 'aderis-support-session';
|
|
22
|
+
/** Keycloak ends a support session after this long however active it is. */
|
|
23
|
+
export const SUPPORT_SESSION_MAX_LIFESPAN_S = 4 * 60 * 60;
|
package/dist/lib/auth/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type Context, type TokenData } from '../config.js';
|
|
1
|
+
import { type Context, type SupportSession, type TokenData } from '../config.js';
|
|
2
2
|
import { type FlowHooks } from './flow.js';
|
|
3
3
|
import { type Tenant } from './types.js';
|
|
4
|
+
export { type SupportSession } from '../config.js';
|
|
4
5
|
export { CALLBACK_PORT, REDIRECT_URI } from './constants.js';
|
|
5
6
|
export { AuthError, TenantMismatchError } from './errors.js';
|
|
6
7
|
export { type FlowHooks } from './flow.js';
|
|
@@ -17,6 +18,15 @@ export declare function getValidToken(contextName: string): Promise<string>;
|
|
|
17
18
|
* needed) or `x-api-key` for `api_key`. The one place credentials become headers.
|
|
18
19
|
*/
|
|
19
20
|
export declare function getAuthHeaders(contextName: string): Promise<Record<string, string>>;
|
|
21
|
+
/** Customers the operator may act for, as Keycloak lists them. */
|
|
22
|
+
export declare function listSupportTenants(contextName: string): Promise<Tenant[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Starts a support session on the context and stores it beside the operator's
|
|
25
|
+
* own login. Subsequent API calls go out as Aderis Support until it is ended.
|
|
26
|
+
*/
|
|
27
|
+
export declare function startSupportSession(contextName: string, tenantId: string): Promise<SupportSession>;
|
|
28
|
+
/** Ends the running support session; the operator's own login is untouched. */
|
|
29
|
+
export declare function endSupportSession(contextName: string): Promise<null | SupportSession>;
|
|
20
30
|
/** The user `/v1/auth/me` resolved for the session; the fields the CLI shows. */
|
|
21
31
|
export interface AuthenticatedUser {
|
|
22
32
|
email?: string;
|
package/dist/lib/auth/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keycloak; the protocol details live in `keycloak-provider.ts`.
|
|
4
4
|
*/
|
|
5
5
|
import axios from 'axios';
|
|
6
|
-
import { getAuthType, getBaseUrls, getContext, missingCredentialsMessage, saveCachedTenants, saveTokens, SESSION_EXPIRED_MESSAGE, unauthorizedMessage, } from '../config.js';
|
|
6
|
+
import { clearSupportSession, getAuthType, getBaseUrls, getContext, getSupportSession, missingCredentialsMessage, saveCachedTenants, saveSupportSession, saveTokens, SESSION_EXPIRED_MESSAGE, unauthorizedMessage, } from '../config.js';
|
|
7
7
|
import { AuthError, TenantMismatchError } from './errors.js';
|
|
8
8
|
import { runBrowserFlow } from './flow.js';
|
|
9
9
|
import { requireOAuthContext } from './guards.js';
|
|
@@ -15,6 +15,8 @@ export { CALLBACK_PORT, REDIRECT_URI } from './constants.js';
|
|
|
15
15
|
export { AuthError, TenantMismatchError } from './errors.js';
|
|
16
16
|
export { keycloakBaseUrlOf, keycloakProvider, parseKeycloakBaseUrl } from './keycloak-provider.js';
|
|
17
17
|
export { buildValidatedUrl } from './validated-url.js';
|
|
18
|
+
/** A token this close to expiry is refreshed rather than sent. */
|
|
19
|
+
const EXPIRY_BUFFER_MS = 60_000;
|
|
18
20
|
function requireContext(contextName) {
|
|
19
21
|
const context = getContext(contextName);
|
|
20
22
|
if (!context) {
|
|
@@ -82,7 +84,7 @@ export async function getValidToken(contextName) {
|
|
|
82
84
|
if (!context.tokens?.access_token) {
|
|
83
85
|
throw new Error('Not logged in. Please run: aderis auth login');
|
|
84
86
|
}
|
|
85
|
-
if (context.tokens.expires_at && context.tokens.expires_at < Date.now() +
|
|
87
|
+
if (context.tokens.expires_at && context.tokens.expires_at < Date.now() + EXPIRY_BUFFER_MS) {
|
|
86
88
|
if (context.tokens.refresh_token) {
|
|
87
89
|
const newTokens = await refreshTokens(contextName);
|
|
88
90
|
return newTokens.access_token;
|
|
@@ -103,8 +105,94 @@ export async function getAuthHeaders(contextName) {
|
|
|
103
105
|
}
|
|
104
106
|
return { 'x-api-key': context.api_key };
|
|
105
107
|
}
|
|
108
|
+
// While acting as Aderis Support every call goes out as the support account,
|
|
109
|
+
// and the operator is told so — a write made here is attributed to them.
|
|
110
|
+
const support = getSupportSession(context);
|
|
111
|
+
if (support) {
|
|
112
|
+
const token = await validSupportToken(contextName, support);
|
|
113
|
+
warnActingAsSupport(context.name, support);
|
|
114
|
+
return { Authorization: `Bearer ${token}` };
|
|
115
|
+
}
|
|
106
116
|
return { Authorization: `Bearer ${await getValidToken(contextName)}` };
|
|
107
117
|
}
|
|
118
|
+
/** Printed once per process, to stderr so it never lands in piped output. */
|
|
119
|
+
let warnedAboutSupport = false;
|
|
120
|
+
function warnActingAsSupport(contextName, support) {
|
|
121
|
+
if (warnedAboutSupport)
|
|
122
|
+
return;
|
|
123
|
+
warnedAboutSupport = true;
|
|
124
|
+
process.stderr.write(`! Acting as Aderis Support in ${support.tenant_name} (context ${contextName}). ` +
|
|
125
|
+
`Everything you do is recorded against you. End with: aderis support end\n`);
|
|
126
|
+
}
|
|
127
|
+
/** The support access token, refreshed when it is within the expiry buffer. */
|
|
128
|
+
async function validSupportToken(contextName, support) {
|
|
129
|
+
if (support.tokens.expires_at && support.tokens.expires_at < Date.now() + EXPIRY_BUFFER_MS) {
|
|
130
|
+
if (!support.tokens.refresh_token) {
|
|
131
|
+
throw new AuthError('The support session has ended. Start a new one with: aderis support start <tenant-id>');
|
|
132
|
+
}
|
|
133
|
+
const context = requireContext(contextName);
|
|
134
|
+
const refreshed = await keycloakProvider.refreshSupportSession(context, support.tokens.refresh_token);
|
|
135
|
+
const tokens = { ...refreshed, refresh_token: refreshed.refresh_token || support.tokens.refresh_token };
|
|
136
|
+
saveSupportSession(contextName, { ...support, tokens });
|
|
137
|
+
return tokens.access_token;
|
|
138
|
+
}
|
|
139
|
+
return support.tokens.access_token;
|
|
140
|
+
}
|
|
141
|
+
/** Customers the operator may act for, as Keycloak lists them. */
|
|
142
|
+
export async function listSupportTenants(contextName) {
|
|
143
|
+
const context = requireContext(contextName);
|
|
144
|
+
requireOAuthContext(context, 'Support sessions');
|
|
145
|
+
if (getSupportSession(context)) {
|
|
146
|
+
throw new AuthError('A support session is already running. End it first with: aderis support end');
|
|
147
|
+
}
|
|
148
|
+
return keycloakProvider.listSupportTenants(context, await getValidToken(contextName));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Starts a support session on the context and stores it beside the operator's
|
|
152
|
+
* own login. Subsequent API calls go out as Aderis Support until it is ended.
|
|
153
|
+
*/
|
|
154
|
+
export async function startSupportSession(contextName, tenantId) {
|
|
155
|
+
const context = requireContext(contextName);
|
|
156
|
+
requireOAuthContext(context, 'Support sessions');
|
|
157
|
+
if (getSupportSession(context)) {
|
|
158
|
+
throw new AuthError('A support session is already running. End it first with: aderis support end');
|
|
159
|
+
}
|
|
160
|
+
const operatorToken = await getValidToken(contextName);
|
|
161
|
+
const operatorSub = decodedSub(operatorToken);
|
|
162
|
+
const tenants = await keycloakProvider.listSupportTenants(context, operatorToken);
|
|
163
|
+
const chosen = tenants.find((t) => t.tenantId === tenantId);
|
|
164
|
+
if (!chosen) {
|
|
165
|
+
throw new AuthError(`Tenant ${tenantId} is not one you may act for. List them with: aderis support tenants --context ${contextName}`);
|
|
166
|
+
}
|
|
167
|
+
const tokens = await keycloakProvider.startSupportSession(context, operatorToken, tenantId);
|
|
168
|
+
const support = {
|
|
169
|
+
operator_sub: operatorSub,
|
|
170
|
+
tenant_id: tenantId,
|
|
171
|
+
tenant_name: chosen.name,
|
|
172
|
+
tokens,
|
|
173
|
+
};
|
|
174
|
+
saveSupportSession(contextName, support);
|
|
175
|
+
return support;
|
|
176
|
+
}
|
|
177
|
+
/** Ends the running support session; the operator's own login is untouched. */
|
|
178
|
+
export async function endSupportSession(contextName) {
|
|
179
|
+
const context = requireContext(contextName);
|
|
180
|
+
const support = getSupportSession(context);
|
|
181
|
+
if (!support)
|
|
182
|
+
return null;
|
|
183
|
+
if (support.tokens.refresh_token) {
|
|
184
|
+
// Keycloak may have ended it already; the local session goes either way.
|
|
185
|
+
await keycloakProvider.endSupportSession(context, support.tokens.refresh_token).catch(() => { });
|
|
186
|
+
}
|
|
187
|
+
clearSupportSession(contextName);
|
|
188
|
+
return support;
|
|
189
|
+
}
|
|
190
|
+
/** The operator's own `sub`, read from a token the realm signed. */
|
|
191
|
+
function decodedSub(accessToken) {
|
|
192
|
+
const [, payload] = accessToken.split('.');
|
|
193
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
194
|
+
return claims.sub ?? '';
|
|
195
|
+
}
|
|
108
196
|
export async function testAuth(contextName) {
|
|
109
197
|
const context = requireContext(contextName);
|
|
110
198
|
const { api: apiUrl } = getBaseUrls(context.environment);
|
|
@@ -16,6 +16,8 @@ export declare const KEYCLOAK_SCOPE = "openid profile email offline_access";
|
|
|
16
16
|
/** The subset of the OIDC discovery document (RFC 8414 / OIDC Discovery 1.0) the CLI uses. */
|
|
17
17
|
export interface OidcDiscovery {
|
|
18
18
|
authorization_endpoint: string;
|
|
19
|
+
/** Ends one session without touching the operator's own login. */
|
|
20
|
+
end_session_endpoint: string;
|
|
19
21
|
issuer: string;
|
|
20
22
|
/** Where the realm publishes the signing keys every issued token is checked against. */
|
|
21
23
|
jwks_uri: string;
|
|
@@ -45,7 +47,18 @@ export declare class KeycloakProvider implements AuthProvider {
|
|
|
45
47
|
* validated against the host allowlist; a failed fetch is not cached.
|
|
46
48
|
*/
|
|
47
49
|
discover(target: KeycloakTarget): Promise<OidcDiscovery>;
|
|
50
|
+
/**
|
|
51
|
+
* Ends exactly the support session. The operator's own login is a different
|
|
52
|
+
* Keycloak session and is left running.
|
|
53
|
+
*/
|
|
54
|
+
endSupportSession(context: Context, refreshToken: string): Promise<void>;
|
|
48
55
|
exchangeCode(context: Context, { code, codeVerifier, nonce }: ExchangeParams): Promise<TokenData>;
|
|
56
|
+
/**
|
|
57
|
+
* Customers this operator may act for. Served by the realm's tenant-claims
|
|
58
|
+
* mapper against the operator's own Bearer, so Keycloak — not the CLI —
|
|
59
|
+
* decides who is an operator and which customers they may pick.
|
|
60
|
+
*/
|
|
61
|
+
listSupportTenants(context: Context, operatorAccessToken: string): Promise<Tenant[]>;
|
|
49
62
|
/** The access token's `availableTenants` claim lists every tenant the user may act in, read once its signature checks out. */
|
|
50
63
|
listTenants(context: Context, accessToken: string): Promise<Tenant[]>;
|
|
51
64
|
/**
|
|
@@ -56,6 +69,15 @@ export declare class KeycloakProvider implements AuthProvider {
|
|
|
56
69
|
*/
|
|
57
70
|
readClaims(context: Context, accessToken: string): Promise<DecodedToken>;
|
|
58
71
|
refresh(context: Context, refreshToken: string): Promise<TokenData>;
|
|
72
|
+
/** Refreshes a support session. No tenant is named: the session never moves customer. */
|
|
73
|
+
refreshSupportSession(context: Context, refreshToken: string): Promise<TokenData>;
|
|
74
|
+
/**
|
|
75
|
+
* Starts a support session for `tenantId`, authorised by the operator's own
|
|
76
|
+
* access token. The returned tokens belong to the shared support account and
|
|
77
|
+
* name the operator in `impersonatorId`; they are a second login, so the
|
|
78
|
+
* operator's own tokens stay valid and untouched.
|
|
79
|
+
*/
|
|
80
|
+
startSupportSession(context: Context, operatorAccessToken: string, tenantId: string): Promise<TokenData>;
|
|
59
81
|
/**
|
|
60
82
|
* One refresh-token grant with the `tenant_id` form field, read by the
|
|
61
83
|
* realm's tenant-claims mapper: the minted token's `tenantId` becomes this
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { URL } from 'node:url';
|
|
2
|
-
import { REDIRECT_URI } from './constants.js';
|
|
2
|
+
import { FORM_POST_HEADERS, REDIRECT_URI, SUPPORT_CLIENT_ID, SUPPORT_SESSION_GRANT_TYPE } from './constants.js';
|
|
3
3
|
import { AuthError } from './errors.js';
|
|
4
4
|
import { requireClientId } from './guards.js';
|
|
5
|
-
import { authHttp, toAuthError } from './http.js';
|
|
5
|
+
import { authHttp, httpStatusOf, toAuthError } from './http.js';
|
|
6
6
|
import { verifyToken } from './jwks.js';
|
|
7
7
|
import { exchangeAuthorizationCode, redeemRefreshToken, tenantIdOf, tenantsOf, toTokenData, } from './tokens.js';
|
|
8
8
|
import { buildValidatedUrl } from './validated-url.js';
|
|
@@ -84,14 +84,25 @@ function validateDiscovery(url, data) {
|
|
|
84
84
|
throw new AuthError(`Keycloak discovery document at ${url} advertises a ${field} outside the allowed hosts: ${value}`);
|
|
85
85
|
}
|
|
86
86
|
};
|
|
87
|
-
// `end_session_endpoint` is unused: logout is local-only.
|
|
88
87
|
return {
|
|
89
88
|
authorization_endpoint: requireEndpoint('authorization_endpoint'),
|
|
89
|
+
end_session_endpoint: requireEndpoint('end_session_endpoint'),
|
|
90
90
|
issuer: requireString('issuer'),
|
|
91
91
|
jwks_uri: requireEndpoint('jwks_uri'),
|
|
92
92
|
token_endpoint: requireEndpoint('token_endpoint'),
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
+
/** `{id, name}` entries from the realm's support-tenant list; an entry without a usable id is dropped. */
|
|
96
|
+
function parseSupportTenants(body) {
|
|
97
|
+
if (!Array.isArray(body))
|
|
98
|
+
return [];
|
|
99
|
+
return body.flatMap((entry) => {
|
|
100
|
+
const { id, name } = (entry ?? {});
|
|
101
|
+
if (typeof id !== 'string' || !id)
|
|
102
|
+
return [];
|
|
103
|
+
return [{ name: typeof name === 'string' && name ? name : id, tenantId: id }];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
95
106
|
/**
|
|
96
107
|
* Authorization Code + PKCE S256 with `state` and `nonce` against the public
|
|
97
108
|
* `aderis-cli-login` client, endpoints from OIDC discovery. Tenants come from
|
|
@@ -131,6 +142,23 @@ export class KeycloakProvider {
|
|
|
131
142
|
this.discoveryCache.set(key, pending);
|
|
132
143
|
return pending;
|
|
133
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Ends exactly the support session. The operator's own login is a different
|
|
147
|
+
* Keycloak session and is left running.
|
|
148
|
+
*/
|
|
149
|
+
async endSupportSession(context, refreshToken) {
|
|
150
|
+
const discovery = await this.discover(context);
|
|
151
|
+
const params = new URLSearchParams({ client_id: SUPPORT_CLIENT_ID, refresh_token: refreshToken });
|
|
152
|
+
try {
|
|
153
|
+
await authHttp.post(discovery.end_session_endpoint, params.toString(), { headers: FORM_POST_HEADERS });
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
// A session Keycloak has already forgotten is the state we wanted.
|
|
157
|
+
const status = httpStatusOf(error);
|
|
158
|
+
if (status !== 400 && status !== 401 && status !== 404)
|
|
159
|
+
throw toAuthError(error, 'Support session logout');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
134
162
|
async exchangeCode(context, { code, codeVerifier, nonce }) {
|
|
135
163
|
const discovery = await this.discover(context);
|
|
136
164
|
const response = await exchangeAuthorizationCode(discovery.token_endpoint, context, { code, codeVerifier });
|
|
@@ -138,6 +166,28 @@ export class KeycloakProvider {
|
|
|
138
166
|
const claims = await this.verifyAccessToken(discovery, response.access_token);
|
|
139
167
|
return this.requireTenant(toTokenData(response, claims), claims);
|
|
140
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Customers this operator may act for. Served by the realm's tenant-claims
|
|
171
|
+
* mapper against the operator's own Bearer, so Keycloak — not the CLI —
|
|
172
|
+
* decides who is an operator and which customers they may pick.
|
|
173
|
+
*/
|
|
174
|
+
async listSupportTenants(context, operatorAccessToken) {
|
|
175
|
+
const discovery = await this.discover(context);
|
|
176
|
+
const url = buildValidatedUrl(new URL(discovery.issuer).origin, `${new URL(discovery.issuer).pathname}/aderis-tenant-claims/me/support/tenants`);
|
|
177
|
+
let response;
|
|
178
|
+
try {
|
|
179
|
+
response = await authHttp.get(url, {
|
|
180
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${operatorAccessToken}` },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (httpStatusOf(error) === 403) {
|
|
185
|
+
throw new AuthError('Your account is not an Aderis Support operator, so it cannot act for a customer.');
|
|
186
|
+
}
|
|
187
|
+
throw toAuthError(error, 'Support tenant list');
|
|
188
|
+
}
|
|
189
|
+
return parseSupportTenants(response.data);
|
|
190
|
+
}
|
|
141
191
|
/** The access token's `availableTenants` claim lists every tenant the user may act in, read once its signature checks out. */
|
|
142
192
|
async listTenants(context, accessToken) {
|
|
143
193
|
const decoded = await this.readClaims(context, accessToken);
|
|
@@ -168,6 +218,61 @@ export class KeycloakProvider {
|
|
|
168
218
|
refresh(context, refreshToken) {
|
|
169
219
|
return this.redeem(context, refreshToken);
|
|
170
220
|
}
|
|
221
|
+
/** Refreshes a support session. No tenant is named: the session never moves customer. */
|
|
222
|
+
async refreshSupportSession(context, refreshToken) {
|
|
223
|
+
const discovery = await this.discover(context);
|
|
224
|
+
const params = new URLSearchParams({
|
|
225
|
+
client_id: SUPPORT_CLIENT_ID,
|
|
226
|
+
grant_type: 'refresh_token',
|
|
227
|
+
refresh_token: refreshToken,
|
|
228
|
+
});
|
|
229
|
+
let response;
|
|
230
|
+
try {
|
|
231
|
+
response = await authHttp.post(discovery.token_endpoint, params.toString(), {
|
|
232
|
+
headers: FORM_POST_HEADERS,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const status = httpStatusOf(error);
|
|
237
|
+
if (status === 400 || status === 401) {
|
|
238
|
+
throw new AuthError('The support session has ended. Start a new one with: aderis support start <tenant-id>', {
|
|
239
|
+
status,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
throw toAuthError(error, 'Support session refresh');
|
|
243
|
+
}
|
|
244
|
+
const claims = await this.verifyAccessToken(discovery, response.data.access_token);
|
|
245
|
+
return toTokenData(response.data, claims);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Starts a support session for `tenantId`, authorised by the operator's own
|
|
249
|
+
* access token. The returned tokens belong to the shared support account and
|
|
250
|
+
* name the operator in `impersonatorId`; they are a second login, so the
|
|
251
|
+
* operator's own tokens stay valid and untouched.
|
|
252
|
+
*/
|
|
253
|
+
async startSupportSession(context, operatorAccessToken, tenantId) {
|
|
254
|
+
const discovery = await this.discover(context);
|
|
255
|
+
const params = new URLSearchParams({
|
|
256
|
+
client_id: SUPPORT_CLIENT_ID,
|
|
257
|
+
grant_type: SUPPORT_SESSION_GRANT_TYPE,
|
|
258
|
+
tenant_id: tenantId,
|
|
259
|
+
});
|
|
260
|
+
let response;
|
|
261
|
+
try {
|
|
262
|
+
response = await authHttp.post(discovery.token_endpoint, params.toString(), {
|
|
263
|
+
headers: { ...FORM_POST_HEADERS, Authorization: `Bearer ${operatorAccessToken}` },
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
const status = httpStatusOf(error);
|
|
268
|
+
if (status === 400 || status === 401 || status === 403) {
|
|
269
|
+
throw new AuthError(`Keycloak refused a support session for tenant ${tenantId}. Check that your account is a support operator and that the customer is one you may act for.`, { status });
|
|
270
|
+
}
|
|
271
|
+
throw toAuthError(error, 'Support session');
|
|
272
|
+
}
|
|
273
|
+
const claims = await this.verifyAccessToken(discovery, response.data.access_token);
|
|
274
|
+
return toTokenData(response.data, claims);
|
|
275
|
+
}
|
|
171
276
|
/**
|
|
172
277
|
* One refresh-token grant with the `tenant_id` form field, read by the
|
|
173
278
|
* realm's tenant-claims mapper: the minted token's `tenantId` becomes this
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -26,6 +26,18 @@ export interface CachedTenant {
|
|
|
26
26
|
name: string;
|
|
27
27
|
tenantId: string;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* A running "act as Aderis Support" session, held beside the operator's own
|
|
31
|
+
* login rather than replacing it: ending it returns to the operator without a
|
|
32
|
+
* new browser login, exactly as the web app does.
|
|
33
|
+
*/
|
|
34
|
+
export interface SupportSession {
|
|
35
|
+
/** The operator's Keycloak `sub`; a session started by someone else is not reused. */
|
|
36
|
+
operator_sub: string;
|
|
37
|
+
tenant_id: string;
|
|
38
|
+
tenant_name: string;
|
|
39
|
+
tokens: TokenData;
|
|
40
|
+
}
|
|
29
41
|
export interface Context {
|
|
30
42
|
api_key?: string;
|
|
31
43
|
auth_type?: AuthType;
|
|
@@ -41,6 +53,8 @@ export interface Context {
|
|
|
41
53
|
*/
|
|
42
54
|
keycloak_base_url?: string;
|
|
43
55
|
name: string;
|
|
56
|
+
/** Set while acting as Aderis Support; the operator's own tokens stay in `tokens`. */
|
|
57
|
+
support?: SupportSession;
|
|
44
58
|
tenant_id?: string;
|
|
45
59
|
tokens?: TokenData;
|
|
46
60
|
}
|
|
@@ -63,6 +77,10 @@ export declare function saveCachedTenants(contextName: string, tenants: CachedTe
|
|
|
63
77
|
export declare function getCachedTenants(contextName: string): CachedTenant[] | undefined;
|
|
64
78
|
export declare function getTokens(contextName: string): null | TokenData;
|
|
65
79
|
export declare function clearTokens(contextName: string): void;
|
|
80
|
+
export declare function saveSupportSession(contextName: string, support: SupportSession): void;
|
|
81
|
+
export declare function clearSupportSession(contextName: string): void;
|
|
82
|
+
/** The running support session of a context, if any. */
|
|
83
|
+
export declare function getSupportSession(context: Context): SupportSession | undefined;
|
|
66
84
|
export declare function saveApiKey(contextName: string, apiKey: string): void;
|
|
67
85
|
export declare function clearApiKey(contextName: string): void;
|
|
68
86
|
/**
|
package/dist/lib/config.js
CHANGED
|
@@ -128,6 +128,16 @@ export function getTokens(contextName) {
|
|
|
128
128
|
export function clearTokens(contextName) {
|
|
129
129
|
updateContext(contextName, { tokens: undefined });
|
|
130
130
|
}
|
|
131
|
+
export function saveSupportSession(contextName, support) {
|
|
132
|
+
updateContext(contextName, { support });
|
|
133
|
+
}
|
|
134
|
+
export function clearSupportSession(contextName) {
|
|
135
|
+
updateContext(contextName, { support: undefined });
|
|
136
|
+
}
|
|
137
|
+
/** The running support session of a context, if any. */
|
|
138
|
+
export function getSupportSession(context) {
|
|
139
|
+
return context.support;
|
|
140
|
+
}
|
|
131
141
|
export function saveApiKey(contextName, apiKey) {
|
|
132
142
|
updateContext(contextName, { api_key: apiKey, auth_type: 'api_key' });
|
|
133
143
|
}
|