@ekanos/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -0
  3. package/dist/auth/credential-store.d.ts +96 -0
  4. package/dist/auth/credential-store.js +274 -0
  5. package/dist/auth/credential-store.js.map +1 -0
  6. package/dist/auth/device-flow.d.ts +57 -0
  7. package/dist/auth/device-flow.js +201 -0
  8. package/dist/auth/device-flow.js.map +1 -0
  9. package/dist/auth/fusion-api.d.ts +48 -0
  10. package/dist/auth/fusion-api.js +310 -0
  11. package/dist/auth/fusion-api.js.map +1 -0
  12. package/dist/auth/session.d.ts +57 -0
  13. package/dist/auth/session.js +114 -0
  14. package/dist/auth/session.js.map +1 -0
  15. package/dist/bin.d.ts +2 -0
  16. package/dist/bin.js +20 -0
  17. package/dist/bin.js.map +1 -0
  18. package/dist/commands/dev.d.ts +36 -0
  19. package/dist/commands/dev.js +207 -0
  20. package/dist/commands/dev.js.map +1 -0
  21. package/dist/commands/init.d.ts +30 -0
  22. package/dist/commands/init.js +178 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/login.d.ts +20 -0
  25. package/dist/commands/login.js +92 -0
  26. package/dist/commands/login.js.map +1 -0
  27. package/dist/commands/logout.d.ts +20 -0
  28. package/dist/commands/logout.js +75 -0
  29. package/dist/commands/logout.js.map +1 -0
  30. package/dist/commands/test.d.ts +16 -0
  31. package/dist/commands/test.js +54 -0
  32. package/dist/commands/test.js.map +1 -0
  33. package/dist/commands/validate.d.ts +14 -0
  34. package/dist/commands/validate.js +98 -0
  35. package/dist/commands/validate.js.map +1 -0
  36. package/dist/commands/whoami.d.ts +18 -0
  37. package/dist/commands/whoami.js +29 -0
  38. package/dist/commands/whoami.js.map +1 -0
  39. package/dist/compatibility.d.ts +81 -0
  40. package/dist/compatibility.js +135 -0
  41. package/dist/compatibility.js.map +1 -0
  42. package/dist/context.d.ts +81 -0
  43. package/dist/context.js +125 -0
  44. package/dist/context.js.map +1 -0
  45. package/dist/errors.d.ts +41 -0
  46. package/dist/errors.js +111 -0
  47. package/dist/errors.js.map +1 -0
  48. package/dist/exit-codes.d.ts +52 -0
  49. package/dist/exit-codes.js +51 -0
  50. package/dist/exit-codes.js.map +1 -0
  51. package/dist/harness-scaffold.d.ts +212 -0
  52. package/dist/harness-scaffold.js +434 -0
  53. package/dist/harness-scaffold.js.map +1 -0
  54. package/dist/index.d.ts +17 -0
  55. package/dist/index.js +262 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/load-definition.d.ts +20 -0
  58. package/dist/load-definition.js +126 -0
  59. package/dist/load-definition.js.map +1 -0
  60. package/dist/merge-manifest.d.ts +44 -0
  61. package/dist/merge-manifest.js +124 -0
  62. package/dist/merge-manifest.js.map +1 -0
  63. package/dist/package-manager.d.ts +25 -0
  64. package/dist/package-manager.js +57 -0
  65. package/dist/package-manager.js.map +1 -0
  66. package/dist/parse-argv.d.ts +27 -0
  67. package/dist/parse-argv.js +74 -0
  68. package/dist/parse-argv.js.map +1 -0
  69. package/dist/project-checks.d.ts +18 -0
  70. package/dist/project-checks.js +77 -0
  71. package/dist/project-checks.js.map +1 -0
  72. package/dist/project.d.ts +140 -0
  73. package/dist/project.js +228 -0
  74. package/dist/project.js.map +1 -0
  75. package/dist/safe-fs.d.ts +55 -0
  76. package/dist/safe-fs.js +209 -0
  77. package/dist/safe-fs.js.map +1 -0
  78. package/dist/templates.d.ts +23 -0
  79. package/dist/templates.js +49 -0
  80. package/dist/templates.js.map +1 -0
  81. package/package.json +62 -0
  82. package/templates/ekanos.json.tmpl +4 -0
  83. package/templates/gitignore.tmpl +12 -0
  84. package/templates/harness/app/[slug]/activation/page.tsx.tmpl +1 -0
  85. package/templates/harness/app/[slug]/layout.tsx.tmpl +1 -0
  86. package/templates/harness/app/[slug]/tile/page.tsx.tmpl +1 -0
  87. package/templates/harness/app/[slug]/triggers/page.tsx.tmpl +1 -0
  88. package/templates/harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl +1 -0
  89. package/templates/harness/app/[slug]/widgets/page.tsx.tmpl +1 -0
  90. package/templates/harness/app/harness-shell.tsx.tmpl +9 -0
  91. package/templates/harness/app/layout.tsx.tmpl +4 -0
  92. package/templates/harness/app/page.tsx.tmpl +7 -0
  93. package/templates/harness/next.config.mjs.tmpl +46 -0
  94. package/templates/harness/package.json.tmpl +11 -0
  95. package/templates/harness/postcss.config.mjs.tmpl +6 -0
  96. package/templates/harness/styles/globals.css.tmpl +33 -0
  97. package/templates/harness/tsconfig.json.tmpl +31 -0
  98. package/templates/harness/types/team-accounts.d.ts.tmpl +16 -0
  99. package/templates/harness.config.entry.ts.tmpl +65 -0
  100. package/templates/harness.config.ts.tmpl +58 -0
  101. package/templates/package.json.tmpl +28 -0
  102. package/templates/src/integration.test.ts.tmpl +24 -0
  103. package/templates/src/integration.ts.tmpl +83 -0
  104. package/templates/tsconfig.json.tmpl +19 -0
  105. package/templates/vitest.config.ts.tmpl +32 -0
@@ -0,0 +1,274 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { preconditionError } from '../errors.js';
5
+ /**
6
+ * The on-disk credential store: `~/.ekanos/credentials.json`, mode 0600,
7
+ * inside a directory forced to 0700.
8
+ *
9
+ * Keyed by host, because a partner routinely holds sessions against more than
10
+ * one Fusion deployment (their sandbox and production) and a single-session
11
+ * file would silently swap one for the other.
12
+ *
13
+ * NOTHING in this module ever writes a token to a stream. `describe()` exists
14
+ * so callers have a safe thing to print; the tokens themselves leave here only
15
+ * as return values.
16
+ */
17
+ const CREDENTIALS_FILENAME = 'credentials.json';
18
+ const DIR_MODE = 0o700;
19
+ const FILE_MODE = 0o600;
20
+ const CURRENT_VERSION = 1;
21
+ /** POSIX permission bits that must not be set on the credentials file. */
22
+ const FORBIDDEN_FILE_BITS = 0o077;
23
+ const FORBIDDEN_DIR_BITS = 0o077;
24
+ export class CredentialStore {
25
+ constructor(options) {
26
+ var _a;
27
+ this.dir = path.join(options.home, '.ekanos');
28
+ this.file = path.join(this.dir, CREDENTIALS_FILENAME);
29
+ this.warn = (_a = options.warn) !== null && _a !== void 0 ? _a : (() => { });
30
+ }
31
+ /** Absolute path to the credentials file — safe to print. */
32
+ get filePath() {
33
+ return this.file;
34
+ }
35
+ /**
36
+ * Read the session for `host`, or null when there is none.
37
+ *
38
+ * A file that exists but is unreadable or corrupt is a precondition failure,
39
+ * not a silent "logged out": treating a corrupt file as absent would send the
40
+ * user through a fresh browser login that then overwrites whatever was
41
+ * really in there.
42
+ */
43
+ read(host) {
44
+ var _a;
45
+ const file = this.load();
46
+ return (_a = file === null || file === void 0 ? void 0 : file.hosts[normalizeHost(host)]) !== null && _a !== void 0 ? _a : null;
47
+ }
48
+ /** Every host with a stored session. Used by `logout --all` and diagnostics. */
49
+ hosts() {
50
+ var _a, _b;
51
+ return Object.keys((_b = (_a = this.load()) === null || _a === void 0 ? void 0 : _a.hosts) !== null && _b !== void 0 ? _b : {});
52
+ }
53
+ write(host, session) {
54
+ var _a;
55
+ const file = (_a = this.load()) !== null && _a !== void 0 ? _a : { version: CURRENT_VERSION, hosts: {} };
56
+ file.version = CURRENT_VERSION;
57
+ file.hosts[normalizeHost(host)] = session;
58
+ this.persist(file);
59
+ }
60
+ /**
61
+ * Remove the session for `host`. Returns whether anything was removed, so
62
+ * `logout` can report accurately while still exiting 0 either way — logging
63
+ * out twice is not an error.
64
+ */
65
+ clear(host) {
66
+ const file = this.load();
67
+ if (!file)
68
+ return false;
69
+ const key = normalizeHost(host);
70
+ if (!(key in file.hosts))
71
+ return false;
72
+ delete file.hosts[key];
73
+ this.persist(file);
74
+ return true;
75
+ }
76
+ /** Remove every session. Returns the number of hosts cleared. */
77
+ clearAll() {
78
+ const file = this.load();
79
+ if (!file)
80
+ return 0;
81
+ const count = Object.keys(file.hosts).length;
82
+ if (count === 0)
83
+ return 0;
84
+ file.hosts = {};
85
+ this.persist(file);
86
+ return count;
87
+ }
88
+ load() {
89
+ if (!fs.existsSync(this.file))
90
+ return null;
91
+ this.auditPermissions();
92
+ let raw;
93
+ try {
94
+ raw = fs.readFileSync(this.file, 'utf8');
95
+ }
96
+ catch (error) {
97
+ throw preconditionError(`Could not read ${this.file}: ${errorMessage(error)}`, `Check the file's ownership and permissions, or delete it and run ` +
98
+ `"ekanos login" again.`);
99
+ }
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(raw);
103
+ }
104
+ catch (_a) {
105
+ throw preconditionError(`${this.file} is not valid JSON.`, `Delete it and run "ekanos login" again — it holds only credentials, ` +
106
+ `nothing you need to keep.`);
107
+ }
108
+ if (!isCredentialsFile(parsed)) {
109
+ throw preconditionError(`${this.file} is not a recognised credentials file.`, `Delete it and run "ekanos login" again.`);
110
+ }
111
+ return parsed;
112
+ }
113
+ /**
114
+ * Write atomically, and never through a mode the umask can widen.
115
+ *
116
+ * `writeFileSync`'s `mode` applies only when the file is CREATED, so a
117
+ * pre-existing world-readable file would keep its mode on every subsequent
118
+ * write. Writing a fresh temp file and renaming over the target sidesteps
119
+ * that, and also means a crash mid-write cannot leave a half-written
120
+ * credentials file behind.
121
+ */
122
+ persist(file) {
123
+ this.ensureDir();
124
+ const tmp = `${this.file}.${process.pid}.tmp`;
125
+ const body = `${JSON.stringify(file, null, 2)}\n`;
126
+ try {
127
+ fs.writeFileSync(tmp, body, { mode: FILE_MODE });
128
+ // Belt and braces: `mode` above is masked by the process umask, so set
129
+ // the mode explicitly rather than trusting it.
130
+ chmodIfPosix(tmp, FILE_MODE);
131
+ fs.renameSync(tmp, this.file);
132
+ chmodIfPosix(this.file, FILE_MODE);
133
+ }
134
+ catch (error) {
135
+ safeUnlink(tmp);
136
+ throw preconditionError(`Could not write ${this.file}: ${errorMessage(error)}`, `Ensure ${this.dir} is writable by the current user, then retry.`);
137
+ }
138
+ }
139
+ /**
140
+ * Create `~/.ekanos` at 0700 — and repair it if it already exists wider.
141
+ *
142
+ * A pre-existing directory is the common case (other Ekanos state may live
143
+ * there), and `mkdir`'s mode is ignored when the directory is already
144
+ * present, so the mode has to be asserted separately every time.
145
+ */
146
+ ensureDir() {
147
+ try {
148
+ fs.mkdirSync(this.dir, { recursive: true, mode: DIR_MODE });
149
+ }
150
+ catch (error) {
151
+ throw preconditionError(`Could not create ${this.dir}: ${errorMessage(error)}`, `Create it manually with "mkdir -p ${this.dir} && chmod 700 ${this.dir}", then retry.`);
152
+ }
153
+ if (process.platform === 'win32')
154
+ return;
155
+ const mode = statMode(this.dir);
156
+ if (mode !== null && (mode & FORBIDDEN_DIR_BITS) !== 0) {
157
+ this.warn(`ekanos: ${this.dir} was group/world accessible (${formatMode(mode)}); tightening it to 0700.`);
158
+ chmodIfPosix(this.dir, DIR_MODE);
159
+ }
160
+ }
161
+ /**
162
+ * Warn about and repair an over-permissive credentials file.
163
+ *
164
+ * Repair rather than refuse: a hard refusal (ssh's behaviour) would leave a
165
+ * partner with a CLI that will not run and no obvious fix, and the token is
166
+ * already exposed by the time we notice — narrowing the window beats
167
+ * blocking. The warning goes to stderr so `--json` stdout stays parseable.
168
+ */
169
+ auditPermissions() {
170
+ if (process.platform === 'win32')
171
+ return;
172
+ const mode = statMode(this.file);
173
+ if (mode === null)
174
+ return;
175
+ if ((mode & FORBIDDEN_FILE_BITS) !== 0) {
176
+ this.warn(`ekanos: ${this.file} was readable by other users (${formatMode(mode)}); ` +
177
+ `tightening it to 0600. Rotate your session with "ekanos logout && ekanos login" ` +
178
+ `if this machine is shared.`);
179
+ chmodIfPosix(this.file, FILE_MODE);
180
+ }
181
+ }
182
+ }
183
+ /**
184
+ * Resolve the home directory the store lives under. `EKANOS_HOME` exists for
185
+ * containers and CI, where `os.homedir()` can resolve to a non-writable path.
186
+ */
187
+ export function resolveEkanosHome(env = process.env) {
188
+ var _a;
189
+ const override = (_a = env.EKANOS_HOME) === null || _a === void 0 ? void 0 : _a.trim();
190
+ return override && override.length > 0 ? override : os.homedir();
191
+ }
192
+ /**
193
+ * Canonical key for a host: scheme + authority, no trailing slash, lowercased.
194
+ * Without this, `https://app.example.com` and `https://app.example.com/` would
195
+ * be two different sessions.
196
+ */
197
+ export function normalizeHost(host) {
198
+ let url;
199
+ try {
200
+ url = new URL(host);
201
+ }
202
+ catch (_a) {
203
+ throw preconditionError(`"${host}" is not a valid URL.`, `Pass a full origin, e.g. "--host https://app.example.com".`);
204
+ }
205
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
206
+ throw preconditionError(`"${host}" must be an http(s) URL.`, `Pass a full origin, e.g. "--host https://app.example.com".`);
207
+ }
208
+ return `${url.protocol}//${url.host}`.toLowerCase();
209
+ }
210
+ /** A printable summary of a session — never includes a token. */
211
+ export function describe(session) {
212
+ return {
213
+ userId: session.userId,
214
+ email: session.email,
215
+ expiresAt: session.expiresAt,
216
+ updatedAt: session.updatedAt,
217
+ };
218
+ }
219
+ function isCredentialsFile(value) {
220
+ if (typeof value !== 'object' || value === null)
221
+ return false;
222
+ const candidate = value;
223
+ if (typeof candidate.version !== 'number')
224
+ return false;
225
+ if (typeof candidate.hosts !== 'object' || candidate.hosts === null) {
226
+ return false;
227
+ }
228
+ return Object.values(candidate.hosts).every(isStoredSession);
229
+ }
230
+ function isStoredSession(value) {
231
+ if (typeof value !== 'object' || value === null)
232
+ return false;
233
+ const candidate = value;
234
+ return (typeof candidate.auth0RefreshToken === 'string' &&
235
+ typeof candidate.accessToken === 'string' &&
236
+ typeof candidate.expiresAt === 'string' &&
237
+ typeof candidate.userId === 'string' &&
238
+ (candidate.email === null || typeof candidate.email === 'string') &&
239
+ typeof candidate.updatedAt === 'string');
240
+ }
241
+ function statMode(target) {
242
+ try {
243
+ return fs.statSync(target).mode & 0o777;
244
+ }
245
+ catch (_a) {
246
+ return null;
247
+ }
248
+ }
249
+ function chmodIfPosix(target, mode) {
250
+ if (process.platform === 'win32')
251
+ return;
252
+ try {
253
+ fs.chmodSync(target, mode);
254
+ }
255
+ catch (_a) {
256
+ // Best effort. A filesystem that rejects chmod (some mounted volumes,
257
+ // WSL interop paths) must not break login; the warning already fired.
258
+ }
259
+ }
260
+ function safeUnlink(target) {
261
+ try {
262
+ fs.unlinkSync(target);
263
+ }
264
+ catch (_a) {
265
+ // The temp file may never have been created.
266
+ }
267
+ }
268
+ function formatMode(mode) {
269
+ return `0${mode.toString(8).padStart(3, '0')}`;
270
+ }
271
+ function errorMessage(error) {
272
+ return error instanceof Error ? error.message : String(error);
273
+ }
274
+ //# sourceMappingURL=credential-store.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,57 @@
1
+ import { type CliAuthConfig } from './fusion-api.js';
2
+ /**
3
+ * RFC 8628 device authorization flow against Auth0.
4
+ *
5
+ * Chosen over loopback-PKCE deliberately: loopback needs a browser on the same
6
+ * machine and a bindable localhost port, and partners debug integrations over
7
+ * SSH and inside containers where neither holds. The device flow moves the
8
+ * browser step to whatever device the developer actually has one on.
9
+ *
10
+ * The user code and verification URL are returned, not printed — the caller
11
+ * owns output, and in this CLI that means stderr, so `--json` stdout stays a
12
+ * single parseable object.
13
+ */
14
+ export declare const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
15
+ export interface DeviceAuthorization {
16
+ deviceCode: string;
17
+ /** The short code the human types. Safe to display — useless on its own. */
18
+ userCode: string;
19
+ verificationUri: string;
20
+ /** Pre-filled URL; absent on IdPs that do not support it. */
21
+ verificationUriComplete: string | null;
22
+ /** Absolute deadline (epoch ms) after which the device code is dead. */
23
+ expiresAt: number;
24
+ intervalSeconds: number;
25
+ }
26
+ export interface DeviceTokens {
27
+ accessToken: string;
28
+ refreshToken: string;
29
+ }
30
+ export interface PollOptions {
31
+ /** Injected in tests. Defaults to a real timer. */
32
+ sleep?: (ms: number) => Promise<void>;
33
+ /** Injected in tests. Defaults to Date.now. */
34
+ now?: () => number;
35
+ /** Called once per poll attempt, for progress output. */
36
+ onPoll?: (attempt: number) => void;
37
+ }
38
+ /** Step 1 — ask Auth0 for a device code and a user code. */
39
+ export declare function startDeviceAuthorization(config: CliAuthConfig, now?: () => number): Promise<DeviceAuthorization>;
40
+ /**
41
+ * Step 2 — poll the token endpoint until the human approves, denies, or the
42
+ * device code expires.
43
+ *
44
+ * Expiry is enforced against `authorization.expiresAt`, not against a count of
45
+ * attempts: an attempt count would drift with every `slow_down`, and on a
46
+ * suspended laptop it would keep polling a code that died an hour ago.
47
+ */
48
+ export declare function pollForDeviceTokens(config: CliAuthConfig, authorization: DeviceAuthorization, options?: PollOptions): Promise<DeviceTokens>;
49
+ /**
50
+ * Best-effort revocation of a refresh token at Auth0 (RFC 7009).
51
+ *
52
+ * `logout` must not depend on this succeeding — a developer on a plane still
53
+ * needs the local credentials removed — but a logout that only forgets the
54
+ * token leaves a live, rotating credential valid for weeks. So: try, report
55
+ * the outcome, never throw.
56
+ */
57
+ export declare function revokeRefreshToken(config: CliAuthConfig, refreshToken: string): Promise<boolean>;
@@ -0,0 +1,201 @@
1
+ import { authRequiredError, networkError } from '../errors.js';
2
+ import { request } from './fusion-api.js';
3
+ /**
4
+ * RFC 8628 device authorization flow against Auth0.
5
+ *
6
+ * Chosen over loopback-PKCE deliberately: loopback needs a browser on the same
7
+ * machine and a bindable localhost port, and partners debug integrations over
8
+ * SSH and inside containers where neither holds. The device flow moves the
9
+ * browser step to whatever device the developer actually has one on.
10
+ *
11
+ * The user code and verification URL are returned, not printed — the caller
12
+ * owns output, and in this CLI that means stderr, so `--json` stdout stays a
13
+ * single parseable object.
14
+ */
15
+ export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
16
+ /** RFC 8628 §3.5 default when the server omits `interval`. */
17
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
18
+ /** RFC 8628 §3.5: back off by 5s each time the server says `slow_down`. */
19
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
20
+ /** Never poll faster than this, whatever the server claims. */
21
+ const MIN_POLL_INTERVAL_SECONDS = 1;
22
+ /** Cap our own patience even if Auth0 hands back an implausible expires_in. */
23
+ const MAX_FLOW_LIFETIME_SECONDS = 15 * 60;
24
+ /** Step 1 — ask Auth0 for a device code and a user code. */
25
+ export async function startDeviceAuthorization(config, now = Date.now) {
26
+ const response = await request(config.deviceAuthorizationEndpoint, {
27
+ method: 'POST',
28
+ headers: {
29
+ 'content-type': 'application/x-www-form-urlencoded',
30
+ accept: 'application/json',
31
+ },
32
+ body: new URLSearchParams({
33
+ client_id: config.clientId,
34
+ scope: config.scope,
35
+ }).toString(),
36
+ });
37
+ const body = await parseOAuthBody(response, 'device authorization');
38
+ if (!response.ok) {
39
+ // The single most likely failure, and the one worth naming precisely: the
40
+ // Auth0 application does not have the device-code grant enabled.
41
+ if (body.error === 'unauthorized_client') {
42
+ throw networkError(`Auth0 rejected the device flow for this application ` +
43
+ `(unauthorized_client).`, `The "Fusion CLI" Auth0 application needs the Device Code grant ` +
44
+ `enabled. Ask the operator to run the provisioner for this tenant, ` +
45
+ `or tick Applications → Fusion CLI → Advanced → Grant Types → ` +
46
+ `Device Code.`);
47
+ }
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 Fusion deployment.`);
50
+ }
51
+ const deviceCode = requiredString(body, 'device_code');
52
+ const userCode = requiredString(body, 'user_code');
53
+ const verificationUri = requiredString(body, 'verification_uri');
54
+ const expiresIn = clampSeconds(numberOr(body.expires_in, MAX_FLOW_LIFETIME_SECONDS), 1, MAX_FLOW_LIFETIME_SECONDS);
55
+ return {
56
+ deviceCode,
57
+ userCode,
58
+ verificationUri,
59
+ verificationUriComplete: typeof body.verification_uri_complete === 'string'
60
+ ? body.verification_uri_complete
61
+ : null,
62
+ expiresAt: now() + expiresIn * 1000,
63
+ intervalSeconds: clampSeconds(numberOr(body.interval, DEFAULT_POLL_INTERVAL_SECONDS), MIN_POLL_INTERVAL_SECONDS, MAX_FLOW_LIFETIME_SECONDS),
64
+ };
65
+ }
66
+ /**
67
+ * Step 2 — poll the token endpoint until the human approves, denies, or the
68
+ * device code expires.
69
+ *
70
+ * Expiry is enforced against `authorization.expiresAt`, not against a count of
71
+ * attempts: an attempt count would drift with every `slow_down`, and on a
72
+ * suspended laptop it would keep polling a code that died an hour ago.
73
+ */
74
+ export async function pollForDeviceTokens(config, authorization, options = {}) {
75
+ var _a, _b, _c;
76
+ const sleep = (_a = options.sleep) !== null && _a !== void 0 ? _a : defaultSleep;
77
+ const now = (_b = options.now) !== null && _b !== void 0 ? _b : Date.now;
78
+ let intervalSeconds = authorization.intervalSeconds;
79
+ let attempt = 0;
80
+ for (;;) {
81
+ if (now() >= authorization.expiresAt) {
82
+ throw authRequiredError('The login code expired before it was approved.', 'Run "ekanos login" again and complete the browser step within the ' +
83
+ 'time shown.');
84
+ }
85
+ await sleep(intervalSeconds * 1000);
86
+ attempt += 1;
87
+ (_c = options.onPoll) === null || _c === void 0 ? void 0 : _c.call(options, attempt);
88
+ const response = await request(config.tokenEndpoint, {
89
+ method: 'POST',
90
+ headers: {
91
+ 'content-type': 'application/x-www-form-urlencoded',
92
+ accept: 'application/json',
93
+ },
94
+ body: new URLSearchParams({
95
+ grant_type: DEVICE_CODE_GRANT_TYPE,
96
+ device_code: authorization.deviceCode,
97
+ client_id: config.clientId,
98
+ }).toString(),
99
+ });
100
+ const body = await parseOAuthBody(response, 'device token');
101
+ if (response.ok) {
102
+ const accessToken = requiredString(body, 'access_token');
103
+ const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token : null;
104
+ if (!refreshToken) {
105
+ // Without offline_access actually granted, `ekanos login` would work
106
+ // once and then silently require a browser again within the hour.
107
+ // Better to fail now, while the cause is still on screen.
108
+ throw authRequiredError('Auth0 completed the login but issued no refresh token.', 'The "Fusion CLI" Auth0 application needs the Refresh Token grant ' +
109
+ 'and the login must request the `offline_access` scope. Ask the ' +
110
+ 'operator to re-run the provisioner for this tenant.');
111
+ }
112
+ return { accessToken, refreshToken };
113
+ }
114
+ switch (body.error) {
115
+ case 'authorization_pending':
116
+ continue;
117
+ case 'slow_down':
118
+ intervalSeconds += SLOW_DOWN_INCREMENT_SECONDS;
119
+ continue;
120
+ case 'expired_token':
121
+ throw authRequiredError('The login code expired before it was approved.', 'Run "ekanos login" again and complete the browser step within the ' +
122
+ 'time shown.');
123
+ case 'access_denied':
124
+ 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 Fusion deployment.');
126
+ default:
127
+ 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 Fusion deployment.');
129
+ }
130
+ }
131
+ }
132
+ /**
133
+ * Parse an OAuth response body without ever throwing on shape. OAuth error
134
+ * documents are `{ error, error_description }`; neither field echoes the
135
+ * device code or any token, so they are safe to surface.
136
+ */
137
+ async function parseOAuthBody(response, what) {
138
+ let parsed;
139
+ try {
140
+ parsed = await response.json();
141
+ }
142
+ catch (_a) {
143
+ if (response.ok) {
144
+ throw networkError(`Auth0 returned a non-JSON ${what} response.`, 'Retry shortly; if it persists, report it to the operator of this ' +
145
+ 'Fusion deployment.');
146
+ }
147
+ return {};
148
+ }
149
+ return typeof parsed === 'object' && parsed !== null
150
+ ? parsed
151
+ : {};
152
+ }
153
+ function requiredString(body, key) {
154
+ const value = body[key];
155
+ if (typeof value !== 'string' || value.length === 0) {
156
+ throw networkError(`Auth0 omitted "${key}" from its response.`, 'Retry shortly; if it persists, report it to the operator of this ' +
157
+ 'Fusion deployment.');
158
+ }
159
+ return value;
160
+ }
161
+ function numberOr(value, fallback) {
162
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
163
+ }
164
+ function clampSeconds(value, min, max) {
165
+ return Math.min(Math.max(Math.floor(value), min), max);
166
+ }
167
+ function defaultSleep(ms) {
168
+ return new Promise((resolve) => {
169
+ setTimeout(resolve, ms);
170
+ });
171
+ }
172
+ /**
173
+ * Best-effort revocation of a refresh token at Auth0 (RFC 7009).
174
+ *
175
+ * `logout` must not depend on this succeeding — a developer on a plane still
176
+ * needs the local credentials removed — but a logout that only forgets the
177
+ * token leaves a live, rotating credential valid for weeks. So: try, report
178
+ * the outcome, never throw.
179
+ */
180
+ export async function revokeRefreshToken(config, refreshToken) {
181
+ try {
182
+ const response = await request(`https://${config.auth0Domain}/oauth/revoke`, {
183
+ method: 'POST',
184
+ headers: {
185
+ 'content-type': 'application/json',
186
+ accept: 'application/json',
187
+ },
188
+ body: JSON.stringify({
189
+ client_id: config.clientId,
190
+ token: refreshToken,
191
+ }),
192
+ });
193
+ return response.ok;
194
+ }
195
+ catch (_a) {
196
+ // `request` throws a CliError on transport failure. Swallowed on purpose:
197
+ // see the note above. Nothing here can carry the token into a log line.
198
+ return false;
199
+ }
200
+ }
201
+ //# sourceMappingURL=device-flow.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,48 @@
1
+ export interface CliAuthConfig {
2
+ auth0Domain: string;
3
+ clientId: string;
4
+ scope: string;
5
+ deviceAuthorizationEndpoint: string;
6
+ tokenEndpoint: string;
7
+ }
8
+ export interface FusionSession {
9
+ accessToken: string;
10
+ expiresAt: string;
11
+ userId: string;
12
+ email: string | null;
13
+ }
14
+ export interface FusionIdentity {
15
+ id: string;
16
+ email: string | null;
17
+ name: string | null;
18
+ }
19
+ /** Fetch the device-flow parameters for a host. */
20
+ export declare function fetchCliAuthConfig(host: string): Promise<CliAuthConfig>;
21
+ /** Exchange a verified Auth0 access token for a Fusion JWT. */
22
+ export declare function createFusionSession(host: string, auth0AccessToken: string): Promise<FusionSession>;
23
+ export interface RefreshedSession extends FusionSession {
24
+ /** The ROTATED refresh token. The presented one is already invalid. */
25
+ auth0RefreshToken: string;
26
+ }
27
+ export interface RefreshOptions {
28
+ /**
29
+ * Called the INSTANT a rotated refresh token is seen in the response — on
30
+ * success and on failure alike, before anything is thrown.
31
+ *
32
+ * Persist-on-learn, not persist-on-success. Once the server's exchange with
33
+ * Auth0 succeeds the presented token is dead and the replacement in that
34
+ * body is the only copy in existence, so a client that only saves it on the
35
+ * happy path destroys the partner's credential the first time /userinfo
36
+ * blips. The callback runs before any throw so no error path can skip it.
37
+ */
38
+ onRotatedToken?: (rotated: string) => void;
39
+ }
40
+ /** Re-mint a Fusion JWT from the stored Auth0 refresh token. */
41
+ export declare function refreshFusionSession(host: string, auth0RefreshToken: string, options?: RefreshOptions): Promise<RefreshedSession>;
42
+ /**
43
+ * Prove the Fusion JWT authenticates. Returns null on 401 so the caller can
44
+ * decide whether to refresh and retry — an expired access token is an ordinary
45
+ * event, not an error.
46
+ */
47
+ export declare function fetchIdentity(host: string, accessToken: string): Promise<FusionIdentity | null>;
48
+ export declare function request(url: string, init: RequestInit): Promise<Response>;