@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,244 @@
1
+ import { URL } from 'node:url';
2
+ import { REDIRECT_URI } from './constants.js';
3
+ import { AuthError } from './errors.js';
4
+ import { requireClientId } from './guards.js';
5
+ import { authHttp, toAuthError } from './http.js';
6
+ import { verifyToken } from './jwks.js';
7
+ import { exchangeAuthorizationCode, redeemRefreshToken, tenantIdOf, tenantsOf, toTokenData, } from './tokens.js';
8
+ import { buildValidatedUrl } from './validated-url.js';
9
+ /**
10
+ * Default Keycloak origin and realm per environment. A context may override
11
+ * the origin with `keycloak_base_url`; a token carries the issuer of the origin
12
+ * it was requested from, and the API compares `iss` exactly.
13
+ */
14
+ export const KEYCLOAK_REALMS = {
15
+ dev: { baseUrl: 'https://iam-dev.aderis.com', realm: 'dev' },
16
+ live: { baseUrl: 'https://iam.aderis.com', realm: 'prod' },
17
+ staging: { baseUrl: 'https://iam-staging.aderis.com', realm: 'staging' },
18
+ test: { baseUrl: 'https://iam-test.aderis.com', realm: 'test' },
19
+ };
20
+ /** The tenant claims need no extra scope; the tenant is selected by the CLI via `tenant_id`, not on the login page. */
21
+ export const KEYCLOAK_SCOPE = 'openid profile email offline_access';
22
+ /**
23
+ * Validates a Keycloak base URL as stored in `keycloak_base_url`: https, an
24
+ * allowlisted host, and nothing but the origin. Returns the normalized origin.
25
+ */
26
+ export function parseKeycloakBaseUrl(value) {
27
+ let url;
28
+ try {
29
+ url = new URL(value);
30
+ }
31
+ catch {
32
+ throw new AuthError(`"${value}" is not a valid URL`);
33
+ }
34
+ if (url.protocol !== 'https:') {
35
+ throw new AuthError(`The Keycloak URL must use https, got "${value}"`);
36
+ }
37
+ if (url.pathname !== '/' || url.search || url.hash) {
38
+ throw new AuthError(`The Keycloak URL must be an origin only, without a path or query, got "${value}"`);
39
+ }
40
+ try {
41
+ buildValidatedUrl(url.origin);
42
+ }
43
+ catch {
44
+ throw new AuthError(`"${url.host}" is not an allowed Keycloak host`);
45
+ }
46
+ return url.origin;
47
+ }
48
+ /** The Keycloak origin a context uses: its override, re-validated, or the environment's default. */
49
+ export function keycloakBaseUrlOf(target) {
50
+ return target.keycloak_base_url === undefined
51
+ ? KEYCLOAK_REALMS[target.environment].baseUrl
52
+ : parseKeycloakBaseUrl(target.keycloak_base_url);
53
+ }
54
+ export function discoveryUrlFor(target) {
55
+ const { realm } = KEYCLOAK_REALMS[target.environment];
56
+ return buildValidatedUrl(keycloakBaseUrlOf(target), `/realms/${realm}/.well-known/openid-configuration`);
57
+ }
58
+ async function fetchDiscovery(target) {
59
+ const url = discoveryUrlFor(target);
60
+ let data;
61
+ try {
62
+ const response = await authHttp.get(url, { headers: { Accept: 'application/json' } });
63
+ data = response.data;
64
+ }
65
+ catch (error) {
66
+ throw new AuthError(`Could not load the Keycloak OIDC discovery document from ${url}: ${toAuthError(error, 'the request').message}`);
67
+ }
68
+ return validateDiscovery(url, data);
69
+ }
70
+ function validateDiscovery(url, data) {
71
+ const requireString = (field) => {
72
+ const value = data[field];
73
+ if (typeof value !== 'string' || !value) {
74
+ throw new AuthError(`Keycloak discovery document at ${url} has no ${field}`);
75
+ }
76
+ return value;
77
+ };
78
+ const requireEndpoint = (field) => {
79
+ const value = requireString(field);
80
+ try {
81
+ return buildValidatedUrl(value);
82
+ }
83
+ catch {
84
+ throw new AuthError(`Keycloak discovery document at ${url} advertises a ${field} outside the allowed hosts: ${value}`);
85
+ }
86
+ };
87
+ // `end_session_endpoint` is unused: logout is local-only.
88
+ return {
89
+ authorization_endpoint: requireEndpoint('authorization_endpoint'),
90
+ issuer: requireString('issuer'),
91
+ jwks_uri: requireEndpoint('jwks_uri'),
92
+ token_endpoint: requireEndpoint('token_endpoint'),
93
+ };
94
+ }
95
+ /**
96
+ * Authorization Code + PKCE S256 with `state` and `nonce` against the public
97
+ * `aderis-cli-login` client, endpoints from OIDC discovery. Tenants come from
98
+ * the access token; a switch is a refresh-token grant carrying `tenant_id=<uuid>`.
99
+ */
100
+ export class KeycloakProvider {
101
+ /** Discovery documents per discovery URL, fetched once per provider instance. */
102
+ discoveryCache = new Map();
103
+ async buildAuthorizeUrl(context, params) {
104
+ const discovery = await this.discover(context);
105
+ const url = new URL(discovery.authorization_endpoint);
106
+ url.searchParams.set('response_type', 'code');
107
+ url.searchParams.set('client_id', requireClientId(context));
108
+ url.searchParams.set('redirect_uri', REDIRECT_URI);
109
+ url.searchParams.set('scope', KEYCLOAK_SCOPE);
110
+ url.searchParams.set('code_challenge', params.codeChallenge);
111
+ url.searchParams.set('code_challenge_method', 'S256');
112
+ url.searchParams.set('state', params.state);
113
+ url.searchParams.set('nonce', params.nonce);
114
+ if (params.prompt)
115
+ url.searchParams.set('prompt', params.prompt);
116
+ return url.toString();
117
+ }
118
+ /**
119
+ * The realm's discovery document, cached per discovery URL. Its endpoints are
120
+ * validated against the host allowlist; a failed fetch is not cached.
121
+ */
122
+ discover(target) {
123
+ const key = discoveryUrlFor(target);
124
+ const cached = this.discoveryCache.get(key);
125
+ if (cached)
126
+ return cached;
127
+ const pending = fetchDiscovery(target).catch((error) => {
128
+ this.discoveryCache.delete(key);
129
+ throw error;
130
+ });
131
+ this.discoveryCache.set(key, pending);
132
+ return pending;
133
+ }
134
+ async exchangeCode(context, { code, codeVerifier, nonce }) {
135
+ const discovery = await this.discover(context);
136
+ const response = await exchangeAuthorizationCode(discovery.token_endpoint, context, { code, codeVerifier });
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);
140
+ }
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);
144
+ const current = tenantIdOf(decoded);
145
+ const tenants = tenantsOf(decoded);
146
+ if (tenants === undefined) {
147
+ throw new AuthError("The Keycloak access token carries no `availableTenants` claim. Check the realm's tenant-claims mapper, then run: aderis auth login");
148
+ }
149
+ return tenants.map(({ id, name }) => ({ isDefault: id === current, name, tenantId: id }));
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
+ }
168
+ refresh(context, refreshToken) {
169
+ return this.redeem(context, refreshToken);
170
+ }
171
+ /**
172
+ * One refresh-token grant with the `tenant_id` form field, read by the
173
+ * realm's tenant-claims mapper: the minted token's `tenantId` becomes this
174
+ * tenant. No browser and no SSO session involved.
175
+ */
176
+ switchTenant(context, refreshToken, tenantId) {
177
+ return this.redeem(context, refreshToken, tenantId);
178
+ }
179
+ /**
180
+ * One login round whose tokens are re-scoped to `tenantId` through
181
+ * {@link switchTenant} right after the exchange. A second browser round
182
+ * could not be silent: exchanging the code for an offline token ends the
183
+ * realm's SSO session.
184
+ */
185
+ tenantSwitchRounds(context, tenantId) {
186
+ return [
187
+ {
188
+ afterExchange: (tokens) => {
189
+ if (!tokens.refresh_token) {
190
+ throw new AuthError('Keycloak returned no refresh token, so the tenant cannot be selected. Aborting.');
191
+ }
192
+ return this.switchTenant(context, tokens.refresh_token, tenantId);
193
+ },
194
+ },
195
+ ];
196
+ }
197
+ async redeem(context, refreshToken, tenantId) {
198
+ const discovery = await this.discover(context);
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);
202
+ }
203
+ /**
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.
208
+ */
209
+ requireTenant(tokens, claims) {
210
+ if (tokens.tenant_id)
211
+ return tokens;
212
+ const tenants = tenantsOf(claims);
213
+ if (tenants !== undefined && tenants.length > 0)
214
+ return tokens;
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.');
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
+ }
226
+ /** Consistency check only: the id_token signature is not checked (the API checks the access token). */
227
+ async verifyNonce(context, discovery, response, expectedNonce) {
228
+ if (!response.id_token) {
229
+ throw new AuthError('Keycloak returned no id_token; the openid scope is missing from the client configuration.');
230
+ }
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) {
239
+ throw new AuthError('The id_token nonce does not match the login request. Aborting — please try again.');
240
+ }
241
+ }
242
+ }
243
+ /** The one identity provider the CLI authenticates against. */
244
+ export const keycloakProvider = new KeycloakProvider();
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pages the loopback callback server serves. Everything interpolated into
3
+ * markup goes through {@link escapeHtml} (`message` can carry
4
+ * `error_description` from the authorization server); callers pass data, never markup.
5
+ */
6
+ /** A message with an optional emphasised subject between `text` and `tail`. */
7
+ export interface PageMessage {
8
+ /** Rendered in bold between `text` and `tail`. */
9
+ subject?: string;
10
+ /** Plain text after the subject. */
11
+ tail?: string;
12
+ /** Plain text before the subject. */
13
+ text: string;
14
+ }
15
+ /** Escapes a string for HTML text or a double-quoted attribute value. */
16
+ export declare function escapeHtml(value: string): string;
17
+ /**
18
+ * The page shown once the callback has been handled. The success page counts
19
+ * down and tries to close its tab; the error page never does.
20
+ */
21
+ export declare function renderAuthPage(type: 'error' | 'success', title: string, message: PageMessage | string): string;
22
+ /**
23
+ * Page that sends the browser tab into the next authorization round via meta refresh.
24
+ */
25
+ export declare function renderRedirectPage(nextUrl: string): string;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Pages the loopback callback server serves. Everything interpolated into
3
+ * markup goes through {@link escapeHtml} (`message` can carry
4
+ * `error_description` from the authorization server); callers pass data, never markup.
5
+ */
6
+ const ADERIS_LOGO_URL = 'https://public.aderis.com/69da47c9-274a-4103-bc3e-d9123fd13fac.png';
7
+ /** Seconds the success page counts down before it tries to close its tab. */
8
+ const AUTO_CLOSE_SECONDS = 3;
9
+ const CLOSE_HINT = 'You can close this tab and return to the CLI.';
10
+ /** Escapes a string for HTML text or a double-quoted attribute value. */
11
+ export function escapeHtml(value) {
12
+ return value
13
+ .replaceAll('&', '&amp;')
14
+ .replaceAll('<', '&lt;')
15
+ .replaceAll('>', '&gt;')
16
+ .replaceAll('"', '&quot;')
17
+ .replaceAll("'", '&#39;');
18
+ }
19
+ /** Palette of the `aderis` Keycloak login theme. */
20
+ const PALETTE_CSS = [
21
+ '--aderis-purple:#9687F5',
22
+ '--aderis-purple-hover:#7d6cf0',
23
+ '--aderis-purple-light:#C1B8FA',
24
+ '--aderis-navy:#3C4A5A',
25
+ '--aderis-muted:#827D93',
26
+ '--aderis-border:#e2ddf8',
27
+ '--aderis-input-bg:#f3f1fe',
28
+ ].join(';');
29
+ const SHARED_CSS = `
30
+ :root{${PALETTE_CSS}}
31
+ *{margin:0;padding:0;box-sizing:border-box}
32
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:linear-gradient(135deg,#fff 0%,var(--aderis-input-bg) 100%);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;color:var(--aderis-navy)}
33
+ .container{background:#fff;border-radius:16px;box-shadow:0 10px 40px rgba(150,135,245,.15);padding:48px;max-width:440px;width:100%;text-align:center;border:1px solid var(--aderis-border)}
34
+ .logo{margin-bottom:32px}
35
+ .logo img{height:40px;width:auto}
36
+ .icon{margin-bottom:24px}
37
+ .icon.success{color:var(--aderis-purple)}
38
+ .icon.error{color:#dc2626}
39
+ h1{font-size:24px;font-weight:600;color:var(--aderis-navy);margin-bottom:12px}
40
+ .message{font-size:16px;color:var(--aderis-muted);line-height:1.6;margin-bottom:24px}
41
+ .message strong{color:var(--aderis-navy);font-weight:600}
42
+ .hint{font-size:14px;color:var(--aderis-muted);margin-top:24px}
43
+ .divider{height:1px;background:linear-gradient(90deg,transparent,var(--aderis-border),transparent);margin:24px 0}
44
+ .spinner{width:40px;height:40px;margin:0 auto 24px;border:4px solid var(--aderis-purple-light);border-top-color:var(--aderis-purple);border-radius:50%;animation:spin .8s linear infinite}
45
+ @keyframes spin{to{transform:rotate(360deg)}}
46
+ `;
47
+ const SUCCESS_ICON = `<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 12l3 3 5-6"/></svg>`;
48
+ const ERROR_ICON = `<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>`;
49
+ /**
50
+ * Counts down, then asks the browser to close the tab; browsers only close
51
+ * script-opened tabs, so the hint then tells the user to close it.
52
+ */
53
+ const AUTO_CLOSE_SCRIPT = `<script>
54
+ (function(){
55
+ var count=${AUTO_CLOSE_SECONDS};
56
+ var hint=document.getElementById('hint');
57
+ var counter=document.getElementById('count');
58
+ var tick=setInterval(function(){
59
+ count-=1;
60
+ if(count>0){counter.textContent=String(count);return;}
61
+ clearInterval(tick);
62
+ window.close();
63
+ setTimeout(function(){hint.textContent=${JSON.stringify(CLOSE_HINT)};},300);
64
+ },1000);
65
+ })();
66
+ </script>`;
67
+ function renderShell(options) {
68
+ return `<!DOCTYPE html>
69
+ <html lang="en">
70
+ <head>
71
+ <meta charset="UTF-8">
72
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
73
+ ${options.head ?? ''}
74
+ <title>${escapeHtml(options.title)} - Aderis CLI</title>
75
+ <style>${SHARED_CSS}</style>
76
+ </head>
77
+ <body>
78
+ <div class="container">
79
+ <div class="logo"><img src="${ADERIS_LOGO_URL}" alt="Aderis" /></div>
80
+ ${options.body}
81
+ </div>
82
+ ${options.script ?? ''}
83
+ </body>
84
+ </html>`;
85
+ }
86
+ function renderMessage(message) {
87
+ if (typeof message === 'string') {
88
+ return escapeHtml(message);
89
+ }
90
+ const subject = message.subject === undefined ? '' : `<strong>${escapeHtml(message.subject)}</strong>`;
91
+ return `${escapeHtml(message.text)}${subject}${escapeHtml(message.tail ?? '')}`;
92
+ }
93
+ /**
94
+ * The page shown once the callback has been handled. The success page counts
95
+ * down and tries to close its tab; the error page never does.
96
+ */
97
+ export function renderAuthPage(type, title, message) {
98
+ const isSuccess = type === 'success';
99
+ const hint = isSuccess
100
+ ? `<p class="hint" id="hint">This tab closes in <span id="count">${AUTO_CLOSE_SECONDS}</span>…</p>`
101
+ : `<p class="hint">${CLOSE_HINT}</p>`;
102
+ return renderShell({
103
+ body: ` <div class="icon ${type}">${isSuccess ? SUCCESS_ICON : ERROR_ICON}</div>
104
+ <h1>${escapeHtml(title)}</h1>
105
+ <p class="message">${renderMessage(message)}</p>
106
+ <div class="divider"></div>
107
+ ${hint}`,
108
+ script: isSuccess ? AUTO_CLOSE_SCRIPT : undefined,
109
+ title,
110
+ });
111
+ }
112
+ /**
113
+ * Page that sends the browser tab into the next authorization round via meta refresh.
114
+ */
115
+ export function renderRedirectPage(nextUrl) {
116
+ return renderShell({
117
+ body: ` <div class="spinner"></div>
118
+ <h1>Switching tenant…</h1>
119
+ <p class="message">Taking you back to the login page.</p>`,
120
+ head: `<meta http-equiv="refresh" content="0; url=${escapeHtml(nextUrl)}">`,
121
+ title: 'Switching tenant…',
122
+ });
123
+ }
@@ -0,0 +1,11 @@
1
+ export interface PkceChallenge {
2
+ challenge: string;
3
+ verifier: string;
4
+ }
5
+ /** RFC 7636 S256: base64url(sha256(verifier)). */
6
+ export declare function codeChallengeFor(verifier: string): string;
7
+ export declare function generatePkce(): PkceChallenge;
8
+ /** Opaque per-request value echoed back on the callback to bind it to this login. */
9
+ export declare function generateState(): string;
10
+ /** Opaque per-request value that must reappear in the id_token (OIDC replay protection). */
11
+ export declare function generateNonce(): string;
@@ -0,0 +1,17 @@
1
+ import * as crypto from 'node:crypto';
2
+ /** RFC 7636 S256: base64url(sha256(verifier)). */
3
+ export function codeChallengeFor(verifier) {
4
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
5
+ }
6
+ export function generatePkce() {
7
+ const verifier = crypto.randomBytes(32).toString('base64url');
8
+ return { challenge: codeChallengeFor(verifier), verifier };
9
+ }
10
+ /** Opaque per-request value echoed back on the callback to bind it to this login. */
11
+ export function generateState() {
12
+ return crypto.randomBytes(16).toString('hex');
13
+ }
14
+ /** Opaque per-request value that must reappear in the id_token (OIDC replay protection). */
15
+ export function generateNonce() {
16
+ return crypto.randomBytes(16).toString('hex');
17
+ }
@@ -0,0 +1,59 @@
1
+ import { URLSearchParams } from 'node:url';
2
+ import { type Context, type TokenData } from '../config.js';
3
+ /** Wire shape of an OAuth token endpoint response (RFC 6749 §5.1, plus OIDC `id_token`). */
4
+ export interface TokenResponse {
5
+ access_token: string;
6
+ expires_in?: number;
7
+ id_token?: string;
8
+ refresh_token?: string;
9
+ token_type: string;
10
+ }
11
+ /** Claims the CLI reads from an access token. Decoded without signature verification — the API verifies. */
12
+ export interface DecodedToken {
13
+ [claim: string]: unknown;
14
+ availableTenants?: unknown;
15
+ exp?: number;
16
+ iss?: string;
17
+ nonce?: string;
18
+ tenant_id?: string;
19
+ tenantId?: string;
20
+ tenants?: unknown;
21
+ }
22
+ /** The tenant an access token is scoped to: `tenantId`, or the legacy `tenant_id`. */
23
+ export declare function tenantIdOf(decoded: DecodedToken | null | undefined): string | undefined;
24
+ /** One entry of the `availableTenants` claim, as the realm's tenant-claims mapper writes it. */
25
+ export interface TenantClaim {
26
+ id: string;
27
+ name: string;
28
+ }
29
+ /**
30
+ * The token's tenant list, or `undefined` when it carries none. Throws on a malformed claim.
31
+ */
32
+ export declare function tenantsOf(decoded: DecodedToken | null): TenantClaim[] | undefined;
33
+ /** Map a token endpoint response onto the stored {@link TokenData} shape. */
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;
41
+ /**
42
+ * POSTs a form to a token endpoint (RFC 6749 §4.1.3 / §6). Failures surface as
43
+ * {@link AuthError} with the HTTP status; the form never leaves this function inside an error.
44
+ */
45
+ export declare function postTokenRequest(tokenUrl: string, params: URLSearchParams): Promise<TokenResponse>;
46
+ /** The PKCE authorization-code grant (RFC 7636 §4.5) against `tokenUrl`. */
47
+ export declare function exchangeAuthorizationCode(tokenUrl: string, context: Context, { code, codeVerifier }: {
48
+ code: string;
49
+ codeVerifier: string;
50
+ }): Promise<TokenResponse>;
51
+ /**
52
+ * The refresh-token grant (RFC 6749 §6). `tenantId` is sent as the `tenant_id`
53
+ * form field, which selects the tenant of the minted token. A 400
54
+ * (`invalid_grant`) or 401 is reported as {@link SESSION_EXPIRED_MESSAGE},
55
+ * which `aderis switch` recognises.
56
+ */
57
+ export declare function redeemRefreshToken(tokenUrl: string, context: Context, refreshToken: string, { tenantId }?: {
58
+ tenantId?: string;
59
+ }): Promise<TokenResponse>;
@@ -0,0 +1,105 @@
1
+ import { URLSearchParams } from 'node:url';
2
+ import { SESSION_EXPIRED_MESSAGE } from '../config.js';
3
+ import { FORM_POST_HEADERS, REDIRECT_URI } from './constants.js';
4
+ import { AuthError } from './errors.js';
5
+ import { requireClientId } from './guards.js';
6
+ import { authHttp, toAuthError } from './http.js';
7
+ /** The tenant an access token is scoped to: `tenantId`, or the legacy `tenant_id`. */
8
+ export function tenantIdOf(decoded) {
9
+ return decoded?.tenantId || decoded?.tenant_id;
10
+ }
11
+ function malformedTenantsClaim(detail) {
12
+ return new AuthError(`The access token's \`availableTenants\` claim is malformed (${detail}; expected a list of {id, name} objects). Check the realm's tenant-claims mapper, then run: aderis auth login`);
13
+ }
14
+ function parseTenantClaim(entry, position) {
15
+ // A bare string is an id without a name.
16
+ if (typeof entry === 'string' && entry !== '')
17
+ return { id: entry, name: entry };
18
+ if (entry === null || typeof entry !== 'object') {
19
+ throw malformedTenantsClaim(`entry ${position} is not an object`);
20
+ }
21
+ const { id, name } = entry;
22
+ if (typeof id !== 'string' || id === '') {
23
+ throw malformedTenantsClaim(`entry ${position} has no id`);
24
+ }
25
+ if (name !== undefined && typeof name !== 'string') {
26
+ throw malformedTenantsClaim(`entry ${position} has a non-string name`);
27
+ }
28
+ return { id, name: name || id };
29
+ }
30
+ /**
31
+ * The token's tenant list, or `undefined` when it carries none. Throws on a malformed claim.
32
+ */
33
+ export function tenantsOf(decoded) {
34
+ // TODO(cli#13): remove the tenants fallback
35
+ const claim = decoded?.availableTenants ?? decoded?.tenants;
36
+ if (claim === undefined || claim === null)
37
+ return undefined;
38
+ if (!Array.isArray(claim))
39
+ throw malformedTenantsClaim(`got ${typeof claim}`);
40
+ return claim.map((entry, index) => parseTenantClaim(entry, index + 1));
41
+ }
42
+ /** Map a token endpoint response onto the stored {@link TokenData} shape. */
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) {
50
+ return {
51
+ access_token: response.access_token,
52
+ expires_at: response.expires_in ? Date.now() + response.expires_in * 1000 : undefined,
53
+ refresh_token: response.refresh_token,
54
+ tenant_id: tenantIdOf(claims),
55
+ };
56
+ }
57
+ /**
58
+ * POSTs a form to a token endpoint (RFC 6749 §4.1.3 / §6). Failures surface as
59
+ * {@link AuthError} with the HTTP status; the form never leaves this function inside an error.
60
+ */
61
+ export async function postTokenRequest(tokenUrl, params) {
62
+ try {
63
+ const response = await authHttp.post(tokenUrl, params.toString(), { headers: FORM_POST_HEADERS });
64
+ return response.data;
65
+ }
66
+ catch (error) {
67
+ throw toAuthError(error, `Token request to ${tokenUrl}`);
68
+ }
69
+ }
70
+ /** `client_id`, plus `client_secret` when the context is confidential — the part every grant shares. */
71
+ function clientParams(context, params) {
72
+ params.set('client_id', requireClientId(context));
73
+ if (context.client_secret)
74
+ params.set('client_secret', context.client_secret);
75
+ return params;
76
+ }
77
+ /** The PKCE authorization-code grant (RFC 7636 §4.5) against `tokenUrl`. */
78
+ export async function exchangeAuthorizationCode(tokenUrl, context, { code, codeVerifier }) {
79
+ const params = clientParams(context, new URLSearchParams({ grant_type: 'authorization_code' }));
80
+ params.set('code', code);
81
+ params.set('redirect_uri', REDIRECT_URI);
82
+ params.set('code_verifier', codeVerifier);
83
+ return postTokenRequest(tokenUrl, params);
84
+ }
85
+ /**
86
+ * The refresh-token grant (RFC 6749 §6). `tenantId` is sent as the `tenant_id`
87
+ * form field, which selects the tenant of the minted token. A 400
88
+ * (`invalid_grant`) or 401 is reported as {@link SESSION_EXPIRED_MESSAGE},
89
+ * which `aderis switch` recognises.
90
+ */
91
+ export async function redeemRefreshToken(tokenUrl, context, refreshToken, { tenantId } = {}) {
92
+ const params = clientParams(context, new URLSearchParams({ grant_type: 'refresh_token' }));
93
+ params.set('refresh_token', refreshToken);
94
+ if (tenantId)
95
+ params.set('tenant_id', tenantId);
96
+ try {
97
+ return await postTokenRequest(tokenUrl, params);
98
+ }
99
+ catch (error) {
100
+ if (error instanceof AuthError && (error.status === 400 || error.status === 401)) {
101
+ throw new AuthError(SESSION_EXPIRED_MESSAGE, { status: error.status });
102
+ }
103
+ throw error;
104
+ }
105
+ }
@@ -0,0 +1,61 @@
1
+ import { type Context, type TokenData } from '../config.js';
2
+ export interface Tenant {
3
+ createdAt?: string;
4
+ isDefault?: boolean;
5
+ name: string;
6
+ tenantId: string;
7
+ }
8
+ /** Inputs to one authorization request. The flow generates them; the provider decides which to send. */
9
+ export interface AuthorizeParams {
10
+ /** PKCE S256 challenge derived from this round's verifier. */
11
+ codeChallenge: string;
12
+ /** OIDC nonce; providers that support it echo it in the id_token. */
13
+ nonce: string;
14
+ prompt?: 'login' | 'none';
15
+ state: string;
16
+ }
17
+ export interface ExchangeParams {
18
+ code: string;
19
+ codeVerifier: string;
20
+ /** The nonce sent with the matching authorization request, for id_token verification. */
21
+ nonce: string;
22
+ }
23
+ /** One browser round of a login or tenant switch; the flow adds PKCE, state, nonce and the URL via the provider. */
24
+ export interface AuthRound {
25
+ /**
26
+ * Runs after this round's tokens are exchanged and stored. Tokens it returns
27
+ * replace them as the round's result and are stored too; a throw aborts the flow.
28
+ */
29
+ afterExchange?: (tokens: TokenData) => Promise<TokenData | void>;
30
+ /** Forwarded to {@link AuthProvider.buildAuthorizeUrl} as `prompt`. */
31
+ prompt?: 'login' | 'none';
32
+ }
33
+ /**
34
+ * The seam between the login orchestration (`flow.ts`, `index.ts`) and Keycloak.
35
+ * The implementation owns endpoints, scopes and token semantics; it never starts
36
+ * servers or opens browsers.
37
+ */
38
+ export interface AuthProvider {
39
+ /** The URL the browser is sent to. Async because Keycloak resolves endpoints via OIDC discovery. */
40
+ buildAuthorizeUrl(context: Context, params: AuthorizeParams): Promise<string>;
41
+ /** Redeem the authorization code. Must validate the id_token nonce when the provider issues one. */
42
+ exchangeCode(context: Context, params: ExchangeParams): Promise<TokenData>;
43
+ /** Tenants the user can switch to, given a valid access token. */
44
+ listTenants(context: Context, accessToken: string): Promise<Tenant[]>;
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
+ */
50
+ refresh(context: Context, refreshToken: string): Promise<TokenData>;
51
+ /**
52
+ * Re-scopes a live session to `tenantId` with one token request and no
53
+ * browser. A rejected refresh must surface as the "Session expired" error.
54
+ */
55
+ switchTenant(context: Context, refreshToken: string, tenantId: string): Promise<TokenData>;
56
+ /**
57
+ * The browser rounds that end with a token for `tenantId` when no live
58
+ * session exists; the flow appends the tenant assertion.
59
+ */
60
+ tenantSwitchRounds(context: Context, tenantId: string): AuthRound[];
61
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Validates `baseUrl` against the host allowlist and optionally appends `path`.
3
+ * Throws `Error('Invalid URL')` for anything off-list, non-http(s), or
4
+ * containing path traversal.
5
+ */
6
+ export declare function buildValidatedUrl(baseUrl: string, path?: string): string;