@aderis-erp/cli 1.0.17 → 1.1.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.
Files changed (105) hide show
  1. package/dist/commands/access-groups.js +49 -17
  2. package/dist/commands/account-transaction-allocations.js +46 -14
  3. package/dist/commands/account-transactions.js +75 -35
  4. package/dist/commands/accounts.js +54 -22
  5. package/dist/commands/agent-platform.js +53 -21
  6. package/dist/commands/{nango-integrations.d.ts → agent-runs.d.ts} +1 -1
  7. package/dist/commands/agent-runs.js +1155 -0
  8. package/dist/commands/analytics.js +47 -15
  9. package/dist/commands/api-keys.js +49 -16
  10. package/dist/commands/{text-templates.d.ts → async-jobs.d.ts} +1 -1
  11. package/dist/commands/async-jobs.js +1158 -0
  12. package/dist/commands/audit-log.js +46 -14
  13. package/dist/commands/bank-accounts.js +63 -30
  14. package/dist/commands/bank-integrations.d.ts +1 -0
  15. package/dist/commands/bank-integrations.js +66 -31
  16. package/dist/commands/business-partners.js +77 -44
  17. package/dist/commands/chat.js +52 -19
  18. package/dist/commands/credit-card-accounts.d.ts +1 -0
  19. package/dist/commands/credit-card-accounts.js +67 -29
  20. package/dist/commands/delivery-notes.js +78 -44
  21. package/dist/commands/dunning-configs.js +49 -17
  22. package/dist/commands/dunning-reminders.js +52 -20
  23. package/dist/commands/dunning-runs.js +45 -13
  24. package/dist/commands/email-transactions.js +47 -15
  25. package/dist/commands/employees.js +76 -44
  26. package/dist/commands/entity-configurations.js +47 -15
  27. package/dist/commands/event-processor-mappings.js +49 -17
  28. package/dist/commands/files.js +56 -24
  29. package/dist/commands/health.js +49 -17
  30. package/dist/commands/{tag-configurations.d.ts → imports.d.ts} +1 -1
  31. package/dist/commands/{tag-configurations.js → imports.js} +59 -31
  32. package/dist/commands/inbox.js +52 -19
  33. package/dist/commands/integration-identities.d.ts +40 -0
  34. package/dist/commands/integration-identities.js +1151 -0
  35. package/dist/commands/integrations.js +47 -15
  36. package/dist/commands/item-categories.js +49 -17
  37. package/dist/commands/items.js +49 -17
  38. package/dist/commands/journal-lines.js +45 -13
  39. package/dist/commands/journals.js +68 -36
  40. package/dist/commands/ledgers.d.ts +3 -1
  41. package/dist/commands/ledgers.js +96 -34
  42. package/dist/commands/my-account.d.ts +39 -0
  43. package/dist/commands/my-account.js +1160 -0
  44. package/dist/commands/office-locations.js +49 -17
  45. package/dist/commands/payment-methods.js +54 -22
  46. package/dist/commands/payment-runs.js +61 -29
  47. package/dist/commands/payment-terms.js +50 -18
  48. package/dist/commands/payments.js +51 -19
  49. package/dist/commands/payroll-runs.d.ts +43 -0
  50. package/dist/commands/payroll-runs.js +1188 -0
  51. package/dist/commands/permissions.d.ts +39 -0
  52. package/dist/commands/{text-templates.js → permissions.js} +54 -30
  53. package/dist/commands/price-lists.js +54 -22
  54. package/dist/commands/processor-mappings.js +48 -16
  55. package/dist/commands/processors.js +55 -24
  56. package/dist/commands/purchase-order-types.js +49 -17
  57. package/dist/commands/purchase-orders.js +69 -36
  58. package/dist/commands/reports.js +49 -17
  59. package/dist/commands/roles.d.ts +39 -0
  60. package/dist/commands/{nango-integrations.js → roles.js} +63 -26
  61. package/dist/commands/salary-types.js +49 -17
  62. package/dist/commands/sales-invoices.js +84 -51
  63. package/dist/commands/sales-orders.js +78 -44
  64. package/dist/commands/saved-views.js +47 -15
  65. package/dist/commands/scheduled-processor-mappings.js +49 -17
  66. package/dist/commands/schedules.js +53 -21
  67. package/dist/commands/support/end.d.ts +9 -0
  68. package/dist/commands/support/end.js +29 -0
  69. package/dist/commands/support/start.d.ts +14 -0
  70. package/dist/commands/support/start.js +50 -0
  71. package/dist/commands/support/status.d.ts +10 -0
  72. package/dist/commands/support/status.js +45 -0
  73. package/dist/commands/support/tenants.d.ts +10 -0
  74. package/dist/commands/support/tenants.js +39 -0
  75. package/dist/commands/tags.js +51 -18
  76. package/dist/commands/tax-codes.js +52 -20
  77. package/dist/commands/tax-matrix.js +49 -17
  78. package/dist/commands/templates.d.ts +39 -0
  79. package/dist/commands/templates.js +1157 -0
  80. package/dist/commands/tenants.js +52 -20
  81. package/dist/commands/tools.js +46 -14
  82. package/dist/commands/users.d.ts +42 -0
  83. package/dist/commands/users.js +1174 -0
  84. package/dist/commands/vat-declarations.d.ts +2 -0
  85. package/dist/commands/vat-declarations.js +72 -36
  86. package/dist/commands/vendor-invoices.js +81 -49
  87. package/dist/commands/webhooks.js +51 -19
  88. package/dist/commands/workflows.js +47 -15
  89. package/dist/lib/api-client.d.ts +6 -0
  90. package/dist/lib/api-client.js +13 -0
  91. package/dist/lib/auth/constants.d.ts +12 -0
  92. package/dist/lib/auth/constants.js +12 -0
  93. package/dist/lib/auth/index.d.ts +11 -1
  94. package/dist/lib/auth/index.js +105 -6
  95. package/dist/lib/auth/jwks.d.ts +19 -0
  96. package/dist/lib/auth/jwks.js +112 -0
  97. package/dist/lib/auth/keycloak-provider.d.ts +45 -7
  98. package/dist/lib/auth/keycloak-provider.js +161 -27
  99. package/dist/lib/auth/tokens.d.ts +8 -3
  100. package/dist/lib/auth/tokens.js +8 -7
  101. package/dist/lib/auth/types.d.ts +5 -1
  102. package/dist/lib/config.d.ts +18 -0
  103. package/dist/lib/config.js +10 -0
  104. package/oclif.manifest.json +2182 -744
  105. package/package.json +7 -4
@@ -1,5 +1,5 @@
1
1
  import { Command, Args, Flags } from '@oclif/core';
2
- import { apiRequest, ApiError, normalizeSortBy } from '../lib/api-client.js';
2
+ import { apiRequest, ApiError, isTextualMediaType, normalizeSortBy } from '../lib/api-client.js';
3
3
  import { getCurrentContext, getContext, getBaseUrls, hasCredentials, missingCredentialsMessage } from '../lib/config.js';
4
4
  // Add 'import' action if both create and update exist
5
5
  const HAS_IMPORT = false;
@@ -8,9 +8,9 @@ const HAS_CAMT053 = false;
8
8
  const NESTED_IMPORTS = [];
9
9
  const SUBCOMMANDS = [...["list", "status", "find"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
10
10
  const ENDPOINTS = {
11
- 'list': { method: 'GET', path: '/v1/workflows', summary: 'Get all workflows with their summary', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
12
- 'status': { method: 'POST', path: '/v1/workflows/status', summary: 'Create a new workflow status update', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
13
- 'find': { method: 'GET', path: '/v1/workflows/{workflowId}', summary: 'Get workflow details including status updates and entity changes', pathParams: ['workflowId'], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
11
+ 'list': { method: 'GET', path: '/v1/workflows', summary: 'Get all workflows with their summary', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
12
+ 'status': { method: 'POST', path: '/v1/workflows/status', summary: 'Create a new workflow status update', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
13
+ 'find': { method: 'GET', path: '/v1/workflows/{workflowId}', summary: 'Get workflow details including status updates and entity changes', pathParams: ['workflowId'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
14
14
  };
15
15
  export default class Workflows extends Command {
16
16
  static description = 'Manage workflows';
@@ -258,10 +258,11 @@ export default class Workflows extends Command {
258
258
  process.stderr.write('\n');
259
259
  }
260
260
  try {
261
- // Binary response: download to file
261
+ // Binary response: to -f / --file, or to stdout when it is redirected.
262
262
  if (endpoint.isBinaryResponse) {
263
- if (!flags.file) {
264
- this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx).`);
263
+ const toStdout = !flags.file;
264
+ if (toStdout && process.stdout.isTTY) {
265
+ this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx), or redirect stdout to a file.`);
265
266
  }
266
267
  const binResponse = await apiRequest(endpoint.method, apiPath, {
267
268
  params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
@@ -269,24 +270,55 @@ export default class Workflows extends Command {
269
270
  contextName: flags.context,
270
271
  binary: true,
271
272
  });
272
- const fsModule = await import('node:fs');
273
273
  const binData = binResponse.data;
274
274
  const buffer = Buffer.isBuffer(binData)
275
275
  ? binData
276
276
  : Buffer.from(binData);
277
+ if (toStdout) {
278
+ // Nothing else may reach stdout here — it is the file.
279
+ process.stdout.write(buffer);
280
+ return;
281
+ }
282
+ const fsModule = await import('node:fs');
277
283
  fsModule.writeFileSync(flags.file, buffer);
278
284
  this.log(`Saved ${buffer.length} bytes to ${flags.file}`);
279
285
  return;
280
286
  }
281
- const response = await apiRequest(endpoint.method, apiPath, {
282
- params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
283
- data,
284
- contextName: flags.context,
285
- acceptAny: true,
286
- });
287
+ let response;
288
+ if (endpoint.hasBinaryVariant) {
289
+ // This route answers JSON or a file depending on the request body, so the
290
+ // bytes are read first and the response's own content-type decides. Asking
291
+ // for text here would corrupt an xlsx/pdf before anything could inspect it.
292
+ const raw = await apiRequest(endpoint.method, apiPath, {
293
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
294
+ data,
295
+ contextName: flags.context,
296
+ binary: true,
297
+ });
298
+ const rawType = String(raw.headers['content-type'] || '').toLowerCase();
299
+ const rawData = raw.data;
300
+ const buffer = Buffer.isBuffer(rawData) ? rawData : Buffer.from(rawData);
301
+ if (!isTextualMediaType(rawType)) {
302
+ if (process.stdout.isTTY) {
303
+ this.error(`The API returned ${rawType || 'binary data'}. Redirect stdout to a file, e.g. ... > report.xlsx`);
304
+ }
305
+ // Nothing else may reach stdout here — it is the file.
306
+ process.stdout.write(buffer);
307
+ return;
308
+ }
309
+ response = { data: buffer.toString('utf8'), headers: raw.headers, status: raw.status };
310
+ }
311
+ else {
312
+ response = await apiRequest(endpoint.method, apiPath, {
313
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
314
+ data,
315
+ contextName: flags.context,
316
+ acceptAny: true,
317
+ });
318
+ }
287
319
  // Check content-type for non-JSON responses (XML, text, etc.)
288
320
  const contentType = (response.headers['content-type'] || '').toLowerCase();
289
- if (contentType.includes('xml') || contentType.includes('text/')) {
321
+ if (isTextualMediaType(contentType) && !contentType.startsWith('application/json')) {
290
322
  // Output raw content for XML/text responses (CSV, plain, HTML, etc.)
291
323
  this.log(String(response.data));
292
324
  return;
@@ -28,6 +28,12 @@ export interface PaginatedResponse<T> {
28
28
  * into repeated `sortBy=a:ASC&sortBy=b:DESC` params.
29
29
  */
30
30
  export declare function normalizeSortBy(input: string): string[];
31
+ /**
32
+ * Whether a response body is text the CLI may decode. Matched on the media type
33
+ * rather than by substring: application/vnd.openxmlformats-officedocument...
34
+ * (xlsx) contains "xml" but is a zip, and decoding it corrupts every byte.
35
+ */
36
+ export declare function isTextualMediaType(contentType: string): boolean;
31
37
  export declare class ApiError extends Error {
32
38
  status: number;
33
39
  data?: unknown | undefined;
@@ -21,6 +21,19 @@ export function normalizeSortBy(input) {
21
21
  return `${part}:${dateLike ? 'DESC' : 'ASC'}`;
22
22
  });
23
23
  }
24
+ /**
25
+ * Whether a response body is text the CLI may decode. Matched on the media type
26
+ * rather than by substring: application/vnd.openxmlformats-officedocument...
27
+ * (xlsx) contains "xml" but is a zip, and decoding it corrupts every byte.
28
+ */
29
+ export function isTextualMediaType(contentType) {
30
+ const type = contentType.split(';')[0].trim().toLowerCase();
31
+ if (type.startsWith('text/'))
32
+ return true;
33
+ if (type === 'application/json' || type === 'application/xml')
34
+ return true;
35
+ return type.endsWith('+json') || type.endsWith('+xml');
36
+ }
24
37
  export class ApiError extends Error {
25
38
  status;
26
39
  data;
@@ -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;
@@ -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;
@@ -3,18 +3,20 @@
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';
10
10
  import { httpStatusOf } from './http.js';
11
11
  import { keycloakProvider } from './keycloak-provider.js';
12
- import { decodeToken, tenantIdOf } from './tokens.js';
12
+ import { tenantIdOf } from './tokens.js';
13
13
  import { buildValidatedUrl } from './validated-url.js';
14
14
  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) {
@@ -62,6 +64,9 @@ export async function refreshTokens(contextName) {
62
64
  if (!context.tokens?.refresh_token) {
63
65
  throw new Error('No refresh token available. Please login again.');
64
66
  }
67
+ // The tenant of the new token is the realm's to decide: the CLI does not name
68
+ // one here, so a mapper that picks the wrong tenant is visible as a server
69
+ // bug rather than papered over per request.
65
70
  const refreshed = await keycloakProvider.refresh(context, context.tokens.refresh_token);
66
71
  const tokenData = {
67
72
  ...refreshed,
@@ -79,7 +84,7 @@ export async function getValidToken(contextName) {
79
84
  if (!context.tokens?.access_token) {
80
85
  throw new Error('Not logged in. Please run: aderis auth login');
81
86
  }
82
- if (context.tokens.expires_at && context.tokens.expires_at < Date.now() + 60_000) {
87
+ if (context.tokens.expires_at && context.tokens.expires_at < Date.now() + EXPIRY_BUFFER_MS) {
83
88
  if (context.tokens.refresh_token) {
84
89
  const newTokens = await refreshTokens(contextName);
85
90
  return newTokens.access_token;
@@ -100,8 +105,94 @@ export async function getAuthHeaders(contextName) {
100
105
  }
101
106
  return { 'x-api-key': context.api_key };
102
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
+ }
103
116
  return { Authorization: `Bearer ${await getValidToken(contextName)}` };
104
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
+ }
105
196
  export async function testAuth(contextName) {
106
197
  const context = requireContext(contextName);
107
198
  const { api: apiUrl } = getBaseUrls(context.environment);
@@ -140,11 +231,19 @@ export function tenantLabel(tenant) {
140
231
  }
141
232
  export async function getCurrentTenant(contextName) {
142
233
  const context = requireContext(contextName);
143
- // API-key contexts carry no JWT, so there is no tenant to decode locally.
234
+ // API-key contexts carry no JWT, so there is no tenant to read.
144
235
  if (getAuthType(context) === 'api_key' || !context.tokens?.access_token) {
145
236
  return null;
146
237
  }
147
- const tenantId = tenantIdOf(decodeToken(context.tokens.access_token));
238
+ // The stored token is checked against the realm's keys before its tenant is
239
+ // believed; a token that fails verification reaches no tenant.
240
+ let tenantId;
241
+ try {
242
+ tenantId = tenantIdOf(await keycloakProvider.readClaims(context, context.tokens.access_token));
243
+ }
244
+ catch {
245
+ return null;
246
+ }
148
247
  if (!tenantId) {
149
248
  return null;
150
249
  }
@@ -205,7 +304,7 @@ export async function completeTenantSelection(contextName, choose) {
205
304
  if (!context.tokens?.access_token) {
206
305
  throw new Error('Not logged in. Please run: aderis auth login');
207
306
  }
208
- if (context.tenant_id || tenantIdOf(decodeToken(context.tokens.access_token))) {
307
+ if (context.tenant_id || tenantIdOf(await keycloakProvider.readClaims(context, context.tokens.access_token))) {
209
308
  return null;
210
309
  }
211
310
  const tenants = await listTenants(contextName);
@@ -0,0 +1,19 @@
1
+ import { type DecodedToken } from './tokens.js';
2
+ /**
3
+ * Verifies a Keycloak-issued JWT against the realm's published signing keys and
4
+ * returns its claims.
5
+ *
6
+ * Claims are only trustworthy once the signature is checked: `jwt.decode` would
7
+ * return whatever a tampered token says. The algorithm is pinned to RS256 and
8
+ * the issuer is matched against the realm's discovery document, so a token from
9
+ * another realm — or one re-signed by the caller — is rejected
10
+ * (AderisERP/cli#25).
11
+ */
12
+ export declare function verifyToken(token: string, { audience, ignoreExpiration, issuer, jwksUri, }: {
13
+ audience?: string;
14
+ ignoreExpiration?: boolean;
15
+ issuer: string;
16
+ jwksUri: string;
17
+ }): Promise<DecodedToken>;
18
+ /** Drops the cached key sets. Exposed for tests. */
19
+ export declare function resetJwksCache(): void;
@@ -0,0 +1,112 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import { createPublicKey } from 'node:crypto';
3
+ import { AuthError } from './errors.js';
4
+ import { authHttp, toAuthError } from './http.js';
5
+ /**
6
+ * The only signature algorithm the CLI accepts. Pinning it is what stops an
7
+ * `alg: none` or HMAC-with-the-public-key token from passing verification.
8
+ */
9
+ const ALLOWED_ALGORITHMS = ['RS256'];
10
+ /** Signing keys per JWKS URI, fetched once per process like the discovery document. */
11
+ const keyCache = new Map();
12
+ async function fetchKeys(jwksUri) {
13
+ let response;
14
+ try {
15
+ response = await authHttp.get(jwksUri, { headers: { Accept: 'application/json' } });
16
+ }
17
+ catch (error) {
18
+ throw new AuthError(`Could not load the Keycloak signing keys from ${jwksUri}: ${toAuthError(error, 'the request').message}`);
19
+ }
20
+ const keys = response.data?.keys;
21
+ if (!Array.isArray(keys) || keys.length === 0) {
22
+ throw new AuthError(`The Keycloak key set at ${jwksUri} lists no keys.`);
23
+ }
24
+ return keys;
25
+ }
26
+ function keysOf(jwksUri) {
27
+ const cached = keyCache.get(jwksUri);
28
+ if (cached)
29
+ return cached;
30
+ // A failed fetch must not be cached, or one network blip breaks the process.
31
+ const pending = fetchKeys(jwksUri).catch((error) => {
32
+ keyCache.delete(jwksUri);
33
+ throw error;
34
+ });
35
+ keyCache.set(jwksUri, pending);
36
+ return pending;
37
+ }
38
+ /** The `kid` a token was signed with, read from its header. */
39
+ function keyIdOf(token) {
40
+ const [header] = token.split('.');
41
+ if (!header)
42
+ return undefined;
43
+ try {
44
+ const parsed = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
45
+ return typeof parsed === 'object' && parsed !== null ? parsed.kid : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function publicKeyFrom(jwk, jwksUri) {
52
+ try {
53
+ return createPublicKey({ format: 'jwk', key: jwk });
54
+ }
55
+ catch {
56
+ throw new AuthError(`A signing key in the Keycloak key set at ${jwksUri} could not be read.`);
57
+ }
58
+ }
59
+ /**
60
+ * Verifies a Keycloak-issued JWT against the realm's published signing keys and
61
+ * returns its claims.
62
+ *
63
+ * Claims are only trustworthy once the signature is checked: `jwt.decode` would
64
+ * return whatever a tampered token says. The algorithm is pinned to RS256 and
65
+ * the issuer is matched against the realm's discovery document, so a token from
66
+ * another realm — or one re-signed by the caller — is rejected
67
+ * (AderisERP/cli#25).
68
+ */
69
+ export async function verifyToken(token, { audience, ignoreExpiration = false, issuer, jwksUri, }) {
70
+ const keys = await keysOf(jwksUri);
71
+ const kid = keyIdOf(token);
72
+ // A `kid` the key set does not list may mean the realm rotated its keys, so
73
+ // every signing key is tried before giving up.
74
+ const candidates = kid ? keys.filter((k) => k.kid === kid) : [];
75
+ const tried = candidates.length > 0 ? candidates : keys.filter((k) => k.use === undefined || k.use === 'sig');
76
+ let lastError;
77
+ for (const jwk of tried) {
78
+ try {
79
+ return jwt.verify(token, publicKeyFrom(jwk, jwksUri), {
80
+ algorithms: [...ALLOWED_ALGORITHMS],
81
+ ...(audience === undefined ? {} : { audience }),
82
+ ignoreExpiration,
83
+ issuer,
84
+ });
85
+ }
86
+ catch (error) {
87
+ lastError = error;
88
+ }
89
+ }
90
+ const detail = lastError instanceof Error ? lastError.message : 'no matching signing key';
91
+ // Read purely to name what was presented; these claims are not trusted.
92
+ const presented = unverifiedIssuerOf(token);
93
+ const named = presented && presented !== issuer ? ` The token names issuer ${presented}.` : '';
94
+ throw new AuthError(`Keycloak issued a token whose signature could not be verified against ${jwksUri}: ${detail}.${named}`);
95
+ }
96
+ /** The `iss` a token claims, for error messages only — never for a decision. */
97
+ function unverifiedIssuerOf(token) {
98
+ const payload = token.split('.')[1];
99
+ if (!payload)
100
+ return undefined;
101
+ try {
102
+ const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
103
+ return typeof parsed === 'object' && parsed !== null ? parsed.iss : undefined;
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ /** Drops the cached key sets. Exposed for tests. */
110
+ export function resetJwksCache() {
111
+ keyCache.clear();
112
+ }
@@ -1,4 +1,5 @@
1
1
  import { type Context, type Environment, type TokenData } from '../config.js';
2
+ import { type DecodedToken } from './tokens.js';
2
3
  import { type AuthorizeParams, type AuthProvider, type AuthRound, type ExchangeParams, type Tenant } from './types.js';
3
4
  export interface KeycloakRealm {
4
5
  baseUrl: string;
@@ -15,7 +16,11 @@ export declare const KEYCLOAK_SCOPE = "openid profile email offline_access";
15
16
  /** The subset of the OIDC discovery document (RFC 8414 / OIDC Discovery 1.0) the CLI uses. */
16
17
  export interface OidcDiscovery {
17
18
  authorization_endpoint: string;
19
+ /** Ends one session without touching the operator's own login. */
20
+ end_session_endpoint: string;
18
21
  issuer: string;
22
+ /** Where the realm publishes the signing keys every issued token is checked against. */
23
+ jwks_uri: string;
19
24
  token_endpoint: string;
20
25
  }
21
26
  /** What decides which Keycloak a context talks to: its environment, and an optional host override. */
@@ -42,10 +47,37 @@ export declare class KeycloakProvider implements AuthProvider {
42
47
  * validated against the host allowlist; a failed fetch is not cached.
43
48
  */
44
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>;
45
55
  exchangeCode(context: Context, { code, codeVerifier, nonce }: ExchangeParams): Promise<TokenData>;
46
- /** No network call: the access token's `availableTenants` claim lists every tenant the user may act in. */
47
- listTenants(_context: Context, accessToken: string): Promise<Tenant[]>;
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[]>;
62
+ /** The access token's `availableTenants` claim lists every tenant the user may act in, read once its signature checks out. */
63
+ listTenants(context: Context, accessToken: string): Promise<Tenant[]>;
64
+ /**
65
+ * The claims of an access token, once its signature has been checked against
66
+ * the realm's keys. Every caller that needs a claim goes through here: reading
67
+ * one off an unverified token would believe whatever the token says
68
+ * (AderisERP/cli#25).
69
+ */
70
+ readClaims(context: Context, accessToken: string): Promise<DecodedToken>;
48
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>;
49
81
  /**
50
82
  * One refresh-token grant with the `tenant_id` form field, read by the
51
83
  * realm's tenant-claims mapper: the minted token's `tenantId` becomes this
@@ -60,14 +92,20 @@ export declare class KeycloakProvider implements AuthProvider {
60
92
  */
61
93
  tenantSwitchRounds(context: Context, tenantId: string): AuthRound[];
62
94
  private redeem;
63
- /** Rejects a token whose `iss` differs from the discovery issuer; the API compares `iss` exactly. */
64
- private requireIssuer;
65
95
  /**
66
- * The mapper is fail-closed: with several reachable tenants and no `tenant_id`
67
- * the token has no `tenantId`. That token is stored and needs a tenant picked
68
- * (`completeTenantSelection`); a token reaching no tenant is refused.
96
+ * A token may legitimately carry no `tenantId`: the mapper is fail-closed for
97
+ * a `tenant_id` the session cannot reach, and a service account reaching
98
+ * several tenants gets none. That token is stored and needs a tenant named
99
+ * (`completeTenantSelection`); a token reaching no tenant at all is refused.
69
100
  */
70
101
  private requireTenant;
102
+ /**
103
+ * Checks an access token's signature against the realm's published keys, and
104
+ * its issuer along with it. Every claim the CLI reads later — the tenant, the
105
+ * reachable tenants — is taken from the claims this returns, so no caller can
106
+ * read a claim that was not checked first (AderisERP/cli#25).
107
+ */
108
+ private verifyAccessToken;
71
109
  /** Consistency check only: the id_token signature is not checked (the API checks the access token). */
72
110
  private verifyNonce;
73
111
  }