@geolonia/geonicdb-cli 0.5.0 → 0.6.1

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 CHANGED
@@ -121,7 +121,47 @@ geonic help [<command>] [<subcommand>]
121
121
  | `auth nonce` | Get a nonce and PoW challenge for API key authentication |
122
122
  | `auth token-exchange` | Exchange API key for a session JWT via nonce + PoW |
123
123
 
124
- The `auth login` command reads `GDB_EMAIL` and `GDB_PASSWORD` environment variables. It also supports OAuth Client Credentials flow with `--client-id` and `--client-secret`.
124
+ #### Email/Password Login
125
+
126
+ `auth login` uses interactive prompts for email and password. A TTY is required — credentials are never accepted via environment variables or command-line arguments to prevent leaking secrets in shell history.
127
+
128
+ ```bash
129
+ geonic auth login
130
+ ```
131
+
132
+ | Option | Description |
133
+ |---|---|
134
+ | `--tenant-id <id>` | Log in to a specific tenant |
135
+
136
+ **Multi-tenant support**: When you belong to multiple tenants, `auth login` displays the list and lets you select one interactively. Use `--tenant-id` to skip the prompt.
137
+
138
+ ```text
139
+ $ geonic auth login
140
+ Email: user@example.com
141
+ Password: ********
142
+ Login successful. Token saved to config.
143
+
144
+ Available tenants:
145
+ * 1) my_city (tenant_admin) ← current
146
+ 2) another_city (user)
147
+
148
+ Select tenant number (Enter to keep current):
149
+ ```
150
+
151
+ #### OAuth Client Credentials
152
+
153
+ For machine-to-machine authentication (CI/CD, scripts), use the OAuth Client Credentials flow:
154
+
155
+ ```bash
156
+ geonic auth login --client-credentials --client-id MY_ID --client-secret MY_SECRET
157
+ ```
158
+
159
+ | Option | Description |
160
+ |---|---|
161
+ | `--client-credentials` | Use OAuth 2.0 Client Credentials flow |
162
+ | `--client-id <id>` | OAuth client ID (or `GDB_OAUTH_CLIENT_ID` env var) |
163
+ | `--client-secret <secret>` | OAuth client secret (or `GDB_OAUTH_CLIENT_SECRET` env var) |
164
+ | `--scope <scopes>` | OAuth scopes (space-separated) |
125
165
 
126
166
  #### API Key Token Exchange
127
167
 
@@ -172,6 +212,7 @@ Displays the current authenticated user, token expiry, and active profile.
172
212
  | `--origins <origins>` | Allowed origins (comma-separated, at least 1 required) |
173
213
  | `--entity-types <types>` | Allowed entity types (comma-separated) |
174
214
  | `--rate-limit <n>` | Rate limit (requests per minute) |
215
+ | `--dpop-required` | Require DPoP token binding (RFC 9449) |
175
216
  | `--save` | Save the API key to profile config |
176
217
 
177
218
  ```bash
@@ -182,6 +223,8 @@ geonic me api-keys create --name my-app --scopes read:entities --save
182
223
  geonic me api-keys create '{"name":"my-app","allowedScopes":["read:entities"]}'
183
224
  ```
184
225
 
226
+ `me api-keys list` output includes a `dpopRequired` field (boolean).
227
+
185
228
  ### entities — Manage context entities
186
229
 
187
230
  | Subcommand | Description |
@@ -369,9 +412,9 @@ Temporal entityOperations query supports: `--aggr-methods`, `--aggr-period`.
369
412
  | `admin api-keys update <keyId> [json]` | Update an API key |
370
413
  | `admin api-keys delete <keyId>` | Delete an API key |
371
414
 
372
- `admin api-keys list` supports `--tenant-id` to filter by tenant. `admin api-keys create` supports flag options: `--name`, `--scopes`, `--origins`, `--entity-types`, `--rate-limit`, `--tenant-id`, `--save`.
415
+ `admin api-keys list` supports `--tenant-id` to filter by tenant. `admin api-keys create` supports flag options: `--name`, `--scopes`, `--origins`, `--entity-types`, `--rate-limit`, `--dpop-required`, `--tenant-id`, `--save`. `admin api-keys update` supports `--name`, `--scopes`, `--origins`, `--entity-types`, `--rate-limit`, `--dpop-required` / `--no-dpop-required`.
373
416
 
374
- **Note**: `allowedOrigins` must contain at least 1 item when specified. Use `*` to allow all origins. `allowedEntityTypes` is enforced at runtime — API key holders can only access entities of the specified types.
417
+ **Note**: `allowedOrigins` must contain at least 1 item when specified. Use `*` to allow all origins. `allowedEntityTypes` is enforced at runtime — API key holders can only access entities of the specified types. `admin api-keys list` / `admin api-keys get` output includes a `dpopRequired` field (boolean).
375
418
 
376
419
  #### admin cadde
377
420
 
package/dist/index.js CHANGED
@@ -848,6 +848,9 @@ var GdbClient = class _GdbClient {
848
848
  async executeRawRequest(method, path, options) {
849
849
  const url = this.buildUrl(path, options?.params);
850
850
  const headers = this.buildHeaders(options?.headers);
851
+ if (options?.skipTenantHeader) {
852
+ delete headers["NGSILD-Tenant"];
853
+ }
851
854
  const body = options?.body ? JSON.stringify(options.body) : void 0;
852
855
  this.logRequest(method, url, headers, body);
853
856
  this.handleDryRun(method, url, headers, body);
@@ -985,7 +988,7 @@ function withErrorHandler(fn) {
985
988
  return;
986
989
  }
987
990
  if (err instanceof GdbClientError && err.status === 401) {
988
- printError("Authentication failed. Please run `geonic login` to re-authenticate.");
991
+ printError("Authentication failed. Please re-authenticate (e.g., `geonic auth login` or check your API key).");
989
992
  } else if (err instanceof GdbClientError && err.status === 403) {
990
993
  const detail = (err.ngsiError?.detail ?? err.ngsiError?.description ?? "").toLowerCase();
991
994
  if (detail.includes("entity type") || detail.includes("allowedentitytypes")) {
@@ -1097,6 +1100,30 @@ async function promptPassword() {
1097
1100
  stdin.on("error", onError);
1098
1101
  });
1099
1102
  }
1103
+ async function promptTenantSelection(tenants, currentTenantId) {
1104
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1105
+ try {
1106
+ console.log("\nAvailable tenants:");
1107
+ for (let i = 0; i < tenants.length; i++) {
1108
+ const t = tenants[i];
1109
+ const current = t.tenantId === currentTenantId ? " \u2190 current" : "";
1110
+ const marker = t.tenantId === currentTenantId ? " *" : " ";
1111
+ console.log(`${marker} ${i + 1}) ${t.tenantId} (${t.role})${current}`);
1112
+ }
1113
+ for (; ; ) {
1114
+ const answer = await rl.question("\nSelect tenant number (Enter to keep current): ");
1115
+ const trimmed = answer.trim();
1116
+ if (!trimmed) return void 0;
1117
+ const index = parseInt(trimmed, 10) - 1;
1118
+ if (index >= 0 && index < tenants.length) {
1119
+ return tenants[index].tenantId;
1120
+ }
1121
+ console.log(`Invalid selection. Please enter a number between 1 and ${tenants.length}.`);
1122
+ }
1123
+ } finally {
1124
+ rl.close();
1125
+ }
1126
+ }
1100
1127
 
1101
1128
  // src/token.ts
1102
1129
  function decodeJwtPayload(token) {
@@ -1370,7 +1397,7 @@ function addMeApiKeysSubcommand(me) {
1370
1397
  command: "geonic me api-keys list"
1371
1398
  }
1372
1399
  ]);
1373
- const create = apiKeys.command("create [json]").description("Create a new API key").option("--name <name>", "Key name").option("--scopes <scopes>", "Allowed scopes (comma-separated)").option("--origins <origins>", "Allowed origins (comma-separated)").option("--entity-types <types>", "Allowed entity types (comma-separated)").option("--rate-limit <n>", "Rate limit per minute").option("--save", "Save the API key to config for automatic use").action(
1400
+ const create = apiKeys.command("create [json]").description("Create a new API key").option("--name <name>", "Key name").option("--scopes <scopes>", "Allowed scopes (comma-separated)").option("--origins <origins>", "Allowed origins (comma-separated)").option("--entity-types <types>", "Allowed entity types (comma-separated)").option("--rate-limit <n>", "Rate limit per minute").option("--dpop-required", "Require DPoP token binding").option("--save", "Save the API key to config for automatic use").action(
1374
1401
  withErrorHandler(async (json, _opts, cmd) => {
1375
1402
  const opts = cmd.opts();
1376
1403
  if (opts.origins !== void 0) {
@@ -1383,12 +1410,13 @@ function addMeApiKeysSubcommand(me) {
1383
1410
  let body;
1384
1411
  if (json) {
1385
1412
  body = await parseJsonInput(json);
1386
- } else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit) {
1413
+ } else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== void 0) {
1387
1414
  const payload = {};
1388
1415
  if (opts.name) payload.name = opts.name;
1389
1416
  if (opts.scopes) payload.allowedScopes = opts.scopes.split(",").map((s) => s.trim()).filter(Boolean);
1390
1417
  if (opts.origins) payload.allowedOrigins = opts.origins.split(",").map((s) => s.trim()).filter(Boolean);
1391
1418
  if (opts.entityTypes) payload.allowedEntityTypes = opts.entityTypes.split(",").map((s) => s.trim()).filter(Boolean);
1419
+ if (opts.dpopRequired !== void 0) payload.dpopRequired = opts.dpopRequired;
1392
1420
  if (opts.rateLimit) {
1393
1421
  const raw = opts.rateLimit.trim();
1394
1422
  if (!/^\d+$/.test(raw)) {
@@ -1453,6 +1481,10 @@ function addMeApiKeysSubcommand(me) {
1453
1481
  {
1454
1482
  description: "Create an API key with rate limiting",
1455
1483
  command: "geonic me api-keys create --name my-app --rate-limit 100"
1484
+ },
1485
+ {
1486
+ description: "Create an API key with DPoP required",
1487
+ command: "geonic me api-keys create --name my-app --dpop-required"
1456
1488
  }
1457
1489
  ]);
1458
1490
  const del = apiKeys.command("delete <keyId>").description("Delete an API key").action(
@@ -1506,32 +1538,59 @@ function createLoginCommand() {
1506
1538
  printSuccess("Login successful (OAuth Client Credentials). Token saved to config.");
1507
1539
  return;
1508
1540
  }
1509
- let email = process.env.GDB_EMAIL;
1510
- let password = process.env.GDB_PASSWORD;
1511
- if (!email || !password) {
1512
- if (isInteractive()) {
1513
- if (!email) email = await promptEmail();
1514
- if (!password) password = await promptPassword();
1515
- } else {
1516
- printError(
1517
- "Set GDB_EMAIL and GDB_PASSWORD environment variables, or run in a terminal for interactive login."
1518
- );
1519
- process.exit(1);
1520
- }
1541
+ if (!globalOpts.url) {
1542
+ printError("No URL configured. Use `geonic config set url <url>` or pass --url.");
1543
+ process.exit(1);
1521
1544
  }
1545
+ try {
1546
+ validateUrl(globalOpts.url);
1547
+ } catch (err) {
1548
+ printError(err.message);
1549
+ process.exit(1);
1550
+ }
1551
+ if (!isInteractive()) {
1552
+ printError(
1553
+ "Interactive terminal required. Run `geonic auth login` in a terminal with TTY."
1554
+ );
1555
+ process.exit(1);
1556
+ }
1557
+ const email = await promptEmail();
1558
+ const password = await promptPassword();
1522
1559
  const client = createClient(cmd);
1523
1560
  const body = { email, password };
1524
1561
  if (loginOpts.tenantId) {
1525
1562
  body.tenantId = loginOpts.tenantId;
1526
1563
  }
1527
- const response = await client.rawRequest("POST", "/auth/login", { body });
1564
+ const response = await client.rawRequest("POST", "/auth/login", {
1565
+ body,
1566
+ skipTenantHeader: true
1567
+ });
1528
1568
  const data = response.data;
1529
- const token = data.accessToken ?? data.token;
1530
- const refreshToken = data.refreshToken;
1569
+ let token = data.accessToken ?? data.token;
1570
+ let refreshToken = data.refreshToken;
1531
1571
  if (!token) {
1532
1572
  printError("No token received from server.");
1533
1573
  process.exit(1);
1534
1574
  }
1575
+ const availableTenants = data.availableTenants;
1576
+ const currentTenantId = data.tenantId;
1577
+ if (availableTenants && availableTenants.length > 1 && !loginOpts.tenantId) {
1578
+ const selectedTenantId = await promptTenantSelection(availableTenants, currentTenantId);
1579
+ if (selectedTenantId && selectedTenantId !== currentTenantId) {
1580
+ const reloginResponse = await client.rawRequest("POST", "/auth/login", {
1581
+ body: { email, password, tenantId: selectedTenantId },
1582
+ skipTenantHeader: true
1583
+ });
1584
+ const reloginData = reloginResponse.data;
1585
+ const newToken = reloginData.accessToken ?? reloginData.token;
1586
+ if (!newToken) {
1587
+ printError("Re-login failed: no token received for selected tenant.");
1588
+ process.exit(1);
1589
+ }
1590
+ token = newToken;
1591
+ refreshToken = reloginData.refreshToken;
1592
+ }
1593
+ }
1535
1594
  const config = loadConfig(globalOpts.profile);
1536
1595
  config.token = token;
1537
1596
  if (refreshToken) {
@@ -1723,10 +1782,6 @@ function registerAuthCommands(program2) {
1723
1782
  description: "Login with OAuth client credentials",
1724
1783
  command: "geonic auth login --client-credentials --client-id MY_ID --client-secret MY_SECRET"
1725
1784
  },
1726
- {
1727
- description: "Login with environment variables",
1728
- command: "GDB_EMAIL=user@example.com GDB_PASSWORD=pass geonic auth login"
1729
- },
1730
1785
  {
1731
1786
  description: "Login to a specific tenant",
1732
1787
  command: "geonic auth login --tenant-id my-tenant"
@@ -3363,6 +3418,7 @@ function buildBodyFromFlags(opts) {
3363
3418
  }
3364
3419
  payload.rateLimit = { perMinute };
3365
3420
  }
3421
+ if (opts.dpopRequired !== void 0) payload.dpopRequired = opts.dpopRequired;
3366
3422
  if (opts.tenantId) payload.tenantId = opts.tenantId;
3367
3423
  return payload;
3368
3424
  }
@@ -3408,14 +3464,14 @@ function registerApiKeysCommand(parent) {
3408
3464
  command: "geonic admin api-keys get <key-id>"
3409
3465
  }
3410
3466
  ]);
3411
- const create = apiKeys.command("create [json]").description("Create a new API key").option("--name <name>", "Key name").option("--scopes <scopes>", "Comma-separated scopes").option("--origins <origins>", "Comma-separated origins").option("--entity-types <types>", "Comma-separated entity types").option("--rate-limit <n>", "Rate limit per minute").option("--tenant-id <id>", "Tenant ID").option("--save", "Save the API key to profile config").action(
3467
+ const create = apiKeys.command("create [json]").description("Create a new API key").option("--name <name>", "Key name").option("--scopes <scopes>", "Comma-separated scopes").option("--origins <origins>", "Comma-separated origins").option("--entity-types <types>", "Comma-separated entity types").option("--rate-limit <n>", "Rate limit per minute").option("--dpop-required", "Require DPoP token binding").option("--tenant-id <id>", "Tenant ID").option("--save", "Save the API key to profile config").action(
3412
3468
  withErrorHandler(async (json, _opts, cmd) => {
3413
3469
  const opts = cmd.opts();
3414
3470
  validateOrigins(void 0, opts);
3415
3471
  let body;
3416
3472
  if (json) {
3417
3473
  body = await parseJsonInput(json);
3418
- } else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.tenantId) {
3474
+ } else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== void 0 || opts.tenantId) {
3419
3475
  body = buildBodyFromFlags(opts);
3420
3476
  } else {
3421
3477
  body = await parseJsonInput();
@@ -3452,12 +3508,16 @@ function registerApiKeysCommand(parent) {
3452
3508
  description: "Create an API key with flags",
3453
3509
  command: "geonic admin api-keys create --name my-key --scopes entities:read,entities:write --origins '*'"
3454
3510
  },
3511
+ {
3512
+ description: "Create an API key with DPoP required",
3513
+ command: "geonic admin api-keys create --name my-key --dpop-required"
3514
+ },
3455
3515
  {
3456
3516
  description: "Create an API key from JSON and save to config",
3457
3517
  command: "geonic admin api-keys create @key.json --save"
3458
3518
  }
3459
3519
  ]);
3460
- const update = apiKeys.command("update <keyId> [json]").description("Update an API key").option("--name <name>", "Key name").option("--scopes <scopes>", "Comma-separated scopes").option("--origins <origins>", "Comma-separated origins").option("--entity-types <types>", "Comma-separated entity types").option("--rate-limit <n>", "Rate limit per minute").action(
3520
+ const update = apiKeys.command("update <keyId> [json]").description("Update an API key").option("--name <name>", "Key name").option("--scopes <scopes>", "Comma-separated scopes").option("--origins <origins>", "Comma-separated origins").option("--entity-types <types>", "Comma-separated entity types").option("--rate-limit <n>", "Rate limit per minute").option("--dpop-required", "Require DPoP token binding").option("--no-dpop-required", "Disable DPoP token binding").action(
3461
3521
  withErrorHandler(
3462
3522
  async (keyId, json, _opts, cmd) => {
3463
3523
  const opts = cmd.opts();
@@ -3465,7 +3525,7 @@ function registerApiKeysCommand(parent) {
3465
3525
  let body;
3466
3526
  if (json) {
3467
3527
  body = await parseJsonInput(json);
3468
- } else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit) {
3528
+ } else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== void 0) {
3469
3529
  body = buildBodyFromFlags(opts);
3470
3530
  } else {
3471
3531
  body = await parseJsonInput();
@@ -3488,6 +3548,14 @@ function registerApiKeysCommand(parent) {
3488
3548
  description: "Update an API key name",
3489
3549
  command: "geonic admin api-keys update <key-id> --name new-name"
3490
3550
  },
3551
+ {
3552
+ description: "Enable DPoP requirement",
3553
+ command: "geonic admin api-keys update <key-id> --dpop-required"
3554
+ },
3555
+ {
3556
+ description: "Disable DPoP requirement",
3557
+ command: "geonic admin api-keys update <key-id> --no-dpop-required"
3558
+ },
3491
3559
  {
3492
3560
  description: "Update an API key from a JSON file",
3493
3561
  command: "geonic admin api-keys update <key-id> @key.json"