@ekanos/cli 0.3.2 → 0.3.3
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 +13 -13
- package/dist/auth/credential-store.d.ts +1 -1
- package/dist/auth/credential-store.js +3 -2
- package/dist/auth/credential-store.js.map +1 -1
- package/dist/auth/device-flow.d.ts +1 -1
- package/dist/auth/device-flow.js +11 -10
- package/dist/auth/device-flow.js.map +1 -1
- package/dist/auth/{fusion-api.d.ts → ekanos-api.d.ts} +9 -9
- package/dist/auth/{fusion-api.js → ekanos-api.js} +28 -28
- package/dist/auth/ekanos-api.js.map +1 -0
- package/dist/auth/session.d.ts +5 -5
- package/dist/auth/session.js +8 -7
- package/dist/auth/session.js.map +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/commands/dev.d.ts +2 -2
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/init.js +4 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +3 -3
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logout.js +1 -1
- package/dist/commands/logout.js.map +1 -1
- package/dist/commands/publish.d.ts +2 -2
- package/dist/commands/publish.js +3 -3
- package/dist/commands/publish.js.map +1 -1
- package/dist/commands/sources.js.map +1 -1
- package/dist/commands/status.js +5 -7
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/test.js.map +1 -1
- package/dist/commands/upgrade.js +1 -1
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/commands/use.js +1 -1
- package/dist/commands/use.js.map +1 -1
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/whoami.d.ts +1 -1
- package/dist/commands/whoami.js +1 -1
- package/dist/commands/whoami.js.map +1 -1
- package/dist/compatibility.js.map +1 -1
- package/dist/context.js.map +1 -1
- package/dist/delegate.js.map +1 -1
- package/dist/did-you-mean.js +26 -15
- package/dist/did-you-mean.js.map +1 -1
- package/dist/exit-codes.d.ts +10 -10
- package/dist/findings-renderer.js +6 -4
- package/dist/findings-renderer.js.map +1 -1
- package/dist/generated/fa-free-names.d.ts +1 -1
- package/dist/generated/fa-free-names.js +1 -1
- package/dist/generated/fa-free-names.js.map +1 -1
- package/dist/harness-scaffold.d.ts +1 -1
- package/dist/harness-scaffold.js +4 -2
- package/dist/harness-scaffold.js.map +1 -1
- package/dist/icon-findings.d.ts +1 -1
- package/dist/icon-findings.js +2 -2
- package/dist/icon-findings.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/merge-manifest.js.map +1 -1
- package/dist/pack.js +1 -1
- package/dist/pack.js.map +1 -1
- package/dist/project-checks.js.map +1 -1
- package/dist/project.d.ts +7 -84
- package/dist/project.js +3 -3
- package/dist/project.js.map +1 -1
- package/dist/publish-api.d.ts +5 -5
- package/dist/publish-api.js +27 -33
- package/dist/publish-api.js.map +1 -1
- package/dist/safe-fs.js.map +1 -1
- package/dist/schema-skew.js.map +1 -1
- package/dist/source-locator.js.map +1 -1
- package/dist/sources-api.d.ts +3 -3
- package/dist/sources-api.js +11 -14
- package/dist/sources-api.js.map +1 -1
- package/dist/templates.js.map +1 -1
- package/dist/test-report.js.map +1 -1
- package/dist/toolchain-api.d.ts +4 -4
- package/dist/toolchain-api.js +1 -1
- package/dist/toolchain-api.js.map +1 -1
- package/dist/toolchain-resolve.js.map +1 -1
- package/dist/toolchain.d.ts +5 -5
- package/dist/ui.js +2 -8
- package/dist/ui.js.map +1 -1
- package/dist/update-notice.d.ts +1 -1
- package/dist/update-notice.js.map +1 -1
- package/dist/validate-findings.js.map +1 -1
- package/dist/wizard.js.map +1 -1
- package/package.json +15 -20
- package/templates/AGENTS.md.tmpl +13 -8
- package/templates/CLAUDE.md.tmpl +1 -1
- package/templates/claude-skill.md.tmpl +2 -2
- package/templates/harness/next.config.mjs.tmpl +1 -1
- package/templates/package.json.tmpl +6 -5
- package/templates/pnpm-workspace.yaml.tmpl +8 -0
- package/templates/skills/build-integration/SKILL.md +3 -3
- package/templates/skills/build-integration/references/api-wiring.md +2 -2
- package/templates/skills/build-integration/references/widget-design.md +1 -1
- package/dist/auth/fusion-api.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @ekanos/cli
|
|
2
2
|
|
|
3
|
-
The Ekanos partner toolchain. Scaffold, validate, and test
|
|
3
|
+
The Ekanos partner toolchain. Scaffold, validate, and test an Ekanos integration
|
|
4
4
|
against the published `@ekanos` packages — from any Node project, with no
|
|
5
5
|
bundler and no dependency on `@ekanos/sdk` at runtime.
|
|
6
6
|
|
|
@@ -17,15 +17,15 @@ npx @ekanos/cli dev
|
|
|
17
17
|
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
18
|
| `init` | Scaffold `ekanos.json`, a working `defineIntegration`, a test, a `tsconfig.json`, a vitest config, `.gitignore`, a `package.json` with real published deps, and the agent-context files (`AGENTS.md`, `CLAUDE.md`, a Claude Code skill). Defaults to a new `./<slug>` directory; `--dir .` scaffolds in place, on top of an existing project if there is one. |
|
|
19
19
|
| `validate` | Load the definition via esbuild and validate it with the **real** `@ekanos/integration-schema` zod schemas; add project checks (the required vitest SDK-inline block). |
|
|
20
|
-
| `dev` | Scaffold `.ekanos/harness/` and run it with **your** Next, so your widgets, tile, activation form and triggers render in real
|
|
20
|
+
| `dev` | Scaffold `.ekanos/harness/` and run it with **your** Next, so your widgets, tile, activation form and triggers render in real Ekanos chrome. |
|
|
21
21
|
| `test` | Delegate to the project's own test script via its package manager. |
|
|
22
|
-
| `login` | Authenticate this machine against
|
|
22
|
+
| `login` | Authenticate this machine against an Ekanos deployment (Auth0 device flow). |
|
|
23
23
|
| `logout` | Revoke the session at Auth0 and delete the local credentials. |
|
|
24
24
|
| `whoami` | Print the identity the stored session authenticates as, plus a compact `seats` list (slug + roles) from the same endpoint `sources` uses — degrades to `seats: null` rather than failing if that lookup is unavailable. |
|
|
25
25
|
| `status` | The read verb: login state for the resolved host, this project's `ekanos.json`, and its submissions on the host (`GET /api/partner/submissions`). Never fails just because you are logged out — it reports `authenticated: false` instead. |
|
|
26
|
-
| `sources` | List every
|
|
26
|
+
| `sources` | List every Ekanos source you hold a developer or source-admin seat on (`GET /api/partner/sources`), with a marker on this project's current publish target. |
|
|
27
27
|
| `use` | `ekanos use <source-slug>` — set this project's publish target to a source you hold a seat on. Refuses (`forbidden`, with your actual seats in the hint) for a slug you don't hold a seat on, so a typo can't silently repoint a project. |
|
|
28
|
-
| `publish` | Validate, pack and submit the integration to
|
|
28
|
+
| `publish` | Validate, pack and submit the integration to an Ekanos host — refusing (`forbidden`) a target you hold no seat on, and confirming an ambiguous multi-seat target before submitting. See [Publish target verification](#publish-target-verification). |
|
|
29
29
|
| `upgrade` | Move this toolchain to a coherent target version, with verify-and-rollback. `--check` reports current vs. target and changes nothing; a bare `upgrade` installs, verifies (`validate` + the test script), and rolls back automatically on any failure. See [Keeping the toolchain current](#keeping-the-toolchain-current). |
|
|
30
30
|
|
|
31
31
|
### Installing: global is fine
|
|
@@ -115,8 +115,8 @@ than a warning.
|
|
|
115
115
|
| `entry` | yes, in the single form | top level | path to the module exporting your `defineIntegration(...)` result |
|
|
116
116
|
| `integrations[]` | yes, in the list form | top level | `{ slug, entry }` per integration, same rules, slugs unique |
|
|
117
117
|
| `sourceGlobs` | no | **top level only** | extra Tailwind `@source` globs; project-relative, no `..` |
|
|
118
|
-
| `source` | no | top level | the
|
|
119
|
-
| `host` | no | top level | the
|
|
118
|
+
| `source` | no | top level | the Ekanos source slug this project publishes to; written by the first successful `publish` |
|
|
119
|
+
| `host` | no | top level | the Ekanos host (http(s) origin) this project publishes to; written by the first successful `publish`, read by every host-taking verb |
|
|
120
120
|
| `$schema` | no | top level | ignored, for editor tooling |
|
|
121
121
|
|
|
122
122
|
The file is **strict**: an unrecognised key is an error naming the key, not a
|
|
@@ -191,7 +191,7 @@ gets the refusal, not the exposure — which is the point.
|
|
|
191
191
|
|
|
192
192
|
An integration that declares `oauth` in `defineIntegration()` can run the whole
|
|
193
193
|
authorize → callback → code-exchange → `onTokens` loop against **your own
|
|
194
|
-
developer app**, in live mode, with no
|
|
194
|
+
developer app**, in live mode, with no Ekanos host involved:
|
|
195
195
|
|
|
196
196
|
1. Switch the toolbar's **Data** dropdown to **Live API** (the entry needs a
|
|
197
197
|
`live` block). An integration whose widgets never call the vendor from the
|
|
@@ -408,14 +408,14 @@ carries the same for manifest fields it could not resolve.
|
|
|
408
408
|
## Authentication
|
|
409
409
|
|
|
410
410
|
```bash
|
|
411
|
-
ekanos login --host https://your-
|
|
411
|
+
ekanos login --host https://your-ekanos-host # or set EKANOS_HOST
|
|
412
412
|
ekanos whoami # host resolves — see below
|
|
413
413
|
ekanos logout
|
|
414
414
|
```
|
|
415
415
|
|
|
416
416
|
### Host resolution
|
|
417
417
|
|
|
418
|
-
`logout`, `whoami`, `status`, `sources`, `use` and `publish` resolve the
|
|
418
|
+
`logout`, `whoami`, `status`, `sources`, `use` and `publish` resolve the Ekanos
|
|
419
419
|
host in this order:
|
|
420
420
|
|
|
421
421
|
1. the `--host` flag
|
|
@@ -460,7 +460,7 @@ If either is found group- or world-accessible, the CLI tightens the permissions
|
|
|
460
460
|
and warns on stderr — it does not refuse to run, but it does tell you, and you
|
|
461
461
|
should assume the token was exposed and re-run `ekanos logout && ekanos login`.
|
|
462
462
|
|
|
463
|
-
The file holds the rotating Auth0 refresh token plus a short-lived
|
|
463
|
+
The file holds the rotating Auth0 refresh token plus a short-lived Ekanos JWT.
|
|
464
464
|
No token is ever printed: not by `whoami`, not in an error, not in a `--json`
|
|
465
465
|
envelope, not at any log level.
|
|
466
466
|
|
|
@@ -499,12 +499,12 @@ signal to log in again — retry it.
|
|
|
499
499
|
### What this does and does not grant
|
|
500
500
|
|
|
501
501
|
`login` establishes **identity**. What that identity is permitted to do is a
|
|
502
|
-
separate concern, handled by
|
|
502
|
+
separate concern, handled by Ekanos's own authorization; the CLI adds no
|
|
503
503
|
permissions of its own and no path to acquire any.
|
|
504
504
|
|
|
505
505
|
## Publish target verification
|
|
506
506
|
|
|
507
|
-
A logged-in developer often holds a seat on more than one
|
|
507
|
+
A logged-in developer often holds a seat on more than one Ekanos source. The
|
|
508
508
|
owner's requirement this mechanism exists for: **it must be impossible to
|
|
509
509
|
publish to the wrong source by accident.**
|
|
510
510
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface StoredSession {
|
|
2
2
|
/** The Auth0 refresh token. Rotating — replaced on every refresh. */
|
|
3
3
|
auth0RefreshToken: string;
|
|
4
|
-
/** The minted
|
|
4
|
+
/** The minted Ekanos JWT. Short-lived; re-minted from the refresh token. */
|
|
5
5
|
accessToken: string;
|
|
6
6
|
/** ISO-8601 expiry of `accessToken`, as reported by the server. */
|
|
7
7
|
expiresAt: string;
|
|
@@ -7,7 +7,7 @@ import { preconditionError } from '../errors.js';
|
|
|
7
7
|
* inside a directory forced to 0700.
|
|
8
8
|
*
|
|
9
9
|
* Keyed by host, because a partner routinely holds sessions against more than
|
|
10
|
-
* one
|
|
10
|
+
* one Ekanos deployment (their sandbox and production) and a single-session
|
|
11
11
|
* file would silently swap one for the other.
|
|
12
12
|
*
|
|
13
13
|
* NOTHING in this module ever writes a token to a stream. `describe()` exists
|
|
@@ -47,7 +47,8 @@ export class CredentialStore {
|
|
|
47
47
|
}
|
|
48
48
|
/** Every host with a stored session. Used by `logout --all` and diagnostics. */
|
|
49
49
|
hosts() {
|
|
50
|
-
var _a
|
|
50
|
+
var _a;
|
|
51
|
+
var _b;
|
|
51
52
|
return Object.keys((_b = (_a = this.load()) === null || _a === void 0 ? void 0 : _a.hosts) !== null && _b !== void 0 ? _b : {});
|
|
52
53
|
}
|
|
53
54
|
write(host, session) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credential-store.js","sourceRoot":"","sources":["../../src/auth/credential-store.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\n\nimport { preconditionError } from '../errors';\n\n/**\n * The on-disk credential store: `~/.ekanos/credentials.json`, mode 0600,\n * inside a directory forced to 0700.\n *\n * Keyed by host, because a partner routinely holds sessions against more than\n * one Fusion deployment (their sandbox and production) and a single-session\n * file would silently swap one for the other.\n *\n * NOTHING in this module ever writes a token to a stream. `describe()` exists\n * so callers have a safe thing to print; the tokens themselves leave here only\n * as return values.\n */\n\nconst CREDENTIALS_FILENAME = 'credentials.json';\nconst DIR_MODE = 0o700;\nconst FILE_MODE = 0o600;\nconst CURRENT_VERSION = 1;\n\n/** POSIX permission bits that must not be set on the credentials file. */\nconst FORBIDDEN_FILE_BITS = 0o077;\nconst FORBIDDEN_DIR_BITS = 0o077;\n\nexport interface StoredSession {\n /** The Auth0 refresh token. Rotating — replaced on every refresh. */\n auth0RefreshToken: string;\n /** The minted Fusion JWT. Short-lived; re-minted from the refresh token. */\n accessToken: string;\n /** ISO-8601 expiry of `accessToken`, as reported by the server. */\n expiresAt: string;\n userId: string;\n email: string | null;\n /** ISO-8601 timestamp of the last write. */\n updatedAt: string;\n}\n\ninterface CredentialsFile {\n version: number;\n hosts: Record<string, StoredSession>;\n}\n\nexport interface CredentialStoreOptions {\n /**\n * Overrides the home directory. `EKANOS_HOME` is honoured by the caller;\n * tests pass a temp dir. Never read from `process.env` in here so the store\n * is deterministic under test.\n */\n home: string;\n /** Where permission warnings go. Always stderr in production. */\n warn?: (message: string) => void;\n}\n\nexport class CredentialStore {\n private readonly dir: string;\n private readonly file: string;\n private readonly warn: (message: string) => void;\n\n constructor(options: CredentialStoreOptions) {\n this.dir = path.join(options.home, '.ekanos');\n this.file = path.join(this.dir, CREDENTIALS_FILENAME);\n this.warn = options.warn ?? (() => {});\n }\n\n /** Absolute path to the credentials file — safe to print. */\n get filePath(): string {\n return this.file;\n }\n\n /**\n * Read the session for `host`, or null when there is none.\n *\n * A file that exists but is unreadable or corrupt is a precondition failure,\n * not a silent \"logged out\": treating a corrupt file as absent would send the\n * user through a fresh browser login that then overwrites whatever was\n * really in there.\n */\n read(host: string): StoredSession | null {\n const file = this.load();\n\n return file?.hosts[normalizeHost(host)] ?? null;\n }\n\n /** Every host with a stored session. Used by `logout --all` and diagnostics. */\n hosts(): string[] {\n return Object.keys(this.load()?.hosts ?? {});\n }\n\n write(host: string, session: StoredSession): void {\n const file = this.load() ?? { version: CURRENT_VERSION, hosts: {} };\n\n file.version = CURRENT_VERSION;\n file.hosts[normalizeHost(host)] = session;\n\n this.persist(file);\n }\n\n /**\n * Remove the session for `host`. Returns whether anything was removed, so\n * `logout` can report accurately while still exiting 0 either way — logging\n * out twice is not an error.\n */\n clear(host: string): boolean {\n const file = this.load();\n\n if (!file) return false;\n\n const key = normalizeHost(host);\n\n if (!(key in file.hosts)) return false;\n\n delete file.hosts[key];\n this.persist(file);\n\n return true;\n }\n\n /** Remove every session. Returns the number of hosts cleared. */\n clearAll(): number {\n const file = this.load();\n\n if (!file) return 0;\n\n const count = Object.keys(file.hosts).length;\n\n if (count === 0) return 0;\n\n file.hosts = {};\n this.persist(file);\n\n return count;\n }\n\n private load(): CredentialsFile | null {\n if (!fs.existsSync(this.file)) return null;\n\n this.auditPermissions();\n\n let raw: string;\n\n try {\n raw = fs.readFileSync(this.file, 'utf8');\n } catch (error) {\n throw preconditionError(\n `Could not read ${this.file}: ${errorMessage(error)}`,\n `Check the file's ownership and permissions, or delete it and run ` +\n `\"ekanos login\" again.`,\n );\n }\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(raw);\n } catch {\n throw preconditionError(\n `${this.file} is not valid JSON.`,\n `Delete it and run \"ekanos login\" again — it holds only credentials, ` +\n `nothing you need to keep.`,\n );\n }\n\n if (!isCredentialsFile(parsed)) {\n throw preconditionError(\n `${this.file} is not a recognised credentials file.`,\n `Delete it and run \"ekanos login\" again.`,\n );\n }\n\n return parsed;\n }\n\n /**\n * Write atomically, and never through a mode the umask can widen.\n *\n * `writeFileSync`'s `mode` applies only when the file is CREATED, so a\n * pre-existing world-readable file would keep its mode on every subsequent\n * write. Writing a fresh temp file and renaming over the target sidesteps\n * that, and also means a crash mid-write cannot leave a half-written\n * credentials file behind.\n */\n private persist(file: CredentialsFile): void {\n this.ensureDir();\n\n const tmp = `${this.file}.${process.pid}.tmp`;\n const body = `${JSON.stringify(file, null, 2)}\\n`;\n\n try {\n fs.writeFileSync(tmp, body, { mode: FILE_MODE });\n // Belt and braces: `mode` above is masked by the process umask, so set\n // the mode explicitly rather than trusting it.\n chmodIfPosix(tmp, FILE_MODE);\n fs.renameSync(tmp, this.file);\n chmodIfPosix(this.file, FILE_MODE);\n } catch (error) {\n safeUnlink(tmp);\n\n throw preconditionError(\n `Could not write ${this.file}: ${errorMessage(error)}`,\n `Ensure ${this.dir} is writable by the current user, then retry.`,\n );\n }\n }\n\n /**\n * Create `~/.ekanos` at 0700 — and repair it if it already exists wider.\n *\n * A pre-existing directory is the common case (other Ekanos state may live\n * there), and `mkdir`'s mode is ignored when the directory is already\n * present, so the mode has to be asserted separately every time.\n */\n private ensureDir(): void {\n try {\n fs.mkdirSync(this.dir, { recursive: true, mode: DIR_MODE });\n } catch (error) {\n throw preconditionError(\n `Could not create ${this.dir}: ${errorMessage(error)}`,\n `Create it manually with \"mkdir -p ${this.dir} && chmod 700 ${this.dir}\", then retry.`,\n );\n }\n\n if (process.platform === 'win32') return;\n\n const mode = statMode(this.dir);\n\n if (mode !== null && (mode & FORBIDDEN_DIR_BITS) !== 0) {\n this.warn(\n `ekanos: ${this.dir} was group/world accessible (${formatMode(mode)}); tightening it to 0700.`,\n );\n chmodIfPosix(this.dir, DIR_MODE);\n }\n }\n\n /**\n * Warn about and repair an over-permissive credentials file.\n *\n * Repair rather than refuse: a hard refusal (ssh's behaviour) would leave a\n * partner with a CLI that will not run and no obvious fix, and the token is\n * already exposed by the time we notice — narrowing the window beats\n * blocking. The warning goes to stderr so `--json` stdout stays parseable.\n */\n private auditPermissions(): void {\n if (process.platform === 'win32') return;\n\n const mode = statMode(this.file);\n\n if (mode === null) return;\n\n if ((mode & FORBIDDEN_FILE_BITS) !== 0) {\n this.warn(\n `ekanos: ${this.file} was readable by other users (${formatMode(mode)}); ` +\n `tightening it to 0600. Rotate your session with \"ekanos logout && ekanos login\" ` +\n `if this machine is shared.`,\n );\n chmodIfPosix(this.file, FILE_MODE);\n }\n }\n}\n\n/**\n * Resolve the home directory the store lives under. `EKANOS_HOME` exists for\n * containers and CI, where `os.homedir()` can resolve to a non-writable path.\n */\nexport function resolveEkanosHome(\n env: Record<string, string | undefined> = process.env,\n): string {\n const override = env.EKANOS_HOME?.trim();\n\n return override && override.length > 0 ? override : os.homedir();\n}\n\n/**\n * Canonical key for a host: scheme + authority, no trailing slash, lowercased.\n * Without this, `https://app.example.com` and `https://app.example.com/` would\n * be two different sessions.\n */\nexport function normalizeHost(host: string): string {\n let url: URL;\n\n try {\n url = new URL(host);\n } catch {\n throw preconditionError(\n `\"${host}\" is not a valid URL.`,\n `Pass a full origin, e.g. \"--host https://app.example.com\".`,\n );\n }\n\n if (url.protocol !== 'https:' && url.protocol !== 'http:') {\n throw preconditionError(\n `\"${host}\" must be an http(s) URL.`,\n `Pass a full origin, e.g. \"--host https://app.example.com\".`,\n );\n }\n\n return `${url.protocol}//${url.host}`.toLowerCase();\n}\n\n/** A printable summary of a session — never includes a token. */\nexport function describe(session: StoredSession): {\n userId: string;\n email: string | null;\n expiresAt: string;\n updatedAt: string;\n} {\n return {\n userId: session.userId,\n email: session.email,\n expiresAt: session.expiresAt,\n updatedAt: session.updatedAt,\n };\n}\n\nfunction isCredentialsFile(value: unknown): value is CredentialsFile {\n if (typeof value !== 'object' || value === null) return false;\n\n const candidate = value as Partial<CredentialsFile>;\n\n if (typeof candidate.version !== 'number') return false;\n if (typeof candidate.hosts !== 'object' || candidate.hosts === null) {\n return false;\n }\n\n return Object.values(candidate.hosts).every(isStoredSession);\n}\n\nfunction isStoredSession(value: unknown): value is StoredSession {\n if (typeof value !== 'object' || value === null) return false;\n\n const candidate = value as Partial<StoredSession>;\n\n return (\n typeof candidate.auth0RefreshToken === 'string' &&\n typeof candidate.accessToken === 'string' &&\n typeof candidate.expiresAt === 'string' &&\n typeof candidate.userId === 'string' &&\n (candidate.email === null || typeof candidate.email === 'string') &&\n typeof candidate.updatedAt === 'string'\n );\n}\n\nfunction statMode(target: string): number | null {\n try {\n return fs.statSync(target).mode & 0o777;\n } catch {\n return null;\n }\n}\n\nfunction chmodIfPosix(target: string, mode: number): void {\n if (process.platform === 'win32') return;\n\n try {\n fs.chmodSync(target, mode);\n } catch {\n // Best effort. A filesystem that rejects chmod (some mounted volumes,\n // WSL interop paths) must not break login; the warning already fired.\n }\n}\n\nfunction safeUnlink(target: string): void {\n try {\n fs.unlinkSync(target);\n } catch {\n // The temp file may never have been created.\n }\n}\n\nfunction formatMode(mode: number): string {\n return `0${mode.toString(8).padStart(3, '0')}`;\n}\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"credential-store.js","sourceRoot":"","sources":["../../src/auth/credential-store.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\n\nimport { preconditionError } from '../errors';\n\n/**\n * The on-disk credential store: `~/.ekanos/credentials.json`, mode 0600,\n * inside a directory forced to 0700.\n *\n * Keyed by host, because a partner routinely holds sessions against more than\n * one Ekanos deployment (their sandbox and production) and a single-session\n * file would silently swap one for the other.\n *\n * NOTHING in this module ever writes a token to a stream. `describe()` exists\n * so callers have a safe thing to print; the tokens themselves leave here only\n * as return values.\n */\n\nconst CREDENTIALS_FILENAME = 'credentials.json';\nconst DIR_MODE = 0o700;\nconst FILE_MODE = 0o600;\nconst CURRENT_VERSION = 1;\n\n/** POSIX permission bits that must not be set on the credentials file. */\nconst FORBIDDEN_FILE_BITS = 0o077;\nconst FORBIDDEN_DIR_BITS = 0o077;\n\nexport interface StoredSession {\n /** The Auth0 refresh token. Rotating — replaced on every refresh. */\n auth0RefreshToken: string;\n /** The minted Ekanos JWT. Short-lived; re-minted from the refresh token. */\n accessToken: string;\n /** ISO-8601 expiry of `accessToken`, as reported by the server. */\n expiresAt: string;\n userId: string;\n email: string | null;\n /** ISO-8601 timestamp of the last write. */\n updatedAt: string;\n}\n\ninterface CredentialsFile {\n version: number;\n hosts: Record<string, StoredSession>;\n}\n\nexport interface CredentialStoreOptions {\n /**\n * Overrides the home directory. `EKANOS_HOME` is honoured by the caller;\n * tests pass a temp dir. Never read from `process.env` in here so the store\n * is deterministic under test.\n */\n home: string;\n /** Where permission warnings go. Always stderr in production. */\n warn?: (message: string) => void;\n}\n\nexport class CredentialStore {\n private readonly dir: string;\n private readonly file: string;\n private readonly warn: (message: string) => void;\n\n constructor(options: CredentialStoreOptions) {\n this.dir = path.join(options.home, '.ekanos');\n this.file = path.join(this.dir, CREDENTIALS_FILENAME);\n this.warn = options.warn ?? (() => {});\n }\n\n /** Absolute path to the credentials file — safe to print. */\n get filePath(): string {\n return this.file;\n }\n\n /**\n * Read the session for `host`, or null when there is none.\n *\n * A file that exists but is unreadable or corrupt is a precondition failure,\n * not a silent \"logged out\": treating a corrupt file as absent would send the\n * user through a fresh browser login that then overwrites whatever was\n * really in there.\n */\n read(host: string): StoredSession | null {\n const file = this.load();\n\n return file?.hosts[normalizeHost(host)] ?? null;\n }\n\n /** Every host with a stored session. Used by `logout --all` and diagnostics. */\n hosts(): string[] {\n return Object.keys(this.load()?.hosts ?? {});\n }\n\n write(host: string, session: StoredSession): void {\n const file = this.load() ?? { version: CURRENT_VERSION, hosts: {} };\n\n file.version = CURRENT_VERSION;\n file.hosts[normalizeHost(host)] = session;\n\n this.persist(file);\n }\n\n /**\n * Remove the session for `host`. Returns whether anything was removed, so\n * `logout` can report accurately while still exiting 0 either way — logging\n * out twice is not an error.\n */\n clear(host: string): boolean {\n const file = this.load();\n\n if (!file) return false;\n\n const key = normalizeHost(host);\n\n if (!(key in file.hosts)) return false;\n\n delete file.hosts[key];\n this.persist(file);\n\n return true;\n }\n\n /** Remove every session. Returns the number of hosts cleared. */\n clearAll(): number {\n const file = this.load();\n\n if (!file) return 0;\n\n const count = Object.keys(file.hosts).length;\n\n if (count === 0) return 0;\n\n file.hosts = {};\n this.persist(file);\n\n return count;\n }\n\n private load(): CredentialsFile | null {\n if (!fs.existsSync(this.file)) return null;\n\n this.auditPermissions();\n\n let raw: string;\n\n try {\n raw = fs.readFileSync(this.file, 'utf8');\n } catch (error) {\n throw preconditionError(\n `Could not read ${this.file}: ${errorMessage(error)}`,\n `Check the file's ownership and permissions, or delete it and run ` +\n `\"ekanos login\" again.`,\n );\n }\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(raw);\n } catch {\n throw preconditionError(\n `${this.file} is not valid JSON.`,\n `Delete it and run \"ekanos login\" again — it holds only credentials, ` +\n `nothing you need to keep.`,\n );\n }\n\n if (!isCredentialsFile(parsed)) {\n throw preconditionError(\n `${this.file} is not a recognised credentials file.`,\n `Delete it and run \"ekanos login\" again.`,\n );\n }\n\n return parsed;\n }\n\n /**\n * Write atomically, and never through a mode the umask can widen.\n *\n * `writeFileSync`'s `mode` applies only when the file is CREATED, so a\n * pre-existing world-readable file would keep its mode on every subsequent\n * write. Writing a fresh temp file and renaming over the target sidesteps\n * that, and also means a crash mid-write cannot leave a half-written\n * credentials file behind.\n */\n private persist(file: CredentialsFile): void {\n this.ensureDir();\n\n const tmp = `${this.file}.${process.pid}.tmp`;\n const body = `${JSON.stringify(file, null, 2)}\\n`;\n\n try {\n fs.writeFileSync(tmp, body, { mode: FILE_MODE });\n // Belt and braces: `mode` above is masked by the process umask, so set\n // the mode explicitly rather than trusting it.\n chmodIfPosix(tmp, FILE_MODE);\n fs.renameSync(tmp, this.file);\n chmodIfPosix(this.file, FILE_MODE);\n } catch (error) {\n safeUnlink(tmp);\n\n throw preconditionError(\n `Could not write ${this.file}: ${errorMessage(error)}`,\n `Ensure ${this.dir} is writable by the current user, then retry.`,\n );\n }\n }\n\n /**\n * Create `~/.ekanos` at 0700 — and repair it if it already exists wider.\n *\n * A pre-existing directory is the common case (other Ekanos state may live\n * there), and `mkdir`'s mode is ignored when the directory is already\n * present, so the mode has to be asserted separately every time.\n */\n private ensureDir(): void {\n try {\n fs.mkdirSync(this.dir, { recursive: true, mode: DIR_MODE });\n } catch (error) {\n throw preconditionError(\n `Could not create ${this.dir}: ${errorMessage(error)}`,\n `Create it manually with \"mkdir -p ${this.dir} && chmod 700 ${this.dir}\", then retry.`,\n );\n }\n\n if (process.platform === 'win32') return;\n\n const mode = statMode(this.dir);\n\n if (mode !== null && (mode & FORBIDDEN_DIR_BITS) !== 0) {\n this.warn(\n `ekanos: ${this.dir} was group/world accessible (${formatMode(mode)}); tightening it to 0700.`,\n );\n chmodIfPosix(this.dir, DIR_MODE);\n }\n }\n\n /**\n * Warn about and repair an over-permissive credentials file.\n *\n * Repair rather than refuse: a hard refusal (ssh's behaviour) would leave a\n * partner with a CLI that will not run and no obvious fix, and the token is\n * already exposed by the time we notice — narrowing the window beats\n * blocking. The warning goes to stderr so `--json` stdout stays parseable.\n */\n private auditPermissions(): void {\n if (process.platform === 'win32') return;\n\n const mode = statMode(this.file);\n\n if (mode === null) return;\n\n if ((mode & FORBIDDEN_FILE_BITS) !== 0) {\n this.warn(\n `ekanos: ${this.file} was readable by other users (${formatMode(mode)}); ` +\n `tightening it to 0600. Rotate your session with \"ekanos logout && ekanos login\" ` +\n `if this machine is shared.`,\n );\n chmodIfPosix(this.file, FILE_MODE);\n }\n }\n}\n\n/**\n * Resolve the home directory the store lives under. `EKANOS_HOME` exists for\n * containers and CI, where `os.homedir()` can resolve to a non-writable path.\n */\nexport function resolveEkanosHome(\n env: Record<string, string | undefined> = process.env,\n): string {\n const override = env.EKANOS_HOME?.trim();\n\n return override && override.length > 0 ? override : os.homedir();\n}\n\n/**\n * Canonical key for a host: scheme + authority, no trailing slash, lowercased.\n * Without this, `https://app.example.com` and `https://app.example.com/` would\n * be two different sessions.\n */\nexport function normalizeHost(host: string): string {\n let url: URL;\n\n try {\n url = new URL(host);\n } catch {\n throw preconditionError(\n `\"${host}\" is not a valid URL.`,\n `Pass a full origin, e.g. \"--host https://app.example.com\".`,\n );\n }\n\n if (url.protocol !== 'https:' && url.protocol !== 'http:') {\n throw preconditionError(\n `\"${host}\" must be an http(s) URL.`,\n `Pass a full origin, e.g. \"--host https://app.example.com\".`,\n );\n }\n\n return `${url.protocol}//${url.host}`.toLowerCase();\n}\n\n/** A printable summary of a session — never includes a token. */\nexport function describe(session: StoredSession): {\n userId: string;\n email: string | null;\n expiresAt: string;\n updatedAt: string;\n} {\n return {\n userId: session.userId,\n email: session.email,\n expiresAt: session.expiresAt,\n updatedAt: session.updatedAt,\n };\n}\n\nfunction isCredentialsFile(value: unknown): value is CredentialsFile {\n if (typeof value !== 'object' || value === null) return false;\n\n const candidate = value as Partial<CredentialsFile>;\n\n if (typeof candidate.version !== 'number') return false;\n if (typeof candidate.hosts !== 'object' || candidate.hosts === null) {\n return false;\n }\n\n return Object.values(candidate.hosts).every(isStoredSession);\n}\n\nfunction isStoredSession(value: unknown): value is StoredSession {\n if (typeof value !== 'object' || value === null) return false;\n\n const candidate = value as Partial<StoredSession>;\n\n return (\n typeof candidate.auth0RefreshToken === 'string' &&\n typeof candidate.accessToken === 'string' &&\n typeof candidate.expiresAt === 'string' &&\n typeof candidate.userId === 'string' &&\n (candidate.email === null || typeof candidate.email === 'string') &&\n typeof candidate.updatedAt === 'string'\n );\n}\n\nfunction statMode(target: string): number | null {\n try {\n return fs.statSync(target).mode & 0o777;\n } catch {\n return null;\n }\n}\n\nfunction chmodIfPosix(target: string, mode: number): void {\n if (process.platform === 'win32') return;\n\n try {\n fs.chmodSync(target, mode);\n } catch {\n // Best effort. A filesystem that rejects chmod (some mounted volumes,\n // WSL interop paths) must not break login; the warning already fired.\n }\n}\n\nfunction safeUnlink(target: string): void {\n try {\n fs.unlinkSync(target);\n } catch {\n // The temp file may never have been created.\n }\n}\n\nfunction formatMode(mode: number): string {\n return `0${mode.toString(8).padStart(3, '0')}`;\n}\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n"]}
|
package/dist/auth/device-flow.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { authRequiredError, networkError } from '../errors.js';
|
|
2
|
-
import { request } from './
|
|
2
|
+
import { request } from './ekanos-api.js';
|
|
3
3
|
/**
|
|
4
4
|
* RFC 8628 device authorization flow against Auth0.
|
|
5
5
|
*
|
|
@@ -46,7 +46,7 @@ export async function startDeviceAuthorization(config, now = Date.now) {
|
|
|
46
46
|
`Device Code.`);
|
|
47
47
|
}
|
|
48
48
|
throw networkError(`Auth0 refused to start the device flow (${response.status}${body.error ? `, ${body.error}` : ''}).`, `Retry shortly. If it persists, report the error code to the operator ` +
|
|
49
|
-
`of this
|
|
49
|
+
`of this Ekanos deployment.`);
|
|
50
50
|
}
|
|
51
51
|
const deviceCode = requiredString(body, 'device_code');
|
|
52
52
|
const userCode = requiredString(body, 'user_code');
|
|
@@ -72,9 +72,10 @@ export async function startDeviceAuthorization(config, now = Date.now) {
|
|
|
72
72
|
* suspended laptop it would keep polling a code that died an hour ago.
|
|
73
73
|
*/
|
|
74
74
|
export async function pollForDeviceTokens(config, authorization, options = {}) {
|
|
75
|
-
var _a
|
|
76
|
-
|
|
77
|
-
const
|
|
75
|
+
var _a;
|
|
76
|
+
var _b, _c;
|
|
77
|
+
const sleep = (_b = options.sleep) !== null && _b !== void 0 ? _b : defaultSleep;
|
|
78
|
+
const now = (_c = options.now) !== null && _c !== void 0 ? _c : Date.now;
|
|
78
79
|
let intervalSeconds = authorization.intervalSeconds;
|
|
79
80
|
let attempt = 0;
|
|
80
81
|
for (;;) {
|
|
@@ -84,7 +85,7 @@ export async function pollForDeviceTokens(config, authorization, options = {}) {
|
|
|
84
85
|
}
|
|
85
86
|
await sleep(intervalSeconds * 1000);
|
|
86
87
|
attempt += 1;
|
|
87
|
-
(
|
|
88
|
+
(_a = options.onPoll) === null || _a === void 0 ? void 0 : _a.call(options, attempt);
|
|
88
89
|
const response = await request(config.tokenEndpoint, {
|
|
89
90
|
method: 'POST',
|
|
90
91
|
headers: {
|
|
@@ -122,10 +123,10 @@ export async function pollForDeviceTokens(config, authorization, options = {}) {
|
|
|
122
123
|
'time shown.');
|
|
123
124
|
case 'access_denied':
|
|
124
125
|
throw authRequiredError('The login request was denied in the browser.', 'Run "ekanos login" again and approve the request, or sign in as ' +
|
|
125
|
-
'an account that has access to this
|
|
126
|
+
'an account that has access to this Ekanos deployment.');
|
|
126
127
|
default:
|
|
127
128
|
throw networkError(`Auth0 rejected the device token request (${response.status}${body.error ? `, ${body.error}` : ''}).`, 'Run "ekanos login" again. If it persists, report the error code ' +
|
|
128
|
-
'to the operator of this
|
|
129
|
+
'to the operator of this Ekanos deployment.');
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
}
|
|
@@ -142,7 +143,7 @@ async function parseOAuthBody(response, what) {
|
|
|
142
143
|
catch (_a) {
|
|
143
144
|
if (response.ok) {
|
|
144
145
|
throw networkError(`Auth0 returned a non-JSON ${what} response.`, 'Retry shortly; if it persists, report it to the operator of this ' +
|
|
145
|
-
'
|
|
146
|
+
'Ekanos deployment.');
|
|
146
147
|
}
|
|
147
148
|
return {};
|
|
148
149
|
}
|
|
@@ -154,7 +155,7 @@ function requiredString(body, key) {
|
|
|
154
155
|
const value = body[key];
|
|
155
156
|
if (typeof value !== 'string' || value.length === 0) {
|
|
156
157
|
throw networkError(`Auth0 omitted "${key}" from its response.`, 'Retry shortly; if it persists, report it to the operator of this ' +
|
|
157
|
-
'
|
|
158
|
+
'Ekanos deployment.');
|
|
158
159
|
}
|
|
159
160
|
return value;
|
|
160
161
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"device-flow.js","sourceRoot":"","sources":["../../src/auth/device-flow.ts"],"names":[],"mappings":"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","sourcesContent":["import { authRequiredError, networkError } from '../errors';\nimport { type CliAuthConfig, request } from './fusion-api';\n\n/**\n * RFC 8628 device authorization flow against Auth0.\n *\n * Chosen over loopback-PKCE deliberately: loopback needs a browser on the same\n * machine and a bindable localhost port, and partners debug integrations over\n * SSH and inside containers where neither holds. The device flow moves the\n * browser step to whatever device the developer actually has one on.\n *\n * The user code and verification URL are returned, not printed — the caller\n * owns output, and in this CLI that means stderr, so `--json` stdout stays a\n * single parseable object.\n */\n\nexport const DEVICE_CODE_GRANT_TYPE =\n 'urn:ietf:params:oauth:grant-type:device_code';\n\n/** RFC 8628 §3.5 default when the server omits `interval`. */\nconst DEFAULT_POLL_INTERVAL_SECONDS = 5;\n\n/** RFC 8628 §3.5: back off by 5s each time the server says `slow_down`. */\nconst SLOW_DOWN_INCREMENT_SECONDS = 5;\n\n/** Never poll faster than this, whatever the server claims. */\nconst MIN_POLL_INTERVAL_SECONDS = 1;\n\n/** Cap our own patience even if Auth0 hands back an implausible expires_in. */\nconst MAX_FLOW_LIFETIME_SECONDS = 15 * 60;\n\nexport interface DeviceAuthorization {\n deviceCode: string;\n /** The short code the human types. Safe to display — useless on its own. */\n userCode: string;\n verificationUri: string;\n /** Pre-filled URL; absent on IdPs that do not support it. */\n verificationUriComplete: string | null;\n /** Absolute deadline (epoch ms) after which the device code is dead. */\n expiresAt: number;\n intervalSeconds: number;\n}\n\nexport interface DeviceTokens {\n accessToken: string;\n refreshToken: string;\n}\n\nexport interface PollOptions {\n /** Injected in tests. Defaults to a real timer. */\n sleep?: (ms: number) => Promise<void>;\n /** Injected in tests. Defaults to Date.now. */\n now?: () => number;\n /** Called once per poll attempt, for progress output. */\n onPoll?: (attempt: number) => void;\n}\n\n/** Step 1 — ask Auth0 for a device code and a user code. */\nexport async function startDeviceAuthorization(\n config: CliAuthConfig,\n now: () => number = Date.now,\n): Promise<DeviceAuthorization> {\n const response = await request(config.deviceAuthorizationEndpoint, {\n method: 'POST',\n headers: {\n 'content-type': 'application/x-www-form-urlencoded',\n accept: 'application/json',\n },\n body: new URLSearchParams({\n client_id: config.clientId,\n scope: config.scope,\n }).toString(),\n });\n\n const body = await parseOAuthBody(response, 'device authorization');\n\n if (!response.ok) {\n // The single most likely failure, and the one worth naming precisely: the\n // Auth0 application does not have the device-code grant enabled.\n if (body.error === 'unauthorized_client') {\n throw networkError(\n `Auth0 rejected the device flow for this application ` +\n `(unauthorized_client).`,\n `The \"Fusion CLI\" Auth0 application needs the Device Code grant ` +\n `enabled. Ask the operator to run the provisioner for this tenant, ` +\n `or tick Applications → Fusion CLI → Advanced → Grant Types → ` +\n `Device Code.`,\n );\n }\n\n throw networkError(\n `Auth0 refused to start the device flow (${response.status}${\n body.error ? `, ${body.error}` : ''\n }).`,\n `Retry shortly. If it persists, report the error code to the operator ` +\n `of this Fusion deployment.`,\n );\n }\n\n const deviceCode = requiredString(body, 'device_code');\n const userCode = requiredString(body, 'user_code');\n const verificationUri = requiredString(body, 'verification_uri');\n\n const expiresIn = clampSeconds(\n numberOr(body.expires_in, MAX_FLOW_LIFETIME_SECONDS),\n 1,\n MAX_FLOW_LIFETIME_SECONDS,\n );\n\n return {\n deviceCode,\n userCode,\n verificationUri,\n verificationUriComplete:\n typeof body.verification_uri_complete === 'string'\n ? body.verification_uri_complete\n : null,\n expiresAt: now() + expiresIn * 1000,\n intervalSeconds: clampSeconds(\n numberOr(body.interval, DEFAULT_POLL_INTERVAL_SECONDS),\n MIN_POLL_INTERVAL_SECONDS,\n MAX_FLOW_LIFETIME_SECONDS,\n ),\n };\n}\n\n/**\n * Step 2 — poll the token endpoint until the human approves, denies, or the\n * device code expires.\n *\n * Expiry is enforced against `authorization.expiresAt`, not against a count of\n * attempts: an attempt count would drift with every `slow_down`, and on a\n * suspended laptop it would keep polling a code that died an hour ago.\n */\nexport async function pollForDeviceTokens(\n config: CliAuthConfig,\n authorization: DeviceAuthorization,\n options: PollOptions = {},\n): Promise<DeviceTokens> {\n const sleep = options.sleep ?? defaultSleep;\n const now = options.now ?? Date.now;\n\n let intervalSeconds = authorization.intervalSeconds;\n let attempt = 0;\n\n for (;;) {\n if (now() >= authorization.expiresAt) {\n throw authRequiredError(\n 'The login code expired before it was approved.',\n 'Run \"ekanos login\" again and complete the browser step within the ' +\n 'time shown.',\n );\n }\n\n await sleep(intervalSeconds * 1000);\n\n attempt += 1;\n options.onPoll?.(attempt);\n\n const response = await request(config.tokenEndpoint, {\n method: 'POST',\n headers: {\n 'content-type': 'application/x-www-form-urlencoded',\n accept: 'application/json',\n },\n body: new URLSearchParams({\n grant_type: DEVICE_CODE_GRANT_TYPE,\n device_code: authorization.deviceCode,\n client_id: config.clientId,\n }).toString(),\n });\n\n const body = await parseOAuthBody(response, 'device token');\n\n if (response.ok) {\n const accessToken = requiredString(body, 'access_token');\n const refreshToken =\n typeof body.refresh_token === 'string' ? body.refresh_token : null;\n\n if (!refreshToken) {\n // Without offline_access actually granted, `ekanos login` would work\n // once and then silently require a browser again within the hour.\n // Better to fail now, while the cause is still on screen.\n throw authRequiredError(\n 'Auth0 completed the login but issued no refresh token.',\n 'The \"Fusion CLI\" Auth0 application needs the Refresh Token grant ' +\n 'and the login must request the `offline_access` scope. Ask the ' +\n 'operator to re-run the provisioner for this tenant.',\n );\n }\n\n return { accessToken, refreshToken };\n }\n\n switch (body.error) {\n case 'authorization_pending':\n continue;\n\n case 'slow_down':\n intervalSeconds += SLOW_DOWN_INCREMENT_SECONDS;\n continue;\n\n case 'expired_token':\n throw authRequiredError(\n 'The login code expired before it was approved.',\n 'Run \"ekanos login\" again and complete the browser step within the ' +\n 'time shown.',\n );\n\n case 'access_denied':\n throw authRequiredError(\n 'The login request was denied in the browser.',\n 'Run \"ekanos login\" again and approve the request, or sign in as ' +\n 'an account that has access to this Fusion deployment.',\n );\n\n default:\n throw networkError(\n `Auth0 rejected the device token request (${response.status}${\n body.error ? `, ${body.error}` : ''\n }).`,\n 'Run \"ekanos login\" again. If it persists, report the error code ' +\n 'to the operator of this Fusion deployment.',\n );\n }\n }\n}\n\n/**\n * Parse an OAuth response body without ever throwing on shape. OAuth error\n * documents are `{ error, error_description }`; neither field echoes the\n * device code or any token, so they are safe to surface.\n */\nasync function parseOAuthBody(\n response: Response,\n what: string,\n): Promise<Record<string, unknown>> {\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch {\n if (response.ok) {\n throw networkError(\n `Auth0 returned a non-JSON ${what} response.`,\n 'Retry shortly; if it persists, report it to the operator of this ' +\n 'Fusion deployment.',\n );\n }\n\n return {};\n }\n\n return typeof parsed === 'object' && parsed !== null\n ? (parsed as Record<string, unknown>)\n : {};\n}\n\nfunction requiredString(body: Record<string, unknown>, key: string): string {\n const value = body[key];\n\n if (typeof value !== 'string' || value.length === 0) {\n throw networkError(\n `Auth0 omitted \"${key}\" from its response.`,\n 'Retry shortly; if it persists, report it to the operator of this ' +\n 'Fusion deployment.',\n );\n }\n\n return value;\n}\n\nfunction numberOr(value: unknown, fallback: number): number {\n return typeof value === 'number' && Number.isFinite(value) ? value : fallback;\n}\n\nfunction clampSeconds(value: number, min: number, max: number): number {\n return Math.min(Math.max(Math.floor(value), min), max);\n}\n\nfunction defaultSleep(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\n/**\n * Best-effort revocation of a refresh token at Auth0 (RFC 7009).\n *\n * `logout` must not depend on this succeeding — a developer on a plane still\n * needs the local credentials removed — but a logout that only forgets the\n * token leaves a live, rotating credential valid for weeks. So: try, report\n * the outcome, never throw.\n */\nexport async function revokeRefreshToken(\n config: CliAuthConfig,\n refreshToken: string,\n): Promise<boolean> {\n try {\n const response = await request(\n `https://${config.auth0Domain}/oauth/revoke`,\n {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n accept: 'application/json',\n },\n body: JSON.stringify({\n client_id: config.clientId,\n token: refreshToken,\n }),\n },\n );\n\n return response.ok;\n } catch {\n // `request` throws a CliError on transport failure. Swallowed on purpose:\n // see the note above. Nothing here can carry the token into a log line.\n return false;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"device-flow.js","sourceRoot":"","sources":["../../src/auth/device-flow.ts"],"names":[],"mappings":"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","sourcesContent":["import { authRequiredError, networkError } from '../errors';\nimport { type CliAuthConfig, request } from './ekanos-api';\n\n/**\n * RFC 8628 device authorization flow against Auth0.\n *\n * Chosen over loopback-PKCE deliberately: loopback needs a browser on the same\n * machine and a bindable localhost port, and partners debug integrations over\n * SSH and inside containers where neither holds. The device flow moves the\n * browser step to whatever device the developer actually has one on.\n *\n * The user code and verification URL are returned, not printed — the caller\n * owns output, and in this CLI that means stderr, so `--json` stdout stays a\n * single parseable object.\n */\n\nexport const DEVICE_CODE_GRANT_TYPE =\n 'urn:ietf:params:oauth:grant-type:device_code';\n\n/** RFC 8628 §3.5 default when the server omits `interval`. */\nconst DEFAULT_POLL_INTERVAL_SECONDS = 5;\n\n/** RFC 8628 §3.5: back off by 5s each time the server says `slow_down`. */\nconst SLOW_DOWN_INCREMENT_SECONDS = 5;\n\n/** Never poll faster than this, whatever the server claims. */\nconst MIN_POLL_INTERVAL_SECONDS = 1;\n\n/** Cap our own patience even if Auth0 hands back an implausible expires_in. */\nconst MAX_FLOW_LIFETIME_SECONDS = 15 * 60;\n\nexport interface DeviceAuthorization {\n deviceCode: string;\n /** The short code the human types. Safe to display — useless on its own. */\n userCode: string;\n verificationUri: string;\n /** Pre-filled URL; absent on IdPs that do not support it. */\n verificationUriComplete: string | null;\n /** Absolute deadline (epoch ms) after which the device code is dead. */\n expiresAt: number;\n intervalSeconds: number;\n}\n\nexport interface DeviceTokens {\n accessToken: string;\n refreshToken: string;\n}\n\nexport interface PollOptions {\n /** Injected in tests. Defaults to a real timer. */\n sleep?: (ms: number) => Promise<void>;\n /** Injected in tests. Defaults to Date.now. */\n now?: () => number;\n /** Called once per poll attempt, for progress output. */\n onPoll?: (attempt: number) => void;\n}\n\n/** Step 1 — ask Auth0 for a device code and a user code. */\nexport async function startDeviceAuthorization(\n config: CliAuthConfig,\n now: () => number = Date.now,\n): Promise<DeviceAuthorization> {\n const response = await request(config.deviceAuthorizationEndpoint, {\n method: 'POST',\n headers: {\n 'content-type': 'application/x-www-form-urlencoded',\n accept: 'application/json',\n },\n body: new URLSearchParams({\n client_id: config.clientId,\n scope: config.scope,\n }).toString(),\n });\n\n const body = await parseOAuthBody(response, 'device authorization');\n\n if (!response.ok) {\n // The single most likely failure, and the one worth naming precisely: the\n // Auth0 application does not have the device-code grant enabled.\n if (body.error === 'unauthorized_client') {\n throw networkError(\n `Auth0 rejected the device flow for this application ` +\n `(unauthorized_client).`,\n `The \"Fusion CLI\" Auth0 application needs the Device Code grant ` +\n `enabled. Ask the operator to run the provisioner for this tenant, ` +\n `or tick Applications → Fusion CLI → Advanced → Grant Types → ` +\n `Device Code.`,\n );\n }\n\n throw networkError(\n `Auth0 refused to start the device flow (${response.status}${\n body.error ? `, ${body.error}` : ''\n }).`,\n `Retry shortly. If it persists, report the error code to the operator ` +\n `of this Ekanos deployment.`,\n );\n }\n\n const deviceCode = requiredString(body, 'device_code');\n const userCode = requiredString(body, 'user_code');\n const verificationUri = requiredString(body, 'verification_uri');\n\n const expiresIn = clampSeconds(\n numberOr(body.expires_in, MAX_FLOW_LIFETIME_SECONDS),\n 1,\n MAX_FLOW_LIFETIME_SECONDS,\n );\n\n return {\n deviceCode,\n userCode,\n verificationUri,\n verificationUriComplete:\n typeof body.verification_uri_complete === 'string'\n ? body.verification_uri_complete\n : null,\n expiresAt: now() + expiresIn * 1000,\n intervalSeconds: clampSeconds(\n numberOr(body.interval, DEFAULT_POLL_INTERVAL_SECONDS),\n MIN_POLL_INTERVAL_SECONDS,\n MAX_FLOW_LIFETIME_SECONDS,\n ),\n };\n}\n\n/**\n * Step 2 — poll the token endpoint until the human approves, denies, or the\n * device code expires.\n *\n * Expiry is enforced against `authorization.expiresAt`, not against a count of\n * attempts: an attempt count would drift with every `slow_down`, and on a\n * suspended laptop it would keep polling a code that died an hour ago.\n */\nexport async function pollForDeviceTokens(\n config: CliAuthConfig,\n authorization: DeviceAuthorization,\n options: PollOptions = {},\n): Promise<DeviceTokens> {\n const sleep = options.sleep ?? defaultSleep;\n const now = options.now ?? Date.now;\n\n let intervalSeconds = authorization.intervalSeconds;\n let attempt = 0;\n\n for (;;) {\n if (now() >= authorization.expiresAt) {\n throw authRequiredError(\n 'The login code expired before it was approved.',\n 'Run \"ekanos login\" again and complete the browser step within the ' +\n 'time shown.',\n );\n }\n\n await sleep(intervalSeconds * 1000);\n\n attempt += 1;\n options.onPoll?.(attempt);\n\n const response = await request(config.tokenEndpoint, {\n method: 'POST',\n headers: {\n 'content-type': 'application/x-www-form-urlencoded',\n accept: 'application/json',\n },\n body: new URLSearchParams({\n grant_type: DEVICE_CODE_GRANT_TYPE,\n device_code: authorization.deviceCode,\n client_id: config.clientId,\n }).toString(),\n });\n\n const body = await parseOAuthBody(response, 'device token');\n\n if (response.ok) {\n const accessToken = requiredString(body, 'access_token');\n const refreshToken =\n typeof body.refresh_token === 'string' ? body.refresh_token : null;\n\n if (!refreshToken) {\n // Without offline_access actually granted, `ekanos login` would work\n // once and then silently require a browser again within the hour.\n // Better to fail now, while the cause is still on screen.\n throw authRequiredError(\n 'Auth0 completed the login but issued no refresh token.',\n 'The \"Fusion CLI\" Auth0 application needs the Refresh Token grant ' +\n 'and the login must request the `offline_access` scope. Ask the ' +\n 'operator to re-run the provisioner for this tenant.',\n );\n }\n\n return { accessToken, refreshToken };\n }\n\n switch (body.error) {\n case 'authorization_pending':\n continue;\n\n case 'slow_down':\n intervalSeconds += SLOW_DOWN_INCREMENT_SECONDS;\n continue;\n\n case 'expired_token':\n throw authRequiredError(\n 'The login code expired before it was approved.',\n 'Run \"ekanos login\" again and complete the browser step within the ' +\n 'time shown.',\n );\n\n case 'access_denied':\n throw authRequiredError(\n 'The login request was denied in the browser.',\n 'Run \"ekanos login\" again and approve the request, or sign in as ' +\n 'an account that has access to this Ekanos deployment.',\n );\n\n default:\n throw networkError(\n `Auth0 rejected the device token request (${response.status}${\n body.error ? `, ${body.error}` : ''\n }).`,\n 'Run \"ekanos login\" again. If it persists, report the error code ' +\n 'to the operator of this Ekanos deployment.',\n );\n }\n }\n}\n\n/**\n * Parse an OAuth response body without ever throwing on shape. OAuth error\n * documents are `{ error, error_description }`; neither field echoes the\n * device code or any token, so they are safe to surface.\n */\nasync function parseOAuthBody(\n response: Response,\n what: string,\n): Promise<Record<string, unknown>> {\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch {\n if (response.ok) {\n throw networkError(\n `Auth0 returned a non-JSON ${what} response.`,\n 'Retry shortly; if it persists, report it to the operator of this ' +\n 'Ekanos deployment.',\n );\n }\n\n return {};\n }\n\n return typeof parsed === 'object' && parsed !== null\n ? (parsed as Record<string, unknown>)\n : {};\n}\n\nfunction requiredString(body: Record<string, unknown>, key: string): string {\n const value = body[key];\n\n if (typeof value !== 'string' || value.length === 0) {\n throw networkError(\n `Auth0 omitted \"${key}\" from its response.`,\n 'Retry shortly; if it persists, report it to the operator of this ' +\n 'Ekanos deployment.',\n );\n }\n\n return value;\n}\n\nfunction numberOr(value: unknown, fallback: number): number {\n return typeof value === 'number' && Number.isFinite(value) ? value : fallback;\n}\n\nfunction clampSeconds(value: number, min: number, max: number): number {\n return Math.min(Math.max(Math.floor(value), min), max);\n}\n\nfunction defaultSleep(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\n/**\n * Best-effort revocation of a refresh token at Auth0 (RFC 7009).\n *\n * `logout` must not depend on this succeeding — a developer on a plane still\n * needs the local credentials removed — but a logout that only forgets the\n * token leaves a live, rotating credential valid for weeks. So: try, report\n * the outcome, never throw.\n */\nexport async function revokeRefreshToken(\n config: CliAuthConfig,\n refreshToken: string,\n): Promise<boolean> {\n try {\n const response = await request(\n `https://${config.auth0Domain}/oauth/revoke`,\n {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n accept: 'application/json',\n },\n body: JSON.stringify({\n client_id: config.clientId,\n token: refreshToken,\n }),\n },\n );\n\n return response.ok;\n } catch {\n // `request` throws a CliError on transport failure. Swallowed on purpose:\n // see the note above. Nothing here can carry the token into a log line.\n return false;\n }\n}\n"]}
|
|
@@ -5,22 +5,22 @@ export interface CliAuthConfig {
|
|
|
5
5
|
deviceAuthorizationEndpoint: string;
|
|
6
6
|
tokenEndpoint: string;
|
|
7
7
|
}
|
|
8
|
-
export interface
|
|
8
|
+
export interface EkanosSession {
|
|
9
9
|
accessToken: string;
|
|
10
10
|
expiresAt: string;
|
|
11
11
|
userId: string;
|
|
12
12
|
email: string | null;
|
|
13
13
|
}
|
|
14
|
-
export interface
|
|
14
|
+
export interface EkanosIdentity {
|
|
15
15
|
id: string;
|
|
16
16
|
email: string | null;
|
|
17
17
|
name: string | null;
|
|
18
18
|
}
|
|
19
19
|
/** Fetch the device-flow parameters for a host. */
|
|
20
20
|
export declare function fetchCliAuthConfig(host: string): Promise<CliAuthConfig>;
|
|
21
|
-
/** Exchange a verified Auth0 access token for
|
|
22
|
-
export declare function
|
|
23
|
-
export interface RefreshedSession extends
|
|
21
|
+
/** Exchange a verified Auth0 access token for an Ekanos JWT. */
|
|
22
|
+
export declare function createEkanosSession(host: string, auth0AccessToken: string): Promise<EkanosSession>;
|
|
23
|
+
export interface RefreshedSession extends EkanosSession {
|
|
24
24
|
/** The ROTATED refresh token. The presented one is already invalid. */
|
|
25
25
|
auth0RefreshToken: string;
|
|
26
26
|
}
|
|
@@ -37,12 +37,12 @@ export interface RefreshOptions {
|
|
|
37
37
|
*/
|
|
38
38
|
onRotatedToken?: (rotated: string) => void;
|
|
39
39
|
}
|
|
40
|
-
/** Re-mint
|
|
41
|
-
export declare function
|
|
40
|
+
/** Re-mint an Ekanos JWT from the stored Auth0 refresh token. */
|
|
41
|
+
export declare function refreshEkanosSession(host: string, auth0RefreshToken: string, options?: RefreshOptions): Promise<RefreshedSession>;
|
|
42
42
|
/**
|
|
43
|
-
* Prove the
|
|
43
|
+
* Prove the Ekanos JWT authenticates. Returns null on 401 so the caller can
|
|
44
44
|
* decide whether to refresh and retry — an expired access token is an ordinary
|
|
45
45
|
* event, not an error.
|
|
46
46
|
*/
|
|
47
|
-
export declare function fetchIdentity(host: string, accessToken: string): Promise<
|
|
47
|
+
export declare function fetchIdentity(host: string, accessToken: string): Promise<EkanosIdentity | null>;
|
|
48
48
|
export declare function request(url: string, init: RequestInit): Promise<Response>;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { authRequiredError, networkError, preconditionError } from '../errors.js';
|
|
2
2
|
/**
|
|
3
|
-
* The CLI's client for the four
|
|
3
|
+
* The CLI's client for the four Ekanos auth routes.
|
|
4
4
|
*
|
|
5
5
|
* ---------------------------------------------------------------------------
|
|
6
|
-
* WIRE CONTRACT — the seam between this package and the
|
|
6
|
+
* WIRE CONTRACT — the seam between this package and the Ekanos web app.
|
|
7
7
|
*
|
|
8
8
|
* The server side is the host's `/api/auth/cli-<verb>` routes, and it ships on
|
|
9
9
|
* a DIFFERENT BRANCH from this file. This block is the single place the shapes
|
|
@@ -21,7 +21,7 @@ import { authRequiredError, networkError, preconditionError } from '../errors.js
|
|
|
21
21
|
* → 200 { access_token, expires_at, user: { id, email } }
|
|
22
22
|
* → 400 { error, code: "INVALID_REQUEST" } malformed / wrong shape
|
|
23
23
|
* → 401 { error, code: "SESSION_EXPIRED" } Auth0 said no, OR no
|
|
24
|
-
*
|
|
24
|
+
* Ekanos account for it
|
|
25
25
|
* → 503 { error, code: "CLI_NOT_CONFIGURED" } deployment not provisioned
|
|
26
26
|
* → 500 { error, code: "INTERNAL_ERROR" }
|
|
27
27
|
*
|
|
@@ -32,7 +32,7 @@ import { authRequiredError, networkError, preconditionError } from '../errors.js
|
|
|
32
32
|
* → 400 { error, code: "INVALID_REQUEST" }
|
|
33
33
|
* → 401 { error, code: "SESSION_EXPIRED" } expired or revoked
|
|
34
34
|
* → 404 { error, code: "ACCOUNT_NOT_FOUND",
|
|
35
|
-
* auth0_refresh_token } no
|
|
35
|
+
* auth0_refresh_token } no Ekanos account
|
|
36
36
|
* → 502 { error, code: "TOKEN_EXCHANGE_FAILED" } Auth0 returned no
|
|
37
37
|
* rotated token
|
|
38
38
|
* → 503 { error, code: "CLI_NOT_CONFIGURED" } deployment not provisioned
|
|
@@ -61,14 +61,14 @@ import { authRequiredError, networkError, preconditionError } from '../errors.js
|
|
|
61
61
|
* → 404 { error, code: "ACCOUNT_NOT_FOUND" }
|
|
62
62
|
* → 3xx treated as 401, see below
|
|
63
63
|
*
|
|
64
|
-
* `access_token` is
|
|
64
|
+
* `access_token` is an Ekanos HS256 JWT. Every authenticated Ekanos route
|
|
65
65
|
* accepts it as `Authorization: Bearer` — see `resolveRequestSupabaseJwt` in
|
|
66
66
|
* `@kit/supabase`, which both `requireUser` and the RLS server client consume.
|
|
67
67
|
* ---------------------------------------------------------------------------
|
|
68
68
|
*
|
|
69
69
|
* Two rules hold everywhere in this file:
|
|
70
70
|
*
|
|
71
|
-
* - `redirect: 'manual'`.
|
|
71
|
+
* - `redirect: 'manual'`. Ekanos answers an unauthenticated page request with
|
|
72
72
|
* a 307 to `/auth/sign-in`; Node's fetch follows redirects by default, so
|
|
73
73
|
* without this a stale token would come back as a 200 full of HTML and be
|
|
74
74
|
* read as success. A 3xx here is treated as "not authenticated", full stop.
|
|
@@ -98,19 +98,19 @@ export async function fetchCliAuthConfig(host) {
|
|
|
98
98
|
tokenEndpoint: str(body.token_endpoint),
|
|
99
99
|
};
|
|
100
100
|
if (Object.values(config).some((value) => value === null)) {
|
|
101
|
-
throw preconditionError(`${host} returned an incomplete CLI login configuration.`, `Confirm the deployment is running
|
|
101
|
+
throw preconditionError(`${host} returned an incomplete CLI login configuration.`, `Confirm the deployment is running an Ekanos build that includes the ` +
|
|
102
102
|
`/api/auth/cli-config route.`);
|
|
103
103
|
}
|
|
104
104
|
// The endpoints come from the server, so they are attacker-controlled if the
|
|
105
105
|
// host is. Pin them to the Auth0 domain the same response declares, so a
|
|
106
|
-
// compromised or misconfigured
|
|
106
|
+
// compromised or misconfigured Ekanos cannot redirect the device flow — and
|
|
107
107
|
// the resulting credentials — to an arbitrary origin.
|
|
108
108
|
assertOnDomain(config.deviceAuthorizationEndpoint, config.auth0Domain);
|
|
109
109
|
assertOnDomain(config.tokenEndpoint, config.auth0Domain);
|
|
110
110
|
return config;
|
|
111
111
|
}
|
|
112
|
-
/** Exchange a verified Auth0 access token for
|
|
113
|
-
export async function
|
|
112
|
+
/** Exchange a verified Auth0 access token for an Ekanos JWT. */
|
|
113
|
+
export async function createEkanosSession(host, auth0AccessToken) {
|
|
114
114
|
const response = await request(`${host}/api/auth/cli-session`, {
|
|
115
115
|
method: 'POST',
|
|
116
116
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
@@ -120,15 +120,15 @@ export async function createFusionSession(host, auth0AccessToken) {
|
|
|
120
120
|
if (response.status === 401) {
|
|
121
121
|
throw authRequiredError(`${host} rejected the Auth0 identity.`, `Run "ekanos login" again. If it keeps failing, confirm your account ` +
|
|
122
122
|
`exists on ${host} — the browser login can succeed for an identity ` +
|
|
123
|
-
`that has no
|
|
123
|
+
`that has no Ekanos account.`);
|
|
124
124
|
}
|
|
125
125
|
if (!response.ok) {
|
|
126
126
|
throw unexpectedStatus(host, '/api/auth/cli-session', response.status);
|
|
127
127
|
}
|
|
128
128
|
return toSession(await readJson(response, `${host}/api/auth/cli-session`), host);
|
|
129
129
|
}
|
|
130
|
-
/** Re-mint
|
|
131
|
-
export async function
|
|
130
|
+
/** Re-mint an Ekanos JWT from the stored Auth0 refresh token. */
|
|
131
|
+
export async function refreshEkanosSession(host, auth0RefreshToken, options = {}) {
|
|
132
132
|
var _a;
|
|
133
133
|
const response = await request(`${host}/api/auth/cli-refresh`, {
|
|
134
134
|
method: 'POST',
|
|
@@ -156,7 +156,7 @@ export async function refreshFusionSession(host, auth0RefreshToken, options = {}
|
|
|
156
156
|
`updated. You do NOT need to run "ekanos login" again.`);
|
|
157
157
|
}
|
|
158
158
|
if (response.status === 404) {
|
|
159
|
-
throw authRequiredError(`${host} has no
|
|
159
|
+
throw authRequiredError(`${host} has no Ekanos account for the signed-in identity.`, `Sign in to ${host} in a browser once to have the account created, ` +
|
|
160
160
|
`then retry.`);
|
|
161
161
|
}
|
|
162
162
|
if (!response.ok) {
|
|
@@ -169,7 +169,7 @@ export async function refreshFusionSession(host, auth0RefreshToken, options = {}
|
|
|
169
169
|
return Object.assign(Object.assign({}, toSession(body, host)), { auth0RefreshToken: rotated });
|
|
170
170
|
}
|
|
171
171
|
/**
|
|
172
|
-
* Prove the
|
|
172
|
+
* Prove the Ekanos JWT authenticates. Returns null on 401 so the caller can
|
|
173
173
|
* decide whether to refresh and retry — an expired access token is an ordinary
|
|
174
174
|
* event, not an error.
|
|
175
175
|
*/
|
|
@@ -182,7 +182,7 @@ export async function fetchIdentity(host, accessToken) {
|
|
|
182
182
|
authorization: `Bearer ${accessToken}`,
|
|
183
183
|
},
|
|
184
184
|
});
|
|
185
|
-
// 401, or the 307 that an auth-gated
|
|
185
|
+
// 401, or the 307 that an auth-gated Ekanos route answers with. Both mean
|
|
186
186
|
// "this token did not authenticate".
|
|
187
187
|
if (response.status === 401 || isRedirect(response.status)) {
|
|
188
188
|
return null;
|
|
@@ -201,7 +201,7 @@ export async function fetchIdentity(host, accessToken) {
|
|
|
201
201
|
const user = ((_a = body.user) !== null && _a !== void 0 ? _a : {});
|
|
202
202
|
const id = str(user.id);
|
|
203
203
|
if (!id) {
|
|
204
|
-
throw networkError(`${host} returned an identity without a user id.`, `Confirm the deployment is running
|
|
204
|
+
throw networkError(`${host} returned an identity without a user id.`, `Confirm the deployment is running an Ekanos build that includes the ` +
|
|
205
205
|
`/api/auth/cli-whoami route.`);
|
|
206
206
|
}
|
|
207
207
|
return { id, email: str(user.email), name: str(user.name) };
|
|
@@ -213,7 +213,7 @@ function toSession(body, host) {
|
|
|
213
213
|
const expiresAt = str(body.expires_at);
|
|
214
214
|
const userId = str(user.id);
|
|
215
215
|
if (!accessToken || !expiresAt || !userId) {
|
|
216
|
-
throw networkError(`${host} returned an incomplete session.`, `Confirm the deployment is running
|
|
216
|
+
throw networkError(`${host} returned an incomplete session.`, `Confirm the deployment is running an Ekanos build that includes the ` +
|
|
217
217
|
`CLI auth routes, then retry.`);
|
|
218
218
|
}
|
|
219
219
|
return { accessToken, expiresAt, userId, email: str(user.email) };
|
|
@@ -251,11 +251,11 @@ async function readJson(response, what) {
|
|
|
251
251
|
parsed = await response.json();
|
|
252
252
|
}
|
|
253
253
|
catch (_a) {
|
|
254
|
-
throw networkError(`${what} did not return JSON.`, `Confirm the host is
|
|
254
|
+
throw networkError(`${what} did not return JSON.`, `Confirm the host is an Ekanos deployment and not a proxy or captive ` +
|
|
255
255
|
`portal sitting in front of one.`);
|
|
256
256
|
}
|
|
257
257
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
258
|
-
throw networkError(`${what} returned an unexpected payload.`, `Confirm the host is
|
|
258
|
+
throw networkError(`${what} returned an unexpected payload.`, `Confirm the host is an Ekanos deployment, then retry.`);
|
|
259
259
|
}
|
|
260
260
|
return parsed;
|
|
261
261
|
}
|
|
@@ -265,8 +265,8 @@ async function readJson(response, what) {
|
|
|
265
265
|
*
|
|
266
266
|
* Every route that reads the Fusion CLI Auth0 config can answer this, so every
|
|
267
267
|
* caller has to check for it. Falling through to `unexpectedStatus` would tell
|
|
268
|
-
* a partner "confirm the host is
|
|
269
|
-
* perfectly good
|
|
268
|
+
* a partner "confirm the host is an Ekanos deployment" when the host is a
|
|
269
|
+
* perfectly good Ekanos deployment that is simply missing one env var — and
|
|
270
270
|
* would exit 8 (network, retryable) instead of 9 (precondition, not).
|
|
271
271
|
*/
|
|
272
272
|
function throwIfNotConfigured(response, host, body) {
|
|
@@ -276,14 +276,14 @@ function throwIfNotConfigured(response, host, body) {
|
|
|
276
276
|
// precondition; REFRESH_INCOMPLETE is transient and handled by its caller.
|
|
277
277
|
if (body && body.code !== 'CLI_NOT_CONFIGURED')
|
|
278
278
|
return;
|
|
279
|
-
throw preconditionError(`${host} does not have CLI login configured.`, `Ask the
|
|
279
|
+
throw preconditionError(`${host} does not have CLI login configured.`, `Ask the Ekanos operator to provision the "Fusion CLI" Auth0 ` +
|
|
280
280
|
`application and set AUTH0_CLI_CLIENT_ID on that deployment.`);
|
|
281
281
|
}
|
|
282
282
|
function unexpectedStatus(host, route, status) {
|
|
283
283
|
return networkError(`${host}${route} responded ${status}.`, status >= 500
|
|
284
|
-
? `The
|
|
284
|
+
? `The Ekanos deployment returned a server error. Retry shortly; if it ` +
|
|
285
285
|
`persists, report it with the status code.`
|
|
286
|
-
: `Confirm the host is
|
|
286
|
+
: `Confirm the host is an Ekanos deployment running a build that ` +
|
|
287
287
|
`includes ${route}.`);
|
|
288
288
|
}
|
|
289
289
|
function assertOnDomain(endpoint, domain) {
|
|
@@ -292,12 +292,12 @@ function assertOnDomain(endpoint, domain) {
|
|
|
292
292
|
url = new URL(endpoint);
|
|
293
293
|
}
|
|
294
294
|
catch (_a) {
|
|
295
|
-
throw preconditionError(`The login configuration contains a malformed endpoint.`, `Report this to the operator of the
|
|
295
|
+
throw preconditionError(`The login configuration contains a malformed endpoint.`, `Report this to the operator of the Ekanos deployment.`);
|
|
296
296
|
}
|
|
297
297
|
if (url.protocol !== 'https:' ||
|
|
298
298
|
url.host.toLowerCase() !== domain.toLowerCase()) {
|
|
299
299
|
throw preconditionError(`The login configuration points at an endpoint outside its own Auth0 ` +
|
|
300
|
-
`domain (${url.host}).`, `Do not continue. Report this to the operator of the
|
|
300
|
+
`domain (${url.host}).`, `Do not continue. Report this to the operator of the Ekanos ` +
|
|
301
301
|
`deployment — it may be misconfigured or compromised.`);
|
|
302
302
|
}
|
|
303
303
|
}
|
|
@@ -307,4 +307,4 @@ function isRedirect(status) {
|
|
|
307
307
|
function str(value) {
|
|
308
308
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
309
309
|
}
|
|
310
|
-
//# sourceMappingURL=
|
|
310
|
+
//# sourceMappingURL=ekanos-api.js.map
|