dedalus-cli 0.0.0-stage → 0.7.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.
- package/LICENSE +201 -0
- package/README.md +296 -2
- package/SKILL.md +73 -0
- package/api.md +340 -0
- package/dist/cjs/bin.d.ts +3 -0
- package/dist/cjs/bin.d.ts.map +1 -0
- package/dist/cjs/bin.js +7 -0
- package/dist/cjs/bin.js.map +1 -0
- package/dist/cjs/cli/completions.d.ts +6 -0
- package/dist/cjs/cli/completions.d.ts.map +1 -0
- package/dist/cjs/cli/completions.js +11 -0
- package/dist/cjs/cli/completions.js.map +1 -0
- package/dist/cjs/cli/credentials.d.ts +169 -0
- package/dist/cjs/cli/credentials.d.ts.map +1 -0
- package/dist/cjs/cli/credentials.js +630 -0
- package/dist/cjs/cli/credentials.js.map +1 -0
- package/dist/cjs/cli/keychain.d.ts +34 -0
- package/dist/cjs/cli/keychain.d.ts.map +1 -0
- package/dist/cjs/cli/keychain.js +246 -0
- package/dist/cjs/cli/keychain.js.map +1 -0
- package/dist/cjs/cli/list.d.ts +23 -0
- package/dist/cjs/cli/list.d.ts.map +1 -0
- package/dist/cjs/cli/list.js +32 -0
- package/dist/cjs/cli/list.js.map +1 -0
- package/dist/cjs/cli/login.d.ts +137 -0
- package/dist/cjs/cli/login.d.ts.map +1 -0
- package/dist/cjs/cli/login.js +2060 -0
- package/dist/cjs/cli/login.js.map +1 -0
- package/dist/cjs/cli/runtime.d.ts +98 -0
- package/dist/cjs/cli/runtime.d.ts.map +1 -0
- package/dist/cjs/cli/runtime.js +1356 -0
- package/dist/cjs/cli/runtime.js.map +1 -0
- package/dist/cjs/cli/toon.d.ts +6 -0
- package/dist/cjs/cli/toon.d.ts.map +1 -0
- package/dist/cjs/cli/toon.js +165 -0
- package/dist/cjs/cli/toon.js.map +1 -0
- package/dist/cjs/cli/upgrade.d.ts +28 -0
- package/dist/cjs/cli/upgrade.d.ts.map +1 -0
- package/dist/cjs/cli/upgrade.js +1191 -0
- package/dist/cjs/cli/upgrade.js.map +1 -0
- package/dist/cjs/commands/index.d.ts +47 -0
- package/dist/cjs/commands/index.d.ts.map +1 -0
- package/dist/cjs/commands/index.js +1202 -0
- package/dist/cjs/commands/index.js.map +1 -0
- package/dist/cjs/custom/browser-routing.d.ts +4 -0
- package/dist/cjs/custom/browser-routing.d.ts.map +1 -0
- package/dist/cjs/custom/browser-routing.js +51 -0
- package/dist/cjs/custom/browser-routing.js.map +1 -0
- package/dist/cjs/custom/images.d.ts +6 -0
- package/dist/cjs/custom/images.d.ts.map +1 -0
- package/dist/cjs/custom/images.js +74 -0
- package/dist/cjs/custom/images.js.map +1 -0
- package/dist/cjs/custom/machines.d.ts +20 -0
- package/dist/cjs/custom/machines.d.ts.map +1 -0
- package/dist/cjs/custom/machines.js +84 -0
- package/dist/cjs/custom/machines.js.map +1 -0
- package/dist/cjs/custom/ssh-picker.d.ts +19 -0
- package/dist/cjs/custom/ssh-picker.d.ts.map +1 -0
- package/dist/cjs/custom/ssh-picker.js +157 -0
- package/dist/cjs/custom/ssh-picker.js.map +1 -0
- package/dist/cjs/custom/ssh.d.ts +18 -0
- package/dist/cjs/custom/ssh.d.ts.map +1 -0
- package/dist/cjs/custom/ssh.js +152 -0
- package/dist/cjs/custom/ssh.js.map +1 -0
- package/dist/cjs/feedback/bundle.d.ts +41 -0
- package/dist/cjs/feedback/bundle.d.ts.map +1 -0
- package/dist/cjs/feedback/bundle.js +88 -0
- package/dist/cjs/feedback/bundle.js.map +1 -0
- package/dist/cjs/feedback/command.d.ts +6 -0
- package/dist/cjs/feedback/command.d.ts.map +1 -0
- package/dist/cjs/feedback/command.js +100 -0
- package/dist/cjs/feedback/command.js.map +1 -0
- package/dist/cjs/feedback/diagnostics.d.ts +30 -0
- package/dist/cjs/feedback/diagnostics.d.ts.map +1 -0
- package/dist/cjs/feedback/diagnostics.js +278 -0
- package/dist/cjs/feedback/diagnostics.js.map +1 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +11 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/sdk/api-promise.d.ts +31 -0
- package/dist/cjs/sdk/api-promise.d.ts.map +1 -0
- package/dist/cjs/sdk/api-promise.js +73 -0
- package/dist/cjs/sdk/api-promise.js.map +1 -0
- package/dist/cjs/sdk/client.d.ts +267 -0
- package/dist/cjs/sdk/client.d.ts.map +1 -0
- package/dist/cjs/sdk/client.js +965 -0
- package/dist/cjs/sdk/client.js.map +1 -0
- package/dist/cjs/sdk/core/api-promise.d.ts +3 -0
- package/dist/cjs/sdk/core/api-promise.d.ts.map +1 -0
- package/dist/cjs/sdk/core/api-promise.js +8 -0
- package/dist/cjs/sdk/core/api-promise.js.map +1 -0
- package/dist/cjs/sdk/core/error.d.ts +46 -0
- package/dist/cjs/sdk/core/error.d.ts.map +1 -0
- package/dist/cjs/sdk/core/error.js +135 -0
- package/dist/cjs/sdk/core/error.js.map +1 -0
- package/dist/cjs/sdk/core/pagination.d.ts +38 -0
- package/dist/cjs/sdk/core/pagination.d.ts.map +1 -0
- package/dist/cjs/sdk/core/pagination.js +98 -0
- package/dist/cjs/sdk/core/pagination.js.map +1 -0
- package/dist/cjs/sdk/core/resource.d.ts +2 -0
- package/dist/cjs/sdk/core/resource.d.ts.map +1 -0
- package/dist/cjs/sdk/core/resource.js +7 -0
- package/dist/cjs/sdk/core/resource.js.map +1 -0
- package/dist/cjs/sdk/core/uploads.d.ts +3 -0
- package/dist/cjs/sdk/core/uploads.d.ts.map +1 -0
- package/dist/cjs/sdk/core/uploads.js +7 -0
- package/dist/cjs/sdk/core/uploads.js.map +1 -0
- package/dist/cjs/sdk/error.d.ts +3 -0
- package/dist/cjs/sdk/error.d.ts.map +1 -0
- package/dist/cjs/sdk/error.js +20 -0
- package/dist/cjs/sdk/error.js.map +1 -0
- package/dist/cjs/sdk/helpers/images.d.ts +31 -0
- package/dist/cjs/sdk/helpers/images.d.ts.map +1 -0
- package/dist/cjs/sdk/helpers/images.js +195 -0
- package/dist/cjs/sdk/helpers/images.js.map +1 -0
- package/dist/cjs/sdk/index.d.ts +7 -0
- package/dist/cjs/sdk/index.d.ts.map +1 -0
- package/dist/cjs/sdk/index.js +29 -0
- package/dist/cjs/sdk/index.js.map +1 -0
- package/dist/cjs/sdk/internal/builtin-types.d.ts +73 -0
- package/dist/cjs/sdk/internal/builtin-types.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/builtin-types.js +4 -0
- package/dist/cjs/sdk/internal/builtin-types.js.map +1 -0
- package/dist/cjs/sdk/internal/detect-platform.d.ts +15 -0
- package/dist/cjs/sdk/internal/detect-platform.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/detect-platform.js +185 -0
- package/dist/cjs/sdk/internal/detect-platform.js.map +1 -0
- package/dist/cjs/sdk/internal/errors.d.ts +3 -0
- package/dist/cjs/sdk/internal/errors.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/errors.js +41 -0
- package/dist/cjs/sdk/internal/errors.js.map +1 -0
- package/dist/cjs/sdk/internal/headers.d.ts +43 -0
- package/dist/cjs/sdk/internal/headers.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/headers.js +142 -0
- package/dist/cjs/sdk/internal/headers.js.map +1 -0
- package/dist/cjs/sdk/internal/parse.d.ts +3 -0
- package/dist/cjs/sdk/internal/parse.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/parse.js +7 -0
- package/dist/cjs/sdk/internal/parse.js.map +1 -0
- package/dist/cjs/sdk/internal/qs/formats.d.ts +7 -0
- package/dist/cjs/sdk/internal/qs/formats.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/qs/formats.js +13 -0
- package/dist/cjs/sdk/internal/qs/formats.js.map +1 -0
- package/dist/cjs/sdk/internal/qs/index.d.ts +10 -0
- package/dist/cjs/sdk/internal/qs/index.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/qs/index.js +14 -0
- package/dist/cjs/sdk/internal/qs/index.js.map +1 -0
- package/dist/cjs/sdk/internal/qs/stringify.d.ts +3 -0
- package/dist/cjs/sdk/internal/qs/stringify.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/qs/stringify.js +278 -0
- package/dist/cjs/sdk/internal/qs/stringify.js.map +1 -0
- package/dist/cjs/sdk/internal/qs/types.d.ts +57 -0
- package/dist/cjs/sdk/internal/qs/types.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/qs/types.js +3 -0
- package/dist/cjs/sdk/internal/qs/types.js.map +1 -0
- package/dist/cjs/sdk/internal/qs/utils.d.ts +15 -0
- package/dist/cjs/sdk/internal/qs/utils.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/qs/utils.js +230 -0
- package/dist/cjs/sdk/internal/qs/utils.js.map +1 -0
- package/dist/cjs/sdk/internal/request-options.d.ts +33 -0
- package/dist/cjs/sdk/internal/request-options.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/request-options.js +15 -0
- package/dist/cjs/sdk/internal/request-options.js.map +1 -0
- package/dist/cjs/sdk/internal/shim-types.d.ts +17 -0
- package/dist/cjs/sdk/internal/shim-types.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/shim-types.js +4 -0
- package/dist/cjs/sdk/internal/shim-types.js.map +1 -0
- package/dist/cjs/sdk/internal/shims.d.ts +26 -0
- package/dist/cjs/sdk/internal/shims.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/shims.js +92 -0
- package/dist/cjs/sdk/internal/shims.js.map +1 -0
- package/dist/cjs/sdk/internal/to-file.d.ts +45 -0
- package/dist/cjs/sdk/internal/to-file.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/to-file.js +91 -0
- package/dist/cjs/sdk/internal/to-file.js.map +1 -0
- package/dist/cjs/sdk/internal/types.d.ts +63 -0
- package/dist/cjs/sdk/internal/types.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/types.js +4 -0
- package/dist/cjs/sdk/internal/types.js.map +1 -0
- package/dist/cjs/sdk/internal/uploads.d.ts +42 -0
- package/dist/cjs/sdk/internal/uploads.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/uploads.js +158 -0
- package/dist/cjs/sdk/internal/uploads.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/base64.d.ts +3 -0
- package/dist/cjs/sdk/internal/utils/base64.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/base64.js +38 -0
- package/dist/cjs/sdk/internal/utils/base64.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/body.d.ts +40 -0
- package/dist/cjs/sdk/internal/utils/body.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/body.js +148 -0
- package/dist/cjs/sdk/internal/utils/body.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/bytes.d.ts +4 -0
- package/dist/cjs/sdk/internal/utils/bytes.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/bytes.js +31 -0
- package/dist/cjs/sdk/internal/utils/bytes.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/env.d.ts +9 -0
- package/dist/cjs/sdk/internal/utils/env.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/env.js +22 -0
- package/dist/cjs/sdk/internal/utils/env.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/log.d.ts +52 -0
- package/dist/cjs/sdk/internal/utils/log.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/log.js +413 -0
- package/dist/cjs/sdk/internal/utils/log.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/path.d.ts +41 -0
- package/dist/cjs/sdk/internal/utils/path.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/path.js +115 -0
- package/dist/cjs/sdk/internal/utils/path.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/query-string.d.ts +21 -0
- package/dist/cjs/sdk/internal/utils/query-string.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/query-string.js +128 -0
- package/dist/cjs/sdk/internal/utils/query-string.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/query.d.ts +2 -0
- package/dist/cjs/sdk/internal/utils/query.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/query.js +12 -0
- package/dist/cjs/sdk/internal/utils/query.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/sleep.d.ts +2 -0
- package/dist/cjs/sdk/internal/utils/sleep.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/sleep.js +7 -0
- package/dist/cjs/sdk/internal/utils/sleep.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/uuid.d.ts +5 -0
- package/dist/cjs/sdk/internal/utils/uuid.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/uuid.js +19 -0
- package/dist/cjs/sdk/internal/utils/uuid.js.map +1 -0
- package/dist/cjs/sdk/internal/utils/values.d.ts +18 -0
- package/dist/cjs/sdk/internal/utils/values.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils/values.js +112 -0
- package/dist/cjs/sdk/internal/utils/values.js.map +1 -0
- package/dist/cjs/sdk/internal/utils.d.ts +8 -0
- package/dist/cjs/sdk/internal/utils.d.ts.map +1 -0
- package/dist/cjs/sdk/internal/utils.js +25 -0
- package/dist/cjs/sdk/internal/utils.js.map +1 -0
- package/dist/cjs/sdk/pagination.d.ts +3 -0
- package/dist/cjs/sdk/pagination.d.ts.map +1 -0
- package/dist/cjs/sdk/pagination.js +20 -0
- package/dist/cjs/sdk/pagination.js.map +1 -0
- package/dist/cjs/sdk/resource.d.ts +6 -0
- package/dist/cjs/sdk/resource.d.ts.map +1 -0
- package/dist/cjs/sdk/resource.js +12 -0
- package/dist/cjs/sdk/resource.js.map +1 -0
- package/dist/cjs/sdk/resources/images/images.d.ts +202 -0
- package/dist/cjs/sdk/resources/images/images.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/images/images.js +139 -0
- package/dist/cjs/sdk/resources/images/images.js.map +1 -0
- package/dist/cjs/sdk/resources/images/index.d.ts +5 -0
- package/dist/cjs/sdk/resources/images/index.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/images/index.js +9 -0
- package/dist/cjs/sdk/resources/images/index.js.map +1 -0
- package/dist/cjs/sdk/resources/images/uploads.d.ts +50 -0
- package/dist/cjs/sdk/resources/images/uploads.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/images/uploads.js +28 -0
- package/dist/cjs/sdk/resources/images/uploads.js.map +1 -0
- package/dist/cjs/sdk/resources/images.d.ts +2 -0
- package/dist/cjs/sdk/resources/images.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/images.js +19 -0
- package/dist/cjs/sdk/resources/images.js.map +1 -0
- package/dist/cjs/sdk/resources/index.d.ts +6 -0
- package/dist/cjs/sdk/resources/index.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/index.js +11 -0
- package/dist/cjs/sdk/resources/index.js.map +1 -0
- package/dist/cjs/sdk/resources/machines/autoresizing.d.ts +115 -0
- package/dist/cjs/sdk/resources/machines/autoresizing.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines/autoresizing.js +46 -0
- package/dist/cjs/sdk/resources/machines/autoresizing.js.map +1 -0
- package/dist/cjs/sdk/resources/machines/executions/executions.d.ts +341 -0
- package/dist/cjs/sdk/resources/machines/executions/executions.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines/executions/executions.js +161 -0
- package/dist/cjs/sdk/resources/machines/executions/executions.js.map +1 -0
- package/dist/cjs/sdk/resources/machines/executions/index.d.ts +5 -0
- package/dist/cjs/sdk/resources/machines/executions/index.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines/executions/index.js +9 -0
- package/dist/cjs/sdk/resources/machines/executions/index.js.map +1 -0
- package/dist/cjs/sdk/resources/machines/executions/logs.d.ts +103 -0
- package/dist/cjs/sdk/resources/machines/executions/logs.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines/executions/logs.js +48 -0
- package/dist/cjs/sdk/resources/machines/executions/logs.js.map +1 -0
- package/dist/cjs/sdk/resources/machines/executions.d.ts +2 -0
- package/dist/cjs/sdk/resources/machines/executions.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines/executions.js +19 -0
- package/dist/cjs/sdk/resources/machines/executions.js.map +1 -0
- package/dist/cjs/sdk/resources/machines/index.d.ts +9 -0
- package/dist/cjs/sdk/resources/machines/index.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines/index.js +13 -0
- package/dist/cjs/sdk/resources/machines/index.js.map +1 -0
- package/dist/cjs/sdk/resources/machines/machines.d.ts +496 -0
- package/dist/cjs/sdk/resources/machines/machines.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines/machines.js +220 -0
- package/dist/cjs/sdk/resources/machines/machines.js.map +1 -0
- package/dist/cjs/sdk/resources/machines/ssh.d.ts +175 -0
- package/dist/cjs/sdk/resources/machines/ssh.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines/ssh.js +89 -0
- package/dist/cjs/sdk/resources/machines/ssh.js.map +1 -0
- package/dist/cjs/sdk/resources/machines.d.ts +2 -0
- package/dist/cjs/sdk/resources/machines.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/machines.js +19 -0
- package/dist/cjs/sdk/resources/machines.js.map +1 -0
- package/dist/cjs/sdk/resources/organization/autoresizing.d.ts +86 -0
- package/dist/cjs/sdk/resources/organization/autoresizing.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/organization/autoresizing.js +38 -0
- package/dist/cjs/sdk/resources/organization/autoresizing.js.map +1 -0
- package/dist/cjs/sdk/resources/organization/index.d.ts +4 -0
- package/dist/cjs/sdk/resources/organization/index.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/organization/index.js +9 -0
- package/dist/cjs/sdk/resources/organization/index.js.map +1 -0
- package/dist/cjs/sdk/resources/organization/organization.d.ts +10 -0
- package/dist/cjs/sdk/resources/organization/organization.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/organization/organization.js +46 -0
- package/dist/cjs/sdk/resources/organization/organization.js.map +1 -0
- package/dist/cjs/sdk/resources/organization.d.ts +2 -0
- package/dist/cjs/sdk/resources/organization.d.ts.map +1 -0
- package/dist/cjs/sdk/resources/organization.js +19 -0
- package/dist/cjs/sdk/resources/organization.js.map +1 -0
- package/dist/cjs/sdk/resources.d.ts +3 -0
- package/dist/cjs/sdk/resources.d.ts.map +1 -0
- package/dist/cjs/sdk/resources.js +9 -0
- package/dist/cjs/sdk/resources.js.map +1 -0
- package/dist/cjs/sdk/uploads.d.ts +2 -0
- package/dist/cjs/sdk/uploads.d.ts.map +1 -0
- package/dist/cjs/sdk/uploads.js +7 -0
- package/dist/cjs/sdk/uploads.js.map +1 -0
- package/dist/cjs/sdk/version.d.ts +2 -0
- package/dist/cjs/sdk/version.d.ts.map +1 -0
- package/dist/cjs/sdk/version.js +6 -0
- package/dist/cjs/sdk/version.js.map +1 -0
- package/dist/esm/bin.d.ts +3 -0
- package/dist/esm/bin.d.ts.map +1 -0
- package/dist/esm/bin.js +5 -0
- package/dist/esm/bin.js.map +1 -0
- package/dist/esm/cli/completions.d.ts +6 -0
- package/dist/esm/cli/completions.d.ts.map +1 -0
- package/dist/esm/cli/completions.js +8 -0
- package/dist/esm/cli/completions.js.map +1 -0
- package/dist/esm/cli/credentials.d.ts +169 -0
- package/dist/esm/cli/credentials.d.ts.map +1 -0
- package/dist/esm/cli/credentials.js +614 -0
- package/dist/esm/cli/credentials.js.map +1 -0
- package/dist/esm/cli/keychain.d.ts +34 -0
- package/dist/esm/cli/keychain.d.ts.map +1 -0
- package/dist/esm/cli/keychain.js +237 -0
- package/dist/esm/cli/keychain.js.map +1 -0
- package/dist/esm/cli/list.d.ts +23 -0
- package/dist/esm/cli/list.d.ts.map +1 -0
- package/dist/esm/cli/list.js +27 -0
- package/dist/esm/cli/list.js.map +1 -0
- package/dist/esm/cli/login.d.ts +137 -0
- package/dist/esm/cli/login.d.ts.map +1 -0
- package/dist/esm/cli/login.js +2053 -0
- package/dist/esm/cli/login.js.map +1 -0
- package/dist/esm/cli/runtime.d.ts +98 -0
- package/dist/esm/cli/runtime.d.ts.map +1 -0
- package/dist/esm/cli/runtime.js +1345 -0
- package/dist/esm/cli/runtime.js.map +1 -0
- package/dist/esm/cli/toon.d.ts +6 -0
- package/dist/esm/cli/toon.d.ts.map +1 -0
- package/dist/esm/cli/toon.js +161 -0
- package/dist/esm/cli/toon.js.map +1 -0
- package/dist/esm/cli/upgrade.d.ts +28 -0
- package/dist/esm/cli/upgrade.d.ts.map +1 -0
- package/dist/esm/cli/upgrade.js +1186 -0
- package/dist/esm/cli/upgrade.js.map +1 -0
- package/dist/esm/commands/index.d.ts +47 -0
- package/dist/esm/commands/index.d.ts.map +1 -0
- package/dist/esm/commands/index.js +1195 -0
- package/dist/esm/commands/index.js.map +1 -0
- package/dist/esm/custom/browser-routing.d.ts +4 -0
- package/dist/esm/custom/browser-routing.d.ts.map +1 -0
- package/dist/esm/custom/browser-routing.js +47 -0
- package/dist/esm/custom/browser-routing.js.map +1 -0
- package/dist/esm/custom/images.d.ts +6 -0
- package/dist/esm/custom/images.d.ts.map +1 -0
- package/dist/esm/custom/images.js +68 -0
- package/dist/esm/custom/images.js.map +1 -0
- package/dist/esm/custom/machines.d.ts +20 -0
- package/dist/esm/custom/machines.d.ts.map +1 -0
- package/dist/esm/custom/machines.js +76 -0
- package/dist/esm/custom/machines.js.map +1 -0
- package/dist/esm/custom/ssh-picker.d.ts +19 -0
- package/dist/esm/custom/ssh-picker.d.ts.map +1 -0
- package/dist/esm/custom/ssh-picker.js +149 -0
- package/dist/esm/custom/ssh-picker.js.map +1 -0
- package/dist/esm/custom/ssh.d.ts +18 -0
- package/dist/esm/custom/ssh.d.ts.map +1 -0
- package/dist/esm/custom/ssh.js +147 -0
- package/dist/esm/custom/ssh.js.map +1 -0
- package/dist/esm/feedback/bundle.d.ts +41 -0
- package/dist/esm/feedback/bundle.d.ts.map +1 -0
- package/dist/esm/feedback/bundle.js +83 -0
- package/dist/esm/feedback/bundle.js.map +1 -0
- package/dist/esm/feedback/command.d.ts +6 -0
- package/dist/esm/feedback/command.d.ts.map +1 -0
- package/dist/esm/feedback/command.js +92 -0
- package/dist/esm/feedback/command.js.map +1 -0
- package/dist/esm/feedback/diagnostics.d.ts +30 -0
- package/dist/esm/feedback/diagnostics.d.ts.map +1 -0
- package/dist/esm/feedback/diagnostics.js +270 -0
- package/dist/esm/feedback/diagnostics.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/sdk/api-promise.d.ts +31 -0
- package/dist/esm/sdk/api-promise.d.ts.map +1 -0
- package/dist/esm/sdk/api-promise.js +68 -0
- package/dist/esm/sdk/api-promise.js.map +1 -0
- package/dist/esm/sdk/client.d.ts +267 -0
- package/dist/esm/sdk/client.d.ts.map +1 -0
- package/dist/esm/sdk/client.js +928 -0
- package/dist/esm/sdk/client.js.map +1 -0
- package/dist/esm/sdk/core/api-promise.d.ts +3 -0
- package/dist/esm/sdk/core/api-promise.d.ts.map +1 -0
- package/dist/esm/sdk/core/api-promise.js +3 -0
- package/dist/esm/sdk/core/api-promise.js.map +1 -0
- package/dist/esm/sdk/core/error.d.ts +46 -0
- package/dist/esm/sdk/core/error.d.ts.map +1 -0
- package/dist/esm/sdk/core/error.js +119 -0
- package/dist/esm/sdk/core/error.js.map +1 -0
- package/dist/esm/sdk/core/pagination.d.ts +38 -0
- package/dist/esm/sdk/core/pagination.d.ts.map +1 -0
- package/dist/esm/sdk/core/pagination.js +92 -0
- package/dist/esm/sdk/core/pagination.js.map +1 -0
- package/dist/esm/sdk/core/resource.d.ts +2 -0
- package/dist/esm/sdk/core/resource.d.ts.map +1 -0
- package/dist/esm/sdk/core/resource.js +3 -0
- package/dist/esm/sdk/core/resource.js.map +1 -0
- package/dist/esm/sdk/core/uploads.d.ts +3 -0
- package/dist/esm/sdk/core/uploads.d.ts.map +1 -0
- package/dist/esm/sdk/core/uploads.js +3 -0
- package/dist/esm/sdk/core/uploads.js.map +1 -0
- package/dist/esm/sdk/error.d.ts +3 -0
- package/dist/esm/sdk/error.d.ts.map +1 -0
- package/dist/esm/sdk/error.js +4 -0
- package/dist/esm/sdk/error.js.map +1 -0
- package/dist/esm/sdk/helpers/images.d.ts +31 -0
- package/dist/esm/sdk/helpers/images.d.ts.map +1 -0
- package/dist/esm/sdk/helpers/images.js +189 -0
- package/dist/esm/sdk/helpers/images.js.map +1 -0
- package/dist/esm/sdk/index.d.ts +7 -0
- package/dist/esm/sdk/index.d.ts.map +1 -0
- package/dist/esm/sdk/index.js +8 -0
- package/dist/esm/sdk/index.js.map +1 -0
- package/dist/esm/sdk/internal/builtin-types.d.ts +73 -0
- package/dist/esm/sdk/internal/builtin-types.d.ts.map +1 -0
- package/dist/esm/sdk/internal/builtin-types.js +3 -0
- package/dist/esm/sdk/internal/builtin-types.js.map +1 -0
- package/dist/esm/sdk/internal/detect-platform.d.ts +15 -0
- package/dist/esm/sdk/internal/detect-platform.d.ts.map +1 -0
- package/dist/esm/sdk/internal/detect-platform.js +180 -0
- package/dist/esm/sdk/internal/detect-platform.js.map +1 -0
- package/dist/esm/sdk/internal/errors.d.ts +3 -0
- package/dist/esm/sdk/internal/errors.d.ts.map +1 -0
- package/dist/esm/sdk/internal/errors.js +36 -0
- package/dist/esm/sdk/internal/errors.js.map +1 -0
- package/dist/esm/sdk/internal/headers.d.ts +43 -0
- package/dist/esm/sdk/internal/headers.d.ts.map +1 -0
- package/dist/esm/sdk/internal/headers.js +133 -0
- package/dist/esm/sdk/internal/headers.js.map +1 -0
- package/dist/esm/sdk/internal/parse.d.ts +3 -0
- package/dist/esm/sdk/internal/parse.d.ts.map +1 -0
- package/dist/esm/sdk/internal/parse.js +3 -0
- package/dist/esm/sdk/internal/parse.js.map +1 -0
- package/dist/esm/sdk/internal/qs/formats.d.ts +7 -0
- package/dist/esm/sdk/internal/qs/formats.d.ts.map +1 -0
- package/dist/esm/sdk/internal/qs/formats.js +9 -0
- package/dist/esm/sdk/internal/qs/formats.js.map +1 -0
- package/dist/esm/sdk/internal/qs/index.d.ts +10 -0
- package/dist/esm/sdk/internal/qs/index.d.ts.map +1 -0
- package/dist/esm/sdk/internal/qs/index.js +10 -0
- package/dist/esm/sdk/internal/qs/index.js.map +1 -0
- package/dist/esm/sdk/internal/qs/stringify.d.ts +3 -0
- package/dist/esm/sdk/internal/qs/stringify.d.ts.map +1 -0
- package/dist/esm/sdk/internal/qs/stringify.js +275 -0
- package/dist/esm/sdk/internal/qs/stringify.js.map +1 -0
- package/dist/esm/sdk/internal/qs/types.d.ts +57 -0
- package/dist/esm/sdk/internal/qs/types.d.ts.map +1 -0
- package/dist/esm/sdk/internal/qs/types.js +2 -0
- package/dist/esm/sdk/internal/qs/types.js.map +1 -0
- package/dist/esm/sdk/internal/qs/utils.d.ts +15 -0
- package/dist/esm/sdk/internal/qs/utils.d.ts.map +1 -0
- package/dist/esm/sdk/internal/qs/utils.js +217 -0
- package/dist/esm/sdk/internal/qs/utils.js.map +1 -0
- package/dist/esm/sdk/internal/request-options.d.ts +33 -0
- package/dist/esm/sdk/internal/request-options.d.ts.map +1 -0
- package/dist/esm/sdk/internal/request-options.js +11 -0
- package/dist/esm/sdk/internal/request-options.js.map +1 -0
- package/dist/esm/sdk/internal/shim-types.d.ts +17 -0
- package/dist/esm/sdk/internal/shim-types.d.ts.map +1 -0
- package/dist/esm/sdk/internal/shim-types.js +3 -0
- package/dist/esm/sdk/internal/shim-types.js.map +1 -0
- package/dist/esm/sdk/internal/shims.d.ts +26 -0
- package/dist/esm/sdk/internal/shims.d.ts.map +1 -0
- package/dist/esm/sdk/internal/shims.js +85 -0
- package/dist/esm/sdk/internal/shims.js.map +1 -0
- package/dist/esm/sdk/internal/to-file.d.ts +45 -0
- package/dist/esm/sdk/internal/to-file.d.ts.map +1 -0
- package/dist/esm/sdk/internal/to-file.js +88 -0
- package/dist/esm/sdk/internal/to-file.js.map +1 -0
- package/dist/esm/sdk/internal/types.d.ts +63 -0
- package/dist/esm/sdk/internal/types.d.ts.map +1 -0
- package/dist/esm/sdk/internal/types.js +3 -0
- package/dist/esm/sdk/internal/types.js.map +1 -0
- package/dist/esm/sdk/internal/uploads.d.ts +42 -0
- package/dist/esm/sdk/internal/uploads.d.ts.map +1 -0
- package/dist/esm/sdk/internal/uploads.js +148 -0
- package/dist/esm/sdk/internal/uploads.js.map +1 -0
- package/dist/esm/sdk/internal/utils/base64.d.ts +3 -0
- package/dist/esm/sdk/internal/utils/base64.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/base64.js +33 -0
- package/dist/esm/sdk/internal/utils/base64.js.map +1 -0
- package/dist/esm/sdk/internal/utils/body.d.ts +40 -0
- package/dist/esm/sdk/internal/utils/body.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/body.js +140 -0
- package/dist/esm/sdk/internal/utils/body.js.map +1 -0
- package/dist/esm/sdk/internal/utils/bytes.d.ts +4 -0
- package/dist/esm/sdk/internal/utils/bytes.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/bytes.js +26 -0
- package/dist/esm/sdk/internal/utils/bytes.js.map +1 -0
- package/dist/esm/sdk/internal/utils/env.d.ts +9 -0
- package/dist/esm/sdk/internal/utils/env.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/env.js +18 -0
- package/dist/esm/sdk/internal/utils/env.js.map +1 -0
- package/dist/esm/sdk/internal/utils/log.d.ts +52 -0
- package/dist/esm/sdk/internal/utils/log.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/log.js +405 -0
- package/dist/esm/sdk/internal/utils/log.js.map +1 -0
- package/dist/esm/sdk/internal/utils/path.d.ts +41 -0
- package/dist/esm/sdk/internal/utils/path.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/path.js +108 -0
- package/dist/esm/sdk/internal/utils/path.js.map +1 -0
- package/dist/esm/sdk/internal/utils/query-string.d.ts +21 -0
- package/dist/esm/sdk/internal/utils/query-string.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/query-string.js +125 -0
- package/dist/esm/sdk/internal/utils/query-string.js.map +1 -0
- package/dist/esm/sdk/internal/utils/query.d.ts +2 -0
- package/dist/esm/sdk/internal/utils/query.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/query.js +9 -0
- package/dist/esm/sdk/internal/utils/query.js.map +1 -0
- package/dist/esm/sdk/internal/utils/sleep.d.ts +2 -0
- package/dist/esm/sdk/internal/utils/sleep.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/sleep.js +3 -0
- package/dist/esm/sdk/internal/utils/sleep.js.map +1 -0
- package/dist/esm/sdk/internal/utils/uuid.d.ts +5 -0
- package/dist/esm/sdk/internal/utils/uuid.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/uuid.js +15 -0
- package/dist/esm/sdk/internal/utils/uuid.js.map +1 -0
- package/dist/esm/sdk/internal/utils/values.d.ts +18 -0
- package/dist/esm/sdk/internal/utils/values.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils/values.js +94 -0
- package/dist/esm/sdk/internal/utils/values.js.map +1 -0
- package/dist/esm/sdk/internal/utils.d.ts +8 -0
- package/dist/esm/sdk/internal/utils.d.ts.map +1 -0
- package/dist/esm/sdk/internal/utils.js +9 -0
- package/dist/esm/sdk/internal/utils.js.map +1 -0
- package/dist/esm/sdk/pagination.d.ts +3 -0
- package/dist/esm/sdk/pagination.d.ts.map +1 -0
- package/dist/esm/sdk/pagination.js +4 -0
- package/dist/esm/sdk/pagination.js.map +1 -0
- package/dist/esm/sdk/resource.d.ts +6 -0
- package/dist/esm/sdk/resource.d.ts.map +1 -0
- package/dist/esm/sdk/resource.js +8 -0
- package/dist/esm/sdk/resource.js.map +1 -0
- package/dist/esm/sdk/resources/images/images.d.ts +202 -0
- package/dist/esm/sdk/resources/images/images.d.ts.map +1 -0
- package/dist/esm/sdk/resources/images/images.js +102 -0
- package/dist/esm/sdk/resources/images/images.js.map +1 -0
- package/dist/esm/sdk/resources/images/index.d.ts +5 -0
- package/dist/esm/sdk/resources/images/index.d.ts.map +1 -0
- package/dist/esm/sdk/resources/images/index.js +4 -0
- package/dist/esm/sdk/resources/images/index.js.map +1 -0
- package/dist/esm/sdk/resources/images/uploads.d.ts +50 -0
- package/dist/esm/sdk/resources/images/uploads.d.ts.map +1 -0
- package/dist/esm/sdk/resources/images/uploads.js +24 -0
- package/dist/esm/sdk/resources/images/uploads.js.map +1 -0
- package/dist/esm/sdk/resources/images.d.ts +2 -0
- package/dist/esm/sdk/resources/images.d.ts.map +1 -0
- package/dist/esm/sdk/resources/images.js +3 -0
- package/dist/esm/sdk/resources/images.js.map +1 -0
- package/dist/esm/sdk/resources/index.d.ts +6 -0
- package/dist/esm/sdk/resources/index.d.ts.map +1 -0
- package/dist/esm/sdk/resources/index.js +5 -0
- package/dist/esm/sdk/resources/index.js.map +1 -0
- package/dist/esm/sdk/resources/machines/autoresizing.d.ts +115 -0
- package/dist/esm/sdk/resources/machines/autoresizing.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines/autoresizing.js +42 -0
- package/dist/esm/sdk/resources/machines/autoresizing.js.map +1 -0
- package/dist/esm/sdk/resources/machines/executions/executions.d.ts +341 -0
- package/dist/esm/sdk/resources/machines/executions/executions.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines/executions/executions.js +124 -0
- package/dist/esm/sdk/resources/machines/executions/executions.js.map +1 -0
- package/dist/esm/sdk/resources/machines/executions/index.d.ts +5 -0
- package/dist/esm/sdk/resources/machines/executions/index.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines/executions/index.js +4 -0
- package/dist/esm/sdk/resources/machines/executions/index.js.map +1 -0
- package/dist/esm/sdk/resources/machines/executions/logs.d.ts +103 -0
- package/dist/esm/sdk/resources/machines/executions/logs.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines/executions/logs.js +44 -0
- package/dist/esm/sdk/resources/machines/executions/logs.js.map +1 -0
- package/dist/esm/sdk/resources/machines/executions.d.ts +2 -0
- package/dist/esm/sdk/resources/machines/executions.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines/executions.js +3 -0
- package/dist/esm/sdk/resources/machines/executions.js.map +1 -0
- package/dist/esm/sdk/resources/machines/index.d.ts +9 -0
- package/dist/esm/sdk/resources/machines/index.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines/index.js +6 -0
- package/dist/esm/sdk/resources/machines/index.js.map +1 -0
- package/dist/esm/sdk/resources/machines/machines.d.ts +496 -0
- package/dist/esm/sdk/resources/machines/machines.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines/machines.js +183 -0
- package/dist/esm/sdk/resources/machines/machines.js.map +1 -0
- package/dist/esm/sdk/resources/machines/ssh.d.ts +175 -0
- package/dist/esm/sdk/resources/machines/ssh.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines/ssh.js +85 -0
- package/dist/esm/sdk/resources/machines/ssh.js.map +1 -0
- package/dist/esm/sdk/resources/machines.d.ts +2 -0
- package/dist/esm/sdk/resources/machines.d.ts.map +1 -0
- package/dist/esm/sdk/resources/machines.js +3 -0
- package/dist/esm/sdk/resources/machines.js.map +1 -0
- package/dist/esm/sdk/resources/organization/autoresizing.d.ts +86 -0
- package/dist/esm/sdk/resources/organization/autoresizing.d.ts.map +1 -0
- package/dist/esm/sdk/resources/organization/autoresizing.js +34 -0
- package/dist/esm/sdk/resources/organization/autoresizing.js.map +1 -0
- package/dist/esm/sdk/resources/organization/index.d.ts +4 -0
- package/dist/esm/sdk/resources/organization/index.d.ts.map +1 -0
- package/dist/esm/sdk/resources/organization/index.js +4 -0
- package/dist/esm/sdk/resources/organization/index.js.map +1 -0
- package/dist/esm/sdk/resources/organization/organization.d.ts +10 -0
- package/dist/esm/sdk/resources/organization/organization.d.ts.map +1 -0
- package/dist/esm/sdk/resources/organization/organization.js +9 -0
- package/dist/esm/sdk/resources/organization/organization.js.map +1 -0
- package/dist/esm/sdk/resources/organization.d.ts +2 -0
- package/dist/esm/sdk/resources/organization.d.ts.map +1 -0
- package/dist/esm/sdk/resources/organization.js +3 -0
- package/dist/esm/sdk/resources/organization.js.map +1 -0
- package/dist/esm/sdk/resources.d.ts +3 -0
- package/dist/esm/sdk/resources.d.ts.map +1 -0
- package/dist/esm/sdk/resources.js +3 -0
- package/dist/esm/sdk/resources.js.map +1 -0
- package/dist/esm/sdk/uploads.d.ts +2 -0
- package/dist/esm/sdk/uploads.d.ts.map +1 -0
- package/dist/esm/sdk/uploads.js +3 -0
- package/dist/esm/sdk/uploads.js.map +1 -0
- package/dist/esm/sdk/version.d.ts +2 -0
- package/dist/esm/sdk/version.d.ts.map +1 -0
- package/dist/esm/sdk/version.js +3 -0
- package/dist/esm/sdk/version.js.map +1 -0
- package/man/dedalus-completion.1 +35 -0
- package/man/dedalus-images-create.1 +126 -0
- package/man/dedalus-images-delete.1 +102 -0
- package/man/dedalus-images-import.1 +34 -0
- package/man/dedalus-images-list.1 +105 -0
- package/man/dedalus-images-retrieve.1 +102 -0
- package/man/dedalus-images-update.1 +108 -0
- package/man/dedalus-images-uploads-create.1 +112 -0
- package/man/dedalus-login.1 +47 -0
- package/man/dedalus-logout.1 +28 -0
- package/man/dedalus-machines-autoresizing-retrieve.1 +104 -0
- package/man/dedalus-machines-autoresizing-update.1 +116 -0
- package/man/dedalus-machines-create.1 +119 -0
- package/man/dedalus-machines-delete.1 +107 -0
- package/man/dedalus-machines-executions-create.1 +120 -0
- package/man/dedalus-machines-executions-delete.1 +108 -0
- package/man/dedalus-machines-executions-events.1 +118 -0
- package/man/dedalus-machines-executions-list.1 +114 -0
- package/man/dedalus-machines-executions-logs-create-token.1 +108 -0
- package/man/dedalus-machines-executions-logs-retrieve.1 +108 -0
- package/man/dedalus-machines-executions-output.1 +108 -0
- package/man/dedalus-machines-executions-retrieve.1 +108 -0
- package/man/dedalus-machines-list.1 +108 -0
- package/man/dedalus-machines-reboot.1 +107 -0
- package/man/dedalus-machines-resize.1 +110 -0
- package/man/dedalus-machines-retrieve.1 +104 -0
- package/man/dedalus-machines-sleep.1 +104 -0
- package/man/dedalus-machines-ssh-create.1 +108 -0
- package/man/dedalus-machines-ssh-delete.1 +108 -0
- package/man/dedalus-machines-ssh-list.1 +114 -0
- package/man/dedalus-machines-ssh-retrieve.1 +108 -0
- package/man/dedalus-machines-update.1 +110 -0
- package/man/dedalus-machines-wake.1 +104 -0
- package/man/dedalus-organization-autoresizing-retrieve.1 +99 -0
- package/man/dedalus-organization-autoresizing-update.1 +111 -0
- package/man/dedalus-uninstall.1 +23 -0
- package/man/dedalus-upgrade.1 +32 -0
- package/man/dedalus.1 +222 -0
- package/package.json +88 -4
|
@@ -0,0 +1,2060 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// File generated from our OpenAPI spec by Scalar. See README.md for details.
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.UsageError = exports.storedCredentials = exports.runLogout = exports.runLogin = void 0;
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const node_http_1 = require("node:http");
|
|
8
|
+
const node_readline_1 = require("node:readline");
|
|
9
|
+
const node_process_1 = require("node:process");
|
|
10
|
+
const credentials_1 = require("./credentials");
|
|
11
|
+
const keychain_1 = require("./keychain");
|
|
12
|
+
const list_1 = require("./list");
|
|
13
|
+
/** How long the browser flow waits for the redirect before giving the terminal back. */
|
|
14
|
+
const BROWSER_FLOW_TIMEOUT_MS = 300000;
|
|
15
|
+
/**
|
|
16
|
+
* How long the browser flow waits for its answered pages to finish sending before closing the
|
|
17
|
+
* listener. Loopback delivers a page in milliseconds; this only bounds a browser that stops reading.
|
|
18
|
+
*/
|
|
19
|
+
const RESPONSE_FLUSH_TIMEOUT_MS = 2000;
|
|
20
|
+
/** The one path the loopback listener answers as a redirect; it is what `redirect_uri` registers. */
|
|
21
|
+
const REDIRECT_PATH = '/callback';
|
|
22
|
+
/**
|
|
23
|
+
* Ceiling on how long the device grant polls, whatever `expires_in` claims.
|
|
24
|
+
*
|
|
25
|
+
* RFC 8628 leaves the lifetime to the server, and the server is named by the OpenAPI document. This
|
|
26
|
+
* is the same bound the browser grant has, so neither interactive sign-in can be made to wait
|
|
27
|
+
* indefinitely by an endpoint that answers `authorization_pending` and a very large expiry.
|
|
28
|
+
*/
|
|
29
|
+
const DEVICE_FLOW_TIMEOUT_MS = 300000;
|
|
30
|
+
/**
|
|
31
|
+
* How long a token request may take, and how much of its answer is read.
|
|
32
|
+
*
|
|
33
|
+
* Both bounds exist because the endpoint comes from the OpenAPI document. `fetch` has no default
|
|
34
|
+
* timeout, and a refresh runs before every command that has no flag or environment credential, so an
|
|
35
|
+
* endpoint that accepts the connection and never answers would hang the whole CLI with nothing on
|
|
36
|
+
* screen. The size cap keeps an endless body from being buffered into memory for a response whose
|
|
37
|
+
* useful part is a few hundred bytes.
|
|
38
|
+
*/
|
|
39
|
+
const TOKEN_REQUEST_TIMEOUT_MS = 30000;
|
|
40
|
+
const TOKEN_RESPONSE_LIMIT = 65536;
|
|
41
|
+
/**
|
|
42
|
+
* Deadline for each token revocation `logout` sends, and for the metadata read `login` makes only to
|
|
43
|
+
* learn where those go.
|
|
44
|
+
*
|
|
45
|
+
* Short because the sign-out has already happened by then — the local copy is gone — so the user is
|
|
46
|
+
* waiting only on a courtesy to the provider, and an endpoint that never answers must not hold the
|
|
47
|
+
* terminal for the full token-request deadline, once per token. The sign-in read is optional in the
|
|
48
|
+
* same way: its grant has succeeded, and the token is not saved until the read returns.
|
|
49
|
+
*/
|
|
50
|
+
const REVOCATION_TIMEOUT_MS = 5000;
|
|
51
|
+
/** Refresh this far ahead of expiry so a token cannot lapse between the check and the request. */
|
|
52
|
+
const REFRESH_LEEWAY_MS = 60000;
|
|
53
|
+
/**
|
|
54
|
+
* Deadline for the refresh that runs ahead of an ordinary command, rather than the full one above.
|
|
55
|
+
*
|
|
56
|
+
* That refresh is not what the user asked for — it is preparation for a request they are waiting on
|
|
57
|
+
* — and its failure is swallowed, so the whole of its cost is delay they cannot see a reason for. An
|
|
58
|
+
* unreachable refresh endpoint would otherwise add `TOKEN_REQUEST_TIMEOUT_MS` to *every* invocation
|
|
59
|
+
* for the life of the stored entry. Giving up sooner costs a refresh that a slow provider might
|
|
60
|
+
* still have answered; the command then goes out with the credential it already had, and a 401 says
|
|
61
|
+
* to sign in again. An interactive `login` keeps the full deadline, because there the exchange is
|
|
62
|
+
* the thing the user is waiting for.
|
|
63
|
+
*/
|
|
64
|
+
const SILENT_REFRESH_TIMEOUT_MS = 5000;
|
|
65
|
+
/**
|
|
66
|
+
* How far past its `exp` an ID token is still accepted, to absorb clock drift between this machine
|
|
67
|
+
* and the provider.
|
|
68
|
+
*
|
|
69
|
+
* OpenID Connect Core leaves the allowance to the client. Five minutes is the customary figure: a
|
|
70
|
+
* token the provider minted seconds ago is not refused because a laptop's clock runs slow, and a
|
|
71
|
+
* token that genuinely expired is still refused long before it could be useful to a replay.
|
|
72
|
+
*/
|
|
73
|
+
const ID_TOKEN_CLOCK_SKEW_MS = 300000;
|
|
74
|
+
/** Lifetime assumed for a token whose endpoint did not state one. See {@link oauthMetadata}. */
|
|
75
|
+
const ASSUMED_TOKEN_LIFETIME_S = 3600;
|
|
76
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
77
|
+
/**
|
|
78
|
+
* Shortest device-code poll interval a server can ask for.
|
|
79
|
+
*
|
|
80
|
+
* Low enough that a local authorization server still feels immediate, high enough that one asking
|
|
81
|
+
* for zero cannot turn the wait for authorization into a request flood.
|
|
82
|
+
*/
|
|
83
|
+
const DEVICE_POLL_FLOOR_MS = 200;
|
|
84
|
+
/** The grant type RFC 8628 registers, sent on each poll and looked for in server metadata. */
|
|
85
|
+
const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
86
|
+
const storeLocation = (auth) => ({
|
|
87
|
+
storeName: auth.storeName,
|
|
88
|
+
storeEnv: auth.storeEnv,
|
|
89
|
+
backend: auth.backend,
|
|
90
|
+
});
|
|
91
|
+
/** The client option keys a method fills, which for HTTP Basic is the username and password pair. */
|
|
92
|
+
const methodKeys = (method) => method.kind === 'basic' ? [method.usernameKey, method.passwordKey] : [method.clientKey];
|
|
93
|
+
/**
|
|
94
|
+
* Names what a request still needs when this sign-in cannot satisfy any alternative by itself.
|
|
95
|
+
*
|
|
96
|
+
* An OpenAPI `security` list is OR-of-ANDs, so an alternative can require two schemes at once — an
|
|
97
|
+
* API key *and* a bearer token. `login` captures one credential per run, which leaves that document
|
|
98
|
+
* signed in only halfway, and saying nothing would send the user off to collect a 401 whose hint
|
|
99
|
+
* tells them to run the command they have just run.
|
|
100
|
+
*
|
|
101
|
+
* Read back from the store rather than from what was captured, so a credential an earlier `login`
|
|
102
|
+
* already left behind counts towards the requirement instead of being asked for a second time.
|
|
103
|
+
*
|
|
104
|
+
* The alternative closest to complete is the one named: it is the cheapest way out for the user,
|
|
105
|
+
* and listing every unmet alternative would describe choices the API treats as interchangeable.
|
|
106
|
+
*
|
|
107
|
+
* `loginCommand` names the environment this `login` selected, when it selected one. The credential
|
|
108
|
+
* just saved is filed under that environment's URL, so a hint naming the bare command would file
|
|
109
|
+
* the next one under the default environment instead, and the pair would never meet. A sign-in made
|
|
110
|
+
* with `--base-url` selects no environment and gets the bare command, as the request hints do.
|
|
111
|
+
*/
|
|
112
|
+
const outstandingRequirement = (auth, location, key, loginCommand) => {
|
|
113
|
+
if (auth.requirements.length === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
// The store *and* the environment, because that is what the request path counts. `applyStoredCredentials`
|
|
116
|
+
// treats a non-blank environment variable as an explicit value and never consults the store for
|
|
117
|
+
// that key, so a user who had exported one half of an AND alternative would otherwise be sent to
|
|
118
|
+
// obtain a credential their very next command was going to send anyway.
|
|
119
|
+
const held = new Set(Object.keys((0, credentials_1.readProfile)(location, key).credentials ?? {}));
|
|
120
|
+
for (const [clientKey, name] of Object.entries(auth.envByKey)) {
|
|
121
|
+
if ((process.env[name] ?? '').trim())
|
|
122
|
+
held.add(clientKey);
|
|
123
|
+
}
|
|
124
|
+
if (auth.requirements.some((keys) => keys.every((candidate) => held.has(candidate))))
|
|
125
|
+
return undefined;
|
|
126
|
+
const missing = auth.requirements
|
|
127
|
+
.map((keys) => keys.filter((candidate) => !held.has(candidate)))
|
|
128
|
+
.filter((keys) => keys.length > 0)
|
|
129
|
+
.sort((left, right) => left.length - right.length)[0];
|
|
130
|
+
if (!missing || missing.length === 0)
|
|
131
|
+
return undefined;
|
|
132
|
+
// A key with no method behind it cannot be asked for, so it is described rather than commanded.
|
|
133
|
+
// Deduplicated by method, because HTTP Basic fills two keys in one sitting and would otherwise be
|
|
134
|
+
// offered twice for the one sign-in that satisfies both.
|
|
135
|
+
const names = new Set();
|
|
136
|
+
const runnable = [];
|
|
137
|
+
for (const candidate of missing) {
|
|
138
|
+
const method = auth.methods.find((entry) => methodKeys(entry).includes(candidate));
|
|
139
|
+
if (!method || names.has(method.name))
|
|
140
|
+
continue;
|
|
141
|
+
names.add(method.name);
|
|
142
|
+
runnable.push(method);
|
|
143
|
+
}
|
|
144
|
+
if (runnable.length === 0)
|
|
145
|
+
return 'Requests here also need another credential this CLI cannot obtain.';
|
|
146
|
+
const commands = runnable.map((method) => loginCommand + ' --flow ' + method.name);
|
|
147
|
+
return ('Requests here also need ' +
|
|
148
|
+
(runnable.length === 1 ? 'one more credential' : String(runnable.length) + ' more credentials') +
|
|
149
|
+
'. Run ' +
|
|
150
|
+
commands.join(', then ') +
|
|
151
|
+
'.');
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Runs an interactive sign-in and saves the result, returning the line to print.
|
|
155
|
+
*
|
|
156
|
+
* Every prompt is written to standard error, so the confirmation this returns is the only thing on
|
|
157
|
+
* standard output and `login` stays usable in a pipeline.
|
|
158
|
+
*
|
|
159
|
+
* `loginCommand` is the sign-in command for the environment this one selected (the bare command
|
|
160
|
+
* when none was); it names the next step when the API needs a further credential.
|
|
161
|
+
*/
|
|
162
|
+
const runLogin = async (auth, baseUrl, flow, loginCommand) => {
|
|
163
|
+
const method = await chooseMethod(auth, flow);
|
|
164
|
+
const location = storeLocation(auth);
|
|
165
|
+
const key = (0, credentials_1.profileKey)(baseUrl);
|
|
166
|
+
const captured = await captureCredentials(method, baseUrl, auth.logo);
|
|
167
|
+
// Where it landed is reported by the write itself rather than predicted, so under `auto` the
|
|
168
|
+
// line a user reads names the store the credential actually went into. The write merges this
|
|
169
|
+
// captured result inside its lock, because a browser grant can sit for minutes before it arrives.
|
|
170
|
+
const backend = (0, credentials_1.writeProfile)(location, key, captured);
|
|
171
|
+
const line = 'Signed in to ' +
|
|
172
|
+
// The base URL can be the document's own `servers[0].url` when no flag and no environment
|
|
173
|
+
// variable named one, which makes it spec-derived text on its way to a terminal.
|
|
174
|
+
safeText(baseUrl || 'the API') +
|
|
175
|
+
'. Credentials saved to ' +
|
|
176
|
+
(0, credentials_1.storeDescription)(location, backend) +
|
|
177
|
+
'.';
|
|
178
|
+
const outstanding = outstandingRequirement(auth, location, key, loginCommand);
|
|
179
|
+
return outstanding ? line + '\n' + outstanding : line;
|
|
180
|
+
};
|
|
181
|
+
exports.runLogin = runLogin;
|
|
182
|
+
/**
|
|
183
|
+
* Forgets stored credentials and revokes their OAuth tokens, returning the line to print.
|
|
184
|
+
*
|
|
185
|
+
* The local copy goes first, under the store lock, and only then is the provider asked to revoke.
|
|
186
|
+
* That order is what makes an interrupted or offline `logout` safe: whatever happens on the
|
|
187
|
+
* network, nothing is left on disk, and the refresh path's conditional write will not bring a
|
|
188
|
+
* deleted profile back. Revocation is best effort — a failure is a warning, never a failed logout,
|
|
189
|
+
* because the user asked to be signed out here and that part has already succeeded.
|
|
190
|
+
*/
|
|
191
|
+
const runLogout = async (auth, baseUrl, all) => {
|
|
192
|
+
const location = storeLocation(auth);
|
|
193
|
+
// Only an OAuth sign-in stores anything revocable, so a CLI without one skips reading what a
|
|
194
|
+
// profile held and signs out exactly as it did before revocation existed.
|
|
195
|
+
const revocable = auth.methods.some((method) => method.kind === 'oauth');
|
|
196
|
+
if (all) {
|
|
197
|
+
const { removed, failure } = (0, credentials_1.clearAll)(location, revocable);
|
|
198
|
+
// Every profile's revocation runs at once, so a host that never answers costs its own timeout
|
|
199
|
+
// rather than one per host. Warnings are raised afterwards, in store order, so the output does
|
|
200
|
+
// not depend on which server answered first.
|
|
201
|
+
const outcomes = await Promise.all(removed.map(({ key, held }) => {
|
|
202
|
+
const base = profileBase(auth, key);
|
|
203
|
+
return revokeHeld(auth, base, base, held);
|
|
204
|
+
}));
|
|
205
|
+
for (const message of outcomes.flat())
|
|
206
|
+
(0, credentials_1.warn)(message);
|
|
207
|
+
if (failure)
|
|
208
|
+
throw new Error(failure);
|
|
209
|
+
return 'Signed out everywhere.';
|
|
210
|
+
}
|
|
211
|
+
const key = (0, credentials_1.profileKey)(baseUrl);
|
|
212
|
+
const { outcome, held } = (0, credentials_1.deleteProfile)(location, key, revocable);
|
|
213
|
+
// Named without any `user:password@` it carries, in the warnings and the result line alike, so
|
|
214
|
+
// the two still name the host the same way.
|
|
215
|
+
const shown = withoutCredentials(baseUrl);
|
|
216
|
+
for (const message of await revokeHeld(auth, baseUrl, shown, held))
|
|
217
|
+
(0, credentials_1.warn)(message);
|
|
218
|
+
if (outcome === 'removed')
|
|
219
|
+
return 'Signed out of ' + safeText(shown || 'the API') + '.';
|
|
220
|
+
return 'No stored credentials for ' + safeText(shown || 'the API') + '.';
|
|
221
|
+
};
|
|
222
|
+
exports.runLogout = runLogout;
|
|
223
|
+
/**
|
|
224
|
+
* The base URL a profile key stands for, for `logout --all`, which knows a profile only by its key.
|
|
225
|
+
*
|
|
226
|
+
* A profile with no stored revocation endpoint falls back to the configured one, resolved against
|
|
227
|
+
* the base URL as sign-in resolves its endpoints. The key drops a trailing slash, which moves where
|
|
228
|
+
* a path-relative endpoint lands (`oauth/revoke` beside `https://api.example.com/v1/` is under
|
|
229
|
+
* `/v1/`, beside the key it is not), so a base URL this run knows that files under this key is
|
|
230
|
+
* used when there is one: the base-URL environment variable if it is still set, the configured
|
|
231
|
+
* default, or an environment URL. The variable comes first because sign-in prefers it to the
|
|
232
|
+
* default, which matters when the two differ only in a trailing slash and so share a key. Only a
|
|
233
|
+
* sign-in under a `--base-url` or a variable since unset falls back to the key.
|
|
234
|
+
*
|
|
235
|
+
* Credentials in the base URL are dropped, as `profileKey` drops them: the result is printed in
|
|
236
|
+
* warnings, and an endpoint resolved against it would carry them into a request `fetch` refuses.
|
|
237
|
+
*/
|
|
238
|
+
const profileBase = (auth, key) => {
|
|
239
|
+
if (key === 'default')
|
|
240
|
+
return '';
|
|
241
|
+
const known = [
|
|
242
|
+
(auth.baseUrlEnv ? process.env[auth.baseUrlEnv] : undefined)?.trim(),
|
|
243
|
+
auth.defaultBaseUrl,
|
|
244
|
+
...(auth.environments ?? []).map((environment) => environment.url),
|
|
245
|
+
];
|
|
246
|
+
const base = known.find((url) => url && (0, credentials_1.profileKey)(url) === key);
|
|
247
|
+
return base ? withoutCredentials(base) : key;
|
|
248
|
+
};
|
|
249
|
+
/** A URL with any `user:password@` removed, or the value unchanged when it does not parse. */
|
|
250
|
+
const withoutCredentials = (raw) => {
|
|
251
|
+
try {
|
|
252
|
+
const url = new URL(raw);
|
|
253
|
+
// Rewritten only when there is something to drop: serializing adds a trailing slash to a bare
|
|
254
|
+
// origin, which would change how every warning names the host.
|
|
255
|
+
if (!url.username && !url.password)
|
|
256
|
+
return raw;
|
|
257
|
+
url.username = '';
|
|
258
|
+
url.password = '';
|
|
259
|
+
return url.toString();
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return raw;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* The revocation endpoint to keep for a token, or `undefined` for a local-only sign-out.
|
|
267
|
+
*
|
|
268
|
+
* Each candidate is resolved against `base` exactly as the token endpoint is, and the first one on
|
|
269
|
+
* the same origin as the endpoint that issued the token wins. `revocationUrl` is one value for
|
|
270
|
+
* every OAuth scheme in the CLI, so without that check a token from one provider would be posted
|
|
271
|
+
* to another's revocation endpoint. The discovered endpoint is held to the same rule: every
|
|
272
|
+
* provider that advertises one serves it beside its token endpoint, and a sign-out that stays
|
|
273
|
+
* local is a smaller loss than a token sent somewhere that did not issue it.
|
|
274
|
+
*/
|
|
275
|
+
const revocationEndpoint = (candidates, tokenUrl, base) => {
|
|
276
|
+
const issuer = tokenUrl ? urlOrigin(tokenUrl, base) : undefined;
|
|
277
|
+
if (!issuer)
|
|
278
|
+
return undefined;
|
|
279
|
+
for (const candidate of candidates) {
|
|
280
|
+
const resolved = storedEndpoint(candidate, base);
|
|
281
|
+
if (resolved && urlOrigin(resolved, '') === issuer)
|
|
282
|
+
return resolved;
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
};
|
|
286
|
+
/** A URL's origin, or `undefined` when it cannot be parsed or has none (an opaque `null` origin). */
|
|
287
|
+
const urlOrigin = (raw, base) => {
|
|
288
|
+
try {
|
|
289
|
+
const origin = new URL(raw, base || undefined).origin;
|
|
290
|
+
return origin === 'null' ? undefined : origin;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Revokes what one removed profile held, returning the warnings to raise.
|
|
298
|
+
*
|
|
299
|
+
* Each warning names the base URL, because under `logout --all` that is the only way to tell which
|
|
300
|
+
* sign-in may still be live at its provider. `label` is the one the success line prints for a
|
|
301
|
+
* single `logout`, so the two lines name the host the same way. `base` is what a fallback
|
|
302
|
+
* endpoint resolves against: the base URL a single `logout` was given, or {@link profileBase}.
|
|
303
|
+
*/
|
|
304
|
+
const revokeHeld = async (auth, base, label, held) => {
|
|
305
|
+
const where = 'Signed out of ' + safeText(label || 'the API') + ' locally, but ';
|
|
306
|
+
if (held.unreadable) {
|
|
307
|
+
// Said only where this build could have revoked something: a configured endpoint, or a
|
|
308
|
+
// provider whose published metadata (OpenID Connect discovery or RFC 8414) may name one.
|
|
309
|
+
// Anywhere else revocation never happens, and the keyring warning the removal already raised
|
|
310
|
+
// covers what is left to say.
|
|
311
|
+
const couldRevoke = auth.methods.some((method) => method.kind === 'oauth' &&
|
|
312
|
+
(method.revocationUrl !== undefined ||
|
|
313
|
+
method.grant === 'openIdConnect' ||
|
|
314
|
+
'metadataUrl' in method ||
|
|
315
|
+
('revocationMetadataUrl' in method && method.revocationMetadataUrl !== undefined)));
|
|
316
|
+
if (!couldRevoke)
|
|
317
|
+
return [];
|
|
318
|
+
return [
|
|
319
|
+
where +
|
|
320
|
+
'the saved credentials could not be read back, so no OAuth token in them was revoked at the provider.',
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
return revokeProfile(auth, base, held.profile, where);
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Revokes every OAuth token one removed profile held (RFC 7009), returning the warnings to raise.
|
|
327
|
+
*
|
|
328
|
+
* API keys and Basic credentials have nothing to revoke, so only entries with OAuth metadata are
|
|
329
|
+
* considered. The endpoint is the one stored at sign-in; a profile that stored none falls back to
|
|
330
|
+
* the one this build is configured with (see {@link fallbackRevocation}). With neither, the
|
|
331
|
+
* sign-out stays local, as it always was. `where` opens every warning.
|
|
332
|
+
*/
|
|
333
|
+
const revokeProfile = async (auth, base, profile, where) => {
|
|
334
|
+
const messages = [];
|
|
335
|
+
for (const [clientKey, meta] of Object.entries(profile.oauth ?? {})) {
|
|
336
|
+
// Same guard as `storedCredentials`: the store is parsed without checking the shape beneath.
|
|
337
|
+
if (!meta || typeof meta !== 'object')
|
|
338
|
+
continue;
|
|
339
|
+
const endpoint = meta.revocationUrl ?? fallbackRevocation(auth, clientKey, meta, base);
|
|
340
|
+
if (!endpoint)
|
|
341
|
+
continue;
|
|
342
|
+
const accessToken = profile.credentials?.[clientKey];
|
|
343
|
+
// Refresh token first: on most servers revoking it ends the whole grant, access tokens included,
|
|
344
|
+
// so it is the one that matters if only one call gets through.
|
|
345
|
+
const tokens = [
|
|
346
|
+
...(meta.refreshToken ? [{ token: meta.refreshToken, hint: 'refresh_token' }] : []),
|
|
347
|
+
...(accessToken ? [{ token: accessToken, hint: 'access_token' }] : []),
|
|
348
|
+
];
|
|
349
|
+
if (tokens.length === 0)
|
|
350
|
+
continue;
|
|
351
|
+
let url;
|
|
352
|
+
try {
|
|
353
|
+
// Checked here rather than at sign-in: this is the request that carries a token, and failing
|
|
354
|
+
// a login over an endpoint only `logout` uses would cost the user the sign-in itself.
|
|
355
|
+
url = requireSecureUrl(endpoint, base, 'revocation endpoint');
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
messages.push(where + 'the token was not revoked. ' + errorMessage(error));
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
let refreshRevoked = false;
|
|
363
|
+
for (const { token, hint } of tokens) {
|
|
364
|
+
await revokeToken(url, token, hint, meta.clientId, refreshRevoked);
|
|
365
|
+
if (hint === 'refresh_token')
|
|
366
|
+
refreshRevoked = true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
// One failure ends this profile's revocation rather than trying the next token: an endpoint
|
|
371
|
+
// that has just timed out or refused is unlikely to do otherwise a second later, and each try
|
|
372
|
+
// costs the user up to `REVOCATION_TIMEOUT_MS`.
|
|
373
|
+
messages.push(where + safeText(url.host) + ' could not revoke the token. ' + errorMessage(error));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return messages;
|
|
377
|
+
};
|
|
378
|
+
/**
|
|
379
|
+
* The configured revocation endpoint for a profile that stored none, or `undefined` to stay local.
|
|
380
|
+
*
|
|
381
|
+
* Reached by every profile that stored none: one saved before revocation existed, one whose
|
|
382
|
+
* sign-in had nothing configured — so a `revocationUrl` a later build adds still reaches it — and
|
|
383
|
+
* one whose sign-in rejected the configured endpoint. That last needs no mark of its own: the
|
|
384
|
+
* checks below are sign-in's rule, so the endpoint it rejected is rejected here too, and only a
|
|
385
|
+
* build whose configuration was since corrected revokes it.
|
|
386
|
+
*
|
|
387
|
+
* The endpoint, resolved against `base`, has to pass two origin checks. One is against the
|
|
388
|
+
* profile's saved refresh endpoint, the only endpoint an older profile recorded. The other is
|
|
389
|
+
* sign-in's own rule against the token endpoint, so a build never revokes an old sign-in somewhere
|
|
390
|
+
* it would refuse for a new one — a provider can serve refresh from another host than the one
|
|
391
|
+
* that issues tokens. A profile does not record which grant produced it, and every grant of one
|
|
392
|
+
* scheme shares its key, so the token endpoint of each of those grants has to pass. An OpenID
|
|
393
|
+
* Connect grant, or a device grant reading RFC 8414 metadata, learns its token endpoint at sign-in
|
|
394
|
+
* and saved that same URL as the refresh endpoint, so the first check covers it.
|
|
395
|
+
*/
|
|
396
|
+
const fallbackRevocation = (auth, clientKey, meta, base) => {
|
|
397
|
+
const grants = auth.methods.filter((method) => method.kind === 'oauth' && method.clientKey === clientKey);
|
|
398
|
+
// One configured value covers every OAuth method, so any grant that carries it names it.
|
|
399
|
+
const configured = grants.find((method) => method.revocationUrl)?.revocationUrl;
|
|
400
|
+
const endpoint = revocationEndpoint([configured], meta.refreshUrl ?? meta.tokenUrl, base);
|
|
401
|
+
if (!endpoint)
|
|
402
|
+
return undefined;
|
|
403
|
+
// OpenID Connect and device sign-in from metadata learn their token endpoint at sign-in and saved
|
|
404
|
+
// it as the refresh endpoint, which the check above already covers.
|
|
405
|
+
const issuedHere = grants.every((method) => !('tokenUrl' in method) || revocationEndpoint([configured], method.tokenUrl, base) !== undefined);
|
|
406
|
+
return issuedHere ? endpoint : undefined;
|
|
407
|
+
};
|
|
408
|
+
/**
|
|
409
|
+
* Sends one RFC 7009 revocation request, throwing a printable reason when it did not succeed.
|
|
410
|
+
*
|
|
411
|
+
* Only the client id goes with the token: the CLI keeps no client secret, so a provider that
|
|
412
|
+
* requires confidential-client authentication here — typical for tokens from the client-credentials
|
|
413
|
+
* grant — refuses, and that surfaces as the warning rather than as anything the CLI could fix.
|
|
414
|
+
*/
|
|
415
|
+
const revokeToken = async (url, token, hint, clientId, refreshRevoked) => {
|
|
416
|
+
const { response, text } = await postForToken(url, { token, token_type_hint: hint, ...(clientId ? { client_id: clientId } : {}) }, REVOCATION_TIMEOUT_MS, 'revocation endpoint');
|
|
417
|
+
// Section 2.2: a 200 is the answer for an invalid or already-revoked token too, so success says
|
|
418
|
+
// nothing about the token's prior state and needs nothing checked beyond the status.
|
|
419
|
+
if (response.ok)
|
|
420
|
+
return;
|
|
421
|
+
const payload = parseJson(text);
|
|
422
|
+
// Section 2.2.1 lets a server that only revokes refresh tokens answer this for an access token.
|
|
423
|
+
// When this call already revoked the refresh token, that ended the grant and there is nothing to
|
|
424
|
+
// report. With no refresh token (a client-credentials grant never gets one) the access token is
|
|
425
|
+
// the whole grant and is still live, so the refusal is a failure like any other.
|
|
426
|
+
if (hint === 'access_token' && refreshRevoked && payload?.['error'] === 'unsupported_token_type')
|
|
427
|
+
return;
|
|
428
|
+
throw new Error('The revocation endpoint rejected the request' + tokenErrorDetail(payload, response.status));
|
|
429
|
+
};
|
|
430
|
+
/** The message of a caught value, which every throw in this module makes an `Error` with safe text. */
|
|
431
|
+
const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
|
|
432
|
+
/**
|
|
433
|
+
* Stored credentials for one base URL, keyed by client-option name.
|
|
434
|
+
*
|
|
435
|
+
* An OAuth access token at or near its expiry is refreshed in place first, so a CLI left alone
|
|
436
|
+
* overnight keeps working without a second `login`. A refresh that fails is swallowed: the stale
|
|
437
|
+
* token still goes out, and the 401 that follows carries the runtime's own sign-in hint, which
|
|
438
|
+
* says more than a refresh error would.
|
|
439
|
+
*
|
|
440
|
+
* Refresh exchanges may overlap, but their results merge into the current profile under the same
|
|
441
|
+
* lock as interactive sign-in. A server that rotates refresh tokens can still reject one of two
|
|
442
|
+
* simultaneous exchanges; that is recoverable with one `login` and never loses another flow's key.
|
|
443
|
+
*/
|
|
444
|
+
const storedCredentials = async (auth, baseUrl) => {
|
|
445
|
+
const location = storeLocation(auth);
|
|
446
|
+
const key = (0, credentials_1.profileKey)(baseUrl);
|
|
447
|
+
const profile = (0, credentials_1.readProfile)(location, key);
|
|
448
|
+
const credentials = { ...(profile.credentials ?? {}) };
|
|
449
|
+
for (const [clientKey, meta] of Object.entries(profile.oauth ?? {})) {
|
|
450
|
+
// `readProfile` checks that the stored value parses and is an object, but not the shape beneath
|
|
451
|
+
// it, so an entry this CLI did not write can still be `null` or a scalar. Reading `expiresAt` off
|
|
452
|
+
// one would throw on the request path of *every* command — the exact failure the corrupt-store
|
|
453
|
+
// handling exists to avoid, and worse than it, since a bad OAuth entry would also take out the
|
|
454
|
+
// commands that need no credential at all. Skipping it falls back to `login`, as a missing
|
|
455
|
+
// entry does.
|
|
456
|
+
if (!meta || typeof meta !== 'object')
|
|
457
|
+
continue;
|
|
458
|
+
// Older generated CLIs stored this as `tokenUrl`; retaining that fallback makes their saved
|
|
459
|
+
// credentials refreshable after an upgrade while new entries preserve a distinct refresh URL.
|
|
460
|
+
const refreshUrl = meta.refreshUrl ?? meta.tokenUrl;
|
|
461
|
+
if (!needsRefresh(meta) || !meta.refreshToken || !refreshUrl)
|
|
462
|
+
continue;
|
|
463
|
+
// The target API is read from this CLI's own definition for the key, not from the stored entry,
|
|
464
|
+
// which never held it: it is configuration rather than something a sign-in produced, so the
|
|
465
|
+
// refresh follows the CLI the user is running now. A key belongs to exactly one security scheme
|
|
466
|
+
// (the emitter gives each scheme its own option name), and every OAuth method of that scheme
|
|
467
|
+
// carries the scheme's one value, so the first is as good as any.
|
|
468
|
+
const target = auth.methods.find((method) => method.kind === 'oauth' && method.clientKey === clientKey);
|
|
469
|
+
try {
|
|
470
|
+
const token = await exchangeToken(refreshUrl, baseUrl, {
|
|
471
|
+
grant_type: 'refresh_token',
|
|
472
|
+
refresh_token: meta.refreshToken,
|
|
473
|
+
...(meta.clientId ? { client_id: meta.clientId } : {}),
|
|
474
|
+
...(target?.kind === 'oauth' ? targetParams(target) : {}),
|
|
475
|
+
}, SILENT_REFRESH_TIMEOUT_MS);
|
|
476
|
+
credentials[clientKey] = token.accessToken;
|
|
477
|
+
// Conditional, not `writeProfile`: a `logout` that landed while the exchange was in flight
|
|
478
|
+
// must not be undone by the token it produced. See `refreshProfile`.
|
|
479
|
+
(0, credentials_1.refreshProfile)(location, key, {
|
|
480
|
+
credentials: { [clientKey]: token.accessToken },
|
|
481
|
+
oauth: {
|
|
482
|
+
[clientKey]: oauthMetadata(token, refreshUrl, meta.clientId, meta.refreshToken, meta.revocationUrl),
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// Deliberately silent; see this function's doc comment.
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return credentials;
|
|
491
|
+
};
|
|
492
|
+
exports.storedCredentials = storedCredentials;
|
|
493
|
+
const needsRefresh = (meta) => meta.expiresAt !== undefined && meta.expiresAt - Date.now() < REFRESH_LEEWAY_MS;
|
|
494
|
+
/**
|
|
495
|
+
* Picks the sign-in method to run.
|
|
496
|
+
*
|
|
497
|
+
* A named flow is matched exactly; otherwise a single method runs unprompted and several are put to
|
|
498
|
+
* the user. The picker needs a terminal, so a non-interactive shell is told to name a flow instead
|
|
499
|
+
* of being left waiting on stdin that will never arrive.
|
|
500
|
+
*/
|
|
501
|
+
const chooseMethod = async (auth, flow) => {
|
|
502
|
+
const names = auth.methods.map((method) => method.name);
|
|
503
|
+
if (flow !== undefined) {
|
|
504
|
+
const chosen = auth.methods.find((method) => method.name === flow);
|
|
505
|
+
if (!chosen)
|
|
506
|
+
throw new UsageError("Unknown sign-in flow '" + flow + "'. Available: " + names.join(', ') + '.');
|
|
507
|
+
return chosen;
|
|
508
|
+
}
|
|
509
|
+
const first = auth.methods[0];
|
|
510
|
+
if (!first)
|
|
511
|
+
throw new Error('This CLI has no sign-in flow.');
|
|
512
|
+
if (auth.methods.length === 1)
|
|
513
|
+
return first;
|
|
514
|
+
const index = await promptChoice('How would you like to sign in?', auth.methods.map((method) => ({ label: method.label, hint: method.name })));
|
|
515
|
+
// The picker only ever resolves an index it drew, so `first` stands in for a state that cannot
|
|
516
|
+
// occur rather than an error message describing one.
|
|
517
|
+
return auth.methods[index] ?? first;
|
|
518
|
+
};
|
|
519
|
+
const captureCredentials = async (method, baseUrl, logo) => {
|
|
520
|
+
if (method.kind === 'token') {
|
|
521
|
+
const value = await promptSecret(method.prompt);
|
|
522
|
+
if (!value)
|
|
523
|
+
throw new UsageError('No value entered; nothing was saved.');
|
|
524
|
+
return { credentials: { [method.clientKey]: value } };
|
|
525
|
+
}
|
|
526
|
+
if (method.kind === 'basic') {
|
|
527
|
+
const username = await promptLine('Username: ');
|
|
528
|
+
if (!username)
|
|
529
|
+
throw new UsageError('No username entered; nothing was saved.');
|
|
530
|
+
const password = await promptSecret('Password: ');
|
|
531
|
+
if (!password)
|
|
532
|
+
throw new UsageError('No password entered; nothing was saved.');
|
|
533
|
+
return { credentials: { [method.usernameKey]: username, [method.passwordKey]: password } };
|
|
534
|
+
}
|
|
535
|
+
const { token, clientId, refreshUrl, revocationUrl } = await runOauthFlow(method, baseUrl, logo);
|
|
536
|
+
return {
|
|
537
|
+
credentials: { [method.clientKey]: token.accessToken },
|
|
538
|
+
oauth: { [method.clientKey]: oauthMetadata(token, refreshUrl, clientId, undefined, revocationUrl) },
|
|
539
|
+
};
|
|
540
|
+
};
|
|
541
|
+
const oauthMetadata = (token, refreshUrl, clientId, previousRefreshToken, revocationUrl) => {
|
|
542
|
+
// A refresh response may legitimately omit `refresh_token`, which means "keep using the one you
|
|
543
|
+
// have" rather than "you no longer have one"; dropping it would turn every refresh into the last.
|
|
544
|
+
const refreshToken = token.refreshToken ?? previousRefreshToken;
|
|
545
|
+
// `expires_in` is OPTIONAL in RFC 6749 §5.1, and an absent one used to mean no `expiresAt`, which
|
|
546
|
+
// `needsRefresh` reads as "never expires" — so a provider that omits it left the refresh token
|
|
547
|
+
// stored and never used, and the user re-ran the whole flow on every expiry. When there is
|
|
548
|
+
// something to refresh with, an unstated lifetime is assumed to be the hour nearly every provider
|
|
549
|
+
// issues; refreshing earlier than necessary costs one request, never refreshing costs the feature.
|
|
550
|
+
const lifetime = token.expiresIn ?? (refreshToken ? ASSUMED_TOKEN_LIFETIME_S : undefined);
|
|
551
|
+
return {
|
|
552
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
553
|
+
...(lifetime === undefined ? {} : { expiresAt: Date.now() + lifetime * 1000 }),
|
|
554
|
+
refreshUrl,
|
|
555
|
+
...(clientId ? { clientId } : {}),
|
|
556
|
+
// Carried through every refresh too, so a token renewed in place is still revoked where it was issued.
|
|
557
|
+
...(revocationUrl ? { revocationUrl } : {}),
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
/**
|
|
561
|
+
* Runs one OAuth grant and picks the revocation endpoint to store beside its token.
|
|
562
|
+
*
|
|
563
|
+
* OpenID Connect picks its own, since only discovery names its token endpoint; every other grant is
|
|
564
|
+
* decided here, after the grant succeeded. The configured endpoint comes first, then whatever the
|
|
565
|
+
* scheme's RFC 8414 metadata advertises — read during a device sign-in that took its endpoints from
|
|
566
|
+
* there, otherwise read now by {@link metadataRevocation} — and both are held to the origin of the
|
|
567
|
+
* token endpoint that issued the token.
|
|
568
|
+
*/
|
|
569
|
+
const runOauthFlow = async (method, baseUrl, logo) => {
|
|
570
|
+
if (method.grant === 'openIdConnect')
|
|
571
|
+
return openIdConnectFlow(method, baseUrl, logo);
|
|
572
|
+
const result = await runTokenGrant(method, baseUrl, logo);
|
|
573
|
+
// A device sign-in from metadata names no token endpoint up front; the one it discovered is the
|
|
574
|
+
// refresh endpoint it returned.
|
|
575
|
+
const tokenUrl = 'tokenUrl' in method ? method.tokenUrl : result.refreshUrl;
|
|
576
|
+
const discovered = result.revocationUrl
|
|
577
|
+
? { revocationUrl: result.revocationUrl }
|
|
578
|
+
: await metadataRevocation(method, tokenUrl, baseUrl);
|
|
579
|
+
return {
|
|
580
|
+
...result,
|
|
581
|
+
revocationUrl: signInRevocation(method.revocationUrl, discovered, tokenUrl, baseUrl),
|
|
582
|
+
};
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* The revocation endpoint a scheme's RFC 8414 metadata advertises, read once a grant has succeeded.
|
|
586
|
+
*
|
|
587
|
+
* Read only when the configured endpoint does not already qualify, so a sign-in the config covers
|
|
588
|
+
* costs no round trip. It passes the same issuer checks as a device sign-in from that metadata.
|
|
589
|
+
* Unlike that sign-in, nothing but revocation depends on it, so a failed read never fails the
|
|
590
|
+
* sign-in: it warns, since `logout` will then only forget the token locally, and the user should
|
|
591
|
+
* hear so at the one point they are watching — {@link signInRevocation} says so, with the reason
|
|
592
|
+
* returned here, in the one warning it raises. For the same reason it gets the revocation request's
|
|
593
|
+
* short deadline rather than a token request's: the grant has already succeeded, its token is not
|
|
594
|
+
* saved until this returns, and a provider that never answers must not hold that up.
|
|
595
|
+
*/
|
|
596
|
+
const metadataRevocation = async (method, tokenUrl, baseUrl) => {
|
|
597
|
+
if (!('revocationMetadataUrl' in method) || !method.revocationMetadataUrl)
|
|
598
|
+
return {};
|
|
599
|
+
if (revocationEndpoint([method.revocationUrl], tokenUrl, baseUrl) !== undefined)
|
|
600
|
+
return {};
|
|
601
|
+
try {
|
|
602
|
+
const { payload, issuerUrl } = await readAuthorizationServerMetadata({ metadataUrl: method.revocationMetadataUrl, issuer: method.issuer }, baseUrl, REVOCATION_TIMEOUT_MS);
|
|
603
|
+
return { revocationUrl: advertisedRevocation(payload, issuerUrl) };
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
return { failure: errorMessage(error) };
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
/**
|
|
610
|
+
* A metadata document's `revocation_endpoint` (RFC 8414 section 2), resolved against its verified
|
|
611
|
+
* issuer, or `undefined` when it names none.
|
|
612
|
+
*
|
|
613
|
+
* Optional, and only `logout` uses it, so a missing or odd one is left out rather than failing the
|
|
614
|
+
* sign-in, and its HTTPS check waits for the revocation itself.
|
|
615
|
+
*/
|
|
616
|
+
const advertisedRevocation = (payload, issuerUrl) => {
|
|
617
|
+
const advertised = payload['revocation_endpoint'];
|
|
618
|
+
return typeof advertised === 'string' && advertised
|
|
619
|
+
? storedEndpoint(advertised, issuerUrl.toString())
|
|
620
|
+
: undefined;
|
|
621
|
+
};
|
|
622
|
+
/** Runs a grant whose token endpoint the document names, returning the token and how to refresh it. */
|
|
623
|
+
const runTokenGrant = async (method, baseUrl, logo) => {
|
|
624
|
+
if (method.grant === 'authorizationCode') {
|
|
625
|
+
const result = await authorizationCodeFlow(method, baseUrl, logo);
|
|
626
|
+
return { ...result, refreshUrl: method.refreshUrl };
|
|
627
|
+
}
|
|
628
|
+
if (method.grant === 'deviceAuthorization')
|
|
629
|
+
return deviceAuthorizationFlow(method, baseUrl);
|
|
630
|
+
if (method.grant === 'clientCredentials') {
|
|
631
|
+
const clientId = method.clientId ?? (await promptLine('Client id: '));
|
|
632
|
+
if (!clientId)
|
|
633
|
+
throw new UsageError('No client id entered; nothing was saved.');
|
|
634
|
+
const clientSecret = await promptSecret('Client secret: ');
|
|
635
|
+
if (!clientSecret)
|
|
636
|
+
throw new UsageError('No client secret entered; nothing was saved.');
|
|
637
|
+
const token = await exchangeToken(method.tokenUrl, baseUrl, {
|
|
638
|
+
grant_type: 'client_credentials',
|
|
639
|
+
client_id: clientId,
|
|
640
|
+
client_secret: clientSecret,
|
|
641
|
+
...scopeParam(method.scopes),
|
|
642
|
+
...targetParams(method),
|
|
643
|
+
});
|
|
644
|
+
return { token, clientId, refreshUrl: method.refreshUrl };
|
|
645
|
+
}
|
|
646
|
+
// Asked for the same way the client-credentials grant above asks, so a provider that requires a
|
|
647
|
+
// client id on this grant — which a public client's does — can be signed into without the value
|
|
648
|
+
// having to be baked into the generated source. It stays optional, unlike there: RFC 6749 lets a
|
|
649
|
+
// client that authenticates some other way leave it out, so a blank answer omits the parameter
|
|
650
|
+
// rather than sending an empty one, and the prompt says which it is.
|
|
651
|
+
const clientId = method.clientId ?? (await promptLine('Client id (optional): '));
|
|
652
|
+
const username = await promptLine('Username: ');
|
|
653
|
+
if (!username)
|
|
654
|
+
throw new UsageError('No username entered; nothing was saved.');
|
|
655
|
+
const password = await promptSecret('Password: ');
|
|
656
|
+
if (!password)
|
|
657
|
+
throw new UsageError('No password entered; nothing was saved.');
|
|
658
|
+
const token = await exchangeToken(method.tokenUrl, baseUrl, {
|
|
659
|
+
grant_type: 'password',
|
|
660
|
+
username,
|
|
661
|
+
password,
|
|
662
|
+
...(clientId ? { client_id: clientId } : {}),
|
|
663
|
+
...scopeParam(method.scopes),
|
|
664
|
+
...targetParams(method),
|
|
665
|
+
});
|
|
666
|
+
// Persisted so the refresh exchange can send the same client id the grant was made with; a blank
|
|
667
|
+
// answer stores nothing, which is what an absent one already did.
|
|
668
|
+
return { token, clientId: clientId || undefined, refreshUrl: method.refreshUrl };
|
|
669
|
+
};
|
|
670
|
+
/**
|
|
671
|
+
* Resolves an endpoint against the base URL it applies to, for storing beside the token.
|
|
672
|
+
*
|
|
673
|
+
* Deliberately not `requireSecureUrl`: nothing is sent to a revocation endpoint at sign-in, so a
|
|
674
|
+
* bad one must not cost the user the sign-in. `logout` checks it before a token goes out. An
|
|
675
|
+
* unparseable value yields `undefined`, so it is never stored; a configured one is reported at
|
|
676
|
+
* sign-in by {@link signInRevocation}, and a discovered one is optional and simply left out.
|
|
677
|
+
*/
|
|
678
|
+
const storedEndpoint = (raw, base) => {
|
|
679
|
+
if (!raw)
|
|
680
|
+
return undefined;
|
|
681
|
+
try {
|
|
682
|
+
const url = new URL(raw, base || undefined);
|
|
683
|
+
// A relative endpoint inherits credentials from a `https://user:pass@host/` base. Kept, they
|
|
684
|
+
// would sit in the store beside the token and make `fetch` refuse every revocation request.
|
|
685
|
+
url.username = '';
|
|
686
|
+
url.password = '';
|
|
687
|
+
return url.toString();
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return undefined;
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
/**
|
|
694
|
+
* The revocation endpoint to store with a token that sign-in has just obtained.
|
|
695
|
+
*
|
|
696
|
+
* Runs only once the grant succeeded, because it can warn, and a failed `login` drains warnings
|
|
697
|
+
* too. It warns when the configured endpoint cannot be used — unparseable, or off the token
|
|
698
|
+
* endpoint's origin — and nothing else qualified either, since `logout` will then only forget
|
|
699
|
+
* this sign-in locally, and the user should hear so while the configuration can still be fixed.
|
|
700
|
+
*
|
|
701
|
+
* It also warns when the provider's metadata could not be read for one, with nothing configured
|
|
702
|
+
* or alongside the configured endpoint's own reason, so both causes reach the user in one warning:
|
|
703
|
+
* each alone would point at only half of why `logout` will not revoke.
|
|
704
|
+
*
|
|
705
|
+
* Nothing stored means `logout` falls back to the configured endpoint under this same rule (see
|
|
706
|
+
* {@link fallbackRevocation}), so a rejected endpoint stays rejected there.
|
|
707
|
+
*/
|
|
708
|
+
const signInRevocation = (configured, discovered, tokenUrl, baseUrl) => {
|
|
709
|
+
const chosen = revocationEndpoint([configured, discovered.revocationUrl], tokenUrl, baseUrl);
|
|
710
|
+
if (chosen !== undefined)
|
|
711
|
+
return chosen;
|
|
712
|
+
const metadataReason = discovered.failure ? ' ' + discovered.failure : '';
|
|
713
|
+
if (!configured) {
|
|
714
|
+
if (discovered.failure)
|
|
715
|
+
(0, credentials_1.warn)('Logout will not revoke this sign-in at the provider.' + metadataReason);
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
const revocationOrigin = urlOrigin(configured, baseUrl);
|
|
719
|
+
const issuerOrigin = urlOrigin(tokenUrl, baseUrl);
|
|
720
|
+
(0, credentials_1.warn)((revocationOrigin === undefined || issuerOrigin === undefined
|
|
721
|
+
? 'The configured revocation endpoint is not a usable URL, so logout will not revoke this sign-in at the provider.'
|
|
722
|
+
: 'The configured revocation endpoint ' +
|
|
723
|
+
safeText(revocationOrigin) +
|
|
724
|
+
" is not on the token endpoint's origin " +
|
|
725
|
+
safeText(issuerOrigin) +
|
|
726
|
+
', so logout will not revoke this sign-in at the provider.') + metadataReason);
|
|
727
|
+
return undefined;
|
|
728
|
+
};
|
|
729
|
+
/**
|
|
730
|
+
* Runs the Device Authorization Grant without needing a loopback listener or a local browser.
|
|
731
|
+
*
|
|
732
|
+
* The device code is never printed: it is the credential that the token endpoint accepts. The
|
|
733
|
+
* user code, by contrast, is deliberately meant to be entered in a browser and is shown beside the
|
|
734
|
+
* verification URL. Polling follows RFC 8628's ‘authorization_pending’ and ‘slow_down’ signals
|
|
735
|
+
* instead of treating them as failed sign-ins.
|
|
736
|
+
*/
|
|
737
|
+
const deviceAuthorizationFlow = async (method, baseUrl) => {
|
|
738
|
+
// Discovered before the client id is asked for, so a provider that cannot run this grant fails
|
|
739
|
+
// the sign-in before the user has been asked to type anything.
|
|
740
|
+
const endpoints = 'metadataUrl' in method ? await discoverDeviceEndpoints(method, baseUrl) : method;
|
|
741
|
+
const clientId = method.clientId ?? (await promptLine('Client id: '));
|
|
742
|
+
if (!clientId)
|
|
743
|
+
throw new UsageError('No client id entered; nothing was saved.');
|
|
744
|
+
const device = await requestDeviceAuthorization(endpoints.deviceAuthorizationUrl, baseUrl, {
|
|
745
|
+
client_id: clientId,
|
|
746
|
+
...scopeParam(method.scopes),
|
|
747
|
+
...targetParams(method),
|
|
748
|
+
});
|
|
749
|
+
node_process_1.stderr.write('To sign in, visit:\n\n ' +
|
|
750
|
+
device.verificationUrl +
|
|
751
|
+
(device.userCode ? '\n\nThen enter code: ' + safeText(device.userCode) : '') +
|
|
752
|
+
'\n\nWaiting for authorization...\n');
|
|
753
|
+
return {
|
|
754
|
+
token: await pollDeviceToken(endpoints.tokenUrl, baseUrl, { client_id: clientId, ...targetParams(method) }, device),
|
|
755
|
+
clientId,
|
|
756
|
+
refreshUrl: endpoints.refreshUrl,
|
|
757
|
+
// From the metadata the endpoints came from, so revocation costs no second read of it.
|
|
758
|
+
...('revocationUrl' in endpoints && endpoints.revocationUrl
|
|
759
|
+
? { revocationUrl: endpoints.revocationUrl }
|
|
760
|
+
: {}),
|
|
761
|
+
};
|
|
762
|
+
};
|
|
763
|
+
/**
|
|
764
|
+
* Reads the device grant's endpoints from the authorization server's RFC 8414 metadata.
|
|
765
|
+
*
|
|
766
|
+
* Both come back absolute, resolved against the verified issuer, so the polls and every later
|
|
767
|
+
* refresh go to the provider that published them rather than to the API host.
|
|
768
|
+
*/
|
|
769
|
+
const discoverDeviceEndpoints = async (method, baseUrl) => {
|
|
770
|
+
const label = AUTHORIZATION_SERVER_METADATA;
|
|
771
|
+
const { payload, issuerUrl } = await readAuthorizationServerMetadata(method, baseUrl);
|
|
772
|
+
// RFC 8414 defaults an omitted `grant_types_supported` to the code and implicit grants, but
|
|
773
|
+
// providers that do run this grant routinely leave the list out, so only a list that is present
|
|
774
|
+
// and leaves the grant out is taken as a refusal. The endpoint below is the signal that counts.
|
|
775
|
+
const grants = payload['grant_types_supported'];
|
|
776
|
+
if (Array.isArray(grants) && !grants.includes(DEVICE_CODE_GRANT)) {
|
|
777
|
+
throw new Error('The ' + label + ' does not list the device authorization grant as supported.');
|
|
778
|
+
}
|
|
779
|
+
const deviceAuthorizationUrl = payload['device_authorization_endpoint'];
|
|
780
|
+
const tokenUrl = payload['token_endpoint'];
|
|
781
|
+
if (typeof deviceAuthorizationUrl !== 'string' || !deviceAuthorizationUrl) {
|
|
782
|
+
throw new Error('The ' + label + ' names no device authorization endpoint.');
|
|
783
|
+
}
|
|
784
|
+
if (typeof tokenUrl !== 'string' || !tokenUrl)
|
|
785
|
+
throw new Error('The ' + label + ' names no token endpoint.');
|
|
786
|
+
const token = requireSecureUrl(tokenUrl, issuerUrl.toString(), 'token endpoint').toString();
|
|
787
|
+
const revocationUrl = advertisedRevocation(payload, issuerUrl);
|
|
788
|
+
return {
|
|
789
|
+
deviceAuthorizationUrl: requireSecureUrl(deviceAuthorizationUrl, issuerUrl.toString(), 'device authorization endpoint').toString(),
|
|
790
|
+
tokenUrl: token,
|
|
791
|
+
refreshUrl: token,
|
|
792
|
+
...(revocationUrl ? { revocationUrl } : {}),
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
const AUTHORIZATION_SERVER_METADATA = 'authorization server metadata';
|
|
796
|
+
/**
|
|
797
|
+
* Reads a scheme's RFC 8414 metadata and holds it to its issuer: the one its URL was built from
|
|
798
|
+
* (section 3.3), and the configured issuer when there is one.
|
|
799
|
+
*
|
|
800
|
+
* Shared by device sign-in, which takes its endpoints from the document, and by revocation, which
|
|
801
|
+
* takes only `revocation_endpoint`, so the two cannot disagree about which documents to trust.
|
|
802
|
+
*/
|
|
803
|
+
const readAuthorizationServerMetadata = async (method, baseUrl, timeoutMs) => {
|
|
804
|
+
const label = AUTHORIZATION_SERVER_METADATA;
|
|
805
|
+
const { payload, issuer, issuerUrl } = await readServerMetadata(method.metadataUrl, baseUrl, { document: label, issuer: 'authorization server issuer' }, metadataIssuer, timeoutMs);
|
|
806
|
+
if (method.issuer !== undefined && !sameIssuer(issuer, method.issuer)) {
|
|
807
|
+
throw new Error('The ' +
|
|
808
|
+
label +
|
|
809
|
+
' is issued by ' +
|
|
810
|
+
safeText(issuer) +
|
|
811
|
+
', not the configured issuer ' +
|
|
812
|
+
safeText(method.issuer) +
|
|
813
|
+
'.');
|
|
814
|
+
}
|
|
815
|
+
return { payload, issuerUrl };
|
|
816
|
+
};
|
|
817
|
+
/**
|
|
818
|
+
* Discovers OpenID Connect endpoints, then uses the same PKCE flow as a declared authorization code
|
|
819
|
+
* grant — holding the redirect's `iss` and the ID token to the issuer discovery verified.
|
|
820
|
+
*/
|
|
821
|
+
const openIdConnectFlow = async (method, baseUrl, logo) => {
|
|
822
|
+
const endpoints = await discoverOpenIdConnect(method.discoveryUrl, baseUrl);
|
|
823
|
+
// A configured issuer is the publisher saying which provider this CLI signs in with. Discovery
|
|
824
|
+
// only proves the document agrees with the URL it came from, and that URL is spec-derived, so a
|
|
825
|
+
// document that points somewhere else is refused here rather than trusted for the rest of the flow.
|
|
826
|
+
if (method.issuer !== undefined && !sameIssuer(endpoints.issuer, method.issuer)) {
|
|
827
|
+
throw new Error('The OpenID Connect discovery document is issued by ' +
|
|
828
|
+
safeText(endpoints.issuer) +
|
|
829
|
+
', not the configured issuer ' +
|
|
830
|
+
safeText(method.issuer) +
|
|
831
|
+
'.');
|
|
832
|
+
}
|
|
833
|
+
// Same randomness as `state`. `state` ties the redirect to this process; the nonce ties the ID
|
|
834
|
+
// token to it, so a token minted for some other sign-in cannot be replayed into this one.
|
|
835
|
+
const nonce = base64Url((0, node_crypto_1.randomBytes)(16));
|
|
836
|
+
const result = await authorizationCodeFlow({
|
|
837
|
+
...method,
|
|
838
|
+
grant: 'authorizationCode',
|
|
839
|
+
authorizationUrl: endpoints.authorizationUrl,
|
|
840
|
+
tokenUrl: endpoints.tokenUrl,
|
|
841
|
+
refreshUrl: endpoints.tokenUrl,
|
|
842
|
+
issuer: endpoints.issuer,
|
|
843
|
+
}, baseUrl, logo, {
|
|
844
|
+
// Discovery always yields an issuer to compare a received `iss` with, but only a configured
|
|
845
|
+
// issuer or the provider's own advertisement makes a *missing* one a failure: RFC 9207 lets a
|
|
846
|
+
// server that never promised the parameter leave it out.
|
|
847
|
+
issRequired: method.issuer !== undefined || endpoints.issParameterSupported,
|
|
848
|
+
nonce,
|
|
849
|
+
// Run inside the code flow, while the browser is still waiting on its page, so a refused ID
|
|
850
|
+
// token is what the browser is told about too. It also runs before `login` writes anything:
|
|
851
|
+
// a token that fails is not stored, and neither is the access token that arrived beside it.
|
|
852
|
+
verify: (token) => {
|
|
853
|
+
if (token.idToken !== undefined) {
|
|
854
|
+
verifyIdToken(token.idToken, { issuer: endpoints.issuer, clientId: method.clientId, nonce });
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
// A configured endpoint wins over the discovered one when it sits beside the token endpoint
|
|
859
|
+
// discovery named; the discovered one was already resolved against the issuer.
|
|
860
|
+
const revocationUrl = signInRevocation(method.revocationUrl, { revocationUrl: endpoints.revocationUrl }, endpoints.tokenUrl, baseUrl);
|
|
861
|
+
return { ...result, refreshUrl: endpoints.tokenUrl, revocationUrl };
|
|
862
|
+
};
|
|
863
|
+
/**
|
|
864
|
+
* Checks the claims of an ID token the token endpoint returned, per OpenID Connect Core 3.1.3.7.
|
|
865
|
+
*
|
|
866
|
+
* The signature is deliberately not verified. Section 3.1.3.7 lets TLS server validation stand in
|
|
867
|
+
* for it when the token is received directly from the token endpoint — which is the only way this
|
|
868
|
+
* flow receives one: `exchangeToken` refuses a plain-HTTP endpoint off loopback and follows no
|
|
869
|
+
* redirects. Verifying it anyway would mean fetching and caching the provider's JWKS and
|
|
870
|
+
* implementing each signing algorithm, in a runtime that ships with no dependencies. The claims are
|
|
871
|
+
* what bind the token to this sign-in, and those are checked in full.
|
|
872
|
+
*/
|
|
873
|
+
const verifyIdToken = (idToken, expected) => {
|
|
874
|
+
const claims = idTokenClaims(idToken);
|
|
875
|
+
const issuer = claims['iss'];
|
|
876
|
+
if (typeof issuer !== 'string' || !sameIssuer(issuer, expected.issuer)) {
|
|
877
|
+
throw new Error('The ID token was issued by ' +
|
|
878
|
+
(typeof issuer === 'string' ? safeText(issuer) || 'an empty issuer' : 'no issuer') +
|
|
879
|
+
', but this sign-in expected ' +
|
|
880
|
+
safeText(expected.issuer) +
|
|
881
|
+
'.');
|
|
882
|
+
}
|
|
883
|
+
const audience = claims['aud'];
|
|
884
|
+
const audiences = typeof audience === 'string' ? [audience] : Array.isArray(audience) ? audience : [];
|
|
885
|
+
if (!audiences.includes(expected.clientId)) {
|
|
886
|
+
throw new Error('The ID token is not addressed to this client (' + safeText(expected.clientId) + ').');
|
|
887
|
+
}
|
|
888
|
+
// With more than one audience, `azp` names the party the token was actually issued to; without
|
|
889
|
+
// it, a token minted for another client that merely lists this one could be passed off as ours.
|
|
890
|
+
// A present `azp` is held to the client id even for a single audience, as the spec recommends.
|
|
891
|
+
const party = claims['azp'];
|
|
892
|
+
if ((audiences.length > 1 || party !== undefined) && party !== expected.clientId) {
|
|
893
|
+
throw new Error(party === undefined
|
|
894
|
+
? 'The ID token lists several audiences but names no authorized party (azp).'
|
|
895
|
+
: 'The ID token was issued to another authorized party (azp) than this client.');
|
|
896
|
+
}
|
|
897
|
+
const expiry = claims['exp'];
|
|
898
|
+
if (typeof expiry !== 'number' || !Number.isFinite(expiry)) {
|
|
899
|
+
throw new Error('The ID token has no usable expiry.');
|
|
900
|
+
}
|
|
901
|
+
if (expiry * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= Date.now()) {
|
|
902
|
+
throw new Error('The ID token has expired.');
|
|
903
|
+
}
|
|
904
|
+
const nonce = claims['nonce'];
|
|
905
|
+
if (typeof nonce !== 'string' || !sameToken(nonce, expected.nonce)) {
|
|
906
|
+
throw new Error('The ID token does not carry the nonce this sign-in sent, so it may belong to another sign-in.');
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
/**
|
|
910
|
+
* Decodes the payload of a compact-serialized JWT into its claims, refusing anything malformed.
|
|
911
|
+
*
|
|
912
|
+
* Strict about the alphabet because Node's base64url decoder is not: it skips characters it does
|
|
913
|
+
* not recognise, so a mangled segment would otherwise decode into *something* rather than fail.
|
|
914
|
+
*/
|
|
915
|
+
const idTokenClaims = (idToken) => {
|
|
916
|
+
const malformed = new Error('The token endpoint returned a malformed ID token.');
|
|
917
|
+
const segments = idToken.split('.');
|
|
918
|
+
const payload = segments[1];
|
|
919
|
+
if (segments.length !== 3 || !payload || !/^[A-Za-z0-9_-]+$/u.test(payload))
|
|
920
|
+
throw malformed;
|
|
921
|
+
let claims;
|
|
922
|
+
try {
|
|
923
|
+
claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
throw malformed;
|
|
927
|
+
}
|
|
928
|
+
if (!claims || typeof claims !== 'object' || Array.isArray(claims))
|
|
929
|
+
throw malformed;
|
|
930
|
+
return claims;
|
|
931
|
+
};
|
|
932
|
+
/** The `scope` parameter, left off entirely when there is nothing to request. */
|
|
933
|
+
const scopeParam = (scopes) => scopes.length > 0 ? { scope: scopes.join(' ') } : {};
|
|
934
|
+
/**
|
|
935
|
+
* The parameters naming which API a token is for, spread into every request a grant makes.
|
|
936
|
+
*
|
|
937
|
+
* Every one — the authorization URL, the device authorization request, each token-endpoint call and
|
|
938
|
+
* the refresh — because providers differ in where they read it: some bind it to the authorization
|
|
939
|
+
* and check the token request against it, others look only at the token endpoint, and a request
|
|
940
|
+
* without it can come back with a token for a different API. Kept in one place so no grant can be
|
|
941
|
+
* written without it. Both are absent unless configured, which leaves an unconfigured CLI's
|
|
942
|
+
* requests exactly as they were.
|
|
943
|
+
*/
|
|
944
|
+
const targetParams = (target) => ({
|
|
945
|
+
...(target.resource && target.resource.length > 0 ? { resource: target.resource } : {}),
|
|
946
|
+
...(target.audience ? { audience: target.audience } : {}),
|
|
947
|
+
});
|
|
948
|
+
/**
|
|
949
|
+
* Writes grant fields into a query or form, one entry per value.
|
|
950
|
+
*
|
|
951
|
+
* Each key is cleared first, so a field replaces whatever the authorization URL already carried
|
|
952
|
+
* under that name — the same outcome `searchParams.set` gives a single value — while a list still
|
|
953
|
+
* repeats its key.
|
|
954
|
+
*/
|
|
955
|
+
const writeParams = (params, fields) => {
|
|
956
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
957
|
+
params.delete(key);
|
|
958
|
+
for (const entry of typeof value === 'string' ? [value] : value)
|
|
959
|
+
params.append(key, entry);
|
|
960
|
+
}
|
|
961
|
+
return params;
|
|
962
|
+
};
|
|
963
|
+
/**
|
|
964
|
+
* Starts a device grant and validates the response before anything is printed to the terminal.
|
|
965
|
+
*
|
|
966
|
+
* `fields` is the whole request body — the client id, the scope and the target API — so the caller
|
|
967
|
+
* that knows the method decides what the grant asks for.
|
|
968
|
+
*/
|
|
969
|
+
const requestDeviceAuthorization = async (endpoint, baseUrl, fields) => {
|
|
970
|
+
const url = requireSecureUrl(endpoint, baseUrl, 'device authorization endpoint');
|
|
971
|
+
const { response, text } = await postForToken(url, fields);
|
|
972
|
+
const payload = parseJson(text);
|
|
973
|
+
if (!response.ok) {
|
|
974
|
+
throw new Error('The device authorization endpoint rejected the request' + tokenErrorDetail(payload, response.status));
|
|
975
|
+
}
|
|
976
|
+
const deviceCode = payload?.['device_code'];
|
|
977
|
+
const verificationUri = payload?.['verification_uri_complete'] ?? payload?.['verification_uri'];
|
|
978
|
+
const userCode = payload?.['user_code'];
|
|
979
|
+
const expiresIn = payload?.['expires_in'];
|
|
980
|
+
const interval = payload?.['interval'];
|
|
981
|
+
if (typeof deviceCode !== 'string' ||
|
|
982
|
+
!deviceCode ||
|
|
983
|
+
typeof verificationUri !== 'string' ||
|
|
984
|
+
!verificationUri) {
|
|
985
|
+
throw new Error('The device authorization endpoint returned an incomplete response.');
|
|
986
|
+
}
|
|
987
|
+
if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
988
|
+
throw new Error('The device authorization endpoint returned no usable expiry.');
|
|
989
|
+
}
|
|
990
|
+
const verificationUrl = requireSecureUrl(verificationUri, baseUrl, 'device verification URL').toString();
|
|
991
|
+
return {
|
|
992
|
+
deviceCode,
|
|
993
|
+
verificationUrl,
|
|
994
|
+
...(typeof userCode === 'string' && userCode ? { userCode } : {}),
|
|
995
|
+
// Capped at this CLI's own ceiling rather than taken as given. `expires_in` comes from a
|
|
996
|
+
// spec-derived endpoint, so a hostile or simply wrong one answering `1e12` would make the only
|
|
997
|
+
// loop guard in `pollDeviceToken` unreachable and leave `login` waiting with no way out but
|
|
998
|
+
// Ctrl-C — the browser grant is bounded by `BROWSER_FLOW_TIMEOUT_MS` and this is its equal.
|
|
999
|
+
expiresAt: Date.now() + Math.min(expiresIn * 1000, DEVICE_FLOW_TIMEOUT_MS),
|
|
1000
|
+
// RFC 8628 defaults to five seconds. A server may shorten it — a local authorization server
|
|
1001
|
+
// answering instantly is the case that matters — but never below `DEVICE_POLL_FLOOR_MS`, and a
|
|
1002
|
+
// negative or non-finite value never shortens it at all. Honouring a literal zero polled with no
|
|
1003
|
+
// delay for the whole of `expires_in`: a request flood against someone else's token endpoint and
|
|
1004
|
+
// a pinned core here, for a grant that answers `authorization_pending` until the user acts.
|
|
1005
|
+
intervalMs: typeof interval === 'number' && Number.isFinite(interval) && interval >= 0
|
|
1006
|
+
? Math.max(interval * 1000, DEVICE_POLL_FLOOR_MS)
|
|
1007
|
+
: 5000,
|
|
1008
|
+
};
|
|
1009
|
+
};
|
|
1010
|
+
/**
|
|
1011
|
+
* Polls the token endpoint until the user authorizes, declines, or the device code expires.
|
|
1012
|
+
*
|
|
1013
|
+
* `fields` carries the client id and the target API, sent on every poll beside the device code.
|
|
1014
|
+
*/
|
|
1015
|
+
const pollDeviceToken = async (endpoint, baseUrl, fields, device) => {
|
|
1016
|
+
const url = requireSecureUrl(endpoint, baseUrl, 'token endpoint');
|
|
1017
|
+
let intervalMs = device.intervalMs;
|
|
1018
|
+
while (Date.now() < device.expiresAt) {
|
|
1019
|
+
await waitFor(Math.min(intervalMs, Math.max(0, device.expiresAt - Date.now())));
|
|
1020
|
+
if (Date.now() >= device.expiresAt)
|
|
1021
|
+
break;
|
|
1022
|
+
const { response, text } = await postForToken(url, {
|
|
1023
|
+
grant_type: DEVICE_CODE_GRANT,
|
|
1024
|
+
device_code: device.deviceCode,
|
|
1025
|
+
...fields,
|
|
1026
|
+
});
|
|
1027
|
+
const payload = parseJson(text);
|
|
1028
|
+
if (response.ok)
|
|
1029
|
+
return tokenResponse(payload);
|
|
1030
|
+
const error = payload?.['error'];
|
|
1031
|
+
if (error === 'authorization_pending')
|
|
1032
|
+
continue;
|
|
1033
|
+
if (error === 'slow_down') {
|
|
1034
|
+
intervalMs += 5000;
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (error === 'expired_token')
|
|
1038
|
+
throw new Error('The device code expired before authorization completed.');
|
|
1039
|
+
if (error === 'access_denied')
|
|
1040
|
+
throw new Error('The device authorization request was denied.');
|
|
1041
|
+
throw new Error('The token endpoint rejected the device code' + tokenErrorDetail(payload, response.status));
|
|
1042
|
+
}
|
|
1043
|
+
throw new Error('The device code expired before authorization completed.');
|
|
1044
|
+
};
|
|
1045
|
+
/** Waits between device-code polls without blocking the event loop that owns terminal I/O. */
|
|
1046
|
+
const waitFor = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
1047
|
+
/** The suffix OpenID Connect Discovery appends to an issuer to reach its configuration document. */
|
|
1048
|
+
const OPENID_CONFIGURATION_PATH = '/.well-known/openid-configuration';
|
|
1049
|
+
/**
|
|
1050
|
+
* The issuer a discovery URL was built from, per OpenID Connect Discovery section 4.
|
|
1051
|
+
*
|
|
1052
|
+
* The spec builds the URL by appending the well-known path to the issuer, so removing it again is
|
|
1053
|
+
* what the returned `issuer` has to match. A URL spelled any other way — a document naming a
|
|
1054
|
+
* configuration endpoint that is not derived from its issuer at all — yields nothing, and the
|
|
1055
|
+
* caller falls back to comparing origins, which still pins the answer to the host that was asked.
|
|
1056
|
+
*/
|
|
1057
|
+
const discoveryIssuer = (url) => url.pathname.endsWith(OPENID_CONFIGURATION_PATH)
|
|
1058
|
+
? url.origin + url.pathname.slice(0, -OPENID_CONFIGURATION_PATH.length)
|
|
1059
|
+
: undefined;
|
|
1060
|
+
/** Compares two issuer spellings, where a trailing slash is the one difference that is not one. */
|
|
1061
|
+
const sameIssuer = (left, right) => left.replace(/\/+$/u, '') === right.replace(/\/+$/u, '');
|
|
1062
|
+
/** The well-known name RFC 8414 registers for an OAuth authorization server's metadata. */
|
|
1063
|
+
const OAUTH_METADATA_PATH = '/.well-known/oauth-authorization-server';
|
|
1064
|
+
/**
|
|
1065
|
+
* The issuer an RFC 8414 metadata URL was built from, or `undefined` when its spelling does not say.
|
|
1066
|
+
*
|
|
1067
|
+
* RFC 8414 section 3.1 *inserts* the well-known path between the host and the issuer's own path
|
|
1068
|
+
* (`https://as.example/.well-known/oauth-authorization-server/tenant` for the issuer
|
|
1069
|
+
* `https://as.example/tenant`), and section 5 uses the same insertion for an OpenID Connect
|
|
1070
|
+
* configuration. OpenID Connect Discovery appends instead, so that spelling is accepted as well.
|
|
1071
|
+
*/
|
|
1072
|
+
const metadataIssuer = (url) => {
|
|
1073
|
+
for (const wellKnown of [OAUTH_METADATA_PATH, OPENID_CONFIGURATION_PATH]) {
|
|
1074
|
+
if (url.pathname === wellKnown || url.pathname.startsWith(wellKnown + '/')) {
|
|
1075
|
+
return url.origin + url.pathname.slice(wellKnown.length);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return discoveryIssuer(url);
|
|
1079
|
+
};
|
|
1080
|
+
/**
|
|
1081
|
+
* Fetches an authorization server's published metadata and verifies who published it.
|
|
1082
|
+
*
|
|
1083
|
+
* Both OpenID Connect Discovery (section 4.3) and RFC 8414 (section 3.3) require the document's
|
|
1084
|
+
* `issuer` to be the one its URL was built from. It is the check that keeps a provider from naming
|
|
1085
|
+
* somebody else's endpoints: without it the sign-in sends the user, and then the code or device
|
|
1086
|
+
* grant it obtains, wherever an untrusted document says. A URL whose spelling names no issuer is
|
|
1087
|
+
* held to its origin instead, which still pins the answer to the host that was asked.
|
|
1088
|
+
*/
|
|
1089
|
+
const readServerMetadata = async (metadataUrl, baseUrl, labels, issuerFor, timeoutMs) => {
|
|
1090
|
+
const url = requireSecureUrl(metadataUrl, baseUrl, labels.document);
|
|
1091
|
+
const { response, text } = await getJson(url, labels.document, timeoutMs);
|
|
1092
|
+
const payload = parseJson(text);
|
|
1093
|
+
if (!response.ok) {
|
|
1094
|
+
throw new Error('The ' + labels.document + ' could not be read' + tokenErrorDetail(payload, response.status));
|
|
1095
|
+
}
|
|
1096
|
+
const issuer = payload?.['issuer'];
|
|
1097
|
+
if (!payload || typeof issuer !== 'string' || !issuer) {
|
|
1098
|
+
throw new Error('The ' + labels.document + ' named no issuer.');
|
|
1099
|
+
}
|
|
1100
|
+
const expected = issuerFor(url);
|
|
1101
|
+
const issuerUrl = requireSecureUrl(issuer, '', labels.issuer);
|
|
1102
|
+
const matches = expected === undefined ? issuerUrl.origin === url.origin : sameIssuer(issuer, expected);
|
|
1103
|
+
if (!matches) {
|
|
1104
|
+
throw new Error('The ' +
|
|
1105
|
+
labels.document +
|
|
1106
|
+
' is issued by ' +
|
|
1107
|
+
safeText(issuer) +
|
|
1108
|
+
', which is not the provider it was fetched from.');
|
|
1109
|
+
}
|
|
1110
|
+
return { payload, issuer, issuerUrl };
|
|
1111
|
+
};
|
|
1112
|
+
/**
|
|
1113
|
+
* Fetches the OpenID Provider configuration and keeps what the browser flow needs: the two
|
|
1114
|
+
* endpoints PKCE uses, the issuer the document was verified to come from, and whether the provider
|
|
1115
|
+
* promises RFC 9207's `iss` on its redirects — plus the revocation endpoint `logout` uses when
|
|
1116
|
+
* the provider advertises one.
|
|
1117
|
+
*/
|
|
1118
|
+
const discoverOpenIdConnect = async (discoveryUrl, baseUrl) => {
|
|
1119
|
+
// Held to the appended spelling alone, which is the derivation the compiler's
|
|
1120
|
+
// `Config/OidcIssuerMismatch` check mirrors at generation time.
|
|
1121
|
+
const { payload, issuer, issuerUrl } = await readServerMetadata(discoveryUrl, baseUrl, { document: 'OpenID Connect discovery document', issuer: 'OpenID Connect issuer' }, discoveryIssuer);
|
|
1122
|
+
const authorizationUrl = payload['authorization_endpoint'];
|
|
1123
|
+
const tokenUrl = payload['token_endpoint'];
|
|
1124
|
+
if (typeof authorizationUrl !== 'string' || typeof tokenUrl !== 'string') {
|
|
1125
|
+
throw new Error('The OpenID Connect discovery document returned no usable authorization and token endpoints.');
|
|
1126
|
+
}
|
|
1127
|
+
// Resolved against the issuer, never the API base URL: a relative `/authorize` belongs to the
|
|
1128
|
+
// provider that answered, and resolving it against the API would point the browser — and the code
|
|
1129
|
+
// exchange after it — at the API host instead.
|
|
1130
|
+
const revocationUrl = advertisedRevocation(payload, issuerUrl);
|
|
1131
|
+
return {
|
|
1132
|
+
authorizationUrl: requireSecureUrl(authorizationUrl, issuerUrl.toString(), 'OpenID Connect authorization endpoint').toString(),
|
|
1133
|
+
tokenUrl: requireSecureUrl(tokenUrl, issuerUrl.toString(), 'OpenID Connect token endpoint').toString(),
|
|
1134
|
+
...(revocationUrl ? { revocationUrl } : {}),
|
|
1135
|
+
issuer,
|
|
1136
|
+
issParameterSupported: payload?.['authorization_response_iss_parameter_supported'] === true,
|
|
1137
|
+
};
|
|
1138
|
+
};
|
|
1139
|
+
/**
|
|
1140
|
+
* The browser sign-in: PKCE authorization code against a loopback redirect (RFC 8252).
|
|
1141
|
+
*
|
|
1142
|
+
* The CLI is a public client, so it holds no secret; PKCE is what stops an intercepted code from
|
|
1143
|
+
* being redeemed by anything but this process. The redirect listens on 127.0.0.1 rather than a
|
|
1144
|
+
* public interface, and the authorization URL is printed before the browser is opened so a headless
|
|
1145
|
+
* or remote shell can still complete the flow by hand.
|
|
1146
|
+
*
|
|
1147
|
+
* `options.issRequired` makes an `iss`-less redirect a failure; it defaults to whether an issuer
|
|
1148
|
+
* is known at all, which for this grant means one was configured. `options.nonce` is sent on the
|
|
1149
|
+
* authorize request, and only OpenID Connect sends one, since only it returns an ID token to hold
|
|
1150
|
+
* the nonce to. `options.verify` checks the exchanged token and throws to refuse it.
|
|
1151
|
+
*
|
|
1152
|
+
* The browser's request for the redirect is answered only after the code exchange and `verify`,
|
|
1153
|
+
* so it never shows "Signed in" for a token this flow goes on to refuse. (Saving the credential
|
|
1154
|
+
* comes later, in `runLogin`; a store that then refuses the write is reported in the terminal.)
|
|
1155
|
+
* The wait is bounded by the token request's own timeout. Afterwards no redirect is held any more,
|
|
1156
|
+
* and the listener is closed only once every answered response has finished sending — or its
|
|
1157
|
+
* browser has gone — or `RESPONSE_FLUSH_TIMEOUT_MS` has passed.
|
|
1158
|
+
*/
|
|
1159
|
+
const authorizationCodeFlow = async (method, baseUrl, logo, options = {}) => {
|
|
1160
|
+
const clientId = method.clientId;
|
|
1161
|
+
if (!clientId || !method.authorizationUrl)
|
|
1162
|
+
throw new Error('This flow needs a configured OAuth client id.');
|
|
1163
|
+
const authorizeUrl = requireSecureUrl(method.authorizationUrl, baseUrl, 'authorization endpoint');
|
|
1164
|
+
const verifier = base64Url((0, node_crypto_1.randomBytes)(32));
|
|
1165
|
+
const challenge = base64Url((0, node_crypto_1.createHash)('sha256').update(verifier).digest());
|
|
1166
|
+
const state = base64Url((0, node_crypto_1.randomBytes)(16));
|
|
1167
|
+
const server = (0, node_http_1.createServer)();
|
|
1168
|
+
// Bound first, and only then given its request handler. Registering the handler earlier meant a
|
|
1169
|
+
// bind failure (a configured `redirectPort` already in use) rejected two promises while only
|
|
1170
|
+
// `listen`'s was ever awaited, and the orphan took the process down with an unhandled rejection —
|
|
1171
|
+
// losing the friendly message and the auth exit status. Nothing can reach the port in between:
|
|
1172
|
+
// the browser has not been opened yet.
|
|
1173
|
+
// A configured port that is already taken is the one bind failure a user can act on, and Node's
|
|
1174
|
+
// raw `EADDRINUSE` names neither the setting that chose it nor the alternative. Port 0 lets the
|
|
1175
|
+
// OS pick, so it cannot reach this.
|
|
1176
|
+
const port = await listen(server, method.redirectPort ?? 0).catch((error) => {
|
|
1177
|
+
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
1178
|
+
if (code === 'EADDRINUSE' && method.redirectPort) {
|
|
1179
|
+
throw new Error('Port ' +
|
|
1180
|
+
String(method.redirectPort) +
|
|
1181
|
+
' is already in use, so the sign-in redirect could not be received. Free it, or remove the configured redirect port to let the operating system choose one.');
|
|
1182
|
+
}
|
|
1183
|
+
throw error;
|
|
1184
|
+
});
|
|
1185
|
+
const listener = { answered: [], closing: false };
|
|
1186
|
+
const redirect = awaitRedirect(server, state, REDIRECT_PATH, logo, { expected: method.issuer, required: options.issRequired ?? method.issuer !== undefined }, listener);
|
|
1187
|
+
const redirectUri = 'http://127.0.0.1:' + String(port) + REDIRECT_PATH;
|
|
1188
|
+
try {
|
|
1189
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
1190
|
+
authorizeUrl.searchParams.set('client_id', clientId);
|
|
1191
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
1192
|
+
authorizeUrl.searchParams.set('state', state);
|
|
1193
|
+
authorizeUrl.searchParams.set('code_challenge', challenge);
|
|
1194
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
1195
|
+
if (options.nonce !== undefined)
|
|
1196
|
+
authorizeUrl.searchParams.set('nonce', options.nonce);
|
|
1197
|
+
if (method.scopes.length > 0)
|
|
1198
|
+
authorizeUrl.searchParams.set('scope', method.scopes.join(' '));
|
|
1199
|
+
writeParams(authorizeUrl.searchParams, targetParams(method));
|
|
1200
|
+
const target = authorizeUrl.toString();
|
|
1201
|
+
node_process_1.stderr.write('Opening your browser to sign in. If it does not open, visit:\n\n ' + target + '\n\n');
|
|
1202
|
+
openBrowser(target);
|
|
1203
|
+
const { code, respond } = await withTimeout(redirect, BROWSER_FLOW_TIMEOUT_MS, 'Timed out waiting for the browser redirect.');
|
|
1204
|
+
// `respond` answers the held browser request once and ignores later calls, so the `finally`
|
|
1205
|
+
// below can answer it with a failure on any path that got here without answering it.
|
|
1206
|
+
try {
|
|
1207
|
+
let token;
|
|
1208
|
+
try {
|
|
1209
|
+
token = await exchangeToken(method.tokenUrl, baseUrl, {
|
|
1210
|
+
grant_type: 'authorization_code',
|
|
1211
|
+
code,
|
|
1212
|
+
redirect_uri: redirectUri,
|
|
1213
|
+
client_id: clientId,
|
|
1214
|
+
code_verifier: verifier,
|
|
1215
|
+
...targetParams(method),
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
catch (error) {
|
|
1219
|
+
respond('Sign-in failed', 'Your provider did not issue a credential. Return to your terminal for details.', false);
|
|
1220
|
+
throw error;
|
|
1221
|
+
}
|
|
1222
|
+
options.verify?.(token);
|
|
1223
|
+
respond('Signed in', 'You can close this window and return to your terminal.', true);
|
|
1224
|
+
return { token, clientId };
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
respond('Sign-in could not be verified', 'The credential your provider issued did not match this sign-in. Return to your terminal for details.', false);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
finally {
|
|
1231
|
+
// From here on a redirect is answered at once with the neutral page and never held, so nothing
|
|
1232
|
+
// that arrives during the wait below is left for nobody to answer — a redirect landing after the
|
|
1233
|
+
// browser wait timed out included.
|
|
1234
|
+
listener.closing = true;
|
|
1235
|
+
// Answered pages are waited for before any socket is destroyed: `closeAllConnections` cancels
|
|
1236
|
+
// writes still queued on a socket, and a page carrying a large publisher logo takes more than one
|
|
1237
|
+
// write to send, so closing straight after `end()` could leave the browser a blank or truncated
|
|
1238
|
+
// page. Bounded, so a browser that stops reading cannot hold the terminal.
|
|
1239
|
+
//
|
|
1240
|
+
// `server.close()` comes after the wait, not before it: since Node 19 it also closes every
|
|
1241
|
+
// connection with no request in progress, and a connection whose response has been ended but not
|
|
1242
|
+
// yet flushed counts as one — so closing first cut off exactly the pages this wait protects.
|
|
1243
|
+
await drainAnswered(listener.answered, RESPONSE_FLUSH_TIMEOUT_MS);
|
|
1244
|
+
server.close();
|
|
1245
|
+
// Belt and braces with the `connection: close` above: a connection opened but never used (a
|
|
1246
|
+
// browser preconnecting to the redirect host) is not covered by that header and would keep the
|
|
1247
|
+
// listener, and so the process, alive.
|
|
1248
|
+
server.closeAllConnections();
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
/**
|
|
1252
|
+
* Waits, up to `ms` in all, for every answered response — including any answered while waiting.
|
|
1253
|
+
*
|
|
1254
|
+
* Re-reads the list after each batch because the listener keeps accepting until it is closed, and a
|
|
1255
|
+
* response written during the wait is just as liable to be cut off as one written before it.
|
|
1256
|
+
*/
|
|
1257
|
+
const drainAnswered = async (answered, ms) => {
|
|
1258
|
+
const deadline = Date.now() + ms;
|
|
1259
|
+
let waited = 0;
|
|
1260
|
+
while (waited < answered.length && Date.now() < deadline) {
|
|
1261
|
+
const batch = answered.slice(waited);
|
|
1262
|
+
waited = answered.length;
|
|
1263
|
+
await settledWithin(Promise.all(batch), deadline - Date.now());
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
/**
|
|
1267
|
+
* Resolves once `promise` settles or `ms` have passed, whichever is first, and never rejects.
|
|
1268
|
+
*
|
|
1269
|
+
* The timer is cleared when the promise wins, so a finished wait leaves nothing pending that would
|
|
1270
|
+
* keep the process alive.
|
|
1271
|
+
*/
|
|
1272
|
+
const settledWithin = (promise, ms) => new Promise((resolve) => {
|
|
1273
|
+
const timer = setTimeout(resolve, ms);
|
|
1274
|
+
const done = () => {
|
|
1275
|
+
clearTimeout(timer);
|
|
1276
|
+
resolve();
|
|
1277
|
+
};
|
|
1278
|
+
promise.then(done, done);
|
|
1279
|
+
});
|
|
1280
|
+
/**
|
|
1281
|
+
* The style for the one page a generated CLI ever puts in front of a browser.
|
|
1282
|
+
*
|
|
1283
|
+
* Laid out after the Scalar dashboard's own sign-in confirmation: the publisher's logo centred a
|
|
1284
|
+
* little above the fold, then a one-line heading with its icon, then a muted line of detail — no
|
|
1285
|
+
* card, no accent colour. Every colour is a token on `:root` so the palette can be restyled in one
|
|
1286
|
+
* block.
|
|
1287
|
+
*
|
|
1288
|
+
* The font stack names Inter first and then falls back to the system UI face. Naming it is free
|
|
1289
|
+
* where it is already installed and costs nothing where it is not — what it must never do is
|
|
1290
|
+
* *fetch* it, for the same reason the rest of this page is inline.
|
|
1291
|
+
*/
|
|
1292
|
+
const PAGE_STYLE =
|
|
1293
|
+
// Values taken from the Scalar dashboard's own theme: `--scalar-background-1` and
|
|
1294
|
+
// `--scalar-color-1/2`, per mode.
|
|
1295
|
+
//
|
|
1296
|
+
// `light-dark()` rather than a `prefers-color-scheme` block, so each token states its light and
|
|
1297
|
+
// dark value together and the palette can be read as pairs. It resolves against the
|
|
1298
|
+
// `color-scheme` declared on the same rule, which is why that comes first.
|
|
1299
|
+
//
|
|
1300
|
+
// Where the function is not understood (before Firefox 120, Chrome 123, Safari 17.5) every token
|
|
1301
|
+
// is invalid at computed-value time and the properties using them fall back to their initial
|
|
1302
|
+
// values — black on white. Plain, still legible, and still saying the sign-in worked, which is the
|
|
1303
|
+
// page's whole job.
|
|
1304
|
+
':root{color-scheme:light dark;' +
|
|
1305
|
+
'--bg:light-dark(#fff,#0f0f0f);' +
|
|
1306
|
+
'--fg:light-dark(#1b1b1b,#e7e7e7);' +
|
|
1307
|
+
'--muted:light-dark(#757575,#a4a4a4)}' +
|
|
1308
|
+
'*{box-sizing:border-box}' +
|
|
1309
|
+
// The dashboard's auth layout: content starts 15% of the way down the viewport in a 380px column.
|
|
1310
|
+
'body{margin:0;padding:15vh 48px 48px;background:var(--bg);color:var(--fg);' +
|
|
1311
|
+
// Mirrors `--scalar-font`, which also leads with Inter and falls back to the system UI face.
|
|
1312
|
+
'font:14px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}' +
|
|
1313
|
+
'main{max-width:380px;margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center}' +
|
|
1314
|
+
// 32px tall like the dashboard's mark, but width left to the image: a publisher's logo is as
|
|
1315
|
+
// often a wordmark as a square icon. The 16px margin plus the 8px gap is the dashboard's 24px.
|
|
1316
|
+
'.logo{display:block;height:32px;max-width:100%;margin-bottom:16px;object-fit:contain}' +
|
|
1317
|
+
// `text-lg` at regular weight, as the dashboard sets it, with a 20px icon beside it.
|
|
1318
|
+
'h1{margin:0;display:flex;align-items:center;justify-content:center;gap:8px;font-size:16px;font-weight:400;line-height:24px}' +
|
|
1319
|
+
'h1 svg{flex:none;width:20px;height:20px}' +
|
|
1320
|
+
// `text-sm` in `--scalar-color-2`.
|
|
1321
|
+
'p{margin:0;color:var(--muted);font-size:13px;line-height:20px}';
|
|
1322
|
+
/**
|
|
1323
|
+
* The page's two marks, as Phosphor's light-weight `sign-in` and `warning` glyphs on a 256 grid —
|
|
1324
|
+
* the icons the dashboard's own confirmation and failure screens use.
|
|
1325
|
+
*
|
|
1326
|
+
* Phosphor Icons, Copyright (c) 2020 Phosphor Icons, MIT License (https://github.com/phosphor-icons/core).
|
|
1327
|
+
*/
|
|
1328
|
+
const SIGNED_IN_ICON = 'M140.24,132.24l-40,40a6,6,0,0,1-8.48-8.48L121.51,134H24a6,6,0,0,1,0-12h97.51L91.76,92.24a6,6,0,0,1,8.48-8.48l40,40A6,6,0,0,1,140.24,132.24ZM200,34H136a6,6,0,0,0,0,12h58V210H136a6,6,0,0,0,0,12h64a6,6,0,0,0,6-6V40A6,6,0,0,0,200,34Z';
|
|
1329
|
+
const WARNING_ICON = 'M235.07,189.09,147.61,37.22h0a22.75,22.75,0,0,0-39.22,0L20.93,189.09a21.53,21.53,0,0,0,0,21.72A22.35,22.35,0,0,0,40.55,222h174.9a22.35,22.35,0,0,0,19.6-11.19A21.53,21.53,0,0,0,235.07,189.09ZM224.66,204.8a10.46,10.46,0,0,1-9.21,5.2H40.55a10.46,10.46,0,0,1-9.21-5.2,9.51,9.51,0,0,1,0-9.72L118.79,43.21a10.75,10.75,0,0,1,18.42,0l87.46,151.87A9.51,9.51,0,0,1,224.66,204.8ZM122,144V104a6,6,0,0,1,12,0v40a6,6,0,0,1-12,0Zm16,36a10,10,0,1,1-10-10A10,10,0,0,1,138,180Z';
|
|
1330
|
+
/**
|
|
1331
|
+
* Escapes a value for a double-quoted HTML attribute.
|
|
1332
|
+
*
|
|
1333
|
+
* The logo arrives through the generator's own filter, which admits only an `https` URL or an image
|
|
1334
|
+
* `data:` URI, but an SVG `data:` URI can legally carry quotes and angle brackets. Escaping here is
|
|
1335
|
+
* lossless — the parser decodes the entities back before the URL is read — so the attribute cannot
|
|
1336
|
+
* be closed early whatever the filter let through.
|
|
1337
|
+
*/
|
|
1338
|
+
const escapeAttribute = (value) => value.replace(/&/gu, '&').replace(/"/gu, '"').replace(/</gu, '<').replace(/>/gu, '>');
|
|
1339
|
+
/**
|
|
1340
|
+
* The tab icon: the publisher's logo when one is configured, otherwise the page's own mark.
|
|
1341
|
+
*
|
|
1342
|
+
* Always declared, so the browser never requests `/favicon.ico` from the loopback listener — which
|
|
1343
|
+
* would otherwise answer a 404 to a request it only receives because the page declared no icon.
|
|
1344
|
+
*
|
|
1345
|
+
* The fallback is deliberately a status mark and not a vendor logo: this page is served by every
|
|
1346
|
+
* generated CLI, so a default icon identifying the SDK's *generator* would put one company's
|
|
1347
|
+
* branding on another's sign-in.
|
|
1348
|
+
*
|
|
1349
|
+
* The fallback's colours are a green and a red that read against a light and a dark tab strip
|
|
1350
|
+
* alike; `light-dark()` is no use here because the SVG is a separate document with no
|
|
1351
|
+
* `color-scheme` of its own. Only `#`, `<` and `>` are escaped, which is all a data URI in an
|
|
1352
|
+
* attribute needs, and the SVG quotes its attributes with apostrophes so the `href` can keep the
|
|
1353
|
+
* double quotes.
|
|
1354
|
+
*/
|
|
1355
|
+
const favicon = (icon, ok, logo) => {
|
|
1356
|
+
if (logo)
|
|
1357
|
+
return '<link rel="icon" href="' + escapeAttribute(logo) + '">';
|
|
1358
|
+
const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256' fill='" +
|
|
1359
|
+
(ok ? '#00b648' : '#dc1b19') +
|
|
1360
|
+
"'><path d='" +
|
|
1361
|
+
icon +
|
|
1362
|
+
"'/></svg>";
|
|
1363
|
+
return ('<link rel="icon" href="data:image/svg+xml,' +
|
|
1364
|
+
svg.replace(/#/gu, '%23').replace(/</gu, '%3C').replace(/>/gu, '%3E') +
|
|
1365
|
+
'">');
|
|
1366
|
+
};
|
|
1367
|
+
/**
|
|
1368
|
+
* Renders the redirect page.
|
|
1369
|
+
*
|
|
1370
|
+
* Inlined down to the icon, with nothing fetched but a configured logo: the listener answers on
|
|
1371
|
+
* loopback, where the machine may well have no route out at all, and a callback page that reached
|
|
1372
|
+
* for a stylesheet or a font would also tell whoever served it that a sign-in had just happened.
|
|
1373
|
+
* The logo is the one exception, because it is the publisher's own and the publisher's provider has
|
|
1374
|
+
* just seen the sign-in anyway. It is sent with no referrer, so the host serving it never sees the
|
|
1375
|
+
* loopback URL, and a `data:` URI keeps even that request from happening. A logo that fails to
|
|
1376
|
+
* load has an empty `alt`, so it leaves nothing on the page rather than a broken-image box.
|
|
1377
|
+
*
|
|
1378
|
+
* Nothing the authorization server sent is interpolated here. The provider's own `error` goes to
|
|
1379
|
+
* the terminal through `safeText` instead, which keeps untrusted text out of markup entirely rather
|
|
1380
|
+
* than relying on this function to escape it.
|
|
1381
|
+
*/
|
|
1382
|
+
const redirectPage = (heading, detail, ok, logo) => {
|
|
1383
|
+
const icon = ok ? SIGNED_IN_ICON : WARNING_ICON;
|
|
1384
|
+
return ('<!doctype html><html lang="en"><meta charset="utf-8">' +
|
|
1385
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
1386
|
+
'<meta name="referrer" content="no-referrer">' +
|
|
1387
|
+
favicon(icon, ok, logo) +
|
|
1388
|
+
'<title>' +
|
|
1389
|
+
heading +
|
|
1390
|
+
'</title><style>' +
|
|
1391
|
+
PAGE_STYLE +
|
|
1392
|
+
'</style><main>' +
|
|
1393
|
+
(logo ? '<img class="logo" src="' + escapeAttribute(logo) + '" alt="">' : '') +
|
|
1394
|
+
'<h1><svg viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="' +
|
|
1395
|
+
icon +
|
|
1396
|
+
'"/></svg>' +
|
|
1397
|
+
heading +
|
|
1398
|
+
'</h1><p>' +
|
|
1399
|
+
detail +
|
|
1400
|
+
'</p></main>');
|
|
1401
|
+
};
|
|
1402
|
+
/**
|
|
1403
|
+
* Resolves with the authorization code the redirect carries, holding the browser's request open.
|
|
1404
|
+
*
|
|
1405
|
+
* A redirect that fails a check here is answered at once. One that passes is left waiting, and the
|
|
1406
|
+
* caller answers it through `respond` once the rest of the sign-in has succeeded or failed.
|
|
1407
|
+
*
|
|
1408
|
+
* Anything that is not the redirect — a browser probing `/favicon.ico`, a stray request to the
|
|
1409
|
+
* port — is answered 404 and ignored, so it cannot resolve or reject the wait. The `state` is
|
|
1410
|
+
* compared in constant time and rejected on mismatch, which is what ties the redirect back to the
|
|
1411
|
+
* request this process started; the `iss` is then held to `issuer` (see {@link redirectIssuerProblem}).
|
|
1412
|
+
*/
|
|
1413
|
+
const awaitRedirect = (server, state, path, logo, issuer, listener) => new Promise((resolve, reject) => {
|
|
1414
|
+
// Set once a redirect has resolved or rejected this wait. A later redirect — the user reloading
|
|
1415
|
+
// the tab while the first is still held — can change nothing, but it still deserves an answer:
|
|
1416
|
+
// left unanswered it would hang until the listener closed and then show an empty-response error.
|
|
1417
|
+
let settled = false;
|
|
1418
|
+
server.on('request', (request, response) => {
|
|
1419
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
1420
|
+
const params = url.searchParams;
|
|
1421
|
+
// Listened for as soon as the request arrives, not when it is answered: a held redirect whose
|
|
1422
|
+
// browser gives up while the code is exchanged (a reload, a closed tab) has already fired its
|
|
1423
|
+
// `'close'` by the time it is answered, and a listener added then would never hear it —
|
|
1424
|
+
// leaving the flow to wait out `RESPONSE_FLUSH_TIMEOUT_MS` for a page nobody will read.
|
|
1425
|
+
const closed = new Promise((done) => response.once('close', () => done()));
|
|
1426
|
+
const send = (status, type, body) => {
|
|
1427
|
+
// `connection: close` rather than the default keep-alive: `server.close()` waits for open
|
|
1428
|
+
// connections, and a browser holding one would leave the CLI running after it had signed in.
|
|
1429
|
+
response.writeHead(status, { 'content-type': type + '; charset=utf-8', connection: 'close' });
|
|
1430
|
+
listener.answered.push(closed);
|
|
1431
|
+
response.end(body);
|
|
1432
|
+
};
|
|
1433
|
+
const finish = (status, heading, detail, ok) => {
|
|
1434
|
+
send(status, 'text/html', redirectPage(heading, detail, ok, logo));
|
|
1435
|
+
};
|
|
1436
|
+
// The path is part of what was registered as `redirect_uri`, so anything else is not the
|
|
1437
|
+
// redirect however it is decorated. Checked alongside the parameters rather than instead of
|
|
1438
|
+
// them: both are cheap, and together they keep this handler from reading a request that was
|
|
1439
|
+
// never the provider's as though it were.
|
|
1440
|
+
if (url.pathname !== path || (!params.has('code') && !params.has('error'))) {
|
|
1441
|
+
// Plain text, and deliberately not the page above: this answers a probe rather than a
|
|
1442
|
+
// person — a browser asking for `/favicon.ico`, or anything else that finds the port.
|
|
1443
|
+
send(404, 'text/plain', 'Not found.\n');
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (settled || listener.closing) {
|
|
1447
|
+
finish(409, 'Return to your terminal', 'This sign-in has already been received. Your terminal shows how it ended.', false);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
settled = true;
|
|
1451
|
+
// scalar-sdk-generator:custom-code oauth-callback-singletons:start
|
|
1452
|
+
// Reject ambiguous callbacks before selecting any security-sensitive value.
|
|
1453
|
+
if (['state', 'iss', 'code', 'error'].some((name) => params.getAll(name).length > 1)) {
|
|
1454
|
+
finish(400, 'Sign-in could not be verified', 'This response contains repeated parameters. Return to your terminal and try again.', false);
|
|
1455
|
+
reject(new Error('The browser redirect contains repeated OAuth parameters.'));
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
// scalar-sdk-generator:custom-code oauth-callback-singletons:end
|
|
1459
|
+
if (!sameToken(params.get('state') ?? '', state)) {
|
|
1460
|
+
// Ends the sign-in rather than waiting for a better redirect. A request that reaches here is
|
|
1461
|
+
// on the registered path and carries a `code` or an `error`, which makes it the provider's
|
|
1462
|
+
// redirect rather than a stray probe — the path check above is what turns those away — so a
|
|
1463
|
+
// `state` that does not match is tampering or a stale tab, and neither is worth leaving the
|
|
1464
|
+
// user watching a terminal for `BROWSER_FLOW_TIMEOUT_MS` on the chance that a second,
|
|
1465
|
+
// better redirect arrives.
|
|
1466
|
+
finish(400, 'Sign-in could not be verified', 'This page did not match the sign-in your terminal started. Return to your terminal and try again.', false);
|
|
1467
|
+
reject(new Error('The browser redirect did not match this sign-in attempt.'));
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
// Checked before the `error` branch as well as the code: RFC 9207 puts `iss` on error
|
|
1471
|
+
// responses too, and an error page from a server that is not the expected one is no more its
|
|
1472
|
+
// provider's word than a code is.
|
|
1473
|
+
const issuerProblem = redirectIssuerProblem(params.get('iss'), issuer);
|
|
1474
|
+
if (issuerProblem) {
|
|
1475
|
+
finish(400, 'Sign-in could not be verified', 'This response did not come from the provider your terminal expected. Return to your terminal and try again.', false);
|
|
1476
|
+
reject(new Error(issuerProblem));
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const failure = params.get('error');
|
|
1480
|
+
if (failure) {
|
|
1481
|
+
finish(400, 'Sign-in failed', 'Your provider declined the request. Return to your terminal for details.', false);
|
|
1482
|
+
reject(new Error('Authorization failed: ' + safeText(failure) + '.'));
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const code = params.get('code');
|
|
1486
|
+
if (!code) {
|
|
1487
|
+
finish(400, 'Sign-in failed', 'The redirect carried no authorization code. Return to your terminal and try again.', false);
|
|
1488
|
+
reject(new Error('The browser redirect carried no authorization code.'));
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
let responded = false;
|
|
1492
|
+
resolve({
|
|
1493
|
+
code,
|
|
1494
|
+
respond: (heading, detail, ok) => {
|
|
1495
|
+
if (responded)
|
|
1496
|
+
return;
|
|
1497
|
+
responded = true;
|
|
1498
|
+
finish(ok ? 200 : 400, heading, detail, ok);
|
|
1499
|
+
},
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
server.on('error', reject);
|
|
1503
|
+
});
|
|
1504
|
+
/**
|
|
1505
|
+
* Why a redirect's `iss` parameter (RFC 9207) is unacceptable, or `undefined` when it is fine.
|
|
1506
|
+
*
|
|
1507
|
+
* This is the mix-up-attack defence. `state` proves the redirect answers this process's request,
|
|
1508
|
+
* but not which authorization server sent it: a CLI that trusts more than one provider can have its
|
|
1509
|
+
* browser steered through an attacker's, and would then redeem the code that server handed back at
|
|
1510
|
+
* the honest provider's token endpoint — or hand an honest code to the attacker's. Naming the issuer
|
|
1511
|
+
* on the redirect, and refusing one that is not the server this sign-in began with, closes that
|
|
1512
|
+
* before the code is exchanged anywhere.
|
|
1513
|
+
*
|
|
1514
|
+
* With no expected issuer — a plain OAuth scheme and none configured — a received `iss` is
|
|
1515
|
+
* ignored: there is nothing to compare it with, so it can neither confirm nor refute the sender,
|
|
1516
|
+
* and refusing it would break every provider that sends one to clients that never asked.
|
|
1517
|
+
*/
|
|
1518
|
+
const redirectIssuerProblem = (received, issuer) => {
|
|
1519
|
+
if (issuer.expected === undefined)
|
|
1520
|
+
return undefined;
|
|
1521
|
+
if (received === null) {
|
|
1522
|
+
return issuer.required
|
|
1523
|
+
? 'The browser redirect did not say which provider sent it (no iss parameter), but this sign-in expected ' +
|
|
1524
|
+
safeText(issuer.expected) +
|
|
1525
|
+
'.'
|
|
1526
|
+
: undefined;
|
|
1527
|
+
}
|
|
1528
|
+
if (sameIssuer(received, issuer.expected))
|
|
1529
|
+
return undefined;
|
|
1530
|
+
return ('The browser redirect was sent by ' +
|
|
1531
|
+
(safeText(received) || 'an empty issuer') +
|
|
1532
|
+
', but this sign-in expected ' +
|
|
1533
|
+
safeText(issuer.expected) +
|
|
1534
|
+
'.');
|
|
1535
|
+
};
|
|
1536
|
+
/** Starts the loopback listener, resolving with the port the OS actually bound. */
|
|
1537
|
+
const listen = (server, port) => new Promise((resolve, reject) => {
|
|
1538
|
+
server.once('error', reject);
|
|
1539
|
+
// 127.0.0.1 rather than every interface: the redirect is for this machine's browser alone.
|
|
1540
|
+
server.listen(port, '127.0.0.1', () => {
|
|
1541
|
+
const address = server.address();
|
|
1542
|
+
if (address && typeof address === 'object')
|
|
1543
|
+
resolve(address.port);
|
|
1544
|
+
else
|
|
1545
|
+
reject(new Error('Could not open a local port for the sign-in redirect.'));
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
/**
|
|
1549
|
+
* Opens a URL in the user's browser, best effort.
|
|
1550
|
+
*
|
|
1551
|
+
* A missing opener is not an error: the URL was printed first precisely so the flow still completes
|
|
1552
|
+
* when this does nothing.
|
|
1553
|
+
*
|
|
1554
|
+
* The URL never reaches a shell. On Unix that is automatic — `open` and `xdg-open` are spawned
|
|
1555
|
+
* directly. Windows is the trap: `cmd /c start` would make cmd itself the shell, and Node quotes an
|
|
1556
|
+
* argument only when it contains a space, tab or quote, so every `&` in an authorization URL would
|
|
1557
|
+
* arrive at cmd as a command separator. That breaks the flow even on a benign document (the browser
|
|
1558
|
+
* opens on the first query parameter alone, with no `state` or `code_challenge`) and runs arbitrary
|
|
1559
|
+
* programs on a hostile one, since `&` survives URL normalization inside the path. So Windows gets
|
|
1560
|
+
* PowerShell with the URL in the environment, where nothing parses it as a command line at all.
|
|
1561
|
+
*/
|
|
1562
|
+
const openBrowser = (url) => {
|
|
1563
|
+
const windows = process.platform === 'win32';
|
|
1564
|
+
const [file, ...args] = windows
|
|
1565
|
+
? [(0, keychain_1.windowsPowerShell)(), '-NoProfile', '-NonInteractive', '-Command', '-']
|
|
1566
|
+
: process.platform === 'darwin'
|
|
1567
|
+
? ['open', url]
|
|
1568
|
+
: ['xdg-open', url];
|
|
1569
|
+
try {
|
|
1570
|
+
const child = (0, node_child_process_1.spawn)(file, args, {
|
|
1571
|
+
stdio: windows ? ['pipe', 'ignore', 'ignore'] : 'ignore',
|
|
1572
|
+
detached: !windows,
|
|
1573
|
+
...(windows ? { env: { ...process.env, SCALAR_BROWSER_URL: url } } : {}),
|
|
1574
|
+
});
|
|
1575
|
+
child.on('error', () => { });
|
|
1576
|
+
if (windows) {
|
|
1577
|
+
// `-FilePath` is explicit so a URL is never read as a PowerShell parameter, and the value comes
|
|
1578
|
+
// from the environment so it is not part of any command line.
|
|
1579
|
+
child.stdin?.end('Start-Process -FilePath $env:SCALAR_BROWSER_URL\n');
|
|
1580
|
+
}
|
|
1581
|
+
child.unref();
|
|
1582
|
+
}
|
|
1583
|
+
catch {
|
|
1584
|
+
// Ignored; see this function's doc comment.
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
/**
|
|
1588
|
+
* Exchanges a grant for an access token.
|
|
1589
|
+
*
|
|
1590
|
+
* The endpoint comes from the OpenAPI document, which is untrusted input, so it is resolved against
|
|
1591
|
+
* the base URL and required to be HTTPS before a secret is put in the request body. Only the
|
|
1592
|
+
* standard `error`/`error_description` fields of a failure response are reported: a server that
|
|
1593
|
+
* echoed the submitted secret back would otherwise have it printed to stderr and into any CI log.
|
|
1594
|
+
*/
|
|
1595
|
+
const exchangeToken = async (tokenUrl, baseUrl, body, timeoutMs = TOKEN_REQUEST_TIMEOUT_MS) => {
|
|
1596
|
+
const url = requireSecureUrl(tokenUrl, baseUrl, 'token endpoint');
|
|
1597
|
+
const { response, text } = await postForToken(url, body, timeoutMs);
|
|
1598
|
+
const payload = parseJson(text);
|
|
1599
|
+
if (!response.ok)
|
|
1600
|
+
throw new Error('The token endpoint rejected the request' + tokenErrorDetail(payload, response.status));
|
|
1601
|
+
return tokenResponse(payload);
|
|
1602
|
+
};
|
|
1603
|
+
/** Validates the standard OAuth token response fields before a token reaches the credential store. */
|
|
1604
|
+
const tokenResponse = (payload) => {
|
|
1605
|
+
const accessToken = payload?.['access_token'];
|
|
1606
|
+
if (typeof accessToken !== 'string' || !accessToken) {
|
|
1607
|
+
throw new Error('The token endpoint returned no access token.');
|
|
1608
|
+
}
|
|
1609
|
+
const refreshToken = payload?.['refresh_token'];
|
|
1610
|
+
const expiresIn = payload?.['expires_in'];
|
|
1611
|
+
const idToken = payload?.['id_token'];
|
|
1612
|
+
return {
|
|
1613
|
+
accessToken,
|
|
1614
|
+
...(typeof refreshToken === 'string' && refreshToken ? { refreshToken } : {}),
|
|
1615
|
+
// Kept as sent, even when empty or not a string, so the OIDC flow refuses a malformed one rather
|
|
1616
|
+
// than treating it as absent.
|
|
1617
|
+
...(idToken === undefined || idToken === null ? {} : { idToken: String(idToken) }),
|
|
1618
|
+
// A lifetime at or below zero is not a lifetime. Stored as one it makes `needsRefresh` true
|
|
1619
|
+
// forever, so every command pays a full refresh round trip ahead of the request it wanted —
|
|
1620
|
+
// on a token that is in fact perfectly good. The device grant already applies this floor to
|
|
1621
|
+
// its own `expires_in`; treating an unusable value as unstated puts the two in step.
|
|
1622
|
+
...(typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 ? { expiresIn } : {}),
|
|
1623
|
+
};
|
|
1624
|
+
};
|
|
1625
|
+
/** Fetches a bounded JSON document under the same timeout policy used for token endpoints. */
|
|
1626
|
+
const getJson = async (url, label, timeoutMs = TOKEN_REQUEST_TIMEOUT_MS) => {
|
|
1627
|
+
try {
|
|
1628
|
+
const response = await fetch(url, {
|
|
1629
|
+
headers: { accept: 'application/json' },
|
|
1630
|
+
// Same reasoning as `postForToken`, for the document that *names* the endpoints rather than
|
|
1631
|
+
// the exchange itself: only the URL handed to `fetch` was checked, so a spec-derived
|
|
1632
|
+
// discovery endpoint that redirects would have its answer — the authorization and token URLs
|
|
1633
|
+
// this whole grant is then pointed at — supplied by whichever origin it forwarded to.
|
|
1634
|
+
redirect: 'error',
|
|
1635
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1636
|
+
});
|
|
1637
|
+
return { response, text: await readBounded(response) };
|
|
1638
|
+
}
|
|
1639
|
+
catch (error) {
|
|
1640
|
+
const name = error instanceof Error ? error.name : '';
|
|
1641
|
+
throw new Error(name === 'TimeoutError' || name === 'AbortError'
|
|
1642
|
+
? 'The ' + label + ' did not answer within ' + String(timeoutMs / 1000) + ' seconds.'
|
|
1643
|
+
: 'Could not reach the ' + label + '.');
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
/**
|
|
1647
|
+
* Posts the grant and reads the answer, both under one deadline.
|
|
1648
|
+
*
|
|
1649
|
+
* The read is inside the same `try` as the request because the deadline covers it too: a server
|
|
1650
|
+
* that answers its headers and then stalls aborts here, and without this the user would see a raw
|
|
1651
|
+
* `The operation was aborted due to timeout` instead of a sentence naming the endpoint.
|
|
1652
|
+
*
|
|
1653
|
+
* The endpoint is spec-derived, so its own error text is never repeated beyond whether the wait ran
|
|
1654
|
+
* out: a `fetch` failure can carry the request, and the request carries the secret.
|
|
1655
|
+
*
|
|
1656
|
+
* `label` names the endpoint in those two messages, so a revocation that times out does not
|
|
1657
|
+
* claim to have been a token request.
|
|
1658
|
+
*/
|
|
1659
|
+
const postForToken = async (url, body, timeoutMs = TOKEN_REQUEST_TIMEOUT_MS, label = 'token endpoint') => {
|
|
1660
|
+
try {
|
|
1661
|
+
const response = await fetch(url, {
|
|
1662
|
+
method: 'POST',
|
|
1663
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
1664
|
+
body: writeParams(new URLSearchParams(), body).toString(),
|
|
1665
|
+
// A 307/308 preserves the POST body. Do not let a spec-derived endpoint forward a refresh
|
|
1666
|
+
// token, authorization code, or device code to a different (possibly insecure) origin.
|
|
1667
|
+
redirect: 'error',
|
|
1668
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1669
|
+
});
|
|
1670
|
+
return { response, text: await readBounded(response) };
|
|
1671
|
+
}
|
|
1672
|
+
catch (error) {
|
|
1673
|
+
// A deadline reached during the request is a `TimeoutError`; one reached while the body is
|
|
1674
|
+
// still arriving surfaces as the abort itself.
|
|
1675
|
+
const name = error instanceof Error ? error.name : '';
|
|
1676
|
+
throw new Error(name === 'TimeoutError' || name === 'AbortError'
|
|
1677
|
+
? 'The ' + label + ' did not answer within ' + String(timeoutMs / 1000) + ' seconds.'
|
|
1678
|
+
: 'Could not reach the ' + label + '.');
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
/**
|
|
1682
|
+
* Reads at most {@link TOKEN_RESPONSE_LIMIT} characters of a response body.
|
|
1683
|
+
*
|
|
1684
|
+
* `response.text()` would buffer whatever the endpoint chooses to send before anything could cap
|
|
1685
|
+
* it, so the stream is read in pieces and cancelled once there is more than enough to parse.
|
|
1686
|
+
*/
|
|
1687
|
+
const readBounded = async (response) => {
|
|
1688
|
+
const body = response.body;
|
|
1689
|
+
if (!body)
|
|
1690
|
+
return '';
|
|
1691
|
+
const reader = body.getReader();
|
|
1692
|
+
const decoder = new TextDecoder();
|
|
1693
|
+
let text = '';
|
|
1694
|
+
try {
|
|
1695
|
+
while (text.length < TOKEN_RESPONSE_LIMIT) {
|
|
1696
|
+
const { done, value } = await reader.read();
|
|
1697
|
+
if (done)
|
|
1698
|
+
break;
|
|
1699
|
+
text += decoder.decode(value, { stream: true });
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
finally {
|
|
1703
|
+
// Nothing is read after this point, and an abandoned body keeps the socket open.
|
|
1704
|
+
void reader.cancel().catch(() => { });
|
|
1705
|
+
}
|
|
1706
|
+
return text.slice(0, TOKEN_RESPONSE_LIMIT);
|
|
1707
|
+
};
|
|
1708
|
+
const parseJson = (text) => {
|
|
1709
|
+
try {
|
|
1710
|
+
const parsed = JSON.parse(text);
|
|
1711
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
1712
|
+
}
|
|
1713
|
+
catch {
|
|
1714
|
+
return undefined;
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
const tokenErrorDetail = (payload, status) => {
|
|
1718
|
+
const code = payload?.['error'];
|
|
1719
|
+
const description = payload?.['error_description'];
|
|
1720
|
+
const parts = [
|
|
1721
|
+
typeof code === 'string' ? code : undefined,
|
|
1722
|
+
typeof description === 'string' ? description : undefined,
|
|
1723
|
+
]
|
|
1724
|
+
.filter((part) => !!part)
|
|
1725
|
+
.map(safeText);
|
|
1726
|
+
return parts.length > 0 ? ' (' + parts.join(': ') + ').' : ' with status ' + String(status) + '.';
|
|
1727
|
+
};
|
|
1728
|
+
/**
|
|
1729
|
+
* Resolves a spec-derived endpoint and refuses to send credentials to it in the clear.
|
|
1730
|
+
*
|
|
1731
|
+
* HTTPS is required, with the usual carve-out for loopback so a local development authorization
|
|
1732
|
+
* server still works. Without this, a document naming an `http://` token endpoint would have the
|
|
1733
|
+
* CLI post a client secret unencrypted to whatever host it named.
|
|
1734
|
+
*/
|
|
1735
|
+
const requireSecureUrl = (raw, baseUrl, label) => {
|
|
1736
|
+
let url;
|
|
1737
|
+
try {
|
|
1738
|
+
url = new URL(raw, baseUrl || undefined);
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
throw new Error('The ' + label + ' is not a usable URL.');
|
|
1742
|
+
}
|
|
1743
|
+
if (url.protocol === 'https:')
|
|
1744
|
+
return url;
|
|
1745
|
+
if (url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname))
|
|
1746
|
+
return url;
|
|
1747
|
+
throw new Error('Refusing to send credentials to a non-HTTPS ' +
|
|
1748
|
+
label +
|
|
1749
|
+
' (' +
|
|
1750
|
+
safeText(url.protocol + '//' + url.host) +
|
|
1751
|
+
').');
|
|
1752
|
+
};
|
|
1753
|
+
/**
|
|
1754
|
+
* Compares two opaque tokens without leaking their contents through timing.
|
|
1755
|
+
*
|
|
1756
|
+
* Lengths are compared first because `timingSafeEqual` throws on a mismatch; both values here are
|
|
1757
|
+
* fixed-length base64url, so an unequal length is already a mismatch.
|
|
1758
|
+
*/
|
|
1759
|
+
const sameToken = (received, expected) => {
|
|
1760
|
+
const a = Buffer.from(received);
|
|
1761
|
+
const b = Buffer.from(expected);
|
|
1762
|
+
return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
1763
|
+
};
|
|
1764
|
+
const base64Url = (bytes) => bytes.toString('base64url');
|
|
1765
|
+
/**
|
|
1766
|
+
* Strips anything from a network-supplied string that a terminal would act on.
|
|
1767
|
+
*
|
|
1768
|
+
* An OAuth error message reaches stderr verbatim, and both the redirect query and the token
|
|
1769
|
+
* endpoint's response body are written by whoever the document points at, so control characters
|
|
1770
|
+
* (escape sequences that reposition the cursor or recolour the line) are removed and the result is
|
|
1771
|
+
* capped rather than printed as received.
|
|
1772
|
+
*/
|
|
1773
|
+
const safeText = (value) => {
|
|
1774
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
|
|
1775
|
+
const stripped = value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').trim();
|
|
1776
|
+
return stripped.length > 200 ? stripped.slice(0, 200) + '...' : stripped;
|
|
1777
|
+
};
|
|
1778
|
+
const withTimeout = (promise, ms, message) => new Promise((resolve, reject) => {
|
|
1779
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
1780
|
+
promise.then((value) => {
|
|
1781
|
+
clearTimeout(timer);
|
|
1782
|
+
resolve(value);
|
|
1783
|
+
}, (error) => {
|
|
1784
|
+
clearTimeout(timer);
|
|
1785
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1786
|
+
});
|
|
1787
|
+
});
|
|
1788
|
+
/**
|
|
1789
|
+
* A failure the user can fix by invoking the command differently, reported as a usage error.
|
|
1790
|
+
*
|
|
1791
|
+
* Distinguished from an authentication failure so a script can tell "you typed the wrong flow name"
|
|
1792
|
+
* (retrying will not help) from "the credential was rejected" (a fresh sign-in might).
|
|
1793
|
+
*/
|
|
1794
|
+
class UsageError extends Error {
|
|
1795
|
+
}
|
|
1796
|
+
exports.UsageError = UsageError;
|
|
1797
|
+
const NO_TERMINAL = 'Sign-in needs an interactive terminal. Run it from a terminal, pick a flow that needs no prompt with --flow <name> (see --help), or set the credential through its flag or environment variable instead.';
|
|
1798
|
+
/** Puts the terminal cursor back, for the picker's own exit paths and for a signal that skips them. */
|
|
1799
|
+
const restoreCursor = () => {
|
|
1800
|
+
node_process_1.stderr.write('\u001b[?25h');
|
|
1801
|
+
};
|
|
1802
|
+
/**
|
|
1803
|
+
* Puts a list of choices to the user and resolves the index of the one chosen.
|
|
1804
|
+
*
|
|
1805
|
+
* A highlight moved with the arrow keys rather than a number typed at a prompt, so the row being
|
|
1806
|
+
* chosen is the row being read. Only the highlighted row carries its `--flow` value, which is what
|
|
1807
|
+
* keeps a nine-line list from spelling out nine of them; the value is still on screen for whichever
|
|
1808
|
+
* flow the user is about to pick, which is the one they would want to write down.
|
|
1809
|
+
*
|
|
1810
|
+
* Raw mode is what delivers a keystroke without Enter behind it, and is required rather than fallen
|
|
1811
|
+
* back from, exactly as `promptSecret` requires it: a terminal Node reports as a TTY always offers
|
|
1812
|
+
* it, so the alternative was a branch that could not be reached to be tested. A digit still jumps to
|
|
1813
|
+
* its row, so the keys that answered the numbered prompt this replaces still answer this one.
|
|
1814
|
+
*
|
|
1815
|
+
* Drawn on stderr, like every other prompt, so stdout stays machine-readable.
|
|
1816
|
+
*/
|
|
1817
|
+
const promptChoice = async (heading, choices) => {
|
|
1818
|
+
if (!node_process_1.stdin.isTTY || typeof node_process_1.stdin.setRawMode !== 'function') {
|
|
1819
|
+
throw new UsageError(NO_TERMINAL);
|
|
1820
|
+
}
|
|
1821
|
+
return new Promise((resolve, reject) => {
|
|
1822
|
+
let active = 0;
|
|
1823
|
+
// How many rows the last draw wrote, which is how far back up the cursor has to go to redraw
|
|
1824
|
+
// over them. Without it every keystroke would leave another copy of the list in the scrollback.
|
|
1825
|
+
let drawn = 0;
|
|
1826
|
+
const width = () => Number(node_process_1.stderr.columns) || 80;
|
|
1827
|
+
/**
|
|
1828
|
+
* The slice of choices to draw, keeping the highlight inside it.
|
|
1829
|
+
*
|
|
1830
|
+
* A list taller than the terminal would scroll the region the redraw is about to move back over,
|
|
1831
|
+
* putting every subsequent frame in the wrong place, so a long list is windowed instead.
|
|
1832
|
+
*/
|
|
1833
|
+
const window = () => {
|
|
1834
|
+
const room = Math.max(1, (Number(node_process_1.stderr.rows) || 24) - 3);
|
|
1835
|
+
if (choices.length <= room)
|
|
1836
|
+
return choices;
|
|
1837
|
+
const start = Math.min(Math.max(0, active - (room >> 1)), choices.length - room);
|
|
1838
|
+
return choices.slice(start, start + room);
|
|
1839
|
+
};
|
|
1840
|
+
const render = () => {
|
|
1841
|
+
if (drawn > 0)
|
|
1842
|
+
node_process_1.stderr.write('\u001b[' + String(drawn) + 'A');
|
|
1843
|
+
const columns = width();
|
|
1844
|
+
const shown = window();
|
|
1845
|
+
const lines = [(0, list_1.clip)(heading, columns)];
|
|
1846
|
+
for (const choice of shown) {
|
|
1847
|
+
const selected = choice === choices[active];
|
|
1848
|
+
const head = (selected ? '> ' : ' ') + choice.label;
|
|
1849
|
+
const tail = ' (--flow ' + choice.hint + ')';
|
|
1850
|
+
const plain = (0, list_1.clip)(head + tail, columns);
|
|
1851
|
+
// The flow name is kept on every row rather than only the highlighted one, because labels
|
|
1852
|
+
// are not unique: an API key in a header, in a query parameter and in a cookie all read as
|
|
1853
|
+
// `Enter your API key`, and the flow name is the only thing telling those rows apart. It is
|
|
1854
|
+
// dimmed so the labels still read as the list, and dropped entirely from a row too narrow
|
|
1855
|
+
// to hold it, where clipping has already eaten the closing bracket.
|
|
1856
|
+
const row = plain === head + tail ? head + '\u001b[2m' + tail + '\u001b[22m' : plain;
|
|
1857
|
+
lines.push(selected ? '\u001b[36m' + row + '\u001b[39m' : row);
|
|
1858
|
+
}
|
|
1859
|
+
const position = shown.length < choices.length ? String(active + 1) + '/' + String(choices.length) + ', ' : '';
|
|
1860
|
+
lines.push((0, list_1.clip)(' ' + position + 'arrows to move, enter to choose', columns));
|
|
1861
|
+
// Each row is cleared to its end before being rewritten: a shorter line would otherwise leave
|
|
1862
|
+
// the tail of whatever longer one occupied that row in the frame before.
|
|
1863
|
+
node_process_1.stderr.write(lines.map((line) => '\u001b[2K' + line).join('\n') + '\n');
|
|
1864
|
+
drawn = lines.length;
|
|
1865
|
+
};
|
|
1866
|
+
const move = (delta) => {
|
|
1867
|
+
active = (active + delta + choices.length) % choices.length;
|
|
1868
|
+
render();
|
|
1869
|
+
};
|
|
1870
|
+
const jump = (index) => {
|
|
1871
|
+
if (index < 0 || index >= choices.length)
|
|
1872
|
+
return;
|
|
1873
|
+
active = index;
|
|
1874
|
+
render();
|
|
1875
|
+
};
|
|
1876
|
+
const onData = (chunk) => {
|
|
1877
|
+
let index = 0;
|
|
1878
|
+
while (index < chunk.length) {
|
|
1879
|
+
const character = chunk[index] ?? '';
|
|
1880
|
+
if (character === '\u001b') {
|
|
1881
|
+
// An arrow is ESC [ A or ESC O A. The sequence is scanned to its final byte so that no
|
|
1882
|
+
// part of it is read as a keystroke in its own right — the `[` of an Up arrow would
|
|
1883
|
+
// otherwise be an ordinary printable character arriving between two frames.
|
|
1884
|
+
let cursor = index + 1;
|
|
1885
|
+
if (chunk[cursor] === '[' || chunk[cursor] === 'O') {
|
|
1886
|
+
cursor += 1;
|
|
1887
|
+
// A CSI sequence ends at its final byte, anything in the range `@` to `~`.
|
|
1888
|
+
while (cursor < chunk.length) {
|
|
1889
|
+
const byte = chunk[cursor] ?? '';
|
|
1890
|
+
if (byte >= '@' && byte <= '~')
|
|
1891
|
+
break;
|
|
1892
|
+
cursor += 1;
|
|
1893
|
+
}
|
|
1894
|
+
const final = chunk[cursor] ?? '';
|
|
1895
|
+
if (final === 'A')
|
|
1896
|
+
move(-1);
|
|
1897
|
+
else if (final === 'B')
|
|
1898
|
+
move(1);
|
|
1899
|
+
else if (final === 'H')
|
|
1900
|
+
jump(0);
|
|
1901
|
+
else if (final === 'F')
|
|
1902
|
+
jump(choices.length - 1);
|
|
1903
|
+
index = cursor + 1;
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
index = cursor;
|
|
1907
|
+
continue;
|
|
1908
|
+
}
|
|
1909
|
+
index += 1;
|
|
1910
|
+
if (character === '\r' || character === '\n') {
|
|
1911
|
+
finish();
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
// Ctrl-C and Ctrl-D: raw mode has taken the terminal's own handling away, so the cancel has
|
|
1915
|
+
// to be honoured here or the picker could never be escaped.
|
|
1916
|
+
if (character === '\u0003' || character === '\u0004') {
|
|
1917
|
+
finish(new UsageError('Cancelled.'));
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
// Ctrl-P and Ctrl-N beside the vim keys, so the three habits a terminal user might arrive
|
|
1921
|
+
// with all work without anything on screen having to offer them.
|
|
1922
|
+
if (character === 'k' || character === '\u0010')
|
|
1923
|
+
move(-1);
|
|
1924
|
+
else if (character === 'j' || character === '\u000e')
|
|
1925
|
+
move(1);
|
|
1926
|
+
else if (character >= '1' && character <= '9')
|
|
1927
|
+
jump(Number(character) - 1);
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1930
|
+
const finish = (error) => {
|
|
1931
|
+
node_process_1.stdin.off('data', onData);
|
|
1932
|
+
node_process_1.stdin.setRawMode(false);
|
|
1933
|
+
node_process_1.stdin.pause();
|
|
1934
|
+
// The list is wound back over and replaced by the single line saying what was chosen: a menu
|
|
1935
|
+
// is worth reading while it is being answered and is clutter in the scrollback afterwards.
|
|
1936
|
+
if (drawn > 0)
|
|
1937
|
+
node_process_1.stderr.write('\u001b[' + String(drawn) + 'A\u001b[0J');
|
|
1938
|
+
process.off('exit', restoreCursor);
|
|
1939
|
+
restoreCursor();
|
|
1940
|
+
if (error) {
|
|
1941
|
+
reject(error);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const chosen = choices[active];
|
|
1945
|
+
node_process_1.stderr.write(heading + ' ' + (chosen?.label ?? '') + ' (--flow ' + (chosen?.hint ?? '') + ')\n');
|
|
1946
|
+
resolve(active);
|
|
1947
|
+
};
|
|
1948
|
+
// Hidden for the duration: the cursor would otherwise sit at the end of the last row drawn,
|
|
1949
|
+
// blinking somewhere that has nothing to do with the highlight. Restoring it is registered with
|
|
1950
|
+
// the hide rather than left to `finish`, because a signal that ends the process while the menu
|
|
1951
|
+
// is up reaches neither the key handler nor the rejection paths, and an invisible cursor
|
|
1952
|
+
// outlives the CLI it belonged to — the user's next shell prompt is where they find out.
|
|
1953
|
+
process.on('exit', restoreCursor);
|
|
1954
|
+
node_process_1.stderr.write('\u001b[?25l');
|
|
1955
|
+
node_process_1.stdin.setRawMode(true);
|
|
1956
|
+
node_process_1.stdin.resume();
|
|
1957
|
+
node_process_1.stdin.setEncoding('utf8');
|
|
1958
|
+
node_process_1.stdin.on('data', onData);
|
|
1959
|
+
render();
|
|
1960
|
+
});
|
|
1961
|
+
};
|
|
1962
|
+
/** Reads one visible line from the terminal. Prompts go to stderr so stdout stays machine-readable. */
|
|
1963
|
+
const promptLine = (label) => new Promise((resolve, reject) => {
|
|
1964
|
+
if (!node_process_1.stdin.isTTY) {
|
|
1965
|
+
reject(new UsageError(NO_TERMINAL));
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const rl = (0, node_readline_1.createInterface)({ input: node_process_1.stdin, output: node_process_1.stderr });
|
|
1969
|
+
let answered = false;
|
|
1970
|
+
// Closing the interface emits 'close' synchronously, so the guard is what keeps the end-of-input
|
|
1971
|
+
// fallback from resolving ahead of the answer that was just typed.
|
|
1972
|
+
rl.on('close', () => {
|
|
1973
|
+
if (!answered)
|
|
1974
|
+
resolve('');
|
|
1975
|
+
});
|
|
1976
|
+
rl.question(label, (answer) => {
|
|
1977
|
+
answered = true;
|
|
1978
|
+
resolve(answer.trim());
|
|
1979
|
+
rl.close();
|
|
1980
|
+
});
|
|
1981
|
+
});
|
|
1982
|
+
/**
|
|
1983
|
+
* Reads one secret from the terminal without echoing it.
|
|
1984
|
+
*
|
|
1985
|
+
* Raw mode is entered directly rather than muting a readline interface: readline exposes no
|
|
1986
|
+
* supported way to suppress its echo, and the credential would otherwise be left on screen and in
|
|
1987
|
+
* any recorded terminal session. Nothing typed here is written back, so there is no cursor to
|
|
1988
|
+
* manage — only the terminating newline is emitted.
|
|
1989
|
+
*/
|
|
1990
|
+
const promptSecret = (label) => new Promise((resolve, reject) => {
|
|
1991
|
+
if (!node_process_1.stdin.isTTY || typeof node_process_1.stdin.setRawMode !== 'function') {
|
|
1992
|
+
reject(new UsageError(NO_TERMINAL));
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
node_process_1.stderr.write(label);
|
|
1996
|
+
let value = '';
|
|
1997
|
+
// Where an escape sequence has got to: 0 outside one, 1 just after ESC, 2 inside CSI/SS3
|
|
1998
|
+
// parameters. Dropping the ESC alone is not enough — an Up arrow is ESC [ A, and the `[` and
|
|
1999
|
+
// `A` are ordinary printable bytes that would otherwise land in the middle of a credential the
|
|
2000
|
+
// user cannot see to correct.
|
|
2001
|
+
let escape = 0;
|
|
2002
|
+
const onData = (chunk) => {
|
|
2003
|
+
for (const character of chunk) {
|
|
2004
|
+
if (escape === 1) {
|
|
2005
|
+
escape = character === '[' || character === 'O' ? 2 : 0;
|
|
2006
|
+
continue;
|
|
2007
|
+
}
|
|
2008
|
+
if (escape === 2) {
|
|
2009
|
+
// A CSI sequence ends at its final byte, anything in the range `@` to `~`.
|
|
2010
|
+
if (character >= '@' && character <= '~')
|
|
2011
|
+
escape = 0;
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
if (character === '\u001b') {
|
|
2015
|
+
escape = 1;
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
if (character === '\r' || character === '\n') {
|
|
2019
|
+
finish();
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
// Ctrl-C and Ctrl-D: raw mode has taken the terminal's own handling away, so the cancel has
|
|
2023
|
+
// to be honoured here or the prompt could never be escaped.
|
|
2024
|
+
if (character === '\u0003') {
|
|
2025
|
+
finish(new UsageError('Cancelled.'));
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
if (character === '\u0004') {
|
|
2029
|
+
if (value)
|
|
2030
|
+
finish();
|
|
2031
|
+
else
|
|
2032
|
+
finish(new UsageError('Cancelled.'));
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (character === '\u007f' || character === '\b') {
|
|
2036
|
+
value = value.slice(0, -1);
|
|
2037
|
+
continue;
|
|
2038
|
+
}
|
|
2039
|
+
// Any other control byte is dropped for the same reason.
|
|
2040
|
+
if (character < ' ')
|
|
2041
|
+
continue;
|
|
2042
|
+
value += character;
|
|
2043
|
+
}
|
|
2044
|
+
};
|
|
2045
|
+
const finish = (error) => {
|
|
2046
|
+
node_process_1.stdin.off('data', onData);
|
|
2047
|
+
node_process_1.stdin.setRawMode(false);
|
|
2048
|
+
node_process_1.stdin.pause();
|
|
2049
|
+
node_process_1.stderr.write('\n');
|
|
2050
|
+
if (error)
|
|
2051
|
+
reject(error);
|
|
2052
|
+
else
|
|
2053
|
+
resolve(value);
|
|
2054
|
+
};
|
|
2055
|
+
node_process_1.stdin.setRawMode(true);
|
|
2056
|
+
node_process_1.stdin.resume();
|
|
2057
|
+
node_process_1.stdin.setEncoding('utf8');
|
|
2058
|
+
node_process_1.stdin.on('data', onData);
|
|
2059
|
+
});
|
|
2060
|
+
//# sourceMappingURL=login.js.map
|