@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.
- package/README.md +30 -8
- package/dist/commands/access-groups.d.ts +0 -1
- package/dist/commands/access-groups.js +5 -61
- package/dist/commands/account-transaction-allocations.d.ts +0 -1
- package/dist/commands/account-transaction-allocations.js +5 -61
- package/dist/commands/account-transactions.d.ts +0 -1
- package/dist/commands/account-transactions.js +9 -63
- package/dist/commands/accounts.d.ts +0 -1
- package/dist/commands/accounts.js +5 -61
- package/dist/commands/agent-platform.d.ts +0 -1
- package/dist/commands/agent-platform.js +5 -61
- package/dist/commands/analytics.d.ts +0 -1
- package/dist/commands/analytics.js +5 -61
- package/dist/commands/api-keys.d.ts +39 -0
- package/dist/commands/api-keys.js +1117 -0
- package/dist/commands/audit-log.d.ts +0 -1
- package/dist/commands/audit-log.js +5 -61
- package/dist/commands/auth/login.d.ts +9 -0
- package/dist/commands/auth/login.js +73 -20
- package/dist/commands/auth/logout.d.ts +4 -0
- package/dist/commands/auth/logout.js +4 -0
- package/dist/commands/auth/status.js +26 -17
- package/dist/commands/auth/tenant.js +3 -6
- package/dist/commands/auth.d.ts +0 -1
- package/dist/commands/auth.js +5 -34
- package/dist/commands/bank-accounts.d.ts +0 -1
- package/dist/commands/bank-accounts.js +5 -61
- package/dist/commands/bank-integrations.d.ts +0 -1
- package/dist/commands/bank-integrations.js +5 -61
- package/dist/commands/business-partners.d.ts +1 -1
- package/dist/commands/business-partners.js +14 -63
- package/dist/commands/chat.d.ts +0 -1
- package/dist/commands/chat.js +5 -61
- package/dist/commands/config/add.d.ts +3 -0
- package/dist/commands/config/add.js +34 -16
- package/dist/commands/config/current.js +3 -0
- package/dist/commands/config/list.js +3 -0
- package/dist/commands/credit-card-accounts.d.ts +0 -1
- package/dist/commands/credit-card-accounts.js +5 -61
- package/dist/commands/delivery-notes.d.ts +0 -1
- package/dist/commands/delivery-notes.js +7 -63
- package/dist/commands/dunning-configs.d.ts +0 -1
- package/dist/commands/dunning-configs.js +5 -61
- package/dist/commands/dunning-reminders.d.ts +0 -1
- package/dist/commands/dunning-reminders.js +5 -61
- package/dist/commands/dunning-runs.d.ts +0 -1
- package/dist/commands/dunning-runs.js +5 -61
- package/dist/commands/email-transactions.d.ts +0 -1
- package/dist/commands/email-transactions.js +5 -61
- package/dist/commands/employees.d.ts +0 -1
- package/dist/commands/employees.js +5 -61
- package/dist/commands/entity-configurations.d.ts +0 -1
- package/dist/commands/entity-configurations.js +5 -61
- package/dist/commands/event-processor-mappings.d.ts +0 -1
- package/dist/commands/event-processor-mappings.js +5 -61
- package/dist/commands/files.d.ts +0 -1
- package/dist/commands/files.js +5 -61
- package/dist/commands/health.d.ts +0 -1
- package/dist/commands/health.js +5 -61
- package/dist/commands/inbox.d.ts +0 -1
- package/dist/commands/inbox.js +5 -61
- package/dist/commands/integrations.d.ts +0 -1
- package/dist/commands/integrations.js +5 -61
- package/dist/commands/item-categories.d.ts +0 -1
- package/dist/commands/item-categories.js +5 -61
- package/dist/commands/items.d.ts +0 -1
- package/dist/commands/items.js +5 -61
- package/dist/commands/journal-lines.d.ts +0 -1
- package/dist/commands/journal-lines.js +5 -61
- package/dist/commands/journals.d.ts +1 -1
- package/dist/commands/journals.js +9 -62
- package/dist/commands/ledgers.d.ts +0 -1
- package/dist/commands/ledgers.js +5 -61
- 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 +5 -61
- package/dist/commands/payment-methods.d.ts +0 -1
- package/dist/commands/payment-methods.js +5 -61
- package/dist/commands/payment-runs.d.ts +0 -1
- package/dist/commands/payment-runs.js +5 -61
- package/dist/commands/payment-terms.d.ts +0 -1
- package/dist/commands/payment-terms.js +5 -61
- package/dist/commands/payments.d.ts +0 -1
- package/dist/commands/payments.js +5 -61
- package/dist/commands/price-lists.d.ts +0 -1
- package/dist/commands/price-lists.js +5 -61
- package/dist/commands/processor-mappings.d.ts +0 -1
- package/dist/commands/processor-mappings.js +5 -61
- package/dist/commands/processors.d.ts +0 -1
- package/dist/commands/processors.js +5 -61
- package/dist/commands/purchase-order-types.d.ts +0 -1
- package/dist/commands/purchase-order-types.js +5 -61
- package/dist/commands/purchase-orders.d.ts +0 -1
- package/dist/commands/purchase-orders.js +7 -63
- package/dist/commands/reports.d.ts +0 -1
- package/dist/commands/reports.js +6 -62
- package/dist/commands/salary-types.d.ts +0 -1
- package/dist/commands/salary-types.js +5 -61
- package/dist/commands/sales-invoices.d.ts +0 -1
- package/dist/commands/sales-invoices.js +12 -65
- package/dist/commands/sales-orders.d.ts +0 -1
- package/dist/commands/sales-orders.js +7 -63
- package/dist/commands/saved-views.d.ts +0 -1
- package/dist/commands/saved-views.js +5 -61
- package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
- package/dist/commands/scheduled-processor-mappings.js +5 -61
- package/dist/commands/schedules.d.ts +0 -1
- package/dist/commands/schedules.js +5 -61
- package/dist/commands/switch.js +4 -4
- package/dist/commands/tag-configurations.d.ts +0 -1
- package/dist/commands/tag-configurations.js +9 -64
- package/dist/commands/tags.d.ts +0 -1
- package/dist/commands/tags.js +5 -61
- package/dist/commands/tax-codes.d.ts +0 -1
- package/dist/commands/tax-codes.js +5 -61
- package/dist/commands/tax-matrix.d.ts +0 -1
- package/dist/commands/tax-matrix.js +5 -61
- package/dist/commands/tenants.d.ts +0 -1
- package/dist/commands/tenants.js +7 -62
- package/dist/commands/text-templates.d.ts +0 -1
- package/dist/commands/text-templates.js +5 -61
- package/dist/commands/tools.d.ts +0 -1
- package/dist/commands/tools.js +5 -61
- package/dist/commands/vat-declarations.d.ts +1 -1
- package/dist/commands/vat-declarations.js +11 -62
- package/dist/commands/vendor-invoices.d.ts +0 -1
- package/dist/commands/vendor-invoices.js +10 -65
- package/dist/commands/webhooks.d.ts +0 -1
- package/dist/commands/webhooks.js +5 -61
- package/dist/commands/workflows.d.ts +0 -1
- package/dist/commands/workflows.js +5 -61
- package/dist/lib/api-client.js +3 -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/config.d.ts +13 -3
- package/dist/lib/config.js +24 -19
- package/oclif.manifest.json +440 -71
- package/package.json +2 -1
- package/dist/lib/auth.d.ts +0 -43
- package/dist/lib/auth.js +0 -610
|
@@ -0,0 +1,215 @@
|
|
|
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 { decodeToken, exchangeAuthorizationCode, redeemRefreshToken, tenantIdOf, tenantsOf, toTokenData, } from './tokens.js';
|
|
7
|
+
import { buildValidatedUrl } from './validated-url.js';
|
|
8
|
+
/**
|
|
9
|
+
* Default Keycloak origin and realm per environment. A context may override
|
|
10
|
+
* the origin with `keycloak_base_url`; a token carries the issuer of the origin
|
|
11
|
+
* it was requested from, and the API compares `iss` exactly.
|
|
12
|
+
*/
|
|
13
|
+
export const KEYCLOAK_REALMS = {
|
|
14
|
+
dev: { baseUrl: 'https://iam-dev.aderis.com', realm: 'dev' },
|
|
15
|
+
live: { baseUrl: 'https://iam.aderis.com', realm: 'prod' },
|
|
16
|
+
staging: { baseUrl: 'https://iam-staging.aderis.com', realm: 'staging' },
|
|
17
|
+
test: { baseUrl: 'https://iam-test.aderis.com', realm: 'test' },
|
|
18
|
+
};
|
|
19
|
+
/** The tenant claims need no extra scope; the tenant is selected by the CLI via `tenant_id`, not on the login page. */
|
|
20
|
+
export const KEYCLOAK_SCOPE = 'openid profile email offline_access';
|
|
21
|
+
/**
|
|
22
|
+
* Validates a Keycloak base URL as stored in `keycloak_base_url`: https, an
|
|
23
|
+
* allowlisted host, and nothing but the origin. Returns the normalized origin.
|
|
24
|
+
*/
|
|
25
|
+
export function parseKeycloakBaseUrl(value) {
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(value);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new AuthError(`"${value}" is not a valid URL`);
|
|
32
|
+
}
|
|
33
|
+
if (url.protocol !== 'https:') {
|
|
34
|
+
throw new AuthError(`The Keycloak URL must use https, got "${value}"`);
|
|
35
|
+
}
|
|
36
|
+
if (url.pathname !== '/' || url.search || url.hash) {
|
|
37
|
+
throw new AuthError(`The Keycloak URL must be an origin only, without a path or query, got "${value}"`);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
buildValidatedUrl(url.origin);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new AuthError(`"${url.host}" is not an allowed Keycloak host`);
|
|
44
|
+
}
|
|
45
|
+
return url.origin;
|
|
46
|
+
}
|
|
47
|
+
/** The Keycloak origin a context uses: its override, re-validated, or the environment's default. */
|
|
48
|
+
export function keycloakBaseUrlOf(target) {
|
|
49
|
+
return target.keycloak_base_url === undefined
|
|
50
|
+
? KEYCLOAK_REALMS[target.environment].baseUrl
|
|
51
|
+
: parseKeycloakBaseUrl(target.keycloak_base_url);
|
|
52
|
+
}
|
|
53
|
+
export function discoveryUrlFor(target) {
|
|
54
|
+
const { realm } = KEYCLOAK_REALMS[target.environment];
|
|
55
|
+
return buildValidatedUrl(keycloakBaseUrlOf(target), `/realms/${realm}/.well-known/openid-configuration`);
|
|
56
|
+
}
|
|
57
|
+
async function fetchDiscovery(target) {
|
|
58
|
+
const url = discoveryUrlFor(target);
|
|
59
|
+
let data;
|
|
60
|
+
try {
|
|
61
|
+
const response = await authHttp.get(url, { headers: { Accept: 'application/json' } });
|
|
62
|
+
data = response.data;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new AuthError(`Could not load the Keycloak OIDC discovery document from ${url}: ${toAuthError(error, 'the request').message}`);
|
|
66
|
+
}
|
|
67
|
+
return validateDiscovery(url, data);
|
|
68
|
+
}
|
|
69
|
+
function validateDiscovery(url, data) {
|
|
70
|
+
const requireString = (field) => {
|
|
71
|
+
const value = data[field];
|
|
72
|
+
if (typeof value !== 'string' || !value) {
|
|
73
|
+
throw new AuthError(`Keycloak discovery document at ${url} has no ${field}`);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
};
|
|
77
|
+
const requireEndpoint = (field) => {
|
|
78
|
+
const value = requireString(field);
|
|
79
|
+
try {
|
|
80
|
+
return buildValidatedUrl(value);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new AuthError(`Keycloak discovery document at ${url} advertises a ${field} outside the allowed hosts: ${value}`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
// `end_session_endpoint` is unused: logout is local-only.
|
|
87
|
+
return {
|
|
88
|
+
authorization_endpoint: requireEndpoint('authorization_endpoint'),
|
|
89
|
+
issuer: requireString('issuer'),
|
|
90
|
+
token_endpoint: requireEndpoint('token_endpoint'),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Authorization Code + PKCE S256 with `state` and `nonce` against the public
|
|
95
|
+
* `aderis-cli-login` client, endpoints from OIDC discovery. Tenants come from
|
|
96
|
+
* the access token; a switch is a refresh-token grant carrying `tenant_id=<uuid>`.
|
|
97
|
+
*/
|
|
98
|
+
export class KeycloakProvider {
|
|
99
|
+
/** Discovery documents per discovery URL, fetched once per provider instance. */
|
|
100
|
+
discoveryCache = new Map();
|
|
101
|
+
async buildAuthorizeUrl(context, params) {
|
|
102
|
+
const discovery = await this.discover(context);
|
|
103
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
104
|
+
url.searchParams.set('response_type', 'code');
|
|
105
|
+
url.searchParams.set('client_id', requireClientId(context));
|
|
106
|
+
url.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
107
|
+
url.searchParams.set('scope', KEYCLOAK_SCOPE);
|
|
108
|
+
url.searchParams.set('code_challenge', params.codeChallenge);
|
|
109
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
110
|
+
url.searchParams.set('state', params.state);
|
|
111
|
+
url.searchParams.set('nonce', params.nonce);
|
|
112
|
+
if (params.prompt)
|
|
113
|
+
url.searchParams.set('prompt', params.prompt);
|
|
114
|
+
return url.toString();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The realm's discovery document, cached per discovery URL. Its endpoints are
|
|
118
|
+
* validated against the host allowlist; a failed fetch is not cached.
|
|
119
|
+
*/
|
|
120
|
+
discover(target) {
|
|
121
|
+
const key = discoveryUrlFor(target);
|
|
122
|
+
const cached = this.discoveryCache.get(key);
|
|
123
|
+
if (cached)
|
|
124
|
+
return cached;
|
|
125
|
+
const pending = fetchDiscovery(target).catch((error) => {
|
|
126
|
+
this.discoveryCache.delete(key);
|
|
127
|
+
throw error;
|
|
128
|
+
});
|
|
129
|
+
this.discoveryCache.set(key, pending);
|
|
130
|
+
return pending;
|
|
131
|
+
}
|
|
132
|
+
async exchangeCode(context, { code, codeVerifier, nonce }) {
|
|
133
|
+
const discovery = await this.discover(context);
|
|
134
|
+
const response = await exchangeAuthorizationCode(discovery.token_endpoint, context, { code, codeVerifier });
|
|
135
|
+
this.verifyNonce(response, nonce);
|
|
136
|
+
return this.requireTenant(this.requireIssuer(discovery, toTokenData(response)));
|
|
137
|
+
}
|
|
138
|
+
/** No network call: the access token's `availableTenants` claim lists every tenant the user may act in. */
|
|
139
|
+
async listTenants(_context, accessToken) {
|
|
140
|
+
const decoded = decodeToken(accessToken);
|
|
141
|
+
const current = tenantIdOf(decoded);
|
|
142
|
+
const tenants = tenantsOf(decoded);
|
|
143
|
+
if (tenants === undefined) {
|
|
144
|
+
throw new AuthError("The Keycloak access token carries no `availableTenants` claim. Check the realm's tenant-claims mapper, then run: aderis auth login");
|
|
145
|
+
}
|
|
146
|
+
return tenants.map(({ id, name }) => ({ isDefault: id === current, name, tenantId: id }));
|
|
147
|
+
}
|
|
148
|
+
refresh(context, refreshToken) {
|
|
149
|
+
return this.redeem(context, refreshToken);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* One refresh-token grant with the `tenant_id` form field, read by the
|
|
153
|
+
* realm's tenant-claims mapper: the minted token's `tenantId` becomes this
|
|
154
|
+
* tenant. No browser and no SSO session involved.
|
|
155
|
+
*/
|
|
156
|
+
switchTenant(context, refreshToken, tenantId) {
|
|
157
|
+
return this.redeem(context, refreshToken, tenantId);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* One login round whose tokens are re-scoped to `tenantId` through
|
|
161
|
+
* {@link switchTenant} right after the exchange. A second browser round
|
|
162
|
+
* could not be silent: exchanging the code for an offline token ends the
|
|
163
|
+
* realm's SSO session.
|
|
164
|
+
*/
|
|
165
|
+
tenantSwitchRounds(context, tenantId) {
|
|
166
|
+
return [
|
|
167
|
+
{
|
|
168
|
+
afterExchange: (tokens) => {
|
|
169
|
+
if (!tokens.refresh_token) {
|
|
170
|
+
throw new AuthError('Keycloak returned no refresh token, so the tenant cannot be selected. Aborting.');
|
|
171
|
+
}
|
|
172
|
+
return this.switchTenant(context, tokens.refresh_token, tenantId);
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
async redeem(context, refreshToken, tenantId) {
|
|
178
|
+
const discovery = await this.discover(context);
|
|
179
|
+
const tokens = toTokenData(await redeemRefreshToken(discovery.token_endpoint, context, refreshToken, { tenantId }));
|
|
180
|
+
return this.requireTenant(this.requireIssuer(discovery, tokens));
|
|
181
|
+
}
|
|
182
|
+
/** Rejects a token whose `iss` differs from the discovery issuer; the API compares `iss` exactly. */
|
|
183
|
+
requireIssuer(discovery, tokens) {
|
|
184
|
+
const issuer = decodeToken(tokens.access_token)?.iss;
|
|
185
|
+
if (issuer !== discovery.issuer) {
|
|
186
|
+
throw new AuthError(`Keycloak issued a token for issuer ${issuer ?? '(none)'}, but the realm's discovery document names ${discovery.issuer}. Aborting.`);
|
|
187
|
+
}
|
|
188
|
+
return tokens;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The mapper is fail-closed: with several reachable tenants and no `tenant_id`
|
|
192
|
+
* the token has no `tenantId`. That token is stored and needs a tenant picked
|
|
193
|
+
* (`completeTenantSelection`); a token reaching no tenant is refused.
|
|
194
|
+
*/
|
|
195
|
+
requireTenant(tokens) {
|
|
196
|
+
if (tokens.tenant_id)
|
|
197
|
+
return tokens;
|
|
198
|
+
const tenants = tenantsOf(decodeToken(tokens.access_token));
|
|
199
|
+
if (tenants !== undefined && tenants.length > 0)
|
|
200
|
+
return tokens;
|
|
201
|
+
throw new AuthError('Keycloak issued a token with no tenants, so you have reached no tenant. Your user is not a member of any tenant in this realm. Contact your administrator.');
|
|
202
|
+
}
|
|
203
|
+
/** Consistency check only: the id_token signature is not checked (the API checks the access token). */
|
|
204
|
+
verifyNonce(response, expectedNonce) {
|
|
205
|
+
if (!response.id_token) {
|
|
206
|
+
throw new AuthError('Keycloak returned no id_token; the openid scope is missing from the client configuration.');
|
|
207
|
+
}
|
|
208
|
+
const claims = decodeToken(response.id_token);
|
|
209
|
+
if (claims?.nonce !== expectedNonce) {
|
|
210
|
+
throw new AuthError('The id_token nonce does not match the login request. Aborting — please try again.');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** The one identity provider the CLI authenticates against. */
|
|
215
|
+
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('&', '&')
|
|
14
|
+
.replaceAll('<', '<')
|
|
15
|
+
.replaceAll('>', '>')
|
|
16
|
+
.replaceAll('"', '"')
|
|
17
|
+
.replaceAll("'", ''');
|
|
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,54 @@
|
|
|
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
|
+
export declare function decodeToken(token: string): DecodedToken | null;
|
|
23
|
+
/** The tenant an access token is scoped to: `tenantId`, or the legacy `tenant_id`. */
|
|
24
|
+
export declare function tenantIdOf(decoded: DecodedToken | null): string | undefined;
|
|
25
|
+
/** One entry of the `availableTenants` claim, as the realm's tenant-claims mapper writes it. */
|
|
26
|
+
export interface TenantClaim {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The token's tenant list, or `undefined` when it carries none. Throws on a malformed claim.
|
|
32
|
+
*/
|
|
33
|
+
export declare function tenantsOf(decoded: DecodedToken | null): TenantClaim[] | undefined;
|
|
34
|
+
/** Map a token endpoint response onto the stored {@link TokenData} shape. */
|
|
35
|
+
export declare function toTokenData(response: TokenResponse): TokenData;
|
|
36
|
+
/**
|
|
37
|
+
* POSTs a form to a token endpoint (RFC 6749 §4.1.3 / §6). Failures surface as
|
|
38
|
+
* {@link AuthError} with the HTTP status; the form never leaves this function inside an error.
|
|
39
|
+
*/
|
|
40
|
+
export declare function postTokenRequest(tokenUrl: string, params: URLSearchParams): Promise<TokenResponse>;
|
|
41
|
+
/** The PKCE authorization-code grant (RFC 7636 §4.5) against `tokenUrl`. */
|
|
42
|
+
export declare function exchangeAuthorizationCode(tokenUrl: string, context: Context, { code, codeVerifier }: {
|
|
43
|
+
code: string;
|
|
44
|
+
codeVerifier: string;
|
|
45
|
+
}): Promise<TokenResponse>;
|
|
46
|
+
/**
|
|
47
|
+
* The refresh-token grant (RFC 6749 §6). `tenantId` is sent as the `tenant_id`
|
|
48
|
+
* form field, which selects the tenant of the minted token. A 400
|
|
49
|
+
* (`invalid_grant`) or 401 is reported as {@link SESSION_EXPIRED_MESSAGE},
|
|
50
|
+
* which `aderis switch` recognises.
|
|
51
|
+
*/
|
|
52
|
+
export declare function redeemRefreshToken(tokenUrl: string, context: Context, refreshToken: string, { tenantId }?: {
|
|
53
|
+
tenantId?: string;
|
|
54
|
+
}): Promise<TokenResponse>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import { URLSearchParams } from 'node:url';
|
|
3
|
+
import { SESSION_EXPIRED_MESSAGE } from '../config.js';
|
|
4
|
+
import { FORM_POST_HEADERS, REDIRECT_URI } from './constants.js';
|
|
5
|
+
import { AuthError } from './errors.js';
|
|
6
|
+
import { requireClientId } from './guards.js';
|
|
7
|
+
import { authHttp, toAuthError } from './http.js';
|
|
8
|
+
export function decodeToken(token) {
|
|
9
|
+
const decoded = jwt.decode(token);
|
|
10
|
+
return decoded && typeof decoded === 'object' ? decoded : null;
|
|
11
|
+
}
|
|
12
|
+
/** The tenant an access token is scoped to: `tenantId`, or the legacy `tenant_id`. */
|
|
13
|
+
export function tenantIdOf(decoded) {
|
|
14
|
+
return decoded?.tenantId || decoded?.tenant_id;
|
|
15
|
+
}
|
|
16
|
+
function malformedTenantsClaim(detail) {
|
|
17
|
+
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`);
|
|
18
|
+
}
|
|
19
|
+
function parseTenantClaim(entry, position) {
|
|
20
|
+
// A bare string is an id without a name.
|
|
21
|
+
if (typeof entry === 'string' && entry !== '')
|
|
22
|
+
return { id: entry, name: entry };
|
|
23
|
+
if (entry === null || typeof entry !== 'object') {
|
|
24
|
+
throw malformedTenantsClaim(`entry ${position} is not an object`);
|
|
25
|
+
}
|
|
26
|
+
const { id, name } = entry;
|
|
27
|
+
if (typeof id !== 'string' || id === '') {
|
|
28
|
+
throw malformedTenantsClaim(`entry ${position} has no id`);
|
|
29
|
+
}
|
|
30
|
+
if (name !== undefined && typeof name !== 'string') {
|
|
31
|
+
throw malformedTenantsClaim(`entry ${position} has a non-string name`);
|
|
32
|
+
}
|
|
33
|
+
return { id, name: name || id };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The token's tenant list, or `undefined` when it carries none. Throws on a malformed claim.
|
|
37
|
+
*/
|
|
38
|
+
export function tenantsOf(decoded) {
|
|
39
|
+
// TODO(cli#13): remove the tenants fallback
|
|
40
|
+
const claim = decoded?.availableTenants ?? decoded?.tenants;
|
|
41
|
+
if (claim === undefined || claim === null)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (!Array.isArray(claim))
|
|
44
|
+
throw malformedTenantsClaim(`got ${typeof claim}`);
|
|
45
|
+
return claim.map((entry, index) => parseTenantClaim(entry, index + 1));
|
|
46
|
+
}
|
|
47
|
+
/** Map a token endpoint response onto the stored {@link TokenData} shape. */
|
|
48
|
+
export function toTokenData(response) {
|
|
49
|
+
return {
|
|
50
|
+
access_token: response.access_token,
|
|
51
|
+
expires_at: response.expires_in ? Date.now() + response.expires_in * 1000 : undefined,
|
|
52
|
+
refresh_token: response.refresh_token,
|
|
53
|
+
tenant_id: tenantIdOf(decodeToken(response.access_token)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* POSTs a form to a token endpoint (RFC 6749 §4.1.3 / §6). Failures surface as
|
|
58
|
+
* {@link AuthError} with the HTTP status; the form never leaves this function inside an error.
|
|
59
|
+
*/
|
|
60
|
+
export async function postTokenRequest(tokenUrl, params) {
|
|
61
|
+
try {
|
|
62
|
+
const response = await authHttp.post(tokenUrl, params.toString(), { headers: FORM_POST_HEADERS });
|
|
63
|
+
return response.data;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw toAuthError(error, `Token request to ${tokenUrl}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** `client_id`, plus `client_secret` when the context is confidential — the part every grant shares. */
|
|
70
|
+
function clientParams(context, params) {
|
|
71
|
+
params.set('client_id', requireClientId(context));
|
|
72
|
+
if (context.client_secret)
|
|
73
|
+
params.set('client_secret', context.client_secret);
|
|
74
|
+
return params;
|
|
75
|
+
}
|
|
76
|
+
/** The PKCE authorization-code grant (RFC 7636 §4.5) against `tokenUrl`. */
|
|
77
|
+
export async function exchangeAuthorizationCode(tokenUrl, context, { code, codeVerifier }) {
|
|
78
|
+
const params = clientParams(context, new URLSearchParams({ grant_type: 'authorization_code' }));
|
|
79
|
+
params.set('code', code);
|
|
80
|
+
params.set('redirect_uri', REDIRECT_URI);
|
|
81
|
+
params.set('code_verifier', codeVerifier);
|
|
82
|
+
return postTokenRequest(tokenUrl, params);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The refresh-token grant (RFC 6749 §6). `tenantId` is sent as the `tenant_id`
|
|
86
|
+
* form field, which selects the tenant of the minted token. A 400
|
|
87
|
+
* (`invalid_grant`) or 401 is reported as {@link SESSION_EXPIRED_MESSAGE},
|
|
88
|
+
* which `aderis switch` recognises.
|
|
89
|
+
*/
|
|
90
|
+
export async function redeemRefreshToken(tokenUrl, context, refreshToken, { tenantId } = {}) {
|
|
91
|
+
const params = clientParams(context, new URLSearchParams({ grant_type: 'refresh_token' }));
|
|
92
|
+
params.set('refresh_token', refreshToken);
|
|
93
|
+
if (tenantId)
|
|
94
|
+
params.set('tenant_id', tenantId);
|
|
95
|
+
try {
|
|
96
|
+
return await postTokenRequest(tokenUrl, params);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof AuthError && (error.status === 400 || error.status === 401)) {
|
|
100
|
+
throw new AuthError(SESSION_EXPIRED_MESSAGE, { status: error.status });
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
/** Refresh-token grant. A rejected refresh must surface as the "Session expired" error. */
|
|
46
|
+
refresh(context: Context, refreshToken: string): Promise<TokenData>;
|
|
47
|
+
/**
|
|
48
|
+
* Re-scopes a live session to `tenantId` with one token request and no
|
|
49
|
+
* browser. A rejected refresh must surface as the "Session expired" error.
|
|
50
|
+
*/
|
|
51
|
+
switchTenant(context: Context, refreshToken: string, tenantId: string): Promise<TokenData>;
|
|
52
|
+
/**
|
|
53
|
+
* The browser rounds that end with a token for `tenantId` when no live
|
|
54
|
+
* session exists; the flow appends the tenant assertion.
|
|
55
|
+
*/
|
|
56
|
+
tenantSwitchRounds(context: Context, tenantId: string): AuthRound[];
|
|
57
|
+
}
|
|
@@ -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;
|