@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.
- package/dist/commands/access-groups.js +49 -17
- package/dist/commands/account-transaction-allocations.js +46 -14
- package/dist/commands/account-transactions.js +75 -35
- package/dist/commands/accounts.js +54 -22
- package/dist/commands/agent-platform.js +53 -21
- package/dist/commands/{nango-integrations.d.ts → agent-runs.d.ts} +1 -1
- package/dist/commands/agent-runs.js +1155 -0
- package/dist/commands/analytics.js +47 -15
- package/dist/commands/api-keys.js +49 -16
- package/dist/commands/{text-templates.d.ts → async-jobs.d.ts} +1 -1
- package/dist/commands/async-jobs.js +1158 -0
- package/dist/commands/audit-log.js +46 -14
- package/dist/commands/bank-accounts.js +63 -30
- package/dist/commands/bank-integrations.d.ts +1 -0
- package/dist/commands/bank-integrations.js +66 -31
- package/dist/commands/business-partners.js +77 -44
- package/dist/commands/chat.js +52 -19
- package/dist/commands/credit-card-accounts.d.ts +1 -0
- package/dist/commands/credit-card-accounts.js +67 -29
- package/dist/commands/delivery-notes.js +78 -44
- package/dist/commands/dunning-configs.js +49 -17
- package/dist/commands/dunning-reminders.js +52 -20
- package/dist/commands/dunning-runs.js +45 -13
- package/dist/commands/email-transactions.js +47 -15
- package/dist/commands/employees.js +76 -44
- package/dist/commands/entity-configurations.js +47 -15
- package/dist/commands/event-processor-mappings.js +49 -17
- package/dist/commands/files.js +56 -24
- package/dist/commands/health.js +49 -17
- package/dist/commands/imports.d.ts +39 -0
- package/dist/commands/{nango-integrations.js → imports.js} +61 -25
- package/dist/commands/inbox.js +52 -19
- package/dist/commands/integration-identities.d.ts +40 -0
- package/dist/commands/integration-identities.js +1151 -0
- package/dist/commands/integrations.js +47 -15
- package/dist/commands/item-categories.js +49 -17
- package/dist/commands/items.js +49 -17
- package/dist/commands/journal-lines.js +45 -13
- package/dist/commands/journals.js +68 -36
- package/dist/commands/ledgers.d.ts +3 -1
- package/dist/commands/ledgers.js +95 -34
- package/dist/commands/my-account.d.ts +39 -0
- package/dist/commands/my-account.js +1160 -0
- package/dist/commands/office-locations.js +49 -17
- package/dist/commands/payment-methods.js +54 -22
- package/dist/commands/payment-runs.js +61 -29
- package/dist/commands/payment-terms.js +50 -18
- package/dist/commands/payments.js +51 -19
- package/dist/commands/payroll-runs.d.ts +43 -0
- package/dist/commands/payroll-runs.js +1188 -0
- package/dist/commands/permissions.d.ts +39 -0
- package/dist/commands/{text-templates.js → permissions.js} +54 -30
- package/dist/commands/price-lists.js +54 -22
- package/dist/commands/processor-mappings.js +48 -16
- package/dist/commands/processors.js +55 -24
- package/dist/commands/purchase-order-types.js +49 -17
- package/dist/commands/purchase-orders.js +69 -36
- package/dist/commands/reports.js +49 -17
- package/dist/commands/roles.d.ts +39 -0
- package/dist/commands/roles.js +1152 -0
- package/dist/commands/salary-types.js +49 -17
- package/dist/commands/sales-invoices.js +84 -51
- package/dist/commands/sales-orders.js +78 -44
- package/dist/commands/saved-views.js +47 -15
- package/dist/commands/scheduled-processor-mappings.js +49 -17
- package/dist/commands/schedules.js +53 -21
- package/dist/commands/tag-configurations.js +50 -18
- package/dist/commands/tags.js +49 -17
- package/dist/commands/tax-codes.js +52 -20
- package/dist/commands/tax-matrix.js +49 -17
- package/dist/commands/templates.d.ts +39 -0
- package/dist/commands/templates.js +1157 -0
- package/dist/commands/tenants.js +52 -20
- package/dist/commands/tools.js +46 -14
- package/dist/commands/users.d.ts +42 -0
- package/dist/commands/users.js +1174 -0
- package/dist/commands/vat-declarations.d.ts +2 -0
- package/dist/commands/vat-declarations.js +72 -36
- package/dist/commands/vendor-invoices.js +81 -49
- package/dist/commands/webhooks.js +51 -19
- package/dist/commands/workflows.js +47 -15
- package/dist/lib/api-client.d.ts +6 -0
- package/dist/lib/api-client.js +13 -0
- package/dist/lib/auth/index.js +15 -4
- package/dist/lib/auth/jwks.d.ts +19 -0
- package/dist/lib/auth/jwks.js +112 -0
- package/dist/lib/auth/keycloak-provider.d.ts +23 -7
- package/dist/lib/auth/keycloak-provider.js +53 -24
- package/dist/lib/auth/tokens.d.ts +8 -3
- package/dist/lib/auth/tokens.js +8 -7
- package/dist/lib/auth/types.d.ts +5 -1
- package/oclif.manifest.json +2152 -715
- 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:
|
|
261
|
+
// Binary response: to -f / --file, or to stdout when it is redirected.
|
|
262
262
|
if (endpoint.isBinaryResponse) {
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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;
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -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;
|
package/dist/lib/auth/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
47
|
-
listTenants(
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
139
|
-
async listTenants(
|
|
140
|
-
const decoded =
|
|
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
|
|
180
|
-
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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(
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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.
|
package/dist/lib/auth/tokens.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
54
|
+
tenant_id: tenantIdOf(claims),
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
57
|
/**
|
package/dist/lib/auth/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|