@aderis-erp/cli 1.0.15 → 1.0.17

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 (163) 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 +5 -61
  4. package/dist/commands/account-transaction-allocations.d.ts +0 -1
  5. package/dist/commands/account-transaction-allocations.js +5 -61
  6. package/dist/commands/account-transactions.d.ts +0 -1
  7. package/dist/commands/account-transactions.js +9 -63
  8. package/dist/commands/accounts.d.ts +0 -1
  9. package/dist/commands/accounts.js +5 -61
  10. package/dist/commands/agent-platform.d.ts +0 -1
  11. package/dist/commands/agent-platform.js +5 -61
  12. package/dist/commands/analytics.d.ts +0 -1
  13. package/dist/commands/analytics.js +5 -61
  14. package/dist/commands/api-keys.d.ts +39 -0
  15. package/dist/commands/api-keys.js +1117 -0
  16. package/dist/commands/audit-log.d.ts +0 -1
  17. package/dist/commands/audit-log.js +5 -61
  18. package/dist/commands/auth/login.d.ts +9 -0
  19. package/dist/commands/auth/login.js +73 -20
  20. package/dist/commands/auth/logout.d.ts +4 -0
  21. package/dist/commands/auth/logout.js +4 -0
  22. package/dist/commands/auth/status.js +26 -17
  23. package/dist/commands/auth/tenant.js +3 -6
  24. package/dist/commands/auth.d.ts +0 -1
  25. package/dist/commands/auth.js +5 -34
  26. package/dist/commands/bank-accounts.d.ts +0 -1
  27. package/dist/commands/bank-accounts.js +5 -61
  28. package/dist/commands/bank-integrations.d.ts +0 -1
  29. package/dist/commands/bank-integrations.js +5 -61
  30. package/dist/commands/business-partners.d.ts +1 -1
  31. package/dist/commands/business-partners.js +14 -63
  32. package/dist/commands/chat.d.ts +0 -1
  33. package/dist/commands/chat.js +5 -61
  34. package/dist/commands/config/add.d.ts +3 -0
  35. package/dist/commands/config/add.js +34 -16
  36. package/dist/commands/config/current.js +3 -0
  37. package/dist/commands/config/list.js +3 -0
  38. package/dist/commands/credit-card-accounts.d.ts +0 -1
  39. package/dist/commands/credit-card-accounts.js +5 -61
  40. package/dist/commands/delivery-notes.d.ts +0 -1
  41. package/dist/commands/delivery-notes.js +7 -63
  42. package/dist/commands/dunning-configs.d.ts +0 -1
  43. package/dist/commands/dunning-configs.js +5 -61
  44. package/dist/commands/dunning-reminders.d.ts +0 -1
  45. package/dist/commands/dunning-reminders.js +5 -61
  46. package/dist/commands/dunning-runs.d.ts +0 -1
  47. package/dist/commands/dunning-runs.js +5 -61
  48. package/dist/commands/email-transactions.d.ts +0 -1
  49. package/dist/commands/email-transactions.js +5 -61
  50. package/dist/commands/employees.d.ts +0 -1
  51. package/dist/commands/employees.js +5 -61
  52. package/dist/commands/entity-configurations.d.ts +0 -1
  53. package/dist/commands/entity-configurations.js +5 -61
  54. package/dist/commands/event-processor-mappings.d.ts +0 -1
  55. package/dist/commands/event-processor-mappings.js +5 -61
  56. package/dist/commands/files.d.ts +0 -1
  57. package/dist/commands/files.js +5 -61
  58. package/dist/commands/health.d.ts +0 -1
  59. package/dist/commands/health.js +5 -61
  60. package/dist/commands/inbox.d.ts +0 -1
  61. package/dist/commands/inbox.js +5 -61
  62. package/dist/commands/integrations.d.ts +0 -1
  63. package/dist/commands/integrations.js +5 -61
  64. package/dist/commands/item-categories.d.ts +0 -1
  65. package/dist/commands/item-categories.js +5 -61
  66. package/dist/commands/items.d.ts +0 -1
  67. package/dist/commands/items.js +5 -61
  68. package/dist/commands/journal-lines.d.ts +0 -1
  69. package/dist/commands/journal-lines.js +5 -61
  70. package/dist/commands/journals.d.ts +1 -1
  71. package/dist/commands/journals.js +9 -62
  72. package/dist/commands/ledgers.d.ts +0 -1
  73. package/dist/commands/ledgers.js +5 -61
  74. package/dist/commands/nango-integrations.d.ts +39 -0
  75. package/dist/commands/nango-integrations.js +1115 -0
  76. package/dist/commands/office-locations.d.ts +0 -1
  77. package/dist/commands/office-locations.js +5 -61
  78. package/dist/commands/payment-methods.d.ts +0 -1
  79. package/dist/commands/payment-methods.js +5 -61
  80. package/dist/commands/payment-runs.d.ts +0 -1
  81. package/dist/commands/payment-runs.js +5 -61
  82. package/dist/commands/payment-terms.d.ts +0 -1
  83. package/dist/commands/payment-terms.js +5 -61
  84. package/dist/commands/payments.d.ts +0 -1
  85. package/dist/commands/payments.js +5 -61
  86. package/dist/commands/price-lists.d.ts +0 -1
  87. package/dist/commands/price-lists.js +5 -61
  88. package/dist/commands/processor-mappings.d.ts +0 -1
  89. package/dist/commands/processor-mappings.js +5 -61
  90. package/dist/commands/processors.d.ts +0 -1
  91. package/dist/commands/processors.js +5 -61
  92. package/dist/commands/purchase-order-types.d.ts +0 -1
  93. package/dist/commands/purchase-order-types.js +5 -61
  94. package/dist/commands/purchase-orders.d.ts +0 -1
  95. package/dist/commands/purchase-orders.js +7 -63
  96. package/dist/commands/reports.d.ts +0 -1
  97. package/dist/commands/reports.js +6 -62
  98. package/dist/commands/salary-types.d.ts +0 -1
  99. package/dist/commands/salary-types.js +5 -61
  100. package/dist/commands/sales-invoices.d.ts +0 -1
  101. package/dist/commands/sales-invoices.js +12 -65
  102. package/dist/commands/sales-orders.d.ts +0 -1
  103. package/dist/commands/sales-orders.js +7 -63
  104. package/dist/commands/saved-views.d.ts +0 -1
  105. package/dist/commands/saved-views.js +5 -61
  106. package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
  107. package/dist/commands/scheduled-processor-mappings.js +5 -61
  108. package/dist/commands/schedules.d.ts +0 -1
  109. package/dist/commands/schedules.js +5 -61
  110. package/dist/commands/switch.js +4 -4
  111. package/dist/commands/tag-configurations.d.ts +0 -1
  112. package/dist/commands/tag-configurations.js +9 -64
  113. package/dist/commands/tags.d.ts +0 -1
  114. package/dist/commands/tags.js +5 -61
  115. package/dist/commands/tax-codes.d.ts +0 -1
  116. package/dist/commands/tax-codes.js +5 -61
  117. package/dist/commands/tax-matrix.d.ts +0 -1
  118. package/dist/commands/tax-matrix.js +5 -61
  119. package/dist/commands/tenants.d.ts +0 -1
  120. package/dist/commands/tenants.js +7 -62
  121. package/dist/commands/text-templates.d.ts +0 -1
  122. package/dist/commands/text-templates.js +5 -61
  123. package/dist/commands/tools.d.ts +0 -1
  124. package/dist/commands/tools.js +5 -61
  125. package/dist/commands/vat-declarations.d.ts +1 -1
  126. package/dist/commands/vat-declarations.js +11 -62
  127. package/dist/commands/vendor-invoices.d.ts +0 -1
  128. package/dist/commands/vendor-invoices.js +10 -65
  129. package/dist/commands/webhooks.d.ts +0 -1
  130. package/dist/commands/webhooks.js +5 -61
  131. package/dist/commands/workflows.d.ts +0 -1
  132. package/dist/commands/workflows.js +5 -61
  133. package/dist/lib/api-client.js +3 -6
  134. package/dist/lib/auth/constants.d.ts +11 -0
  135. package/dist/lib/auth/constants.js +11 -0
  136. package/dist/lib/auth/errors.d.ts +17 -0
  137. package/dist/lib/auth/errors.js +22 -0
  138. package/dist/lib/auth/flow.d.ts +27 -0
  139. package/dist/lib/auth/flow.js +163 -0
  140. package/dist/lib/auth/guards.d.ts +5 -0
  141. package/dist/lib/auth/guards.js +14 -0
  142. package/dist/lib/auth/http.d.ts +16 -0
  143. package/dist/lib/auth/http.js +49 -0
  144. package/dist/lib/auth/index.d.ts +49 -0
  145. package/dist/lib/auth/index.js +217 -0
  146. package/dist/lib/auth/keycloak-provider.d.ts +75 -0
  147. package/dist/lib/auth/keycloak-provider.js +215 -0
  148. package/dist/lib/auth/pages.d.ts +25 -0
  149. package/dist/lib/auth/pages.js +123 -0
  150. package/dist/lib/auth/pkce.d.ts +11 -0
  151. package/dist/lib/auth/pkce.js +17 -0
  152. package/dist/lib/auth/tokens.d.ts +54 -0
  153. package/dist/lib/auth/tokens.js +104 -0
  154. package/dist/lib/auth/types.d.ts +57 -0
  155. package/dist/lib/auth/types.js +1 -0
  156. package/dist/lib/auth/validated-url.d.ts +6 -0
  157. package/dist/lib/auth/validated-url.js +53 -0
  158. package/dist/lib/config.d.ts +13 -3
  159. package/dist/lib/config.js +24 -19
  160. package/oclif.manifest.json +440 -71
  161. package/package.json +2 -1
  162. package/dist/lib/auth.d.ts +0 -43
  163. package/dist/lib/auth.js +0 -610
@@ -0,0 +1,163 @@
1
+ import * as http from 'node:http';
2
+ import { URL } from 'node:url';
3
+ import open from 'open';
4
+ import { saveTokens } from '../config.js';
5
+ import { CALLBACK_PORT } from './constants.js';
6
+ import { TenantMismatchError } from './errors.js';
7
+ import { renderAuthPage, renderRedirectPage } from './pages.js';
8
+ import { generateNonce, generatePkce, generateState } from './pkce.js';
9
+ const FLOW_TIMEOUT_MS = 5 * 60 * 1000;
10
+ async function prepareRounds(context, provider, rounds) {
11
+ const prepared = [];
12
+ for (const round of rounds) {
13
+ const pkce = generatePkce();
14
+ const state = generateState();
15
+ const nonce = generateNonce();
16
+ const authorizeUrl = await provider.buildAuthorizeUrl(context, {
17
+ codeChallenge: pkce.challenge,
18
+ nonce,
19
+ prompt: round.prompt,
20
+ state,
21
+ });
22
+ prepared.push({ ...round, authorizeUrl, codeVerifier: pkce.verifier, nonce, state });
23
+ }
24
+ return prepared;
25
+ }
26
+ /**
27
+ * Runs one or more authorization rounds through a single local callback server
28
+ * and a single browser tab. Per round: the browser lands on `/oauth/callback`,
29
+ * the code is exchanged via the provider, the tokens are saved to the context
30
+ * and the round's `afterExchange` hook runs (tokens it returns are saved and
31
+ * become the round's result). A following round is entered via
32
+ * a meta-refresh into its authorize URL; the last round answers the success
33
+ * page and resolves with its tokens. A callback whose `state` is not the
34
+ * expected round's is rejected.
35
+ */
36
+ export async function runBrowserFlow(context, provider, rounds, options) {
37
+ if (rounds.length === 0) {
38
+ throw new Error('A browser flow needs at least one authorization round');
39
+ }
40
+ const log = options.log ?? console.log;
41
+ const openBrowser = options.openBrowser ?? ((url) => open(url));
42
+ const prepared = await prepareRounds(context, provider, rounds);
43
+ return new Promise((resolve, reject) => {
44
+ const connections = new Set();
45
+ let currentRound = 0;
46
+ // States whose callback has been taken: a duplicate arriving while its
47
+ // exchange is in flight is answered, not redeemed again.
48
+ const redeemed = new Set();
49
+ // Holder: closeServer references the timer before it exists.
50
+ const timeout = {};
51
+ const closeServer = () => {
52
+ if (timeout.id)
53
+ clearTimeout(timeout.id);
54
+ for (const socket of connections) {
55
+ socket.destroy();
56
+ }
57
+ connections.clear();
58
+ server.close();
59
+ process.off('SIGINT', onSigint);
60
+ };
61
+ const onSigint = () => {
62
+ closeServer();
63
+ // Exit with the SIGINT status so the parent shell sees the termination.
64
+ // eslint-disable-next-line unicorn/no-process-exit, n/no-process-exit
65
+ process.exit(130);
66
+ };
67
+ const fail = (res, status, title, error) => {
68
+ res.writeHead(status, { 'Content-Type': 'text/html' });
69
+ res.end(renderAuthPage('error', title, error.message));
70
+ // Let the page reach the browser before the connection is torn down.
71
+ setImmediate(() => {
72
+ closeServer();
73
+ reject(error);
74
+ });
75
+ };
76
+ const server = http.createServer(async (req, res) => {
77
+ const url = new URL(req.url || '', `http://localhost:${CALLBACK_PORT}`);
78
+ if (url.pathname !== '/oauth/callback') {
79
+ res.writeHead(404);
80
+ res.end('Not Found');
81
+ return;
82
+ }
83
+ const code = url.searchParams.get('code');
84
+ const returnedState = url.searchParams.get('state');
85
+ const error = url.searchParams.get('error');
86
+ const errorDescription = url.searchParams.get('error_description');
87
+ if (error) {
88
+ fail(res, 400, 'Authentication Failed', new Error(errorDescription || error));
89
+ return;
90
+ }
91
+ // A callback already taken (a browser retry, or a late one after the last
92
+ // round) has nothing to redeem and must not abort the flow.
93
+ const round = prepared[currentRound];
94
+ if (!round || (returnedState && redeemed.has(returnedState))) {
95
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
96
+ res.end('This authorization response has already been used');
97
+ return;
98
+ }
99
+ if (!code || returnedState !== round.state) {
100
+ fail(res, 400, 'Authentication Failed', new Error('Invalid authorization response'));
101
+ return;
102
+ }
103
+ redeemed.add(round.state);
104
+ try {
105
+ const exchanged = await provider.exchangeCode(context, {
106
+ code,
107
+ codeVerifier: round.codeVerifier,
108
+ nonce: round.nonce,
109
+ });
110
+ saveTokens(context.name, exchanged);
111
+ const tokens = (await round.afterExchange?.(exchanged)) ?? exchanged;
112
+ if (tokens !== exchanged)
113
+ saveTokens(context.name, tokens);
114
+ currentRound += 1;
115
+ const next = prepared[currentRound];
116
+ if (next) {
117
+ res.writeHead(200, { 'Content-Type': 'text/html' });
118
+ res.end(renderRedirectPage(next.authorizeUrl));
119
+ return;
120
+ }
121
+ res.writeHead(200, { 'Content-Type': 'text/html' });
122
+ res.end(renderAuthPage('success', options.success.title, options.success.message));
123
+ // Let the response flush before tearing the server down.
124
+ setImmediate(() => {
125
+ closeServer();
126
+ resolve(tokens);
127
+ });
128
+ }
129
+ catch (error_) {
130
+ const failure = error_ instanceof Error ? error_ : new Error(String(error_));
131
+ if (failure instanceof TenantMismatchError) {
132
+ fail(res, 400, 'Tenant Switch Failed', failure);
133
+ }
134
+ else {
135
+ fail(res, 500, 'Authentication Failed', failure);
136
+ }
137
+ }
138
+ });
139
+ // Tracked so closeServer can destroy them.
140
+ server.on('connection', (socket) => {
141
+ connections.add(socket);
142
+ socket.on('close', () => connections.delete(socket));
143
+ });
144
+ server.on('error', (err) => {
145
+ closeServer();
146
+ reject(new Error(`Failed to start local server: ${err.message}`));
147
+ });
148
+ process.once('SIGINT', onSigint);
149
+ server.listen(CALLBACK_PORT, () => {
150
+ const first = prepared[0].authorizeUrl;
151
+ log('Opening browser for authentication...');
152
+ log(`If the browser doesn't open, visit: ${first}`);
153
+ openBrowser(first).catch((error) => {
154
+ // The URL was printed above, so a browser that fails to launch is not fatal.
155
+ log(`Could not open the browser automatically: ${error instanceof Error ? error.message : String(error)}`);
156
+ });
157
+ });
158
+ timeout.id = setTimeout(() => {
159
+ closeServer();
160
+ reject(new Error('Authentication timed out'));
161
+ }, FLOW_TIMEOUT_MS);
162
+ });
163
+ }
@@ -0,0 +1,5 @@
1
+ import { type Context } from '../config.js';
2
+ /** Guard for OAuth-only flows (browser login, token refresh, tenant switching); API keys are issued per tenant. */
3
+ export declare function requireOAuthContext(context: Context, action: string): void;
4
+ /** `client_id` is optional on Context (API-key contexts have none), so OAuth flows must assert it. */
5
+ export declare function requireClientId(context: Context): string;
@@ -0,0 +1,14 @@
1
+ import { getAuthType } from '../config.js';
2
+ /** Guard for OAuth-only flows (browser login, token refresh, tenant switching); API keys are issued per tenant. */
3
+ export function requireOAuthContext(context, action) {
4
+ if (getAuthType(context) === 'api_key') {
5
+ throw new Error(`${action} requires OAuth. Context "${context.name}" authenticates with an API key, which is already scoped to one tenant.`);
6
+ }
7
+ }
8
+ /** `client_id` is optional on Context (API-key contexts have none), so OAuth flows must assert it. */
9
+ export function requireClientId(context) {
10
+ if (!context.client_id) {
11
+ throw new Error(`Context "${context.name}" has no OAuth client ID. Recreate it with: aderis config add`);
12
+ }
13
+ return context.client_id;
14
+ }
@@ -0,0 +1,16 @@
1
+ import { AuthError } from './errors.js';
2
+ /**
3
+ * Upper bound for every auth-layer HTTP request (OIDC discovery, token
4
+ * endpoint); the browser-flow timer only starts after discovery.
5
+ */
6
+ export declare const HTTP_TIMEOUT_MS = 10000;
7
+ /** The one axios instance the auth layer uses, so the timeout applies everywhere. */
8
+ export declare const authHttp: import("axios").AxiosInstance;
9
+ /** HTTP status behind an error, whether axios raised it or an {@link AuthError} wrapped it. */
10
+ export declare function httpStatusOf(error: unknown): number | undefined;
11
+ /**
12
+ * Turns any failure of an auth-layer request into an {@link AuthError} naming
13
+ * `what` was attempted and what came back (status and OAuth error code, timeout
14
+ * or transport message), never the request itself. An {@link AuthError} passes through.
15
+ */
16
+ export declare function toAuthError(error: unknown, what: string): AuthError;
@@ -0,0 +1,49 @@
1
+ import axios from 'axios';
2
+ import { AuthError } from './errors.js';
3
+ /**
4
+ * Upper bound for every auth-layer HTTP request (OIDC discovery, token
5
+ * endpoint); the browser-flow timer only starts after discovery.
6
+ */
7
+ export const HTTP_TIMEOUT_MS = 10_000;
8
+ /** The one axios instance the auth layer uses, so the timeout applies everywhere. */
9
+ export const authHttp = axios.create({ timeout: HTTP_TIMEOUT_MS });
10
+ /** HTTP status behind an error, whether axios raised it or an {@link AuthError} wrapped it. */
11
+ export function httpStatusOf(error) {
12
+ if (error instanceof AuthError)
13
+ return error.status;
14
+ if (axios.isAxiosError(error))
15
+ return error.response?.status;
16
+ return undefined;
17
+ }
18
+ function isTimeout(error) {
19
+ return error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT';
20
+ }
21
+ /** `error` / `error_description` from an OAuth error body (RFC 6749 §5.2), when the server sent them. */
22
+ function describeErrorBody(data) {
23
+ if (!data || typeof data !== 'object')
24
+ return '';
25
+ const { error, error_description: description } = data;
26
+ if (typeof error !== 'string' || !error)
27
+ return '';
28
+ return typeof description === 'string' && description ? `: ${error} (${description})` : `: ${error}`;
29
+ }
30
+ /**
31
+ * Turns any failure of an auth-layer request into an {@link AuthError} naming
32
+ * `what` was attempted and what came back (status and OAuth error code, timeout
33
+ * or transport message), never the request itself. An {@link AuthError} passes through.
34
+ */
35
+ export function toAuthError(error, what) {
36
+ if (error instanceof AuthError)
37
+ return error;
38
+ if (axios.isAxiosError(error)) {
39
+ if (error.response) {
40
+ const { status } = error.response;
41
+ return new AuthError(`${what} failed with HTTP ${status}${describeErrorBody(error.response.data)}`, { status });
42
+ }
43
+ if (isTimeout(error)) {
44
+ return new AuthError(`${what} timed out after ${error.config?.timeout ?? HTTP_TIMEOUT_MS} ms`);
45
+ }
46
+ return new AuthError(`${what} failed: ${error.message}`);
47
+ }
48
+ return new AuthError(`${what} failed: ${error instanceof Error ? error.message : String(error)}`);
49
+ }
@@ -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,217 @@
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 { decodeToken, 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
+ const refreshed = await keycloakProvider.refresh(context, context.tokens.refresh_token);
66
+ const tokenData = {
67
+ ...refreshed,
68
+ // A refresh response may omit the refresh token; the stored one stays valid.
69
+ refresh_token: refreshed.refresh_token || context.tokens.refresh_token,
70
+ };
71
+ saveTokens(contextName, tokenData);
72
+ return tokenData;
73
+ }
74
+ export async function getValidToken(contextName) {
75
+ const context = requireContext(contextName);
76
+ if (getAuthType(context) === 'api_key') {
77
+ throw new Error(`Context "${contextName}" authenticates with an API key and has no access token. Use getAuthHeaders() instead.`);
78
+ }
79
+ if (!context.tokens?.access_token) {
80
+ throw new Error('Not logged in. Please run: aderis auth login');
81
+ }
82
+ if (context.tokens.expires_at && context.tokens.expires_at < Date.now() + 60_000) {
83
+ if (context.tokens.refresh_token) {
84
+ const newTokens = await refreshTokens(contextName);
85
+ return newTokens.access_token;
86
+ }
87
+ throw new Error('Token expired. Please run: aderis auth login');
88
+ }
89
+ return context.tokens.access_token;
90
+ }
91
+ /**
92
+ * Auth headers for a context: a bearer token for `oauth2` (refreshed first when
93
+ * needed) or `x-api-key` for `api_key`. The one place credentials become headers.
94
+ */
95
+ export async function getAuthHeaders(contextName) {
96
+ const context = requireContext(contextName);
97
+ if (getAuthType(context) === 'api_key') {
98
+ if (!context.api_key) {
99
+ throw new Error(missingCredentialsMessage(context));
100
+ }
101
+ return { 'x-api-key': context.api_key };
102
+ }
103
+ return { Authorization: `Bearer ${await getValidToken(contextName)}` };
104
+ }
105
+ export async function testAuth(contextName) {
106
+ const context = requireContext(contextName);
107
+ const { api: apiUrl } = getBaseUrls(context.environment);
108
+ try {
109
+ const response = await axios.get(buildValidatedUrl(apiUrl, '/v1/auth/me'), {
110
+ headers: {
111
+ Accept: 'application/json',
112
+ ...(await getAuthHeaders(contextName)),
113
+ },
114
+ });
115
+ const { user } = response.data;
116
+ if (!user) {
117
+ throw new Error('The API answered /v1/auth/me without a user object.');
118
+ }
119
+ return user;
120
+ }
121
+ catch (error) {
122
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
123
+ throw new Error(unauthorizedMessage(context));
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+ export async function listTenants(contextName) {
129
+ const context = requireContext(contextName);
130
+ requireOAuthContext(context, 'Listing tenants');
131
+ const token = await getValidToken(contextName);
132
+ const tenants = await keycloakProvider.listTenants(context, token);
133
+ // Cached for the picker `aderis switch` shows once the access token has expired.
134
+ saveCachedTenants(contextName, tenants.map((t) => ({ name: t.name, tenantId: t.tenantId })));
135
+ return tenants;
136
+ }
137
+ /** A tenant for display: `name (id)`, or the id alone when the name is missing or equals the id. */
138
+ export function tenantLabel(tenant) {
139
+ return tenant.name && tenant.name !== tenant.tenantId ? `${tenant.name} (${tenant.tenantId})` : tenant.tenantId;
140
+ }
141
+ export async function getCurrentTenant(contextName) {
142
+ const context = requireContext(contextName);
143
+ // API-key contexts carry no JWT, so there is no tenant to decode locally.
144
+ if (getAuthType(context) === 'api_key' || !context.tokens?.access_token) {
145
+ return null;
146
+ }
147
+ const tenantId = tenantIdOf(decodeToken(context.tokens.access_token));
148
+ if (!tenantId) {
149
+ return null;
150
+ }
151
+ try {
152
+ const tenants = await listTenants(contextName);
153
+ const tenant = tenants.find((t) => t.tenantId === tenantId);
154
+ return { name: tenant?.name, tenantId };
155
+ }
156
+ catch {
157
+ return { tenantId };
158
+ }
159
+ }
160
+ /**
161
+ * Switches the tenant of a context whose session is still valid: one
162
+ * refresh-token request carrying the tenant, asserted to have landed on
163
+ * `tenantId` and stored. No browser is involved, so there are no flow hooks.
164
+ */
165
+ export async function switchTenant(contextName, tenantId) {
166
+ const context = requireContext(contextName);
167
+ requireOAuthContext(context, 'Switching tenants');
168
+ const refreshToken = context.tokens?.refresh_token;
169
+ if (!refreshToken) {
170
+ throw new Error('No refresh token available. Please login again.');
171
+ }
172
+ try {
173
+ const tokens = assertTenant(await keycloakProvider.switchTenant(context, refreshToken, tenantId), tenantId);
174
+ saveTokens(contextName, tokens);
175
+ return tokens;
176
+ }
177
+ catch (error) {
178
+ const status = httpStatusOf(error);
179
+ if (status === 400 || status === 401) {
180
+ throw new AuthError(SESSION_EXPIRED_MESSAGE, { status });
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+ /** Login and tenant switch for `aderis switch` on an expired session: the provider's rounds in one browser tab, asserted like {@link switchTenant}. */
186
+ export async function loginAndSwitchTenant(context, tenantId, hooks = {}) {
187
+ requireOAuthContext(context, 'Switching tenants');
188
+ const rounds = withTenantAssertion(keycloakProvider.tenantSwitchRounds(context, tenantId), tenantId);
189
+ return runBrowserFlow(context, keycloakProvider, rounds, {
190
+ ...hooks,
191
+ success: {
192
+ message: { subject: context.name, tail: ' on the selected tenant.', text: 'You have been logged in to ' },
193
+ title: 'Tenant Switched',
194
+ },
195
+ });
196
+ }
197
+ /**
198
+ * Gives a fresh login an active tenant when its token has none: the sole tenant
199
+ * is taken, several are offered to `choose`, and {@link switchTenant} follows.
200
+ * Resolves `null` when the stored token already has a tenant.
201
+ */
202
+ export async function completeTenantSelection(contextName, choose) {
203
+ const context = requireContext(contextName);
204
+ requireOAuthContext(context, 'Tenant selection');
205
+ if (!context.tokens?.access_token) {
206
+ throw new Error('Not logged in. Please run: aderis auth login');
207
+ }
208
+ if (context.tenant_id || tenantIdOf(decodeToken(context.tokens.access_token))) {
209
+ return null;
210
+ }
211
+ const tenants = await listTenants(contextName);
212
+ if (tenants.length === 0) {
213
+ throw new AuthError(`The Keycloak token for "${contextName}" lists no tenants, so you have reached no tenant. Contact your administrator.`);
214
+ }
215
+ const tenantId = tenants.length === 1 ? tenants[0].tenantId : await choose(tenants);
216
+ return switchTenant(contextName, tenantId);
217
+ }
@@ -0,0 +1,75 @@
1
+ import { type Context, type Environment, type TokenData } from '../config.js';
2
+ import { type AuthorizeParams, type AuthProvider, type AuthRound, type ExchangeParams, type Tenant } from './types.js';
3
+ export interface KeycloakRealm {
4
+ baseUrl: string;
5
+ realm: string;
6
+ }
7
+ /**
8
+ * Default Keycloak origin and realm per environment. A context may override
9
+ * the origin with `keycloak_base_url`; a token carries the issuer of the origin
10
+ * it was requested from, and the API compares `iss` exactly.
11
+ */
12
+ export declare const KEYCLOAK_REALMS: Record<Environment, KeycloakRealm>;
13
+ /** The tenant claims need no extra scope; the tenant is selected by the CLI via `tenant_id`, not on the login page. */
14
+ export declare const KEYCLOAK_SCOPE = "openid profile email offline_access";
15
+ /** The subset of the OIDC discovery document (RFC 8414 / OIDC Discovery 1.0) the CLI uses. */
16
+ export interface OidcDiscovery {
17
+ authorization_endpoint: string;
18
+ issuer: string;
19
+ token_endpoint: string;
20
+ }
21
+ /** What decides which Keycloak a context talks to: its environment, and an optional host override. */
22
+ export type KeycloakTarget = Pick<Context, 'environment' | 'keycloak_base_url'>;
23
+ /**
24
+ * Validates a Keycloak base URL as stored in `keycloak_base_url`: https, an
25
+ * allowlisted host, and nothing but the origin. Returns the normalized origin.
26
+ */
27
+ export declare function parseKeycloakBaseUrl(value: string): string;
28
+ /** The Keycloak origin a context uses: its override, re-validated, or the environment's default. */
29
+ export declare function keycloakBaseUrlOf(target: KeycloakTarget): string;
30
+ export declare function discoveryUrlFor(target: KeycloakTarget): string;
31
+ /**
32
+ * Authorization Code + PKCE S256 with `state` and `nonce` against the public
33
+ * `aderis-cli-login` client, endpoints from OIDC discovery. Tenants come from
34
+ * the access token; a switch is a refresh-token grant carrying `tenant_id=<uuid>`.
35
+ */
36
+ export declare class KeycloakProvider implements AuthProvider {
37
+ /** Discovery documents per discovery URL, fetched once per provider instance. */
38
+ private readonly discoveryCache;
39
+ buildAuthorizeUrl(context: Context, params: AuthorizeParams): Promise<string>;
40
+ /**
41
+ * The realm's discovery document, cached per discovery URL. Its endpoints are
42
+ * validated against the host allowlist; a failed fetch is not cached.
43
+ */
44
+ discover(target: KeycloakTarget): Promise<OidcDiscovery>;
45
+ exchangeCode(context: Context, { code, codeVerifier, nonce }: ExchangeParams): Promise<TokenData>;
46
+ /** No network call: the access token's `availableTenants` claim lists every tenant the user may act in. */
47
+ listTenants(_context: Context, accessToken: string): Promise<Tenant[]>;
48
+ refresh(context: Context, refreshToken: string): Promise<TokenData>;
49
+ /**
50
+ * One refresh-token grant with the `tenant_id` form field, read by the
51
+ * realm's tenant-claims mapper: the minted token's `tenantId` becomes this
52
+ * tenant. No browser and no SSO session involved.
53
+ */
54
+ switchTenant(context: Context, refreshToken: string, tenantId: string): Promise<TokenData>;
55
+ /**
56
+ * One login round whose tokens are re-scoped to `tenantId` through
57
+ * {@link switchTenant} right after the exchange. A second browser round
58
+ * could not be silent: exchanging the code for an offline token ends the
59
+ * realm's SSO session.
60
+ */
61
+ tenantSwitchRounds(context: Context, tenantId: string): AuthRound[];
62
+ private redeem;
63
+ /** Rejects a token whose `iss` differs from the discovery issuer; the API compares `iss` exactly. */
64
+ private requireIssuer;
65
+ /**
66
+ * The mapper is fail-closed: with several reachable tenants and no `tenant_id`
67
+ * the token has no `tenantId`. That token is stored and needs a tenant picked
68
+ * (`completeTenantSelection`); a token reaching no tenant is refused.
69
+ */
70
+ private requireTenant;
71
+ /** Consistency check only: the id_token signature is not checked (the API checks the access token). */
72
+ private verifyNonce;
73
+ }
74
+ /** The one identity provider the CLI authenticates against. */
75
+ export declare const keycloakProvider: KeycloakProvider;