@aderis-erp/cli 1.0.15 → 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 (182) hide show
  1. package/README.md +30 -8
  2. package/dist/commands/access-groups.d.ts +0 -1
  3. package/dist/commands/access-groups.js +54 -78
  4. package/dist/commands/account-transaction-allocations.d.ts +0 -1
  5. package/dist/commands/account-transaction-allocations.js +51 -75
  6. package/dist/commands/account-transactions.d.ts +0 -1
  7. package/dist/commands/account-transactions.js +80 -94
  8. package/dist/commands/accounts.d.ts +0 -1
  9. package/dist/commands/accounts.js +59 -83
  10. package/dist/commands/agent-platform.d.ts +0 -1
  11. package/dist/commands/agent-platform.js +58 -82
  12. package/dist/commands/agent-runs.d.ts +39 -0
  13. package/dist/commands/agent-runs.js +1155 -0
  14. package/dist/commands/analytics.d.ts +0 -1
  15. package/dist/commands/analytics.js +52 -76
  16. package/dist/commands/{text-templates.d.ts → api-keys.d.ts} +1 -2
  17. package/dist/commands/api-keys.js +1150 -0
  18. package/dist/commands/async-jobs.d.ts +39 -0
  19. package/dist/commands/async-jobs.js +1158 -0
  20. package/dist/commands/audit-log.d.ts +0 -1
  21. package/dist/commands/audit-log.js +51 -75
  22. package/dist/commands/auth/login.d.ts +9 -0
  23. package/dist/commands/auth/login.js +73 -20
  24. package/dist/commands/auth/logout.d.ts +4 -0
  25. package/dist/commands/auth/logout.js +4 -0
  26. package/dist/commands/auth/status.js +26 -17
  27. package/dist/commands/auth/tenant.js +3 -6
  28. package/dist/commands/auth.d.ts +0 -1
  29. package/dist/commands/auth.js +5 -34
  30. package/dist/commands/bank-accounts.d.ts +0 -1
  31. package/dist/commands/bank-accounts.js +68 -91
  32. package/dist/commands/bank-integrations.d.ts +1 -1
  33. package/dist/commands/bank-integrations.js +71 -92
  34. package/dist/commands/business-partners.d.ts +1 -1
  35. package/dist/commands/business-partners.js +84 -100
  36. package/dist/commands/chat.d.ts +0 -1
  37. package/dist/commands/chat.js +57 -80
  38. package/dist/commands/config/add.d.ts +3 -0
  39. package/dist/commands/config/add.js +34 -16
  40. package/dist/commands/config/current.js +3 -0
  41. package/dist/commands/config/list.js +3 -0
  42. package/dist/commands/credit-card-accounts.d.ts +1 -1
  43. package/dist/commands/credit-card-accounts.js +72 -90
  44. package/dist/commands/delivery-notes.d.ts +0 -1
  45. package/dist/commands/delivery-notes.js +83 -105
  46. package/dist/commands/dunning-configs.d.ts +0 -1
  47. package/dist/commands/dunning-configs.js +54 -78
  48. package/dist/commands/dunning-reminders.d.ts +0 -1
  49. package/dist/commands/dunning-reminders.js +57 -81
  50. package/dist/commands/dunning-runs.d.ts +0 -1
  51. package/dist/commands/dunning-runs.js +50 -74
  52. package/dist/commands/email-transactions.d.ts +0 -1
  53. package/dist/commands/email-transactions.js +52 -76
  54. package/dist/commands/employees.d.ts +0 -1
  55. package/dist/commands/employees.js +81 -105
  56. package/dist/commands/entity-configurations.d.ts +0 -1
  57. package/dist/commands/entity-configurations.js +52 -76
  58. package/dist/commands/event-processor-mappings.d.ts +0 -1
  59. package/dist/commands/event-processor-mappings.js +54 -78
  60. package/dist/commands/files.d.ts +0 -1
  61. package/dist/commands/files.js +61 -85
  62. package/dist/commands/health.d.ts +0 -1
  63. package/dist/commands/health.js +54 -78
  64. package/dist/commands/imports.d.ts +39 -0
  65. package/dist/commands/imports.js +1151 -0
  66. package/dist/commands/inbox.d.ts +0 -1
  67. package/dist/commands/inbox.js +57 -80
  68. package/dist/commands/integration-identities.d.ts +40 -0
  69. package/dist/commands/integration-identities.js +1151 -0
  70. package/dist/commands/integrations.d.ts +0 -1
  71. package/dist/commands/integrations.js +52 -76
  72. package/dist/commands/item-categories.d.ts +0 -1
  73. package/dist/commands/item-categories.js +54 -78
  74. package/dist/commands/items.d.ts +0 -1
  75. package/dist/commands/items.js +54 -78
  76. package/dist/commands/journal-lines.d.ts +0 -1
  77. package/dist/commands/journal-lines.js +50 -74
  78. package/dist/commands/journals.d.ts +1 -1
  79. package/dist/commands/journals.js +75 -96
  80. package/dist/commands/ledgers.d.ts +3 -2
  81. package/dist/commands/ledgers.js +100 -95
  82. package/dist/commands/my-account.d.ts +39 -0
  83. package/dist/commands/my-account.js +1160 -0
  84. package/dist/commands/office-locations.d.ts +0 -1
  85. package/dist/commands/office-locations.js +54 -78
  86. package/dist/commands/payment-methods.d.ts +0 -1
  87. package/dist/commands/payment-methods.js +59 -83
  88. package/dist/commands/payment-runs.d.ts +0 -1
  89. package/dist/commands/payment-runs.js +66 -90
  90. package/dist/commands/payment-terms.d.ts +0 -1
  91. package/dist/commands/payment-terms.js +55 -79
  92. package/dist/commands/payments.d.ts +0 -1
  93. package/dist/commands/payments.js +56 -80
  94. package/dist/commands/payroll-runs.d.ts +43 -0
  95. package/dist/commands/payroll-runs.js +1188 -0
  96. package/dist/commands/permissions.d.ts +39 -0
  97. package/dist/commands/permissions.js +1146 -0
  98. package/dist/commands/price-lists.d.ts +0 -1
  99. package/dist/commands/price-lists.js +59 -83
  100. package/dist/commands/processor-mappings.d.ts +0 -1
  101. package/dist/commands/processor-mappings.js +53 -77
  102. package/dist/commands/processors.d.ts +0 -1
  103. package/dist/commands/processors.js +60 -85
  104. package/dist/commands/purchase-order-types.d.ts +0 -1
  105. package/dist/commands/purchase-order-types.js +54 -78
  106. package/dist/commands/purchase-orders.d.ts +0 -1
  107. package/dist/commands/purchase-orders.js +74 -97
  108. package/dist/commands/reports.d.ts +0 -1
  109. package/dist/commands/reports.js +54 -78
  110. package/dist/commands/roles.d.ts +39 -0
  111. package/dist/commands/{text-templates.js → roles.js} +64 -90
  112. package/dist/commands/salary-types.d.ts +0 -1
  113. package/dist/commands/salary-types.js +54 -78
  114. package/dist/commands/sales-invoices.d.ts +0 -1
  115. package/dist/commands/sales-invoices.js +90 -110
  116. package/dist/commands/sales-orders.d.ts +0 -1
  117. package/dist/commands/sales-orders.js +83 -105
  118. package/dist/commands/saved-views.d.ts +0 -1
  119. package/dist/commands/saved-views.js +52 -76
  120. package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
  121. package/dist/commands/scheduled-processor-mappings.js +54 -78
  122. package/dist/commands/schedules.d.ts +0 -1
  123. package/dist/commands/schedules.js +58 -82
  124. package/dist/commands/switch.js +4 -4
  125. package/dist/commands/tag-configurations.d.ts +0 -1
  126. package/dist/commands/tag-configurations.js +56 -79
  127. package/dist/commands/tags.d.ts +0 -1
  128. package/dist/commands/tags.js +54 -78
  129. package/dist/commands/tax-codes.d.ts +0 -1
  130. package/dist/commands/tax-codes.js +57 -81
  131. package/dist/commands/tax-matrix.d.ts +0 -1
  132. package/dist/commands/tax-matrix.js +54 -78
  133. package/dist/commands/templates.d.ts +39 -0
  134. package/dist/commands/templates.js +1157 -0
  135. package/dist/commands/tenants.d.ts +0 -1
  136. package/dist/commands/tenants.js +58 -81
  137. package/dist/commands/tools.d.ts +0 -1
  138. package/dist/commands/tools.js +51 -75
  139. package/dist/commands/users.d.ts +42 -0
  140. package/dist/commands/users.js +1174 -0
  141. package/dist/commands/vat-declarations.d.ts +3 -1
  142. package/dist/commands/vat-declarations.js +78 -93
  143. package/dist/commands/vendor-invoices.d.ts +0 -1
  144. package/dist/commands/vendor-invoices.js +87 -110
  145. package/dist/commands/webhooks.d.ts +0 -1
  146. package/dist/commands/webhooks.js +56 -80
  147. package/dist/commands/workflows.d.ts +0 -1
  148. package/dist/commands/workflows.js +52 -76
  149. package/dist/lib/api-client.d.ts +6 -0
  150. package/dist/lib/api-client.js +16 -6
  151. package/dist/lib/auth/constants.d.ts +11 -0
  152. package/dist/lib/auth/constants.js +11 -0
  153. package/dist/lib/auth/errors.d.ts +17 -0
  154. package/dist/lib/auth/errors.js +22 -0
  155. package/dist/lib/auth/flow.d.ts +27 -0
  156. package/dist/lib/auth/flow.js +163 -0
  157. package/dist/lib/auth/guards.d.ts +5 -0
  158. package/dist/lib/auth/guards.js +14 -0
  159. package/dist/lib/auth/http.d.ts +16 -0
  160. package/dist/lib/auth/http.js +49 -0
  161. package/dist/lib/auth/index.d.ts +49 -0
  162. package/dist/lib/auth/index.js +228 -0
  163. package/dist/lib/auth/jwks.d.ts +19 -0
  164. package/dist/lib/auth/jwks.js +112 -0
  165. package/dist/lib/auth/keycloak-provider.d.ts +91 -0
  166. package/dist/lib/auth/keycloak-provider.js +244 -0
  167. package/dist/lib/auth/pages.d.ts +25 -0
  168. package/dist/lib/auth/pages.js +123 -0
  169. package/dist/lib/auth/pkce.d.ts +11 -0
  170. package/dist/lib/auth/pkce.js +17 -0
  171. package/dist/lib/auth/tokens.d.ts +59 -0
  172. package/dist/lib/auth/tokens.js +105 -0
  173. package/dist/lib/auth/types.d.ts +61 -0
  174. package/dist/lib/auth/types.js +1 -0
  175. package/dist/lib/auth/validated-url.d.ts +6 -0
  176. package/dist/lib/auth/validated-url.js +53 -0
  177. package/dist/lib/config.d.ts +13 -3
  178. package/dist/lib/config.js +24 -19
  179. package/oclif.manifest.json +2447 -641
  180. package/package.json +5 -4
  181. package/dist/lib/auth.d.ts +0 -43
  182. package/dist/lib/auth.js +0 -610
@@ -0,0 +1,49 @@
1
+ import { type Context, type TokenData } from '../config.js';
2
+ import { type FlowHooks } from './flow.js';
3
+ import { type Tenant } from './types.js';
4
+ export { CALLBACK_PORT, REDIRECT_URI } from './constants.js';
5
+ export { AuthError, TenantMismatchError } from './errors.js';
6
+ export { type FlowHooks } from './flow.js';
7
+ export { keycloakBaseUrlOf, keycloakProvider, parseKeycloakBaseUrl } from './keycloak-provider.js';
8
+ export { type AuthProvider, type Tenant } from './types.js';
9
+ export { buildValidatedUrl } from './validated-url.js';
10
+ export declare function login(context: Context, options?: FlowHooks & {
11
+ prompt?: 'login' | 'none';
12
+ }): Promise<TokenData>;
13
+ export declare function refreshTokens(contextName: string): Promise<TokenData>;
14
+ export declare function getValidToken(contextName: string): Promise<string>;
15
+ /**
16
+ * Auth headers for a context: a bearer token for `oauth2` (refreshed first when
17
+ * needed) or `x-api-key` for `api_key`. The one place credentials become headers.
18
+ */
19
+ export declare function getAuthHeaders(contextName: string): Promise<Record<string, string>>;
20
+ /** The user `/v1/auth/me` resolved for the session; the fields the CLI shows. */
21
+ export interface AuthenticatedUser {
22
+ email?: string;
23
+ name?: string;
24
+ }
25
+ export declare function testAuth(contextName: string): Promise<AuthenticatedUser>;
26
+ export declare function listTenants(contextName: string): Promise<Tenant[]>;
27
+ /** A tenant for display: `name (id)`, or the id alone when the name is missing or equals the id. */
28
+ export declare function tenantLabel(tenant: {
29
+ name?: string;
30
+ tenantId: string;
31
+ }): string;
32
+ export declare function getCurrentTenant(contextName: string): Promise<null | {
33
+ name?: string;
34
+ tenantId: string;
35
+ }>;
36
+ /**
37
+ * Switches the tenant of a context whose session is still valid: one
38
+ * refresh-token request carrying the tenant, asserted to have landed on
39
+ * `tenantId` and stored. No browser is involved, so there are no flow hooks.
40
+ */
41
+ export declare function switchTenant(contextName: string, tenantId: string): Promise<TokenData>;
42
+ /** Login and tenant switch for `aderis switch` on an expired session: the provider's rounds in one browser tab, asserted like {@link switchTenant}. */
43
+ export declare function loginAndSwitchTenant(context: Context, tenantId: string, hooks?: FlowHooks): Promise<TokenData>;
44
+ /**
45
+ * Gives a fresh login an active tenant when its token has none: the sole tenant
46
+ * is taken, several are offered to `choose`, and {@link switchTenant} follows.
47
+ * Resolves `null` when the stored token already has a tenant.
48
+ */
49
+ export declare function completeTenantSelection(contextName: string, choose: (tenants: Tenant[]) => Promise<string>): Promise<null | TokenData>;
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Public surface of the auth layer. OAuth contexts authenticate against
3
+ * Keycloak; the protocol details live in `keycloak-provider.ts`.
4
+ */
5
+ import axios from 'axios';
6
+ import { getAuthType, getBaseUrls, getContext, missingCredentialsMessage, saveCachedTenants, saveTokens, SESSION_EXPIRED_MESSAGE, unauthorizedMessage, } from '../config.js';
7
+ import { AuthError, TenantMismatchError } from './errors.js';
8
+ import { runBrowserFlow } from './flow.js';
9
+ import { requireOAuthContext } from './guards.js';
10
+ import { httpStatusOf } from './http.js';
11
+ import { keycloakProvider } from './keycloak-provider.js';
12
+ import { tenantIdOf } from './tokens.js';
13
+ import { buildValidatedUrl } from './validated-url.js';
14
+ export { CALLBACK_PORT, REDIRECT_URI } from './constants.js';
15
+ export { AuthError, TenantMismatchError } from './errors.js';
16
+ export { keycloakBaseUrlOf, keycloakProvider, parseKeycloakBaseUrl } from './keycloak-provider.js';
17
+ export { buildValidatedUrl } from './validated-url.js';
18
+ function requireContext(contextName) {
19
+ const context = getContext(contextName);
20
+ if (!context) {
21
+ throw new Error(`Context "${contextName}" not found`);
22
+ }
23
+ return context;
24
+ }
25
+ /** A tenant switch must have landed on the requested tenant. */
26
+ function assertTenant(tokens, tenantId) {
27
+ if (tokens.tenant_id !== tenantId) {
28
+ throw new TenantMismatchError(`Keycloak returned a token for tenant ${tokens.tenant_id ?? 'none'} instead of ${tenantId}. Please try again in a few seconds.`);
29
+ }
30
+ return tokens;
31
+ }
32
+ /** The final round of a tenant switch ends with the tenant assertion on its resulting tokens. */
33
+ function withTenantAssertion(rounds, tenantId) {
34
+ const last = rounds.at(-1);
35
+ if (!last)
36
+ return rounds;
37
+ const { afterExchange } = last;
38
+ return [
39
+ ...rounds.slice(0, -1),
40
+ {
41
+ ...last,
42
+ async afterExchange(tokens) {
43
+ return assertTenant((await afterExchange?.(tokens)) ?? tokens, tenantId);
44
+ },
45
+ },
46
+ ];
47
+ }
48
+ export async function login(context, options = {}) {
49
+ requireOAuthContext(context, 'Browser login');
50
+ return runBrowserFlow(context, keycloakProvider, [{ prompt: options.prompt }], {
51
+ log: options.log,
52
+ openBrowser: options.openBrowser,
53
+ success: {
54
+ message: { subject: context.name, tail: ` (${context.environment}).`, text: 'You have been logged in to ' },
55
+ title: 'Authentication Successful',
56
+ },
57
+ });
58
+ }
59
+ export async function refreshTokens(contextName) {
60
+ const context = requireContext(contextName);
61
+ requireOAuthContext(context, 'Token refresh');
62
+ if (!context.tokens?.refresh_token) {
63
+ throw new Error('No refresh token available. Please login again.');
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.
68
+ const refreshed = await keycloakProvider.refresh(context, context.tokens.refresh_token);
69
+ const tokenData = {
70
+ ...refreshed,
71
+ // A refresh response may omit the refresh token; the stored one stays valid.
72
+ refresh_token: refreshed.refresh_token || context.tokens.refresh_token,
73
+ };
74
+ saveTokens(contextName, tokenData);
75
+ return tokenData;
76
+ }
77
+ export async function getValidToken(contextName) {
78
+ const context = requireContext(contextName);
79
+ if (getAuthType(context) === 'api_key') {
80
+ throw new Error(`Context "${contextName}" authenticates with an API key and has no access token. Use getAuthHeaders() instead.`);
81
+ }
82
+ if (!context.tokens?.access_token) {
83
+ throw new Error('Not logged in. Please run: aderis auth login');
84
+ }
85
+ if (context.tokens.expires_at && context.tokens.expires_at < Date.now() + 60_000) {
86
+ if (context.tokens.refresh_token) {
87
+ const newTokens = await refreshTokens(contextName);
88
+ return newTokens.access_token;
89
+ }
90
+ throw new Error('Token expired. Please run: aderis auth login');
91
+ }
92
+ return context.tokens.access_token;
93
+ }
94
+ /**
95
+ * Auth headers for a context: a bearer token for `oauth2` (refreshed first when
96
+ * needed) or `x-api-key` for `api_key`. The one place credentials become headers.
97
+ */
98
+ export async function getAuthHeaders(contextName) {
99
+ const context = requireContext(contextName);
100
+ if (getAuthType(context) === 'api_key') {
101
+ if (!context.api_key) {
102
+ throw new Error(missingCredentialsMessage(context));
103
+ }
104
+ return { 'x-api-key': context.api_key };
105
+ }
106
+ return { Authorization: `Bearer ${await getValidToken(contextName)}` };
107
+ }
108
+ export async function testAuth(contextName) {
109
+ const context = requireContext(contextName);
110
+ const { api: apiUrl } = getBaseUrls(context.environment);
111
+ try {
112
+ const response = await axios.get(buildValidatedUrl(apiUrl, '/v1/auth/me'), {
113
+ headers: {
114
+ Accept: 'application/json',
115
+ ...(await getAuthHeaders(contextName)),
116
+ },
117
+ });
118
+ const { user } = response.data;
119
+ if (!user) {
120
+ throw new Error('The API answered /v1/auth/me without a user object.');
121
+ }
122
+ return user;
123
+ }
124
+ catch (error) {
125
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
126
+ throw new Error(unauthorizedMessage(context));
127
+ }
128
+ throw error;
129
+ }
130
+ }
131
+ export async function listTenants(contextName) {
132
+ const context = requireContext(contextName);
133
+ requireOAuthContext(context, 'Listing tenants');
134
+ const token = await getValidToken(contextName);
135
+ const tenants = await keycloakProvider.listTenants(context, token);
136
+ // Cached for the picker `aderis switch` shows once the access token has expired.
137
+ saveCachedTenants(contextName, tenants.map((t) => ({ name: t.name, tenantId: t.tenantId })));
138
+ return tenants;
139
+ }
140
+ /** A tenant for display: `name (id)`, or the id alone when the name is missing or equals the id. */
141
+ export function tenantLabel(tenant) {
142
+ return tenant.name && tenant.name !== tenant.tenantId ? `${tenant.name} (${tenant.tenantId})` : tenant.tenantId;
143
+ }
144
+ export async function getCurrentTenant(contextName) {
145
+ const context = requireContext(contextName);
146
+ // API-key contexts carry no JWT, so there is no tenant to read.
147
+ if (getAuthType(context) === 'api_key' || !context.tokens?.access_token) {
148
+ return null;
149
+ }
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
+ }
159
+ if (!tenantId) {
160
+ return null;
161
+ }
162
+ try {
163
+ const tenants = await listTenants(contextName);
164
+ const tenant = tenants.find((t) => t.tenantId === tenantId);
165
+ return { name: tenant?.name, tenantId };
166
+ }
167
+ catch {
168
+ return { tenantId };
169
+ }
170
+ }
171
+ /**
172
+ * Switches the tenant of a context whose session is still valid: one
173
+ * refresh-token request carrying the tenant, asserted to have landed on
174
+ * `tenantId` and stored. No browser is involved, so there are no flow hooks.
175
+ */
176
+ export async function switchTenant(contextName, tenantId) {
177
+ const context = requireContext(contextName);
178
+ requireOAuthContext(context, 'Switching tenants');
179
+ const refreshToken = context.tokens?.refresh_token;
180
+ if (!refreshToken) {
181
+ throw new Error('No refresh token available. Please login again.');
182
+ }
183
+ try {
184
+ const tokens = assertTenant(await keycloakProvider.switchTenant(context, refreshToken, tenantId), tenantId);
185
+ saveTokens(contextName, tokens);
186
+ return tokens;
187
+ }
188
+ catch (error) {
189
+ const status = httpStatusOf(error);
190
+ if (status === 400 || status === 401) {
191
+ throw new AuthError(SESSION_EXPIRED_MESSAGE, { status });
192
+ }
193
+ throw error;
194
+ }
195
+ }
196
+ /** Login and tenant switch for `aderis switch` on an expired session: the provider's rounds in one browser tab, asserted like {@link switchTenant}. */
197
+ export async function loginAndSwitchTenant(context, tenantId, hooks = {}) {
198
+ requireOAuthContext(context, 'Switching tenants');
199
+ const rounds = withTenantAssertion(keycloakProvider.tenantSwitchRounds(context, tenantId), tenantId);
200
+ return runBrowserFlow(context, keycloakProvider, rounds, {
201
+ ...hooks,
202
+ success: {
203
+ message: { subject: context.name, tail: ' on the selected tenant.', text: 'You have been logged in to ' },
204
+ title: 'Tenant Switched',
205
+ },
206
+ });
207
+ }
208
+ /**
209
+ * Gives a fresh login an active tenant when its token has none: the sole tenant
210
+ * is taken, several are offered to `choose`, and {@link switchTenant} follows.
211
+ * Resolves `null` when the stored token already has a tenant.
212
+ */
213
+ export async function completeTenantSelection(contextName, choose) {
214
+ const context = requireContext(contextName);
215
+ requireOAuthContext(context, 'Tenant selection');
216
+ if (!context.tokens?.access_token) {
217
+ throw new Error('Not logged in. Please run: aderis auth login');
218
+ }
219
+ if (context.tenant_id || tenantIdOf(await keycloakProvider.readClaims(context, context.tokens.access_token))) {
220
+ return null;
221
+ }
222
+ const tenants = await listTenants(contextName);
223
+ if (tenants.length === 0) {
224
+ throw new AuthError(`The Keycloak token for "${contextName}" lists no tenants, so you have reached no tenant. Contact your administrator.`);
225
+ }
226
+ const tenantId = tenants.length === 1 ? tenants[0].tenantId : await choose(tenants);
227
+ return switchTenant(contextName, tenantId);
228
+ }
@@ -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
+ }
@@ -0,0 +1,91 @@
1
+ import { type Context, type Environment, type TokenData } from '../config.js';
2
+ import { type DecodedToken } from './tokens.js';
3
+ import { type AuthorizeParams, type AuthProvider, type AuthRound, type ExchangeParams, type Tenant } from './types.js';
4
+ export interface KeycloakRealm {
5
+ baseUrl: string;
6
+ realm: string;
7
+ }
8
+ /**
9
+ * Default Keycloak origin and realm per environment. A context may override
10
+ * the origin with `keycloak_base_url`; a token carries the issuer of the origin
11
+ * it was requested from, and the API compares `iss` exactly.
12
+ */
13
+ export declare const KEYCLOAK_REALMS: Record<Environment, KeycloakRealm>;
14
+ /** The tenant claims need no extra scope; the tenant is selected by the CLI via `tenant_id`, not on the login page. */
15
+ export declare const KEYCLOAK_SCOPE = "openid profile email offline_access";
16
+ /** The subset of the OIDC discovery document (RFC 8414 / OIDC Discovery 1.0) the CLI uses. */
17
+ export interface OidcDiscovery {
18
+ authorization_endpoint: string;
19
+ issuer: string;
20
+ /** Where the realm publishes the signing keys every issued token is checked against. */
21
+ jwks_uri: string;
22
+ token_endpoint: string;
23
+ }
24
+ /** What decides which Keycloak a context talks to: its environment, and an optional host override. */
25
+ export type KeycloakTarget = Pick<Context, 'environment' | 'keycloak_base_url'>;
26
+ /**
27
+ * Validates a Keycloak base URL as stored in `keycloak_base_url`: https, an
28
+ * allowlisted host, and nothing but the origin. Returns the normalized origin.
29
+ */
30
+ export declare function parseKeycloakBaseUrl(value: string): string;
31
+ /** The Keycloak origin a context uses: its override, re-validated, or the environment's default. */
32
+ export declare function keycloakBaseUrlOf(target: KeycloakTarget): string;
33
+ export declare function discoveryUrlFor(target: KeycloakTarget): string;
34
+ /**
35
+ * Authorization Code + PKCE S256 with `state` and `nonce` against the public
36
+ * `aderis-cli-login` client, endpoints from OIDC discovery. Tenants come from
37
+ * the access token; a switch is a refresh-token grant carrying `tenant_id=<uuid>`.
38
+ */
39
+ export declare class KeycloakProvider implements AuthProvider {
40
+ /** Discovery documents per discovery URL, fetched once per provider instance. */
41
+ private readonly discoveryCache;
42
+ buildAuthorizeUrl(context: Context, params: AuthorizeParams): Promise<string>;
43
+ /**
44
+ * The realm's discovery document, cached per discovery URL. Its endpoints are
45
+ * validated against the host allowlist; a failed fetch is not cached.
46
+ */
47
+ discover(target: KeycloakTarget): Promise<OidcDiscovery>;
48
+ exchangeCode(context: Context, { code, codeVerifier, nonce }: ExchangeParams): Promise<TokenData>;
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>;
58
+ refresh(context: Context, refreshToken: string): Promise<TokenData>;
59
+ /**
60
+ * One refresh-token grant with the `tenant_id` form field, read by the
61
+ * realm's tenant-claims mapper: the minted token's `tenantId` becomes this
62
+ * tenant. No browser and no SSO session involved.
63
+ */
64
+ switchTenant(context: Context, refreshToken: string, tenantId: string): Promise<TokenData>;
65
+ /**
66
+ * One login round whose tokens are re-scoped to `tenantId` through
67
+ * {@link switchTenant} right after the exchange. A second browser round
68
+ * could not be silent: exchanging the code for an offline token ends the
69
+ * realm's SSO session.
70
+ */
71
+ tenantSwitchRounds(context: Context, tenantId: string): AuthRound[];
72
+ private redeem;
73
+ /**
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.
78
+ */
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;
87
+ /** Consistency check only: the id_token signature is not checked (the API checks the access token). */
88
+ private verifyNonce;
89
+ }
90
+ /** The one identity provider the CLI authenticates against. */
91
+ export declare const keycloakProvider: KeycloakProvider;