@aderis-erp/cli 1.0.14 → 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.
- package/README.md +30 -8
- package/dist/commands/access-groups.d.ts +0 -1
- package/dist/commands/access-groups.js +22 -78
- package/dist/commands/account-transaction-allocations.d.ts +0 -1
- package/dist/commands/account-transaction-allocations.js +22 -78
- package/dist/commands/account-transactions.d.ts +0 -1
- package/dist/commands/account-transactions.js +28 -82
- package/dist/commands/accounts.d.ts +0 -1
- package/dist/commands/accounts.js +24 -80
- package/dist/commands/agent-platform.d.ts +0 -1
- package/dist/commands/agent-platform.js +24 -79
- package/dist/commands/analytics.d.ts +39 -0
- package/dist/commands/analytics.js +1113 -0
- package/dist/commands/{documents.d.ts → api-keys.d.ts} +1 -2
- package/dist/commands/api-keys.js +1117 -0
- package/dist/commands/audit-log.d.ts +0 -1
- package/dist/commands/audit-log.js +22 -78
- package/dist/commands/auth/login.d.ts +9 -0
- package/dist/commands/auth/login.js +83 -21
- package/dist/commands/auth/logout.d.ts +6 -0
- package/dist/commands/auth/logout.js +23 -6
- package/dist/commands/auth/status.js +46 -23
- package/dist/commands/auth/tenant.js +15 -9
- package/dist/commands/auth.d.ts +0 -1
- package/dist/commands/auth.js +37 -51
- package/dist/commands/bank-accounts.d.ts +0 -1
- package/dist/commands/bank-accounts.js +22 -78
- package/dist/commands/bank-integrations.d.ts +0 -1
- package/dist/commands/bank-integrations.js +22 -78
- package/dist/commands/business-partners.d.ts +1 -1
- package/dist/commands/business-partners.js +38 -84
- package/dist/commands/chat.d.ts +0 -1
- package/dist/commands/chat.js +22 -78
- package/dist/commands/config/add.d.ts +12 -0
- package/dist/commands/config/add.js +97 -17
- package/dist/commands/config/current.js +12 -4
- package/dist/commands/config/list.js +12 -4
- package/dist/commands/config/set-api-key.d.ts +10 -0
- package/dist/commands/config/set-api-key.js +49 -0
- package/dist/commands/config/use.js +3 -4
- package/dist/commands/credit-card-accounts.d.ts +0 -1
- package/dist/commands/credit-card-accounts.js +22 -78
- package/dist/commands/delivery-notes.d.ts +0 -1
- package/dist/commands/delivery-notes.js +29 -84
- package/dist/commands/dunning-configs.d.ts +0 -1
- package/dist/commands/dunning-configs.js +22 -78
- package/dist/commands/dunning-reminders.d.ts +0 -1
- package/dist/commands/dunning-reminders.js +22 -78
- package/dist/commands/dunning-runs.d.ts +0 -1
- package/dist/commands/dunning-runs.js +22 -78
- package/dist/commands/email-transactions.d.ts +0 -1
- package/dist/commands/email-transactions.js +22 -78
- package/dist/commands/employees.d.ts +0 -1
- package/dist/commands/employees.js +24 -80
- package/dist/commands/entity-configurations.d.ts +0 -1
- package/dist/commands/entity-configurations.js +22 -78
- package/dist/commands/event-processor-mappings.d.ts +0 -1
- package/dist/commands/event-processor-mappings.js +22 -78
- package/dist/commands/{files-internal.d.ts → files.d.ts} +1 -2
- package/dist/commands/{files-internal.js → files.js} +40 -90
- package/dist/commands/health.d.ts +0 -1
- package/dist/commands/health.js +22 -78
- package/dist/commands/inbox.d.ts +0 -1
- package/dist/commands/inbox.js +22 -78
- package/dist/commands/integrations.d.ts +0 -1
- package/dist/commands/integrations.js +22 -78
- package/dist/commands/item-categories.d.ts +0 -1
- package/dist/commands/item-categories.js +22 -78
- package/dist/commands/items.d.ts +0 -1
- package/dist/commands/items.js +24 -80
- package/dist/commands/journal-lines.d.ts +0 -1
- package/dist/commands/journal-lines.js +22 -78
- package/dist/commands/journals.d.ts +1 -1
- package/dist/commands/journals.js +29 -81
- package/dist/commands/ledgers.d.ts +0 -1
- package/dist/commands/ledgers.js +26 -79
- package/dist/commands/nango-integrations.d.ts +39 -0
- package/dist/commands/nango-integrations.js +1115 -0
- package/dist/commands/office-locations.d.ts +0 -1
- package/dist/commands/office-locations.js +22 -78
- package/dist/commands/payment-methods.d.ts +0 -1
- package/dist/commands/payment-methods.js +22 -78
- package/dist/commands/payment-runs.d.ts +0 -1
- package/dist/commands/payment-runs.js +24 -79
- package/dist/commands/payment-terms.d.ts +0 -1
- package/dist/commands/payment-terms.js +22 -78
- package/dist/commands/payments.d.ts +0 -1
- package/dist/commands/payments.js +22 -78
- package/dist/commands/price-lists.d.ts +0 -1
- package/dist/commands/price-lists.js +22 -78
- package/dist/commands/processor-mappings.d.ts +0 -1
- package/dist/commands/processor-mappings.js +22 -78
- package/dist/commands/processors.d.ts +0 -1
- package/dist/commands/processors.js +22 -78
- package/dist/commands/purchase-order-types.d.ts +0 -1
- package/dist/commands/purchase-order-types.js +22 -78
- package/dist/commands/purchase-orders.d.ts +1 -1
- package/dist/commands/purchase-orders.js +35 -84
- package/dist/commands/reports.d.ts +0 -1
- package/dist/commands/reports.js +23 -79
- package/dist/commands/salary-types.d.ts +0 -1
- package/dist/commands/salary-types.js +22 -78
- package/dist/commands/sales-invoices.d.ts +0 -1
- package/dist/commands/sales-invoices.js +38 -89
- package/dist/commands/sales-orders.d.ts +0 -1
- package/dist/commands/sales-orders.js +26 -82
- package/dist/commands/saved-views.d.ts +0 -1
- package/dist/commands/saved-views.js +22 -78
- package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
- package/dist/commands/scheduled-processor-mappings.js +22 -78
- package/dist/commands/schedules.d.ts +0 -1
- package/dist/commands/schedules.js +22 -78
- package/dist/commands/switch.js +14 -7
- package/dist/commands/tag-configurations.d.ts +39 -0
- package/dist/commands/{documents.js → tag-configurations.js} +40 -96
- package/dist/commands/tags.d.ts +0 -1
- package/dist/commands/tags.js +22 -78
- package/dist/commands/tax-codes.d.ts +0 -1
- package/dist/commands/tax-codes.js +22 -78
- package/dist/commands/tax-matrix.d.ts +0 -1
- package/dist/commands/tax-matrix.js +22 -78
- package/dist/commands/tenants.d.ts +0 -1
- package/dist/commands/tenants.js +24 -79
- package/dist/commands/text-templates.d.ts +0 -1
- package/dist/commands/text-templates.js +22 -78
- package/dist/commands/tools.d.ts +0 -1
- package/dist/commands/tools.js +22 -78
- package/dist/commands/vat-declarations.d.ts +1 -1
- package/dist/commands/vat-declarations.js +28 -79
- package/dist/commands/vendor-invoices.d.ts +0 -1
- package/dist/commands/vendor-invoices.js +31 -86
- package/dist/commands/webhooks.d.ts +0 -1
- package/dist/commands/webhooks.js +22 -78
- package/dist/commands/workflows.d.ts +0 -1
- package/dist/commands/workflows.js +22 -78
- package/dist/lib/api-client.js +8 -6
- package/dist/lib/auth/constants.d.ts +11 -0
- package/dist/lib/auth/constants.js +11 -0
- package/dist/lib/auth/errors.d.ts +17 -0
- package/dist/lib/auth/errors.js +22 -0
- package/dist/lib/auth/flow.d.ts +27 -0
- package/dist/lib/auth/flow.js +163 -0
- package/dist/lib/auth/guards.d.ts +5 -0
- package/dist/lib/auth/guards.js +14 -0
- package/dist/lib/auth/http.d.ts +16 -0
- package/dist/lib/auth/http.js +49 -0
- package/dist/lib/auth/index.d.ts +49 -0
- package/dist/lib/auth/index.js +217 -0
- package/dist/lib/auth/keycloak-provider.d.ts +75 -0
- package/dist/lib/auth/keycloak-provider.js +215 -0
- package/dist/lib/auth/pages.d.ts +25 -0
- package/dist/lib/auth/pages.js +123 -0
- package/dist/lib/auth/pkce.d.ts +11 -0
- package/dist/lib/auth/pkce.js +17 -0
- package/dist/lib/auth/tokens.d.ts +54 -0
- package/dist/lib/auth/tokens.js +104 -0
- package/dist/lib/auth/types.d.ts +57 -0
- package/dist/lib/auth/types.js +1 -0
- package/dist/lib/auth/validated-url.d.ts +6 -0
- package/dist/lib/auth/validated-url.js +53 -0
- package/dist/lib/base-api-command.js +6 -4
- package/dist/lib/config.d.ts +44 -4
- package/dist/lib/config.js +55 -19
- package/oclif.manifest.json +1181 -330
- package/package.json +3 -2
- package/dist/lib/auth.d.ts +0 -35
- package/dist/lib/auth.js +0 -566
package/dist/lib/api-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import {
|
|
3
|
-
import { getBaseUrls, getCurrentContext } from './config.js';
|
|
2
|
+
import { getAuthHeaders } from './auth/index.js';
|
|
3
|
+
import { getBaseUrls, getCurrentContext, unauthorizedMessage } from './config.js';
|
|
4
4
|
/**
|
|
5
5
|
* Normalize a --sort-by value: comma-separated fields, each optionally suffixed
|
|
6
6
|
* with `:ASC` or `:DESC`. Fields without an explicit direction default to DESC
|
|
@@ -39,14 +39,15 @@ export async function createApiClient(contextName, options = false) {
|
|
|
39
39
|
if (!context) {
|
|
40
40
|
throw new Error('No context configured. Run: aderis auth login --new');
|
|
41
41
|
}
|
|
42
|
-
const
|
|
42
|
+
const authHeaders = await getAuthHeaders(context.name);
|
|
43
43
|
const { api: baseURL } = getBaseUrls(context.environment);
|
|
44
44
|
const client = axios.create({
|
|
45
45
|
baseURL,
|
|
46
46
|
headers: {
|
|
47
47
|
Accept: binary ? '*/*' : (acceptAny ? '*/*' : 'application/json'),
|
|
48
|
-
Authorization: `Bearer ${token}`,
|
|
49
48
|
'Content-Type': 'application/json',
|
|
49
|
+
// Either `Authorization: Bearer <token>` or `x-api-key: <key>`.
|
|
50
|
+
...authHeaders,
|
|
50
51
|
},
|
|
51
52
|
// Serialize arrays as repeated params (key=v1&key=v2) instead of bracket format (key[]=v1)
|
|
52
53
|
paramsSerializer: { indexes: null },
|
|
@@ -56,9 +57,10 @@ export async function createApiClient(contextName, options = false) {
|
|
|
56
57
|
// Response interceptor for error handling
|
|
57
58
|
client.interceptors.response.use((response) => response, (error) => {
|
|
58
59
|
if (error.response) {
|
|
59
|
-
// Handle 401 with a clear message
|
|
60
|
+
// Handle 401 with a clear message, pointing at the right remedy for the
|
|
61
|
+
// context's auth type — re-login only helps OAuth contexts.
|
|
60
62
|
if (error.response.status === 401) {
|
|
61
|
-
throw new ApiError(
|
|
63
|
+
throw new ApiError(unauthorizedMessage(context), 401, error.response.data);
|
|
62
64
|
}
|
|
63
65
|
const message = extractErrorMessage(error.response.data);
|
|
64
66
|
throw new ApiError(message, error.response.status, error.response.data);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback redirect (RFC 8252 §7.3). The Keycloak client registers
|
|
3
|
+
* `http://localhost:*`; one shared port keeps every context on a registered redirect.
|
|
4
|
+
*/
|
|
5
|
+
export declare const CALLBACK_PORT = 8085;
|
|
6
|
+
export declare const REDIRECT_URI = "http://localhost:8085/oauth/callback";
|
|
7
|
+
/** Headers for `application/x-www-form-urlencoded` token endpoint requests (RFC 6749 §4.1.3). */
|
|
8
|
+
export declare const FORM_POST_HEADERS: {
|
|
9
|
+
readonly Accept: "application/json";
|
|
10
|
+
readonly 'Content-Type': "application/x-www-form-urlencoded";
|
|
11
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback redirect (RFC 8252 §7.3). The Keycloak client registers
|
|
3
|
+
* `http://localhost:*`; one shared port keeps every context on a registered redirect.
|
|
4
|
+
*/
|
|
5
|
+
export const CALLBACK_PORT = 8085;
|
|
6
|
+
export const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/oauth/callback`;
|
|
7
|
+
/** Headers for `application/x-www-form-urlencoded` token endpoint requests (RFC 6749 §4.1.3). */
|
|
8
|
+
export const FORM_POST_HEADERS = {
|
|
9
|
+
Accept: 'application/json',
|
|
10
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
11
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An authentication-protocol failure reported to the user, distinct from
|
|
3
|
+
* transport errors (axios) and from `ApiError`. Never wraps the axios error:
|
|
4
|
+
* its request config carries the form body (refresh token, PKCE verifier,
|
|
5
|
+
* client secret).
|
|
6
|
+
*/
|
|
7
|
+
export declare class AuthError extends Error {
|
|
8
|
+
/** HTTP status of the response that caused this error, when there was one. */
|
|
9
|
+
readonly status?: number;
|
|
10
|
+
constructor(message: string, options?: {
|
|
11
|
+
status?: number;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/** The token that ended a tenant switch is not for the requested tenant; shown as "Tenant Switch Failed" (HTTP 400). */
|
|
15
|
+
export declare class TenantMismatchError extends AuthError {
|
|
16
|
+
constructor(message: string);
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An authentication-protocol failure reported to the user, distinct from
|
|
3
|
+
* transport errors (axios) and from `ApiError`. Never wraps the axios error:
|
|
4
|
+
* its request config carries the form body (refresh token, PKCE verifier,
|
|
5
|
+
* client secret).
|
|
6
|
+
*/
|
|
7
|
+
export class AuthError extends Error {
|
|
8
|
+
/** HTTP status of the response that caused this error, when there was one. */
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, options = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'AuthError';
|
|
13
|
+
this.status = options.status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The token that ended a tenant switch is not for the requested tenant; shown as "Tenant Switch Failed" (HTTP 400). */
|
|
17
|
+
export class TenantMismatchError extends AuthError {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'TenantMismatchError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Context, type TokenData } from '../config.js';
|
|
2
|
+
import { type PageMessage } from './pages.js';
|
|
3
|
+
import { type AuthProvider, type AuthRound } from './types.js';
|
|
4
|
+
export interface BrowserFlowOptions {
|
|
5
|
+
/** Line logger for the "Opening browser…" hints. Defaults to `console.log`. */
|
|
6
|
+
log?: (line: string) => void;
|
|
7
|
+
/** Opens the authorize URL. Defaults to the system browser; tests inject a stub. */
|
|
8
|
+
openBrowser?: (url: string) => Promise<unknown>;
|
|
9
|
+
/** Page shown after the last round succeeds. */
|
|
10
|
+
success: {
|
|
11
|
+
message: PageMessage | string;
|
|
12
|
+
title: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** The injectable parts of a browser flow. */
|
|
16
|
+
export type FlowHooks = Pick<BrowserFlowOptions, 'log' | 'openBrowser'>;
|
|
17
|
+
/**
|
|
18
|
+
* Runs one or more authorization rounds through a single local callback server
|
|
19
|
+
* and a single browser tab. Per round: the browser lands on `/oauth/callback`,
|
|
20
|
+
* the code is exchanged via the provider, the tokens are saved to the context
|
|
21
|
+
* and the round's `afterExchange` hook runs (tokens it returns are saved and
|
|
22
|
+
* become the round's result). A following round is entered via
|
|
23
|
+
* a meta-refresh into its authorize URL; the last round answers the success
|
|
24
|
+
* page and resolves with its tokens. A callback whose `state` is not the
|
|
25
|
+
* expected round's is rejected.
|
|
26
|
+
*/
|
|
27
|
+
export declare function runBrowserFlow(context: Context, provider: AuthProvider, rounds: AuthRound[], options: BrowserFlowOptions): Promise<TokenData>;
|
|
@@ -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
|
+
}
|