@aderis-erp/cli 1.0.17 → 1.1.0

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 (93) 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/imports.d.ts +39 -0
  31. package/dist/commands/{nango-integrations.js → imports.js} +61 -25
  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 +95 -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/roles.js +1152 -0
  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/tag-configurations.js +50 -18
  68. package/dist/commands/tags.js +49 -17
  69. package/dist/commands/tax-codes.js +52 -20
  70. package/dist/commands/tax-matrix.js +49 -17
  71. package/dist/commands/templates.d.ts +39 -0
  72. package/dist/commands/templates.js +1157 -0
  73. package/dist/commands/tenants.js +52 -20
  74. package/dist/commands/tools.js +46 -14
  75. package/dist/commands/users.d.ts +42 -0
  76. package/dist/commands/users.js +1174 -0
  77. package/dist/commands/vat-declarations.d.ts +2 -0
  78. package/dist/commands/vat-declarations.js +72 -36
  79. package/dist/commands/vendor-invoices.js +81 -49
  80. package/dist/commands/webhooks.js +51 -19
  81. package/dist/commands/workflows.js +47 -15
  82. package/dist/lib/api-client.d.ts +6 -0
  83. package/dist/lib/api-client.js +13 -0
  84. package/dist/lib/auth/index.js +15 -4
  85. package/dist/lib/auth/jwks.d.ts +19 -0
  86. package/dist/lib/auth/jwks.js +112 -0
  87. package/dist/lib/auth/keycloak-provider.d.ts +23 -7
  88. package/dist/lib/auth/keycloak-provider.js +53 -24
  89. package/dist/lib/auth/tokens.d.ts +8 -3
  90. package/dist/lib/auth/tokens.js +8 -7
  91. package/dist/lib/auth/types.d.ts +5 -1
  92. package/oclif.manifest.json +2152 -715
  93. package/package.json +4 -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,7 +9,7 @@ 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';
@@ -62,6 +62,9 @@ export async function refreshTokens(contextName) {
62
62
  if (!context.tokens?.refresh_token) {
63
63
  throw new Error('No refresh token available. Please login again.');
64
64
  }
65
+ // The tenant of the new token is the realm's to decide: the CLI does not name
66
+ // one here, so a mapper that picks the wrong tenant is visible as a server
67
+ // bug rather than papered over per request.
65
68
  const refreshed = await keycloakProvider.refresh(context, context.tokens.refresh_token);
66
69
  const tokenData = {
67
70
  ...refreshed,
@@ -140,11 +143,19 @@ export function tenantLabel(tenant) {
140
143
  }
141
144
  export async function getCurrentTenant(contextName) {
142
145
  const context = requireContext(contextName);
143
- // API-key contexts carry no JWT, so there is no tenant to decode locally.
146
+ // API-key contexts carry no JWT, so there is no tenant to read.
144
147
  if (getAuthType(context) === 'api_key' || !context.tokens?.access_token) {
145
148
  return null;
146
149
  }
147
- const tenantId = tenantIdOf(decodeToken(context.tokens.access_token));
150
+ // The stored token is checked against the realm's keys before its tenant is
151
+ // believed; a token that fails verification reaches no tenant.
152
+ let tenantId;
153
+ try {
154
+ tenantId = tenantIdOf(await keycloakProvider.readClaims(context, context.tokens.access_token));
155
+ }
156
+ catch {
157
+ return null;
158
+ }
148
159
  if (!tenantId) {
149
160
  return null;
150
161
  }
@@ -205,7 +216,7 @@ export async function completeTenantSelection(contextName, choose) {
205
216
  if (!context.tokens?.access_token) {
206
217
  throw new Error('Not logged in. Please run: aderis auth login');
207
218
  }
208
- if (context.tenant_id || tenantIdOf(decodeToken(context.tokens.access_token))) {
219
+ if (context.tenant_id || tenantIdOf(await keycloakProvider.readClaims(context, context.tokens.access_token))) {
209
220
  return null;
210
221
  }
211
222
  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;
@@ -16,6 +17,8 @@ export declare const KEYCLOAK_SCOPE = "openid profile email offline_access";
16
17
  export interface OidcDiscovery {
17
18
  authorization_endpoint: string;
18
19
  issuer: string;
20
+ /** Where the realm publishes the signing keys every issued token is checked against. */
21
+ jwks_uri: string;
19
22
  token_endpoint: string;
20
23
  }
21
24
  /** What decides which Keycloak a context talks to: its environment, and an optional host override. */
@@ -43,8 +46,15 @@ export declare class KeycloakProvider implements AuthProvider {
43
46
  */
44
47
  discover(target: KeycloakTarget): Promise<OidcDiscovery>;
45
48
  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[]>;
49
+ /** The access token's `availableTenants` claim lists every tenant the user may act in, read once its signature checks out. */
50
+ listTenants(context: Context, accessToken: string): Promise<Tenant[]>;
51
+ /**
52
+ * The claims of an access token, once its signature has been checked against
53
+ * the realm's keys. Every caller that needs a claim goes through here: reading
54
+ * one off an unverified token would believe whatever the token says
55
+ * (AderisERP/cli#25).
56
+ */
57
+ readClaims(context: Context, accessToken: string): Promise<DecodedToken>;
48
58
  refresh(context: Context, refreshToken: string): Promise<TokenData>;
49
59
  /**
50
60
  * One refresh-token grant with the `tenant_id` form field, read by the
@@ -60,14 +70,20 @@ export declare class KeycloakProvider implements AuthProvider {
60
70
  */
61
71
  tenantSwitchRounds(context: Context, tenantId: string): AuthRound[];
62
72
  private redeem;
63
- /** Rejects a token whose `iss` differs from the discovery issuer; the API compares `iss` exactly. */
64
- private requireIssuer;
65
73
  /**
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.
74
+ * A token may legitimately carry no `tenantId`: the mapper is fail-closed for
75
+ * a `tenant_id` the session cannot reach, and a service account reaching
76
+ * several tenants gets none. That token is stored and needs a tenant named
77
+ * (`completeTenantSelection`); a token reaching no tenant at all is refused.
69
78
  */
70
79
  private requireTenant;
80
+ /**
81
+ * Checks an access token's signature against the realm's published keys, and
82
+ * its issuer along with it. Every claim the CLI reads later — the tenant, the
83
+ * reachable tenants — is taken from the claims this returns, so no caller can
84
+ * read a claim that was not checked first (AderisERP/cli#25).
85
+ */
86
+ private verifyAccessToken;
71
87
  /** Consistency check only: the id_token signature is not checked (the API checks the access token). */
72
88
  private verifyNonce;
73
89
  }
@@ -3,7 +3,8 @@ import { REDIRECT_URI } from './constants.js';
3
3
  import { AuthError } from './errors.js';
4
4
  import { requireClientId } from './guards.js';
5
5
  import { authHttp, toAuthError } from './http.js';
6
- import { decodeToken, exchangeAuthorizationCode, redeemRefreshToken, tenantIdOf, tenantsOf, toTokenData, } from './tokens.js';
6
+ import { verifyToken } from './jwks.js';
7
+ import { exchangeAuthorizationCode, redeemRefreshToken, tenantIdOf, tenantsOf, toTokenData, } from './tokens.js';
7
8
  import { buildValidatedUrl } from './validated-url.js';
8
9
  /**
9
10
  * Default Keycloak origin and realm per environment. A context may override
@@ -87,6 +88,7 @@ function validateDiscovery(url, data) {
87
88
  return {
88
89
  authorization_endpoint: requireEndpoint('authorization_endpoint'),
89
90
  issuer: requireString('issuer'),
91
+ jwks_uri: requireEndpoint('jwks_uri'),
90
92
  token_endpoint: requireEndpoint('token_endpoint'),
91
93
  };
92
94
  }
@@ -132,12 +134,13 @@ export class KeycloakProvider {
132
134
  async exchangeCode(context, { code, codeVerifier, nonce }) {
133
135
  const discovery = await this.discover(context);
134
136
  const response = await exchangeAuthorizationCode(discovery.token_endpoint, context, { code, codeVerifier });
135
- this.verifyNonce(response, nonce);
136
- return this.requireTenant(this.requireIssuer(discovery, toTokenData(response)));
137
+ await this.verifyNonce(context, discovery, response, nonce);
138
+ const claims = await this.verifyAccessToken(discovery, response.access_token);
139
+ return this.requireTenant(toTokenData(response, claims), claims);
137
140
  }
138
- /** No network call: the access token's `availableTenants` claim lists every tenant the user may act in. */
139
- async listTenants(_context, accessToken) {
140
- const decoded = decodeToken(accessToken);
141
+ /** The access token's `availableTenants` claim lists every tenant the user may act in, read once its signature checks out. */
142
+ async listTenants(context, accessToken) {
143
+ const decoded = await this.readClaims(context, accessToken);
141
144
  const current = tenantIdOf(decoded);
142
145
  const tenants = tenantsOf(decoded);
143
146
  if (tenants === undefined) {
@@ -145,6 +148,23 @@ export class KeycloakProvider {
145
148
  }
146
149
  return tenants.map(({ id, name }) => ({ isDefault: id === current, name, tenantId: id }));
147
150
  }
151
+ /**
152
+ * The claims of an access token, once its signature has been checked against
153
+ * the realm's keys. Every caller that needs a claim goes through here: reading
154
+ * one off an unverified token would believe whatever the token says
155
+ * (AderisERP/cli#25).
156
+ */
157
+ async readClaims(context, accessToken) {
158
+ const discovery = await this.discover(context);
159
+ // A stored token may well be past its five-minute expiry; that says nothing
160
+ // about who issued it. The signature and issuer still have to hold, and the
161
+ // API enforces expiry on its own.
162
+ return verifyToken(accessToken, {
163
+ ignoreExpiration: true,
164
+ issuer: discovery.issuer,
165
+ jwksUri: discovery.jwks_uri,
166
+ });
167
+ }
148
168
  refresh(context, refreshToken) {
149
169
  return this.redeem(context, refreshToken);
150
170
  }
@@ -176,37 +196,46 @@ export class KeycloakProvider {
176
196
  }
177
197
  async redeem(context, refreshToken, tenantId) {
178
198
  const discovery = await this.discover(context);
179
- const tokens = toTokenData(await redeemRefreshToken(discovery.token_endpoint, context, refreshToken, { tenantId }));
180
- return this.requireTenant(this.requireIssuer(discovery, tokens));
181
- }
182
- /** Rejects a token whose `iss` differs from the discovery issuer; the API compares `iss` exactly. */
183
- requireIssuer(discovery, tokens) {
184
- const issuer = decodeToken(tokens.access_token)?.iss;
185
- if (issuer !== discovery.issuer) {
186
- throw new AuthError(`Keycloak issued a token for issuer ${issuer ?? '(none)'}, but the realm's discovery document names ${discovery.issuer}. Aborting.`);
187
- }
188
- return tokens;
199
+ const response = await redeemRefreshToken(discovery.token_endpoint, context, refreshToken, { tenantId });
200
+ const claims = await this.verifyAccessToken(discovery, response.access_token);
201
+ return this.requireTenant(toTokenData(response, claims), claims);
189
202
  }
190
203
  /**
191
- * The mapper is fail-closed: with several reachable tenants and no `tenant_id`
192
- * the token has no `tenantId`. That token is stored and needs a tenant picked
193
- * (`completeTenantSelection`); a token reaching no tenant is refused.
204
+ * A token may legitimately carry no `tenantId`: the mapper is fail-closed for
205
+ * a `tenant_id` the session cannot reach, and a service account reaching
206
+ * several tenants gets none. That token is stored and needs a tenant named
207
+ * (`completeTenantSelection`); a token reaching no tenant at all is refused.
194
208
  */
195
- requireTenant(tokens) {
209
+ requireTenant(tokens, claims) {
196
210
  if (tokens.tenant_id)
197
211
  return tokens;
198
- const tenants = tenantsOf(decodeToken(tokens.access_token));
212
+ const tenants = tenantsOf(claims);
199
213
  if (tenants !== undefined && tenants.length > 0)
200
214
  return tokens;
201
215
  throw new AuthError('Keycloak issued a token with no tenants, so you have reached no tenant. Your user is not a member of any tenant in this realm. Contact your administrator.');
202
216
  }
217
+ /**
218
+ * Checks an access token's signature against the realm's published keys, and
219
+ * its issuer along with it. Every claim the CLI reads later — the tenant, the
220
+ * reachable tenants — is taken from the claims this returns, so no caller can
221
+ * read a claim that was not checked first (AderisERP/cli#25).
222
+ */
223
+ verifyAccessToken(discovery, accessToken) {
224
+ return verifyToken(accessToken, { issuer: discovery.issuer, jwksUri: discovery.jwks_uri });
225
+ }
203
226
  /** Consistency check only: the id_token signature is not checked (the API checks the access token). */
204
- verifyNonce(response, expectedNonce) {
227
+ async verifyNonce(context, discovery, response, expectedNonce) {
205
228
  if (!response.id_token) {
206
229
  throw new AuthError('Keycloak returned no id_token; the openid scope is missing from the client configuration.');
207
230
  }
208
- const claims = decodeToken(response.id_token);
209
- if (claims?.nonce !== expectedNonce) {
231
+ // Verified rather than decoded: a nonce read off an unverified token proves
232
+ // nothing, since anyone able to tamper with it can set the nonce too.
233
+ const claims = await verifyToken(response.id_token, {
234
+ audience: requireClientId(context),
235
+ issuer: discovery.issuer,
236
+ jwksUri: discovery.jwks_uri,
237
+ });
238
+ if (claims.nonce !== expectedNonce) {
210
239
  throw new AuthError('The id_token nonce does not match the login request. Aborting — please try again.');
211
240
  }
212
241
  }
@@ -19,9 +19,8 @@ export interface DecodedToken {
19
19
  tenantId?: string;
20
20
  tenants?: unknown;
21
21
  }
22
- export declare function decodeToken(token: string): DecodedToken | null;
23
22
  /** The tenant an access token is scoped to: `tenantId`, or the legacy `tenant_id`. */
24
- export declare function tenantIdOf(decoded: DecodedToken | null): string | undefined;
23
+ export declare function tenantIdOf(decoded: DecodedToken | null | undefined): string | undefined;
25
24
  /** One entry of the `availableTenants` claim, as the realm's tenant-claims mapper writes it. */
26
25
  export interface TenantClaim {
27
26
  id: string;
@@ -32,7 +31,13 @@ export interface TenantClaim {
32
31
  */
33
32
  export declare function tenantsOf(decoded: DecodedToken | null): TenantClaim[] | undefined;
34
33
  /** Map a token endpoint response onto the stored {@link TokenData} shape. */
35
- export declare function toTokenData(response: TokenResponse): TokenData;
34
+ /**
35
+ * Builds the stored token record from a token response and the claims that were
36
+ * verified from its access token. The claims are passed in rather than decoded
37
+ * here: a tenant read off an unverified token is attacker-controlled
38
+ * (AderisERP/cli#25).
39
+ */
40
+ export declare function toTokenData(response: TokenResponse, claims: DecodedToken): TokenData;
36
41
  /**
37
42
  * POSTs a form to a token endpoint (RFC 6749 §4.1.3 / §6). Failures surface as
38
43
  * {@link AuthError} with the HTTP status; the form never leaves this function inside an error.
@@ -1,14 +1,9 @@
1
- import jwt from 'jsonwebtoken';
2
1
  import { URLSearchParams } from 'node:url';
3
2
  import { SESSION_EXPIRED_MESSAGE } from '../config.js';
4
3
  import { FORM_POST_HEADERS, REDIRECT_URI } from './constants.js';
5
4
  import { AuthError } from './errors.js';
6
5
  import { requireClientId } from './guards.js';
7
6
  import { authHttp, toAuthError } from './http.js';
8
- export function decodeToken(token) {
9
- const decoded = jwt.decode(token);
10
- return decoded && typeof decoded === 'object' ? decoded : null;
11
- }
12
7
  /** The tenant an access token is scoped to: `tenantId`, or the legacy `tenant_id`. */
13
8
  export function tenantIdOf(decoded) {
14
9
  return decoded?.tenantId || decoded?.tenant_id;
@@ -45,12 +40,18 @@ export function tenantsOf(decoded) {
45
40
  return claim.map((entry, index) => parseTenantClaim(entry, index + 1));
46
41
  }
47
42
  /** Map a token endpoint response onto the stored {@link TokenData} shape. */
48
- export function toTokenData(response) {
43
+ /**
44
+ * Builds the stored token record from a token response and the claims that were
45
+ * verified from its access token. The claims are passed in rather than decoded
46
+ * here: a tenant read off an unverified token is attacker-controlled
47
+ * (AderisERP/cli#25).
48
+ */
49
+ export function toTokenData(response, claims) {
49
50
  return {
50
51
  access_token: response.access_token,
51
52
  expires_at: response.expires_in ? Date.now() + response.expires_in * 1000 : undefined,
52
53
  refresh_token: response.refresh_token,
53
- tenant_id: tenantIdOf(decodeToken(response.access_token)),
54
+ tenant_id: tenantIdOf(claims),
54
55
  };
55
56
  }
56
57
  /**
@@ -42,7 +42,11 @@ export interface AuthProvider {
42
42
  exchangeCode(context: Context, params: ExchangeParams): Promise<TokenData>;
43
43
  /** Tenants the user can switch to, given a valid access token. */
44
44
  listTenants(context: Context, accessToken: string): Promise<Tenant[]>;
45
- /** Refresh-token grant. A rejected refresh must surface as the "Session expired" error. */
45
+ /**
46
+ * Refresh-token grant. The tenant of the new token is the realm's to decide;
47
+ * only an explicit {@link switchTenant} names one. A rejected refresh must
48
+ * surface as the "Session expired" error.
49
+ */
46
50
  refresh(context: Context, refreshToken: string): Promise<TokenData>;
47
51
  /**
48
52
  * Re-scopes a live session to `tenantId` with one token request and no