From 674e80887ebc3fae362c0e1aa396439461b059a3 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 8 Mar 2026 21:43:33 +0800 Subject: [PATCH] 055-logseq-cli-login-logout.md --- .../src/logseq/common/cognito_config.cljs | 41 ++ .../055-logseq-cli-login-logout.md | 317 ++++++++++++ docs/cli/logseq-cli.md | 49 +- shadow-cljs.edn | 7 +- src/main/frontend/config.cljs | 21 +- src/main/logseq/cli/auth.cljs | 466 ++++++++++++++++++ src/main/logseq/cli/command/auth.cljs | 56 +++ src/main/logseq/cli/command/core.cljs | 4 +- src/main/logseq/cli/command/sync.cljs | 70 ++- src/main/logseq/cli/commands.cljs | 9 +- src/main/logseq/cli/config.cljs | 22 +- src/main/logseq/cli/format.cljs | 40 +- src/test/logseq/cli/auth_test.cljs | 102 ++++ src/test/logseq/cli/command/auth_test.cljs | 215 ++++++++ src/test/logseq/cli/command/sync_test.cljs | 137 +++-- src/test/logseq/cli/commands_test.cljs | 26 + src/test/logseq/cli/config_test.cljs | 20 +- src/test/logseq/cli/format_test.cljs | 52 +- src/test/logseq/cli/integration_test.cljs | 145 +++++- .../logseq/common/cognito_config_test.cljs | 15 + 20 files changed, 1702 insertions(+), 112 deletions(-) create mode 100644 deps/common/src/logseq/common/cognito_config.cljs create mode 100644 docs/agent-guide/055-logseq-cli-login-logout.md create mode 100644 src/main/logseq/cli/auth.cljs create mode 100644 src/main/logseq/cli/command/auth.cljs create mode 100644 src/test/logseq/cli/auth_test.cljs create mode 100644 src/test/logseq/cli/command/auth_test.cljs create mode 100644 src/test/logseq/common/cognito_config_test.cljs diff --git a/deps/common/src/logseq/common/cognito_config.cljs b/deps/common/src/logseq/common/cognito_config.cljs new file mode 100644 index 0000000000..71824e50d5 --- /dev/null +++ b/deps/common/src/logseq/common/cognito_config.cljs @@ -0,0 +1,41 @@ +(ns logseq.common.cognito-config + "Shared Cognito configuration for frontend and CLI-safe consumers." + (:require [clojure.string :as string])) + +(goog-define ENABLE-FILE-SYNC-PRODUCTION false) + +(def ^:private prod-login-url + "https://logseq-prod.auth.us-east-1.amazoncognito.com/login?client_id=3c7np6bjtb4r1k1bi9i049ops5&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback") + +(def ^:private test-login-url + "https://logseq-test2.auth.us-east-2.amazoncognito.com/login?client_id=3ji1a0059hspovjq5fhed3uil8&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback") + +(def LOGIN-URL + (if ENABLE-FILE-SYNC-PRODUCTION + prod-login-url + test-login-url)) + +(def COGNITO-CLIENT-ID + (or (some-> (js/URL. LOGIN-URL) + .-searchParams + (.get "client_id")) + (if ENABLE-FILE-SYNC-PRODUCTION + "69cs1lgme7p8kbgld8n5kseii6" + "1qi1uijg8b6ra70nejvbptis0q"))) + +(def CLI-COGNITO-CLIENT-ID + (if ENABLE-FILE-SYNC-PRODUCTION + "69cs1lgme7p8kbgld8n5kseii6" + "1qi1uijg8b6ra70nejvbptis0q")) + +(def OAUTH-DOMAIN + (if ENABLE-FILE-SYNC-PRODUCTION + "logseq-prod.auth.us-east-1.amazoncognito.com" + "logseq-test2.auth.us-east-2.amazoncognito.com")) + +(def OAUTH-SCOPE + (or (some-> (js/URL. LOGIN-URL) + .-searchParams + (.get "scope") + (string/replace #"\+" " ")) + "email openid phone")) diff --git a/docs/agent-guide/055-logseq-cli-login-logout.md b/docs/agent-guide/055-logseq-cli-login-logout.md new file mode 100644 index 0000000000..978976febe --- /dev/null +++ b/docs/agent-guide/055-logseq-cli-login-logout.md @@ -0,0 +1,317 @@ +# Logseq CLI Login and Logout Implementation Plan + +Goal: Add `logseq login` and `logseq logout`, persist Cognito auth in `/Users/rcmerci/logseq/auth.json`, and remove `:auth-token` persistence from `/Users/rcmerci/logseq/cli.edn`. + +Architecture: The CLI will get a dedicated auth module that owns loopback OAuth login, token persistence, token refresh, and logout file cleanup. +Architecture: Sync commands will continue to pass `:auth-token` to db-sync at runtime, but that token will be resolved from `auth.json` instead of being edited through `sync config set|get|unset`. +Architecture: The flow will reuse existing Cognito constants and token refresh semantics from the frontend user code, while following the ECA browser plus localhost callback pattern for headless login. + +Tech Stack: ClojureScript, babashka.cli, Node.js HTTP server APIs, Cognito Hosted UI OAuth, Promesa, JSON file persistence. + +Related: Builds on `docs/agent-guide/047-logseq-cli-sync-command.md`, `docs/agent-guide/048-sync-download-start-reliability.md`, `docs/agent-guide/051-logseq-cli-sync-upload-fix.md`, and `docs/agent-guide/033-desktop-db-worker-node-backend.md`. + +## Problem statement + +The current CLI expects headless sync authentication to be provided as `:auth-token` inside `/Users/rcmerci/logseq/cli.edn`. + +That approach is awkward for users, leaks auth concerns into general CLI config, and does not provide a first-class login or logout flow. + +The current sync code path in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/sync.cljs` still reads `:auth-token` directly from resolved config and writes it through `sync config set|get|unset`. + +The db-sync worker then consumes that runtime token through `worker-state/*db-sync-config` in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/sync.cljs` and `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/sync/crypt.cljs`. + +Logseq user management already uses AWS Cognito in the frontend app, with Cognito constants in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/config.cljs` and refresh-token logic in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/handler/user.cljs`. + +The ECA repository shows the closest CLI-friendly reference implementation for this feature, because it opens a browser, listens on localhost for the callback, exchanges the authorization code for tokens, and persists auth state locally. + +This plan keeps the existing db-sync runtime contract stable by continuing to inject `:auth-token` into the worker config map, while changing only how the CLI obtains and persists that token. + +I will use @planning-documents for naming, @writing-plans for task granularity, @test-driven-development for implementation order, and the current `logseq-cli` plus `db-worker-node` implementation as the baseline architecture. + +## Testing Plan + +I will add unit tests for auth file path resolution, JSON persistence, token refresh, and auth file deletion before adding implementation behavior. + +I will add command parser tests for the new `login` and `logout` commands before wiring them into the CLI. + +I will add execution tests for `login` and `logout` that verify browser launch, callback handling, Cognito token exchange, auth file writes, and logout cleanup. + +I will add sync command regression tests that fail first and verify `sync config set|get|unset` no longer accept `auth-token`. + +I will add config resolution tests that fail first and verify `resolve-config` no longer loads or persists `:auth-token` from `cli.edn`, while a new auth resolver loads the effective token from `auth.json`. + +I will add worker-facing tests that fail first and verify CLI sync runtime still injects an `:auth-token` into `worker-state/*db-sync-config` when `auth.json` exists. + +I will add integration tests that fail first and cover `login`, `logout`, and one authenticated sync command using a stubbed Cognito token exchange. + +I will run targeted tests after each slice, and I will finish with `bb dev:lint-and-test`. + +NOTE: I will write *all* tests before I add any implementation behavior. + +## Proposed CLI surface + +| Command | Purpose | Persistence effect | Notes | +|---|---|---|---| +| `logseq login` | Authenticate the current machine against Logseq cloud. | Creates or updates `/Users/rcmerci/logseq/auth.json`. | Opens browser and completes a localhost callback flow. | +| `logseq logout` | Remove locally persisted cloud auth for the CLI. | Deletes or clears `/Users/rcmerci/logseq/auth.json`. | Idempotent when the file does not exist. | +| `logseq sync config set ws-url|http-base|e2ee-password ` | Persist non-auth sync config. | Updates `/Users/rcmerci/logseq/cli.edn`. | `auth-token` is removed from this command. | +| `logseq sync config get ws-url|http-base|e2ee-password` | Read non-auth sync config. | Reads `/Users/rcmerci/logseq/cli.edn`. | `auth-token` is removed from this command. | +| `logseq sync config unset ws-url|http-base|e2ee-password` | Remove non-auth sync config. | Updates `/Users/rcmerci/logseq/cli.edn`. | `auth-token` is removed from this command. | + +The runtime db-sync config map will still contain `:auth-token` when the CLI invokes worker methods. + +Only the persistence source changes from `cli.edn` to `auth.json`. + +## Auth file design + +The new auth file will live at `/Users/rcmerci/logseq/auth.json` by default. + +The implementation should expose an internal override path for tests, but it should not add a new public CLI flag unless later required. + +The file format should be JSON rather than EDN so it is easy to inspect and delete manually. + +The file should contain enough information to refresh expired tokens without asking the user to log in again. + +A good starting shape is the following. + +```json +{ + "provider": "cognito", + "id-token": "", + "access-token": "", + "refresh-token": "", + "expires-at": 1735689600000, + "sub": "", + "email": "", + "updated-at": 1735686000000 +} +``` + +`id-token` should remain the value injected into db-sync as runtime `:auth-token`, because current CLI and worker behavior already assumes the sync token is the same value as `state/get-auth-id-token` on desktop. + +`refresh-token` is required so the CLI can refresh auth non-interactively before sync commands. + +`access-token` can be stored for parity with the existing frontend token model, even if the first CLI release does not consume it directly. + +The file should be written with restrictive permissions on Unix when feasible, and tests should verify the implementation does not fail on platforms where chmod semantics differ. + +## OAuth flow design + +The login flow should follow the ECA pattern more than the current browser app pattern. + +The CLI should start a temporary localhost callback server, generate a PKCE verifier and challenge, build a Cognito Hosted UI authorization URL, and then open the browser. + +The callback should validate `state`, read the authorization `code`, exchange it against Cognito `/oauth2/token`, persist the resulting tokens, and print a concise success result. + +A suggested flow is shown below. + +```text +logseq login + -> /Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/auth.cljs + -> /Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/auth.cljs + -> start localhost callback server on an ephemeral port + -> build Cognito authorize URL with PKCE and redirect_uri http://127.0.0.1:/auth/callback + -> open browser with system launcher, or print URL fallback + -> receive code on callback server + -> POST code exchange to https:///oauth2/token + -> persist /Users/rcmerci/logseq/auth.json + -> future sync commands refresh token if needed and inject runtime :auth-token +``` + +The callback server should prefer an ephemeral port rather than a fixed port, because that avoids collisions during local development and test runs. + +The login result should include non-sensitive metadata such as email, subject, auth file path, and whether the token was freshly created or refreshed. + +The CLI should not print raw tokens in human output. + +## Refresh and runtime token resolution + +The current worker code does not refresh tokens in CLI-owned node mode. + +`frontend.worker.sync//oauth2/token` using `grant_type=refresh_token` and `client_id`. + +`/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/sync.cljs` should stop reading `:auth-token` from resolved config and instead call the new auth helper when building the sync runtime config sent through `:thread-api/set-db-sync-config`. + +If no valid auth file exists, authenticated sync commands should return a dedicated error with a hint such as `Run logseq login first.`. + +## Implementation plan + +### Phase 1. Add failing tests for auth file helpers. + +1. Create `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/auth_test.cljs`. +2. Add a failing test that the default auth path resolves to `/Users/rcmerci/logseq/auth.json`. +3. Add a failing test that writing auth data creates the parent directory when missing. +4. Add a failing test that reading a missing auth file returns `nil` instead of throwing. +5. Add a failing test that deleting auth data is idempotent when the file does not exist. +6. Add a failing test that expired `id-token` plus valid `refresh-token` triggers refresh and persists updated JSON. +7. Add a failing test that malformed JSON returns a stable `invalid-auth-file` error code. +8. Run `bb dev:test -v logseq.cli.auth-test` and confirm only auth helper tests fail. + +### Phase 2. Add failing parser and help tests for `login` and `logout`. + +9. Update `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs` with a failing assertion that top-level help lists `login` and `logout`. +10. Add a failing assertion that `logseq login --help` and `logseq logout --help` produce command-specific help. +11. Add a failing assertion that `sync config set|get|unset` help no longer mentions `auth-token`. +12. Run `bb dev:test -v logseq.cli.commands-test` and confirm the failures reflect missing auth command wiring and old sync config text. + +### Phase 3. Add failing command execution tests for auth commands. + +13. Create `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/auth_test.cljs`. +14. Add a failing test that `login` starts a callback server and opens the browser with a Cognito authorize URL. +15. Add a failing test that `login` validates `state` before exchanging the authorization code. +16. Add a failing test that a successful code exchange writes `/Users/rcmerci/logseq/auth.json` through the auth persistence helper. +17. Add a failing test that `logout` removes the auth file and returns success when the file existed. +18. Add a failing test that `logout` still succeeds when the auth file was already absent. +19. Add a failing test that `login` returns a timeout error when no browser callback arrives. +20. Run `bb dev:test -v logseq.cli.command.auth-test` and confirm the tests fail for missing implementation only. + +### Phase 4. Implement auth helpers and OAuth plumbing. + +21. Add `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/auth.cljs` for auth path resolution, JSON read and write helpers, token expiry checks, refresh logic, and auth file deletion. +22. Add `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/auth.cljs` for command entries, action builders, and command execution. +23. Implement PKCE helpers and loopback callback server support inside `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/auth.cljs`, unless the file becomes too large, in which case split transport helpers into `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/auth_oauth.cljs`. +24. Build the Cognito authorize URL using constants from `/Users/rcmerci/gh-repos/logseq/src/main/frontend/config.cljs` so the CLI and app share the same cloud environment. +25. Implement the token exchange POST against `https:///oauth2/token` and map the response into the `auth.json` shape. +26. Implement refresh-token exchange using the same Cognito domain and client id. +27. Add best-effort browser launching for macOS and Linux, and always print the URL so the flow remains usable when auto-open fails. +28. Run `bb dev:test -v logseq.cli.auth-test` and `bb dev:test -v logseq.cli.command.auth-test` until green. + +### Phase 5. Wire `login` and `logout` into the CLI parser and help output. + +29. Register `auth-command/entries` in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs`. +30. Extend `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/core.cljs` so top-level summaries include `login` and `logout` in a dedicated auth section or the existing management section. +31. Extend action building and execute dispatch in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs` to route `:login` and `:logout`. +32. Re-run `bb dev:test -v logseq.cli.commands-test` until the new parser and help output tests pass. + +### Phase 6. Remove `:auth-token` persistence from `cli.edn`. + +33. Add failing tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/config_test.cljs` that `resolve-config` no longer returns `:auth-token` from file config. +34. Add a failing test that `update-config!` strips `:auth-token` from updates instead of persisting it. +35. Update `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/config.cljs` so file-backed config excludes `:auth-token` while still preserving `ws-url`, `http-base`, `e2ee-password`, graph selection, and output settings. +36. Update any config-related tests that currently expect `:auth-token` round-tripping through `cli.edn`. +37. Run `bb dev:test -v logseq.cli.config-test` until green. + +### Phase 7. Update sync commands to resolve auth from `auth.json`. + +38. Add failing tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/sync_test.cljs` that `sync config set|get|unset auth-token` is rejected as an unknown key. +39. Add a failing test that sync execution uses the auth helper to resolve a runtime `:auth-token` before invoking `:thread-api/set-db-sync-config`. +40. Add a failing test that missing auth file returns a `missing-auth` style error for authenticated sync operations such as `sync remote-graphs`. +41. Update `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/sync.cljs` so `config-key-map` removes `auth-token`. +42. Update sync execution to call the auth helper and merge the effective runtime token into the in-memory sync config map. +43. Update human-readable hints that currently say `Set sync config keys (ws-url/http-base/auth-token)` so they instead mention login plus the remaining sync config keys. +44. Run `bb dev:test -v logseq.cli.command.sync-test` until green. + +### Phase 8. Keep worker behavior stable while clarifying CLI ownership of auth. + +45. Add a failing regression test in `/Users/rcmerci/gh-repos/logseq/src/test/frontend/worker/sync/crypt_test.cljs` that CLI node mode still prefers the runtime `:auth-token` provided by sync config over renderer state. +46. Decide whether an additional worker test is needed to prove no worker-side changes are required beyond existing runtime behavior. +47. Make only the smallest worker change necessary, and prefer no production worker changes if CLI runtime injection remains sufficient. +48. Run `bb dev:test -v frontend.worker.sync.crypt-test` and any related worker namespace tests. + +### Phase 9. Update output formatting and docs. + +49. Add failing tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/format_test.cljs` for human output of `login` and `logout`. +50. Remove or rewrite any format tests that currently mention `sync config get auth-token` redaction. +51. Update `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs` so auth command output reports file path, email, and status without printing tokens. +52. Update `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md` to document `login`, `logout`, `auth.json`, and the reduced `sync config` key set. +53. Add one short troubleshooting section telling users to delete `/Users/rcmerci/logseq/auth.json` or run `logseq logout` when local auth becomes invalid. +54. Run `bb dev:test -v logseq.cli.format-test` after formatter updates. + +### Phase 10. Add integration coverage and perform final verification. + +55. Add a failing integration test in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/integration_test.cljs` that simulates a successful `login` callback and verifies `auth.json` contents. +56. Add a failing integration test that `logout` removes the auth file. +57. Add a failing integration test that an authenticated sync command reads `auth.json`, refreshes if needed, and sends runtime `:auth-token` to the worker. +58. Stub browser opening and Cognito HTTP responses so the integration tests remain deterministic and offline. +59. Run `bb dev:test -v logseq.cli.integration-test` until the new coverage is green. +60. Run `bb dev:lint-and-test` from `/Users/rcmerci/gh-repos/logseq` and confirm exit code `0`. + +## File map + +| Area | Files to update or add | Reason | +|---|---|---| +| Auth persistence and OAuth flow | `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/auth.cljs`, optionally `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/auth_oauth.cljs` | Own `auth.json`, login callback server, PKCE, refresh, and logout deletion. | +| CLI command wiring | `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs`, `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/core.cljs`, `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/auth.cljs` | Expose `login` and `logout` and update help text. | +| Sync runtime auth | `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/sync.cljs` | Replace file-backed `:auth-token` lookup with auth helper resolution. | +| Config cleanup | `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/config.cljs` | Stop round-tripping `:auth-token` in `cli.edn`. | +| Cloud constants reuse | `/Users/rcmerci/gh-repos/logseq/src/main/frontend/config.cljs`, `/Users/rcmerci/gh-repos/logseq/src/main/frontend/handler/user.cljs` | Reference existing Cognito endpoints and refresh semantics. | +| Tests | `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/auth_test.cljs`, `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/auth_test.cljs`, `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/config_test.cljs`, `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/sync_test.cljs`, `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`, `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/format_test.cljs`, `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/integration_test.cljs`, and possibly `/Users/rcmerci/gh-repos/logseq/src/test/frontend/worker/sync/crypt_test.cljs` | Cover behavior before implementation. | +| User-facing docs | `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md` | Explain the new auth workflow and removal of `auth-token` from sync config commands. | + +## Edge cases and failure handling + +`logseq login` must fail cleanly when the localhost callback port cannot be opened. + +`logseq login` must reject callbacks with mismatched `state` or missing `code`. + +`logseq login` must surface Cognito code-exchange failures without writing a partial auth file. + +`logseq logout` must succeed even when `/Users/rcmerci/logseq/auth.json` is already missing. + +Malformed or partially written `auth.json` must produce a deterministic error code and an actionable hint rather than a generic JSON parse exception. + +Expired `id-token` with a valid `refresh-token` should auto-refresh before sync commands instead of forcing an immediate re-login. + +Expired `id-token` with an invalid or missing `refresh-token` should fail with a hint to run `logseq login`. + +`sync start`, `sync remote-graphs`, `sync upload`, `sync download`, `sync ensure-keys`, and `sync grant-access` should all use the same auth resolution path so behavior is consistent. + +The CLI must not print raw JWTs or refresh tokens in human output, logs, or test snapshots. + +The implementation should preserve the current worker contract by continuing to pass `:auth-token` in runtime sync config, because changing the worker key name would expand scope without user benefit. + +## Verification commands + +| Command | Expected outcome | +|---|---| +| `bb dev:test -v logseq.cli.auth-test` | Auth helper tests pass. | +| `bb dev:test -v logseq.cli.command.auth-test` | Login and logout command tests pass. | +| `bb dev:test -v logseq.cli.commands-test` | Help output includes `login` and `logout`, and sync config no longer mentions `auth-token`. | +| `bb dev:test -v logseq.cli.config-test` | `cli.edn` no longer persists `:auth-token`. | +| `bb dev:test -v logseq.cli.command.sync-test` | Sync command tests pass with auth resolved from `auth.json`. | +| `bb dev:test -v logseq.cli.integration-test` | End-to-end auth flow tests pass with stubs. | +| `bb dev:lint-and-test` | Full repo lint and test suite passes with exit code `0`. | +| `node ./dist/logseq.js login` | Opens browser or prints login URL, then writes `/Users/rcmerci/logseq/auth.json` after callback. | +| `node ./dist/logseq.js logout` | Removes local auth state and reports success. | +| `node ./dist/logseq.js sync remote-graphs --output json` | Reads auth from `auth.json` and returns remote graphs without requiring `sync config set auth-token`. | + +## Testing Details + +The tests focus on externally observable behavior, including CLI help text, browser launch requests, callback validation, auth file contents, refresh-on-expiry behavior, sync runtime payloads, and user-facing error hints. + +The tests should not assert private helper structure unless that structure is itself the behavior contract, such as the persisted JSON keys or the generated Cognito callback URL. + +## Implementation Details + +- Keep `auth-token` as an in-memory db-sync runtime key, but remove it from persistent `cli.edn` config and from `sync config` command parsing. +- Add a dedicated CLI auth module instead of scattering login and refresh logic across `sync.cljs` and `config.cljs`. +- Reuse Cognito constants from `frontend.config` so the CLI always targets the same environment as the app. +- Reuse the refresh grant semantics from `frontend.handler.user.cljs` instead of inventing a second refresh protocol. +- Prefer an ephemeral localhost callback port and PKCE-based authorization code flow. +- Always print a copyable login URL even when automatic browser opening succeeds. +- Make `logout` local and idempotent first, and treat remote Cognito session invalidation as optional future scope. +- Keep the worker contract stable and prefer solving expiration entirely in the CLI auth module. +- Add an internal test-only auth path override rather than a new public CLI flag. +- Update user-facing docs and error hints so `Run logseq login first.` becomes the primary recovery path. + +## Decision + +The implementation will use a loopback redirect URI such as `http://127.0.0.1:/auth/callback`. + +If the current Cognito app client does not yet allow that redirect URI pattern, updating the Cognito app-client configuration is part of the implementation prerequisite rather than a reason to change the CLI design. + +`logout` will only clear `/Users/rcmerci/logseq/auth.json` in the first release. + +Best-effort browser sign-out against the Cognito Hosted UI logout endpoint is explicitly out of scope for this iteration. + +`auth.json` will persist `id-token`, `access-token`, and `refresh-token`, plus non-sensitive metadata such as `expires-at`, `sub`, `email`, and `updated-at`. + +This keeps the first CLI implementation aligned with the existing frontend token model while still using `id-token` as the runtime `:auth-token` injected into db-sync. + +--- diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index c4ae857c39..b900b63274 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -56,13 +56,33 @@ Supported keys include: - `:data-dir` - `:timeout-ms` - `:output-format` (use `:json` or `:edn` for scripting) +- sync config persisted via `sync config set|get|unset`: `:ws-url`, `:http-base`, `:e2ee-password` + +`cli.edn` no longer persists cloud auth tokens. CLI login state is stored separately in `~/logseq/auth.json`. CLI flags take precedence over environment variables, which take precedence over the config file. +## Authentication + +Use `logseq login` to authenticate the current machine with Logseq cloud. + +- `logseq login` starts a temporary callback server at `http://localhost:8765/auth/callback`, opens a browser to the Logseq Cognito Hosted UI, exchanges the returned authorization code, and writes `~/logseq/auth.json`. +- `logseq logout` removes `~/logseq/auth.json`, opens a browser to the Cognito Hosted UI logout endpoint, and completes the browser logout flow at `http://localhost:8765/logout-complete`. +- Sync commands still pass an in-memory runtime `:auth-token` to db-sync, but that token is now resolved from `auth.json` instead of `cli.edn`. + +Default auth file: `~/logseq/auth.json` + +Auth file contents include the persisted Cognito `id-token`, `access-token`, `refresh-token`, `expires-at`, `sub`, `email`, and `updated-at` values needed for headless refresh. + Verbose logging: - `--verbose` enables structured debug logs to stderr for CLI option parsing and db-worker-node API calls. - stdout remains reserved for command output; large payloads are truncated in debug previews. +Timeouts: +- `--timeout-ms` continues to control request timeout behavior for CLI transport. +- Login callback timeout is controlled separately by `:login-timeout-ms` / `LOGSEQ_CLI_LOGIN_TIMEOUT_MS` and defaults to 5 minutes. +- Logout callback timeout is controlled separately by `:logout-timeout-ms` / `LOGSEQ_CLI_LOGOUT_TIMEOUT_MS` and defaults to 2 minutes. + ## Commands Graph commands: @@ -85,6 +105,10 @@ Server commands: - `server restart --graph ` - restart db-worker-node for a graph - `doctor [--dev-script]` - run runtime diagnostics for `db-worker-node.js`, `data-dir` permissions, and running server readiness (`--dev-script` checks `static/db-worker-node.js` explicitly) +Auth commands: +- `login` - authenticate this machine and create/update `~/logseq/auth.json` +- `logout` - remove persisted CLI auth from `~/logseq/auth.json` + Server ownership behavior: - `server stop` and `server restart` can return `server-owned-by-other` if the daemon was started by another owner source. - `server start` can return `server-start-timeout-orphan` when lock creation times out and orphan matching processes are detected. @@ -96,12 +120,12 @@ Sync commands: - `sync stop --graph ` - stop db-sync client on a graph daemon - `sync upload --graph ` - upload local graph snapshot to remote - `sync download --graph ` - download remote graph `` into a same-name local graph directory -- `sync remote-graphs [--graph ]` - list remote graphs visible to the current auth context +- `sync remote-graphs [--graph ]` - list remote graphs visible to the current login context - `sync ensure-keys [--graph ]` - ensure user RSA keys for sync/e2ee - `sync grant-access --graph --graph-id --email ` - grant encrypted graph access to a user -- `sync config set [--graph ] ws-url|http-base|auth-token|e2ee-password ` - set db-sync runtime config key -- `sync config get [--graph ] ws-url|http-base|auth-token|e2ee-password` - get db-sync runtime config key -- `sync config unset [--graph ] ws-url|http-base|auth-token|e2ee-password` - remove db-sync runtime config key +- `sync config set [--graph ] ws-url|http-base|e2ee-password ` - set non-auth db-sync runtime config key +- `sync config get [--graph ] ws-url|http-base|e2ee-password` - get non-auth db-sync runtime config key +- `sync config unset [--graph ] ws-url|http-base|e2ee-password` - remove non-auth db-sync runtime config key Sync upload behavior: - `sync upload` requires `--graph `. @@ -110,9 +134,9 @@ Sync upload behavior: - If the local graph does not have a stored remote `graph-id`, upload first lists visible remote graphs and reuses an exact same-name match when one exists. - If no same-name remote graph exists, upload creates a new remote graph and persists the returned remote metadata locally before snapshot transfer. - Successful upload persists graph identity metadata locally in both client-op state and graph KV (`logseq.kv/graph-uuid`, `logseq.kv/graph-remote?`, and `logseq.kv/graph-rtc-e2ee?`) so CLI and web upload/bootstrap flows stay aligned. -- Fresh uploads default to encrypted remote graph creation unless local sync metadata explicitly marks the graph as non-e2ee. In headless CLI mode, set `e2ee-password` via `sync config set` (or in `--config`) before uploading encrypted graphs. -- `sync upload` returns a real error instead of false success when auth, remote graph bootstrap, or snapshot upload fails. -- Common upload failures include missing/invalid `auth-token`, missing `http-base`, remote graph creation failure, snapshot upload failure, and local DB/worker startup failure. +- Fresh uploads default to encrypted remote graph creation unless local sync metadata explicitly marks the graph as non-e2ee. In headless CLI mode, run `logseq login` first and set `e2ee-password` via `sync config set` (or in `--config`) before uploading encrypted graphs. +- `sync upload` returns a real error instead of false success when login state, remote graph bootstrap, or snapshot upload fails. +- Common upload failures include missing/invalid CLI login state, missing `http-base`, remote graph creation failure, snapshot upload failure, and local DB/worker startup failure. - Troubleshooting: after a successful upload, run `graph info --graph --output json` and confirm `data.kv.logseq.kv/graph-uuid` is present. If it is missing, rerun `sync upload` for the same graph to trigger identity backfill. Sync download behavior: @@ -121,12 +145,13 @@ Sync download behavior: - If no remote graph with that name exists, the CLI returns `remote-graph-not-found`. - `sync download` starts `db-worker-node` in create-empty mode so local startup does not write `db-initial-data` before snapshot import. - If the target graph DB is not empty at download time, the CLI returns `graph-db-not-empty` and aborts before import. -- For e2ee remote graphs in headless CLI mode, set `e2ee-password` via `sync config set` (or in `--config`) before download. +- For e2ee remote graphs in headless CLI mode, run `logseq login` first and set `e2ee-password` via `sync config set` (or in `--config`) before download. Sync config persistence: -- `sync config set/unset` writes to the CLI config file selected by `--config`. +- `sync config set/unset` writes non-auth sync config to the CLI config file selected by `--config`. - If `--config` is not provided, the default config path is `~/logseq/cli.edn`. - `sync config get` reads from that same config source. +- Cloud auth is persisted separately in `~/logseq/auth.json`. Inspect and edit commands: - `list page [--expand] [--limit ] [--offset ] [--sort ] [--order asc|desc]` - list pages @@ -222,9 +247,14 @@ id7 │ └── b7 id8 └── b8 ``` +Troubleshooting: +- If authenticated sync commands fail with missing or invalid local auth, run `logseq logout` and then `logseq login` again. +- You can also manually remove `~/logseq/auth.json` and repeat `logseq login`. + Examples: ```bash +node ./dist/logseq.js login node ./dist/logseq.js graph create --graph demo node ./dist/logseq.js graph export --type edn --file /tmp/demo.edn --graph demo node ./dist/logseq.js graph import --type edn --input /tmp/demo.edn --graph demo-import @@ -236,4 +266,5 @@ node ./dist/logseq.js server list node ./dist/logseq.js doctor node ./dist/logseq.js doctor --dev-script node ./dist/logseq.js doctor --output json +node ./dist/logseq.js logout ``` diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 33645efdfc..289a2e2cff 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -42,8 +42,7 @@ frontend.modules.instrumentation.posthog/POSTHOG-TOKEN #shadow/env "LOGSEQ_POSTHOG_TOKEN" frontend.config/ENABLE-PLUGINS #shadow/env ["ENABLE_PLUGINS" :as :bool :default true] ;; Set to switch file sync server to dev, set this to false in `yarn watch` - frontend.config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env ["ENABLE_FILE_SYNC_PRODUCTION" :as :bool :default true] - frontend.config/ENABLE-RTC-SYNC-PRODUCTION #shadow/env ["ENABLE_RTC_SYNC_PRODUCTION" :as :bool :default true] + logseq.common.cognito-config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env ["ENABLE_FILE_SYNC_PRODUCTION" :as :bool :default true] frontend.config/ENABLE-DB-SYNC-LOCAL #shadow/env ["ENABLE_DB_SYNC_LOCAL" :as :bool :default false] frontend.config/REVISION #shadow/env ["LOGSEQ_REVISION" :default "dev"]} ;; set by git-revision-hook @@ -101,6 +100,7 @@ :output-to "static/logseq-cli.js" :main logseq.cli.main/main :build-hooks [(shadow.hooks/logseq-cli-metadata-hook "--long --always --dirty")] + :closure-defines {logseq.common.cognito-config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env ["ENABLE_FILE_SYNC_PRODUCTION" :as :bool :default true]} :compiler-options {:infer-externs :auto :source-map true :externs ["datascript/externs.js" @@ -167,8 +167,7 @@ frontend.modules.instrumentation.posthog/POSTHOG-TOKEN #shadow/env "LOGSEQ_POSTHOG_TOKEN" ;; frontend.config/ENABLE-PLUGINS #shadow/env ["ENABLE_PLUGINS" :as :bool :default true] ;; Set to switch file sync server to dev, set this to false in `yarn watch` - frontend.config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env ["ENABLE_FILE_SYNC_PRODUCTION" :as :bool :default true] - frontend.config/ENABLE-RTC-SYNC-PRODUCTION #shadow/env ["ENABLE_RTC_SYNC_PRODUCTION" :as :bool :default true] + logseq.common.cognito-config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env ["ENABLE_FILE_SYNC_PRODUCTION" :as :bool :default true] frontend.config/ENABLE-DB-SYNC-LOCAL #shadow/env ["ENABLE_DB_SYNC_LOCAL" :as :bool :default false] frontend.config/REVISION #shadow/env ["LOGSEQ_REVISION" :default "dev"]} diff --git a/src/main/frontend/config.cljs b/src/main/frontend/config.cljs index 9396d9c4c3..0539a99b61 100644 --- a/src/main/frontend/config.cljs +++ b/src/main/frontend/config.cljs @@ -5,6 +5,7 @@ [frontend.state :as state] [frontend.util :as util] [goog.crypt.Md5] + [logseq.common.cognito-config :as cognito-config] [logseq.common.config :as common-config] [logseq.common.path :as path] [logseq.db.sqlite.util :as sqlite-util] @@ -19,33 +20,27 @@ (goog-define REVISION "unknown") (defonce revision REVISION) -(goog-define ENABLE-FILE-SYNC-PRODUCTION false) - ;; this is a feature flag to enable the account tab ;; when it launches (when pro plan launches) it should be removed (def ENABLE-SETTINGS-ACCOUNT-TAB false) -(if ENABLE-FILE-SYNC-PRODUCTION - (do (def LOGIN-URL - "https://logseq-prod.auth.us-east-1.amazoncognito.com/login?client_id=3c7np6bjtb4r1k1bi9i049ops5&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback") - (def API-DOMAIN "api.logseq.com") +(def LOGIN-URL cognito-config/LOGIN-URL) +(def COGNITO-CLIENT-ID cognito-config/COGNITO-CLIENT-ID) +(def OAUTH-DOMAIN cognito-config/OAUTH-DOMAIN) + +(if cognito-config/ENABLE-FILE-SYNC-PRODUCTION + (do (def API-DOMAIN "api.logseq.com") (def COGNITO-IDP "https://cognito-idp.us-east-1.amazonaws.com/") - (def COGNITO-CLIENT-ID "69cs1lgme7p8kbgld8n5kseii6") (def REGION "us-east-1") (def USER-POOL-ID "us-east-1_dtagLnju8") (def IDENTITY-POOL-ID "us-east-1:d6d3b034-1631-402b-b838-b44513e93ee0") - (def OAUTH-DOMAIN "logseq-prod.auth.us-east-1.amazoncognito.com") (def PUBLISH-API-BASE "https://logseq.io")) - (do (def LOGIN-URL - "https://logseq-test2.auth.us-east-2.amazoncognito.com/login?client_id=3ji1a0059hspovjq5fhed3uil8&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback") - (def API-DOMAIN "api-dev.logseq.com") + (do (def API-DOMAIN "api-dev.logseq.com") (def COGNITO-IDP "https://cognito-idp.us-east-2.amazonaws.com/") - (def COGNITO-CLIENT-ID "1qi1uijg8b6ra70nejvbptis0q") (def REGION "us-east-2") (def USER-POOL-ID "us-east-2_kAqZcxIeM") (def IDENTITY-POOL-ID "us-east-2:cc7d2ad3-84d0-4faf-98fe-628f6b52c0a5") - (def OAUTH-DOMAIN "logseq-test2.auth.us-east-2.amazoncognito.com") (def PUBLISH-API-BASE "https://logseq-publish-staging.logseq.workers.dev"))) ;; Enable for local development diff --git a/src/main/logseq/cli/auth.cljs b/src/main/logseq/cli/auth.cljs new file mode 100644 index 0000000000..9c8dd95032 --- /dev/null +++ b/src/main/logseq/cli/auth.cljs @@ -0,0 +1,466 @@ +(ns logseq.cli.auth + "CLI auth helpers for persisted login state." + (:require [clojure.string :as string] + [logseq.cli.transport :as transport] + [logseq.common.cognito-config :as cognito-config] + [promesa.core :as p] + ["child_process" :as child-process] + ["crypto" :as crypto] + ["fs" :as fs] + ["http" :as http] + ["os" :as os] + ["path" :as node-path])) + +(def ^:private default-login-timeout-ms 300000) +(def ^:private default-logout-timeout-ms 120000) +(def ^:private redirect-path "/auth/callback") +(def ^:private logout-complete-path "/logout-complete") +(def ^:private callback-host "localhost") +(def ^:private callback-port 8765) +(def ^:private auth-provider "cognito") +(def ^:private default-scope "email openid phone") +(def ^:private token-endpoint-path "/oauth2/token") +(def ^:private authorize-endpoint-path "/oauth2/authorize") +(def ^:private logout-endpoint-path "/logout") + +(defn default-auth-path + [] + (node-path/join (.homedir os) "logseq" "auth.json")) + +(defn auth-path + [{custom-auth-path :auth-path}] + (or custom-auth-path (default-auth-path))) + +(defn- ensure-auth-dir! + [path] + (let [dir (node-path/dirname path)] + (when (and (seq dir) (not (fs/existsSync dir))) + (.mkdirSync fs dir #js {:recursive true})))) + +(defn- try-chmod! + [path] + (try + (.chmodSync fs path 384) + (catch :default _ + nil))) + +(defn- parse-json + [text] + (js->clj (js/JSON.parse text) :keywordize-keys true)) + +(defn- login-url + [] + (js/URL. cognito-config/LOGIN-URL)) + +(defn- oauth-client-id + [] + cognito-config/CLI-COGNITO-CLIENT-ID) + +(defn- oauth-scope + [] + (or (.get (.-searchParams (login-url)) "scope") + cognito-config/OAUTH-SCOPE + default-scope)) + +(defn- oauth-domain + [] + cognito-config/OAUTH-DOMAIN) + +(defn- logout-complete-uri + [] + (str "http://" callback-host ":" callback-port logout-complete-path)) + +(defn- token-endpoint + [] + (str "https://" (oauth-domain) token-endpoint-path)) + +(defn- authorize-endpoint + [] + (str "https://" (oauth-domain) authorize-endpoint-path)) + +(defn- logout-endpoint + [] + (str "https://" (oauth-domain) logout-endpoint-path)) + +(defn- build-logout-url + [] + (let [params (doto (js/URLSearchParams.) + (.set "client_id" (oauth-client-id)) + (.set "logout_uri" (logout-complete-uri)))] + (str (logout-endpoint) "?" (.toString params)))) + +(defn- parse-jwt + [jwt] + (when (seq jwt) + (try + (let [parts (string/split jwt #"\.") + payload (second parts)] + (when (seq payload) + (-> (js/Buffer.from payload "base64url") + (.toString "utf8") + parse-json))) + (catch :default _ + nil)))) + +(defn write-auth-file! + [opts auth-data] + (let [path (auth-path opts) + payload (js/JSON.stringify (clj->js auth-data) nil 2)] + (ensure-auth-dir! path) + (.writeFileSync fs path payload "utf8") + (try-chmod! path) + auth-data)) + +(defn read-auth-file + [opts] + (let [path (auth-path opts)] + (when (fs/existsSync path) + (try + (-> (fs/readFileSync path) + (.toString "utf8") + parse-json) + (catch :default e + (throw (ex-info "invalid auth file" + {:code :invalid-auth-file + :auth-path path} + e))))))) + +(defn delete-auth-file! + [opts] + (let [path (auth-path opts)] + (when (fs/existsSync path) + (.unlinkSync fs path)) + nil)) + +(declare start-logout-complete-server! open-browser!) + +(defn logout! + [opts] + (let [path (auth-path opts) + existed? (fs/existsSync path) + logout-url (build-logout-url)] + (delete-auth-file! opts) + (-> (p/let [callback-server (start-logout-complete-server! opts)] + (-> (p/let [open-result (open-browser! logout-url) + logout-completed? (if (:opened? open-result) + (-> ((:wait! callback-server)) + (p/then (constantly true)) + (p/catch (fn [_] + false))) + false)] + {:auth-path path + :deleted? existed? + :logout-url logout-url + :opened? (:opened? open-result) + :logout-completed? logout-completed?}) + (p/finally (fn [] + ((:stop! callback-server)))))) + (p/catch (fn [_] + {:auth-path path + :deleted? existed? + :logout-url logout-url + :opened? false + :logout-completed? false}))))) + +(defn expired-auth? + [{:keys [expires-at]}] + (or (not (number? expires-at)) + (<= expires-at (js/Date.now)))) + +(defn- random-base64url + [size] + (.toString (.randomBytes crypto size) "base64url")) + +(defn- code-challenge + [code-verifier] + (-> (.createHash crypto "sha256") + (.update code-verifier) + (.digest "base64url"))) + +(defn build-authorize-url + [{:keys [redirect-uri state pkce-challenge]}] + (let [params (doto (js/URLSearchParams.) + (.set "response_type" "code") + (.set "client_id" (oauth-client-id)) + (.set "scope" (oauth-scope)) + (.set "redirect_uri" redirect-uri) + (.set "state" state) + (.set "code_challenge" pkce-challenge) + (.set "code_challenge_method" "S256"))] + (str (authorize-endpoint) "?" (.toString params)))) + +(defn- stop-server! + [server] + (if (some? server) + (p/create (fn [resolve _reject] + (.close server (fn [] + (resolve true))))) + (p/resolved true))) + +(defn start-login-callback-server! + [{:keys [state login-timeout-ms]}] + (p/create + (fn [resolve reject] + (let [callback-handlers (atom nil) + settled? (atom false) + callback-promise (p/create (fn [resolve' reject'] + (reset! callback-handlers {:resolve resolve' + :reject reject'}))) + finish! (fn [kind payload] + (when-not @settled? + (reset! settled? true) + (when-let [{:keys [resolve reject]} @callback-handlers] + ((if (= kind :resolve) resolve reject) payload)))) + server (.createServer http + (fn [^js req ^js res] + (let [url (js/URL. (str "http://" callback-host (.-url req))) + pathname (.-pathname url) + params (.-searchParams url) + code (.get params "code") + callback-state (.get params "state") + error-code (.get params "error")] + (cond + (not= redirect-path pathname) + (do + (.writeHead res 404 #js {"Content-Type" "text/plain; charset=utf-8"}) + (.end res "Not found")) + + (seq error-code) + (do + (.writeHead res 400 #js {"Content-Type" "text/plain; charset=utf-8"}) + (.end res "Login failed. You can return to the CLI.") + (finish! :reject (ex-info "login callback returned oauth error" + {:code :login-callback-error + :oauth-error error-code}))) + + (not= state callback-state) + (do + (.writeHead res 400 #js {"Content-Type" "text/plain; charset=utf-8"}) + (.end res "Login failed due to state mismatch. Return to the CLI and retry.") + (finish! :reject (ex-info "login callback state mismatch" + {:code :invalid-callback-state}))) + + (not (seq code)) + (do + (.writeHead res 400 #js {"Content-Type" "text/plain; charset=utf-8"}) + (.end res "Login failed because the callback did not include a code.") + (finish! :reject (ex-info "missing authorization code" + {:code :missing-callback-code}))) + + :else + (do + (.writeHead res 200 #js {"Content-Type" "text/plain; charset=utf-8"}) + (.end res "Login successful. You can return to the CLI.") + (finish! :resolve {:code code})))))) + timeout-id (js/setTimeout (fn [] + (finish! :reject (ex-info "login callback timed out" + {:code :login-timeout}))) + (or login-timeout-ms default-login-timeout-ms))] + (.on server "error" (fn [error] + (js/clearTimeout timeout-id) + (reject (ex-info "failed to start login callback server" + {:code :login-callback-server-start-failed} + error)))) + (.listen server callback-port callback-host + (fn [] + (let [address (.address server) + port (.-port address) + redirect-uri (str "http://" callback-host ":" port redirect-path)] + (resolve {:port port + :redirect-uri redirect-uri + :wait! (fn [] + (-> callback-promise + (p/finally (fn [] + (js/clearTimeout timeout-id))))) + :stop! (fn [] + (js/clearTimeout timeout-id) + (stop-server! server))})))))))) + +(defn start-logout-complete-server! + [{:keys [logout-timeout-ms]}] + (p/create + (fn [resolve reject] + (let [settled? (atom false) + callback-handlers (atom nil) + callback-promise (p/create (fn [resolve' reject'] + (reset! callback-handlers {:resolve resolve' + :reject reject'}))) + finish! (fn [kind payload] + (when-not @settled? + (reset! settled? true) + (when-let [{:keys [resolve reject]} @callback-handlers] + ((if (= kind :resolve) resolve reject) payload)))) + server (.createServer http + (fn [^js req ^js res] + (let [url (js/URL. (str "http://" callback-host (.-url req))) + pathname (.-pathname url)] + (if (= logout-complete-path pathname) + (do + (.writeHead res 200 #js {"Content-Type" "text/plain; charset=utf-8"}) + (.end res "Logout successful. You can return to the CLI.") + (finish! :resolve true)) + (do + (.writeHead res 404 #js {"Content-Type" "text/plain; charset=utf-8"}) + (.end res "Not found")))))) + timeout-id (js/setTimeout (fn [] + (finish! :reject (ex-info "logout callback timed out" + {:code :logout-timeout}))) + (or logout-timeout-ms default-logout-timeout-ms))] + (.on server "error" (fn [error] + (js/clearTimeout timeout-id) + (reject (ex-info "failed to start logout callback server" + {:code :logout-callback-server-start-failed} + error)))) + (.listen server callback-port callback-host + (fn [] + (resolve {:logout-uri (logout-complete-uri) + :wait! (fn [] + (-> callback-promise + (p/finally (fn [] + (js/clearTimeout timeout-id))))) + :stop! (fn [] + (js/clearTimeout timeout-id) + (stop-server! server))}))))))) + +(defn open-browser! + [url] + (let [platform (.-platform js/process) + [command args] (case platform + "darwin" ["open" [url]] + "linux" ["xdg-open" [url]] + "win32" ["cmd" ["/c" "start" "" url]] + [nil nil])] + (if-not (seq command) + (p/resolved {:opened? false}) + (p/create + (fn [resolve _reject] + (try + (let [child (.spawn child-process command (clj->js args) + #js {:detached true + :stdio "ignore" + :shell false})] + (.unref child) + (resolve {:opened? true + :command command})) + (catch :default e + (resolve {:opened? false + :command command + :error (or (.-message e) (str e))})))))))) + +(defn- oauth-token-request! + [params] + (let [search-params (js/URLSearchParams.)] + (.set search-params "client_id" (oauth-client-id)) + (doseq [[k v] params] + (.set search-params k (str v))) + (let [body (.toString search-params)] + (-> (transport/request {:method "POST" + :url (token-endpoint) + :headers {"Content-Type" "application/x-www-form-urlencoded" + "Accept" "application/json"} + :body body + :timeout-ms 10000}) + (p/then (fn [{:keys [body]}] + (parse-json body))))))) + +(defn- token-body->auth-data + [token-body current-auth] + (let [id-token (:id_token token-body) + claims (parse-jwt id-token) + refresh-token (or (:refresh_token token-body) + (:refresh-token current-auth))] + {:provider auth-provider + :id-token id-token + :access-token (:access_token token-body) + :refresh-token refresh-token + :expires-at (some-> (:exp claims) (* 1000)) + :sub (:sub claims) + :email (:email claims) + :updated-at (js/Date.now)})) + +(defn exchange-code-for-auth! + [_opts {:keys [code redirect-uri code-verifier]}] + (-> (oauth-token-request! {"grant_type" "authorization_code" + "code" code + "redirect_uri" redirect-uri + "code_verifier" code-verifier}) + (p/then (fn [token-body] + (token-body->auth-data token-body nil))) + (p/catch (fn [error] + (let [data (ex-data error)] + (p/rejected + (ex-info "authorization code exchange failed" + (merge {:code :auth-code-exchange-failed} + (when data {:context data})) + error))))))) + +(defn refresh-auth! + [opts auth-data] + (let [refresh-token (:refresh-token auth-data)] + (if (seq refresh-token) + (-> (oauth-token-request! {"grant_type" "refresh_token" + "refresh_token" refresh-token}) + (p/then (fn [token-body] + (token-body->auth-data token-body auth-data))) + (p/catch (fn [error] + (let [data (ex-data error) + parsed-body (try + (some-> (:body data) parse-json) + (catch :default _ + nil))] + (if (= "invalid_grant" (:error parsed-body)) + (p/rejected + (ex-info "refresh token is invalid" + {:code :missing-auth + :hint "Run logseq login first." + :auth-path (auth-path opts)} + error)) + (p/rejected + (ex-info "auth refresh failed" + {:code :auth-refresh-failed + :hint "Run logseq login first." + :auth-path (auth-path opts) + :context data} + error))))))) + (p/rejected (ex-info "missing refresh token" + {:code :missing-auth + :hint "Run logseq login first." + :auth-path (auth-path opts)}))))) + +(defn login! + [opts] + (let [state (or (:state opts) (random-base64url 24)) + code-verifier (or (:code-verifier opts) (random-base64url 48)) + authorize-payload {:state state + :pkce-challenge (code-challenge code-verifier)}] + (p/let [callback-server (start-login-callback-server! (merge opts {:state state})) + redirect-uri (:redirect-uri callback-server) + authorize-url (build-authorize-url (assoc authorize-payload :redirect-uri redirect-uri))] + (-> (p/let [open-result (open-browser! authorize-url) + callback-result ((:wait! callback-server)) + auth-data (exchange-code-for-auth! opts {:code (:code callback-result) + :redirect-uri redirect-uri + :code-verifier code-verifier}) + _ (write-auth-file! opts auth-data)] + {:auth-path (auth-path opts) + :authorize-url authorize-url + :opened? (:opened? open-result) + :email (:email auth-data) + :sub (:sub auth-data) + :updated-at (:updated-at auth-data)}) + (p/finally (fn [] + ((:stop! callback-server)))))))) + +(defn resolve-auth-token! + [opts] + (if-let [current-auth (read-auth-file opts)] + (if (expired-auth? current-auth) + (p/let [refreshed-auth (refresh-auth! opts current-auth) + next-auth (merge current-auth refreshed-auth)] + (write-auth-file! opts next-auth) + (:id-token next-auth)) + (p/resolved (:id-token current-auth))) + (p/rejected (ex-info "missing auth" + {:code :missing-auth + :hint "Run logseq login first." + :auth-path (auth-path opts)})))) diff --git a/src/main/logseq/cli/command/auth.cljs b/src/main/logseq/cli/command/auth.cljs new file mode 100644 index 0000000000..628573232a --- /dev/null +++ b/src/main/logseq/cli/command/auth.cljs @@ -0,0 +1,56 @@ +(ns logseq.cli.command.auth + "Authentication-related CLI commands." + (:require [logseq.cli.auth :as cli-auth] + [logseq.cli.command.core :as core] + [promesa.core :as p])) + +(def entries + [(core/command-entry ["login"] + :login + "Authenticate this machine with Logseq cloud" + {}) + (core/command-entry ["logout"] + :logout + "Remove persisted CLI auth" + {})]) + +(defn build-action + [command] + {:ok? true + :action {:type command}}) + +(defn- ex-message->code + [message] + (when (and (string? message) + (re-matches #"[a-zA-Z0-9._/\-]+" message)) + (keyword message))) + +(defn- exception->error + [error] + (let [data (or (ex-data error) {}) + code (or (:code data) + (ex-message->code (ex-message error)) + :exception)] + {:status :error + :error (merge {:code code + :message (or (ex-message error) (str error))} + (when (seq data) {:context data}))})) + +(defn execute + [action config] + (case (:type action) + :login + (-> (p/let [data (cli-auth/login! config)] + {:status :ok + :data data}) + (p/catch exception->error)) + + :logout + (-> (p/let [data (cli-auth/logout! config)] + {:status :ok + :data data}) + (p/catch exception->error)) + + (p/resolved {:status :error + :error {:code :unknown-action + :message "unknown auth action"}}))) diff --git a/src/main/logseq/cli/command/core.cljs b/src/main/logseq/cli/command/core.cljs index 05059c6642..c0134a6807 100644 --- a/src/main/logseq/cli/command/core.cljs +++ b/src/main/logseq/cli/command/core.cljs @@ -99,7 +99,9 @@ (let [groups [{:title "Graph Inspect and Edit" :commands #{"list" "upsert" "remove" "query" "show"}} {:title "Graph Management" - :commands #{"graph" "server" "doctor" "sync"}}] + :commands #{"graph" "server" "doctor" "sync"}} + {:title "Authentication" + :commands #{"login" "logout"}}] render-group (fn [{:keys [title commands]}] (let [entries (filter #(contains? commands (first (:cmds %))) table)] (string/join "\n" [title (format-commands entries)])))] diff --git a/src/main/logseq/cli/command/sync.cljs b/src/main/logseq/cli/command/sync.cljs index 2be65fa2ca..bdbe2f8c72 100644 --- a/src/main/logseq/cli/command/sync.cljs +++ b/src/main/logseq/cli/command/sync.cljs @@ -1,6 +1,7 @@ (ns logseq.cli.command.sync "Sync-related CLI commands." (:require [clojure.string :as string] + [logseq.cli.auth :as cli-auth] [logseq.cli.command.core :as core] [logseq.cli.config :as cli-config] [logseq.cli.server :as cli-server] @@ -27,9 +28,16 @@ (def ^:private config-key-map {"ws-url" :ws-url "http-base" :http-base - "auth-token" :auth-token "e2ee-password" :e2ee-password}) +(def ^:private authenticated-sync-actions + #{:sync-start + :sync-upload + :sync-download + :sync-remote-graphs + :sync-ensure-keys + :sync-grant-access}) + (def ^:private sync-start-timeout-ms 10000) (def ^:private sync-start-poll-interval-ms 100) @@ -190,6 +198,15 @@ :auth-token (:auth-token config) :e2ee-password (:e2ee-password config)}) +(defn- resolve-runtime-config! + [action config] + (if (contains? authenticated-sync-actions (:type action)) + (if (seq (:auth-token config)) + (p/resolved config) + (p/let [auth-token (cli-auth/resolve-auth-token! config)] + (assoc config :auth-token auth-token))) + (p/resolved config))) + (defn- invoke-with-repo [config repo method args] (let [sync-cfg (sync-config config)] @@ -235,7 +252,7 @@ (let [timeout-ms (max 0 (or (:wait-timeout-ms action) sync-start-timeout-ms)) poll-interval-ms (max 0 (or (:wait-poll-interval-ms action) sync-start-poll-interval-ms)) deadline (+ (js/Date.now) timeout-ms) - config-skipped-hint "Set sync config keys (ws-url/http-base/auth-token) and retry sync start." + config-skipped-hint "Run logseq login, set sync config keys (ws-url/http-base), and retry sync start." graph-id-skipped-hint "Graph-id is missing locally. Run sync download first, then retry sync start." runtime-error-hint "Run sync status to inspect last-error and fix sync runtime error before retrying." timeout-hint "Run sync status to inspect ws-state and ensure sync endpoint/token are valid."] @@ -336,10 +353,11 @@ :data result}) :sync-start - (-> (p/let [_ (invoke-with-repo config (:repo action) + (-> (p/let [config' (resolve-runtime-config! action config) + _ (invoke-with-repo config' (:repo action) :thread-api/db-sync-start [(:repo action)]) - result (wait-sync-start-ready config (:repo action) action)] + result (wait-sync-start-ready config' (:repo action) action)] result) (p/catch (fn [error] (exception->error error {:repo (:repo action)})))) @@ -352,27 +370,45 @@ :data {:result result}}) :sync-upload - (execute-sync-upload action config) + (-> (p/let [config' (resolve-runtime-config! action config)] + (execute-sync-upload action config')) + (p/catch (fn [error] + (exception->error error {:repo (:repo action)})))) :sync-download - (execute-sync-download action config) + (-> (p/let [config' (resolve-runtime-config! action config)] + (execute-sync-download action config')) + (p/catch (fn [error] + (exception->error error {:repo (:repo action) + :graph (:graph action)})))) :sync-remote-graphs - (p/let [graphs (invoke-global config :thread-api/db-sync-list-remote-graphs [])] - {:status :ok - :data {:graphs (or graphs [])}}) + (-> (p/let [config' (resolve-runtime-config! action config) + graphs (invoke-global config' :thread-api/db-sync-list-remote-graphs [])] + {:status :ok + :data {:graphs (or graphs [])}}) + (p/catch (fn [error] + (exception->error error nil)))) :sync-ensure-keys - (p/let [result (invoke-global config :thread-api/db-sync-ensure-user-rsa-keys [])] - {:status :ok - :data {:result result}}) + (-> (p/let [config' (resolve-runtime-config! action config) + result (invoke-global config' :thread-api/db-sync-ensure-user-rsa-keys [])] + {:status :ok + :data {:result result}}) + (p/catch (fn [error] + (exception->error error nil)))) :sync-grant-access - (p/let [result (invoke-with-repo config (:repo action) - :thread-api/db-sync-grant-graph-access - [(:repo action) (:graph-id action) (:email action)])] - {:status :ok - :data {:result result}}) + (-> (p/let [config' (resolve-runtime-config! action config) + result (invoke-with-repo config' (:repo action) + :thread-api/db-sync-grant-graph-access + [(:repo action) (:graph-id action) (:email action)])] + {:status :ok + :data {:result result}}) + (p/catch (fn [error] + (exception->error error {:repo (:repo action) + :graph-id (:graph-id action) + :email (:email action)})))) :sync-config-get (p/let [current config] diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index 5e21a2033d..21b178250e 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -2,6 +2,7 @@ "Command parsing and action building for the Logseq CLI." (:require [babashka.cli :as cli] [clojure.string :as string] + [logseq.cli.command.auth :as auth-command] [logseq.cli.command.core :as command-core] [logseq.cli.command.doctor :as doctor-command] [logseq.cli.command.graph :as graph-command] @@ -103,7 +104,8 @@ query-command/entries show-command/entries doctor-command/entries - sync-command/entries))) + sync-command/entries + auth-command/entries))) ;; Global option parsing lives in logseq.cli.command.core. @@ -417,6 +419,9 @@ :sync-config-set :sync-config-get :sync-config-unset) (sync-command/build-action command options args repo) + (:login :logout) + (auth-command/build-action command) + {:ok? false :error {:code :unknown-command :message (str "unknown command: " command)}})))) @@ -466,6 +471,8 @@ :sync-remote-graphs :sync-ensure-keys :sync-grant-access :sync-config-set :sync-config-get :sync-config-unset) (sync-command/execute action config) + (:login :logout) + (auth-command/execute action config) {:status :error :error {:code :unknown-action :message "unknown action"}}))] diff --git a/src/main/logseq/cli/config.cljs b/src/main/logseq/cli/config.cljs index e050e113f3..4874c7d6c6 100644 --- a/src/main/logseq/cli/config.cljs +++ b/src/main/logseq/cli/config.cljs @@ -28,11 +28,19 @@ [] (node-path/join (.homedir os) "logseq" "cli.edn")) +(def ^:private removed-config-keys + #{:auth-token :retries}) + +(defn- sanitize-file-config + [config] + (apply dissoc (or config {}) removed-config-keys)) + (defn- read-config-file [config-path] (when (and (some? config-path) (fs/existsSync config-path)) (let [contents (.toString (fs/readFileSync config-path) "utf8")] - (reader/read-string contents)))) + (-> (reader/read-string contents) + sanitize-file-config)))) (defn- ensure-config-dir! [config-path] @@ -45,8 +53,8 @@ [{:keys [config-path]} updates] (let [path (or config-path (default-config-path)) current (or (read-config-file path) {}) - filtered-current (dissoc current :retries) - filtered-updates (dissoc (or updates {}) :retries) + filtered-current (sanitize-file-config current) + filtered-updates (sanitize-file-config updates) nil-keys (->> filtered-updates (keep (fn [[k v]] (when (nil? v) @@ -72,6 +80,12 @@ (seq (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS")) (assoc :timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS"))) + (seq (gobj/get env "LOGSEQ_CLI_LOGIN_TIMEOUT_MS")) + (assoc :login-timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_LOGIN_TIMEOUT_MS"))) + + (seq (gobj/get env "LOGSEQ_CLI_LOGOUT_TIMEOUT_MS")) + (assoc :logout-timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_LOGOUT_TIMEOUT_MS"))) + (seq (gobj/get env "LOGSEQ_CLI_OUTPUT")) (assoc :output-format (parse-output-format (gobj/get env "LOGSEQ_CLI_OUTPUT"))) @@ -81,6 +95,8 @@ (defn resolve-config [opts] (let [defaults {:timeout-ms 10000 + :login-timeout-ms 300000 + :logout-timeout-ms 120000 :output-format nil :data-dir "~/logseq/graphs" :config-path (default-config-path)} diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index 4d267f083e..57f3445987 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -341,11 +341,23 @@ (update data :kv redact-graph-kv) data)) +(defn- sanitize-auth-data + [data] + (if (map? data) + (apply dissoc data [:id-token :access-token :refresh-token]) + data)) + (defn- sanitize-result [result] - (if (and (= :ok (:status result)) - (= :graph-info (:command result))) + (cond + (and (= :ok (:status result)) + (= :graph-info (:command result))) (update result :data sanitize-graph-info-data) + + (= :login (:command result)) + (update result :data sanitize-auth-data) + + :else result)) (defn- format-sync-status @@ -406,6 +418,28 @@ [{:keys [key]}] (str "sync config unset: " (name key))) +(defn- format-login + [{:keys [auth-path email sub]}] + (string/join "\n" + (cond-> ["Login successful" + (str "Auth file: " (or auth-path "-"))] + (seq email) (conj (str "Email: " email)) + (seq sub) (conj (str "User: " sub))))) + +(defn- format-logout + [{:keys [auth-path deleted? opened? logout-completed?]}] + (string/join "\n" + (cond-> [(str (if deleted? + "Logged out" + "Already logged out") + ": " + (or auth-path "-"))] + logout-completed? (conj "Cognito logout: completed") + (and (not logout-completed?) (true? opened?)) + (conj "Cognito logout: browser opened, completion not confirmed") + (false? opened?) + (conj "Cognito logout: could not open browser")))) + (defn- format-upsert-block [{:keys [repo source target update-tags update-properties remove-tags remove-properties]} result] (if (vector? result) @@ -508,6 +542,8 @@ :sync-config-get (format-sync-config-get data) :sync-config-set (format-sync-config-set data) :sync-config-unset (format-sync-config-unset data) + :login (format-login data) + :logout (format-logout data) :list-page (format-list-page (:items data) now-ms) :list-tag (format-list-tag (:items data) now-ms) :list-property (format-list-property (:items data) now-ms) diff --git a/src/test/logseq/cli/auth_test.cljs b/src/test/logseq/cli/auth_test.cljs new file mode 100644 index 0000000000..b5b25a96c9 --- /dev/null +++ b/src/test/logseq/cli/auth_test.cljs @@ -0,0 +1,102 @@ +(ns logseq.cli.auth-test + (:require [cljs.test :refer [async deftest is]] + [frontend.test.node-helper :as node-helper] + [logseq.cli.auth :as auth] + [promesa.core :as p] + ["fs" :as fs] + ["os" :as os] + ["path" :as node-path])) + +(defn- sample-auth + ([] + (sample-auth {})) + ([overrides] + (merge {:provider "cognito" + :id-token "id-token-1" + :access-token "access-token-1" + :refresh-token "refresh-token-1" + :expires-at (+ (js/Date.now) 3600000) + :sub "user-123" + :email "user@example.com" + :updated-at 1735686000000} + overrides))) + +(defn- read-json-file + [path] + (-> (fs/readFileSync path) + (.toString "utf8") + js/JSON.parse + (js->clj :keywordize-keys true))) + +(deftest test-default-auth-path + (is (= (node-path/join (.homedir os) "logseq" "auth.json") + (auth/default-auth-path)))) + +(deftest test-write-auth-file-creates-parent-dir + (let [dir (node-helper/create-tmp-dir "cli-auth") + auth-dir (node-path/join dir "nested" "tokens") + auth-path (node-path/join auth-dir "auth.json") + payload (sample-auth)] + (is (not (fs/existsSync auth-dir))) + (auth/write-auth-file! {:auth-path auth-path} payload) + (is (fs/existsSync auth-dir)) + (is (fs/existsSync auth-path)) + (when (fs/existsSync auth-path) + (is (= payload (read-json-file auth-path)))))) + +(deftest test-read-auth-file-returns-nil-when-missing + (let [dir (node-helper/create-tmp-dir "cli-auth") + auth-path (node-path/join dir "missing" "auth.json")] + (is (nil? (auth/read-auth-file {:auth-path auth-path}))))) + +(deftest test-delete-auth-file-is-idempotent + (let [dir (node-helper/create-tmp-dir "cli-auth") + auth-path (node-path/join dir "auth.json")] + (auth/delete-auth-file! {:auth-path auth-path}) + (is (not (fs/existsSync auth-path))) + (auth/write-auth-file! {:auth-path auth-path} (sample-auth)) + (is (fs/existsSync auth-path)) + (auth/delete-auth-file! {:auth-path auth-path}) + (is (not (fs/existsSync auth-path))))) + +(deftest test-read-auth-file-invalid-json + (let [dir (node-helper/create-tmp-dir "cli-auth") + auth-path (node-path/join dir "auth.json")] + (fs/writeFileSync auth-path "{\"provider\":") + (try + (auth/read-auth-file {:auth-path auth-path}) + (is false "expected invalid-auth-file error") + (catch :default e + (is (= :invalid-auth-file (-> e ex-data :code))) + (is (= auth-path (-> e ex-data :auth-path))))))) + +(deftest test-resolve-auth-token-refreshes-expired-token + (async done + (let [dir (node-helper/create-tmp-dir "cli-auth") + auth-path (node-path/join dir "auth.json") + refresh-calls (atom []) + expired-auth (sample-auth {:id-token "expired-id-token" + :access-token "expired-access-token" + :expires-at 0}) + refreshed-auth (sample-auth {:id-token "fresh-id-token" + :access-token "fresh-access-token" + :refresh-token "refresh-token-1" + :expires-at (+ (js/Date.now) 7200000) + :updated-at 1735689600000})] + (auth/write-auth-file! {:auth-path auth-path} expired-auth) + (let [result-promise + (p/with-redefs [auth/refresh-auth! (fn [opts auth-data] + (swap! refresh-calls conj [opts auth-data]) + (p/resolved refreshed-auth))] + (p/let [token (auth/resolve-auth-token! {:auth-path auth-path}) + stored (auth/read-auth-file {:auth-path auth-path})] + (is (= [[{:auth-path auth-path} expired-auth]] @refresh-calls)) + (is (= "fresh-id-token" token)) + (is (= refreshed-auth stored)) + (when (fs/existsSync auth-path) + (is (= refreshed-auth (read-json-file auth-path))))))] + (-> result-promise + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (done)))))))) diff --git a/src/test/logseq/cli/command/auth_test.cljs b/src/test/logseq/cli/command/auth_test.cljs new file mode 100644 index 0000000000..4ced86b4b4 --- /dev/null +++ b/src/test/logseq/cli/command/auth_test.cljs @@ -0,0 +1,215 @@ +(ns logseq.cli.command.auth-test + (:require [cljs.test :refer [async deftest is]] + [frontend.test.node-helper :as node-helper] + [logseq.cli.auth :as cli-auth] + [logseq.cli.command.auth :as auth-command] + [logseq.common.cognito-config :as cognito-config] + [promesa.core :as p] + ["fs" :as fs] + ["path" :as node-path])) + +(defn- sample-auth + ([] + (sample-auth {})) + ([overrides] + (merge {:provider "cognito" + :id-token "id-token-1" + :access-token "access-token-1" + :refresh-token "refresh-token-1" + :expires-at (+ (js/Date.now) 3600000) + :sub "user-123" + :email "user@example.com" + :updated-at 1735686000000} + overrides))) + +(deftest test-login-opens-browser-with-authorize-url-and-persists-auth + (async done + (let [dir (node-helper/create-tmp-dir "cli-auth") + auth-path (node-path/join dir "auth.json") + open-calls (atom []) + exchange-calls (atom []) + write-calls (atom []) + auth-data (sample-auth) + callback-server {:port 8765 + :redirect-uri "http://localhost:8765/auth/callback" + :wait! (fn [] (p/resolved {:code "oauth-code"})) + :stop! (fn [] (p/resolved true))}] + (-> (p/with-redefs [cognito-config/CLI-COGNITO-CLIENT-ID "69cs1lgme7p8kbgld8n5kseii6" + cli-auth/start-login-callback-server! (fn [_opts] + (p/resolved callback-server)) + cli-auth/open-browser! (fn [url] + (swap! open-calls conj url) + (p/resolved {:opened? true})) + cli-auth/exchange-code-for-auth! (fn [opts payload] + (swap! exchange-calls conj [opts payload]) + (p/resolved auth-data)) + cli-auth/write-auth-file! (fn [opts payload] + (swap! write-calls conj [opts payload]) + payload)] + (p/let [result (cli-auth/login! {:auth-path auth-path}) + authorize-url (first @open-calls)] + (is (= 1 (count @open-calls))) + (is (string? authorize-url)) + (is (re-find #"/oauth2/authorize" authorize-url)) + (is (re-find #"response_type=code" authorize-url)) + (is (re-find #"client_id=69cs1lgme7p8kbgld8n5kseii6" authorize-url)) + (is (re-find #"redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fauth%2Fcallback" authorize-url)) + (is (re-find #"state=" authorize-url)) + (is (re-find #"code_challenge=" authorize-url)) + (is (= 1 (count @exchange-calls))) + (let [[exchange-opts exchange-payload] (first @exchange-calls)] + (is (= {:auth-path auth-path} exchange-opts)) + (is (= "oauth-code" (:code exchange-payload))) + (is (= "http://localhost:8765/auth/callback" (:redirect-uri exchange-payload))) + (is (string? (:code-verifier exchange-payload)))) + (is (= [[{:auth-path auth-path} auth-data]] @write-calls)) + (is (= auth-path (:auth-path result))) + (is (= "user@example.com" (:email result))) + (is (= "user-123" (:sub result))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] (done))))))) + +(deftest test-login-validates-state-before-code-exchange + (async done + (let [exchange-calls (atom [])] + (-> (p/with-redefs [cli-auth/open-browser! (fn [authorize-url] + (let [parsed (js/URL. authorize-url) + redirect-uri (.get (.-searchParams parsed) "redirect_uri")] + (-> (js/fetch (str redirect-uri "?code=oauth-code&state=wrong-state")) + (p/then (fn [_] + {:opened? true})))) ) + cli-auth/exchange-code-for-auth! (fn [opts payload] + (swap! exchange-calls conj [opts payload]) + (p/resolved (sample-auth)))] + (-> (cli-auth/login! {:timeout-ms 200}) + (p/then (fn [_] + (is false "expected invalid callback state error"))) + (p/catch (fn [e] + (is (= :invalid-callback-state (-> e ex-data :code))) + (is (= [] @exchange-calls)))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] (done))))))) + +(deftest test-login-timeout-when-no-browser-callback-arrives + (async done + (let [exchange-calls (atom [])] + (-> (p/with-redefs [cli-auth/open-browser! (fn [_authorize-url] + (p/resolved {:opened? false})) + cli-auth/exchange-code-for-auth! (fn [opts payload] + (swap! exchange-calls conj [opts payload]) + (p/resolved (sample-auth)))] + (-> (cli-auth/login! {:login-timeout-ms 10}) + (p/then (fn [_] + (is false "expected login timeout error"))) + (p/catch (fn [e] + (is (= :login-timeout (-> e ex-data :code))) + (is (= [] @exchange-calls)))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] (done))))))) + +(deftest test-login-ignores-global-request-timeout-for-callback-wait + (async done + (let [exchange-calls (atom [])] + (-> (p/with-redefs [cli-auth/open-browser! (fn [_authorize-url] + (p/resolved {:opened? false})) + cli-auth/exchange-code-for-auth! (fn [opts payload] + (swap! exchange-calls conj [opts payload]) + (p/resolved (sample-auth)))] + (-> (cli-auth/login! {:timeout-ms 10 + :login-timeout-ms 200}) + (p/then (fn [_] + (is false "expected login timeout error"))) + (p/catch (fn [e] + (is (= :login-timeout (-> e ex-data :code))) + (is (= [] @exchange-calls)))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] (done))))))) + +(deftest test-logout-removes-auth-file-and-completes-cognito-logout-when-file-existed + (async done + (let [dir (node-helper/create-tmp-dir "cli-auth") + auth-path (node-path/join dir "auth.json") + open-calls (atom [])] + (cli-auth/write-auth-file! {:auth-path auth-path} (sample-auth)) + (is (fs/existsSync auth-path)) + (-> (p/with-redefs [cognito-config/CLI-COGNITO-CLIENT-ID "69cs1lgme7p8kbgld8n5kseii6" + cli-auth/open-browser! (fn [url] + (swap! open-calls conj url) + (let [parsed (js/URL. url) + logout-uri (.get (.-searchParams parsed) "logout_uri")] + (-> (js/fetch logout-uri) + (p/then (fn [_] + {:opened? true})))))] + (p/let [result (cli-auth/logout! {:auth-path auth-path}) + logout-url (first @open-calls)] + (is (= 1 (count @open-calls))) + (is (= auth-path (:auth-path result))) + (is (true? (:deleted? result))) + (is (true? (:opened? result))) + (is (true? (:logout-completed? result))) + (is (not (fs/existsSync auth-path))) + (is (string? logout-url)) + (when (string? logout-url) + (is (re-find #"/logout\?" logout-url)) + (is (re-find #"client_id=69cs1lgme7p8kbgld8n5kseii6" logout-url)) + (is (re-find #"logout_uri=http%3A%2F%2Flocalhost%3A8765%2Flogout-complete" logout-url))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] (done))))))) + +(deftest test-logout-completes-cognito-logout-when-auth-file-is-already-absent + (async done + (let [dir (node-helper/create-tmp-dir "cli-auth") + auth-path (node-path/join dir "auth.json") + open-calls (atom [])] + (-> (p/with-redefs [cognito-config/CLI-COGNITO-CLIENT-ID "69cs1lgme7p8kbgld8n5kseii6" + cli-auth/open-browser! (fn [url] + (swap! open-calls conj url) + (let [parsed (js/URL. url) + logout-uri (.get (.-searchParams parsed) "logout_uri")] + (-> (js/fetch logout-uri) + (p/then (fn [_] + {:opened? true})))))] + (p/let [result (cli-auth/logout! {:auth-path auth-path}) + logout-url (first @open-calls)] + (is (= 1 (count @open-calls))) + (is (= auth-path (:auth-path result))) + (is (false? (:deleted? result))) + (is (true? (:opened? result))) + (is (true? (:logout-completed? result))) + (is (not (fs/existsSync auth-path))) + (is (string? logout-url)) + (when (string? logout-url) + (is (re-find #"logout_uri=http%3A%2F%2Flocalhost%3A8765%2Flogout-complete" logout-url))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] (done))))))) + +(deftest test-command-execute-login-and-logout + (async done + (let [login-calls (atom []) + logout-calls (atom [])] + (-> (p/with-redefs [cli-auth/login! (fn [config] + (swap! login-calls conj config) + (p/resolved {:auth-path "/tmp/auth.json" + :email "user@example.com" + :sub "user-123"})) + cli-auth/logout! (fn [config] + (swap! logout-calls conj config) + {:auth-path "/tmp/auth.json" + :deleted? true})] + (p/let [login-result (auth-command/execute {:type :login} {:auth-path "/tmp/auth.json"}) + logout-result (auth-command/execute {:type :logout} {:auth-path "/tmp/auth.json"})] + (is (= [{:auth-path "/tmp/auth.json"}] @login-calls)) + (is (= [{:auth-path "/tmp/auth.json"}] @logout-calls)) + (is (= :ok (:status login-result))) + (is (= "user@example.com" (get-in login-result [:data :email]))) + (is (= :ok (:status logout-result))) + (is (true? (get-in logout-result [:data :deleted?]))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] (done))))))) diff --git a/src/test/logseq/cli/command/sync_test.cljs b/src/test/logseq/cli/command/sync_test.cljs index 4278ed9a67..4e9f83791a 100644 --- a/src/test/logseq/cli/command/sync_test.cljs +++ b/src/test/logseq/cli/command/sync_test.cljs @@ -1,11 +1,16 @@ (ns logseq.cli.command.sync-test (:require [cljs.test :refer [async deftest is testing]] + [logseq.cli.auth :as cli-auth] [logseq.cli.command.sync :as sync-command] [logseq.cli.config :as cli-config] [logseq.cli.server :as cli-server] [logseq.cli.transport :as transport] [promesa.core :as p])) +(defn- execute-with-runtime-auth + [action config] + (sync-command/execute action (assoc config :auth-token "runtime-token"))) + (deftest test-build-action-validation (testing "sync status requires repo" (let [result (sync-command/build-action :sync-status {} [] nil)] @@ -25,12 +30,23 @@ (testing "sync config set requires name and value" (let [missing-both (sync-command/build-action :sync-config-set {} [] nil) - missing-value (sync-command/build-action :sync-config-set {} ["auth-token"] nil)] + missing-value (sync-command/build-action :sync-config-set {} ["ws-url"] nil)] (is (false? (:ok? missing-both))) (is (= :invalid-options (get-in missing-both [:error :code]))) (is (false? (:ok? missing-value))) (is (= :invalid-options (get-in missing-value [:error :code]))))) + (testing "sync config rejects auth-token key" + (let [set-result (sync-command/build-action :sync-config-set {} ["auth-token" "secret"] nil) + get-result (sync-command/build-action :sync-config-get {} ["auth-token"] nil) + unset-result (sync-command/build-action :sync-config-unset {} ["auth-token"] nil)] + (is (false? (:ok? set-result))) + (is (= :invalid-options (get-in set-result [:error :code]))) + (is (false? (:ok? get-result))) + (is (= :invalid-options (get-in get-result [:error :code]))) + (is (false? (:ok? unset-result))) + (is (= :invalid-options (get-in unset-result [:error :code]))))) + (testing "sync config accepts e2ee-password key" (let [result (sync-command/build-action :sync-config-set {} ["e2ee-password" "pw"] nil)] (is (true? (:ok? result))) @@ -63,7 +79,7 @@ :pending-asset 0 :pending-server 0})) (p/resolved {:ok true})))] - (p/let [result (sync-command/execute {:type :sync-start + (p/let [result (execute-with-runtime-auth {:type :sync-start :repo "logseq_db_demo"} {:data-dir "/tmp"}) invoked-methods (map first @invoke-calls)] @@ -92,7 +108,7 @@ :pending-asset 0 :pending-server 0}) (p/resolved {:ok true})))] - (p/let [result (sync-command/execute {:type :sync-start + (p/let [result (execute-with-runtime-auth {:type :sync-start :repo "logseq_db_demo" :wait-timeout-ms 20 :wait-poll-interval-ms 0} @@ -118,7 +134,7 @@ :pending-asset 0 :pending-server 0}) (p/resolved {:ok true})))] - (p/let [result (sync-command/execute {:type :sync-start + (p/let [result (execute-with-runtime-auth {:type :sync-start :repo "logseq_db_demo" :wait-timeout-ms 20 :wait-poll-interval-ms 0} @@ -153,7 +169,7 @@ :last-error {:code :decrypt-aes-key :message "decrypt-aes-key"}}))) (p/resolved {:ok true})))] - (p/let [result (sync-command/execute {:type :sync-start + (p/let [result (execute-with-runtime-auth {:type :sync-start :repo "logseq_db_demo" :wait-timeout-ms 200 :wait-poll-interval-ms 0} @@ -178,7 +194,8 @@ (p/let [_ (sync-command/execute {:type :sync-stop :repo "logseq_db_demo"} {:data-dir "/tmp"})] - (is (= [[{:data-dir "/tmp"} "logseq_db_demo"]] + (is (= [[{:data-dir "/tmp"} + "logseq_db_demo"]] @ensure-calls)) (is (= [[:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil @@ -200,14 +217,16 @@ transport/invoke (fn [_ method direct-pass? args] (swap! invoke-calls conj [method direct-pass? args]) (p/resolved {:ok true}))] - (p/let [_ (sync-command/execute {:type :sync-upload + (p/let [_ (execute-with-runtime-auth {:type :sync-upload :repo "logseq_db_demo"} {:data-dir "/tmp"})] - (is (= [[{:data-dir "/tmp"} "logseq_db_demo"]] + (is (= [[{:data-dir "/tmp" + :auth-token "runtime-token"} + "logseq_db_demo"]] @ensure-calls)) (is (= [[:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] [:thread-api/db-sync-upload-graph false ["logseq_db_demo"]]] @invoke-calls)))) @@ -231,7 +250,7 @@ :graph-id "graph-1"})) (p/resolved nil)))] - (p/let [result (sync-command/execute {:type :sync-upload + (p/let [result (execute-with-runtime-auth {:type :sync-upload :repo "logseq_db_demo"} {:data-dir "/tmp"})] (is (= :error (:status result))) @@ -261,26 +280,27 @@ :thread-api/db-sync-download-graph-by-id (p/resolved {:ok true}) (p/resolved nil)))] - (p/let [_ (sync-command/execute {:type :sync-download + (p/let [_ (execute-with-runtime-auth {:type :sync-download :repo "logseq_db_demo" :graph "demo"} {:base-url "http://example" :data-dir "/tmp"})] (is (= [[{:base-url "http://example" :create-empty-db? true - :data-dir "/tmp"} + :data-dir "/tmp" + :auth-token "runtime-token"} "logseq_db_demo"]] @ensure-calls)) (is (= [:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] (nth @invoke-calls 0))) (is (= [:thread-api/db-sync-list-remote-graphs false []] (nth @invoke-calls 1))) (is (= [:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] (nth @invoke-calls 2))) (let [[method direct-pass? args] (nth @invoke-calls 3)] @@ -312,30 +332,32 @@ :thread-api/db-sync-download-graph-by-id (p/resolved {:ok true}) (p/resolved nil)))] - (p/let [_ (sync-command/execute {:type :sync-download + (p/let [_ (execute-with-runtime-auth {:type :sync-download :repo "logseq_db_demo" :graph "demo"} {:graph "demo" :data-dir "/tmp"})] (is (= [[{:graph "demo" :create-empty-db? true - :data-dir "/tmp"} + :data-dir "/tmp" + :auth-token "runtime-token"} "logseq_db_demo"] [{:graph "demo" :create-empty-db? true - :data-dir "/tmp"} + :data-dir "/tmp" + :auth-token "runtime-token"} "logseq_db_demo"]] @ensure-calls)) (is (= [:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] (nth @invoke-calls 0))) (is (= [:thread-api/db-sync-list-remote-graphs false []] (nth @invoke-calls 1))) (is (= [:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] (nth @invoke-calls 2))) (let [[method direct-pass? args] (nth @invoke-calls 3)] @@ -362,7 +384,7 @@ :thread-api/db-sync-download-graph-by-id (p/resolved {:ok true}) (p/resolved nil)))] - (p/let [result (sync-command/execute {:type :sync-download + (p/let [result (execute-with-runtime-auth {:type :sync-download :repo "logseq_db_demo" :graph "demo"} {:base-url "http://example" @@ -371,7 +393,7 @@ (is (= :remote-graph-not-found (get-in result [:error :code]))) (is (= [[:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] [:thread-api/db-sync-list-remote-graphs false []]] @invoke-calls)))) @@ -396,7 +418,7 @@ {:code :db-sync/incomplete-snapshot-frame :graph-id "remote-graph-id"})) (p/resolved nil)))] - (p/let [result (sync-command/execute {:type :sync-download + (p/let [result (execute-with-runtime-auth {:type :sync-download :repo "logseq_db_demo" :graph "demo"} {:base-url "http://example" @@ -426,7 +448,7 @@ :thread-api/db-sync-download-graph-by-id (p/resolved {:ok true}) (p/resolved nil)))] - (p/let [result (sync-command/execute {:type :sync-download + (p/let [result (execute-with-runtime-auth {:type :sync-download :repo "logseq_db_demo" :graph "demo"} {:data-dir "/tmp"})] @@ -444,8 +466,12 @@ (deftest test-execute-sync-remote-graphs (async done (let [ensure-calls (atom []) - invoke-calls (atom [])] - (-> (p/with-redefs [cli-server/ensure-server! (fn [config repo] + invoke-calls (atom []) + auth-calls (atom [])] + (-> (p/with-redefs [cli-auth/resolve-auth-token! (fn [config] + (swap! auth-calls conj config) + (p/resolved "resolved-token")) + cli-server/ensure-server! (fn [config repo] (swap! ensure-calls conj [config repo]) (p/resolved (assoc config :base-url "http://example"))) transport/invoke (fn [_ method direct-pass? args] @@ -455,13 +481,18 @@ {:base-url "http://example" :http-base "https://sync.example.com" :ws-url "wss://sync.example.com/sync/%s" - :auth-token "test-token" :e2ee-password "pw" :data-dir "/tmp"})] (is (= [] @ensure-calls)) + (is (= [{:base-url "http://example" + :http-base "https://sync.example.com" + :ws-url "wss://sync.example.com/sync/%s" + :e2ee-password "pw" + :data-dir "/tmp"}] + @auth-calls)) (is (= [[:thread-api/set-db-sync-config false [{:ws-url "wss://sync.example.com/sync/%s" :http-base "https://sync.example.com" - :auth-token "test-token" + :auth-token "resolved-token" :e2ee-password "pw"}]] [:thread-api/db-sync-list-remote-graphs false []]] @invoke-calls)))) @@ -469,6 +500,27 @@ (is false (str "unexpected error: " e)))) (p/finally done))))) +(deftest test-execute-sync-remote-graphs-missing-auth + (async done + (let [invoke-calls (atom [])] + (-> (p/with-redefs [cli-auth/resolve-auth-token! (fn [_config] + (p/rejected (ex-info "missing auth" + {:code :missing-auth + :hint "Run logseq login first."}))) + transport/invoke (fn [_ method direct-pass? args] + (swap! invoke-calls conj [method direct-pass? args]) + (p/resolved []))] + (p/let [result (sync-command/execute {:type :sync-remote-graphs} + {:base-url "http://example" + :data-dir "/tmp"})] + (is (= :error (:status result))) + (is (= :missing-auth (get-in result [:error :code]))) + (is (= "Run logseq login first." (get-in result [:error :context :hint]))) + (is (= [] @invoke-calls)))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) + (deftest test-execute-sync-ensure-keys (async done (let [ensure-calls (atom []) @@ -479,13 +531,13 @@ transport/invoke (fn [_ method direct-pass? args] (swap! invoke-calls conj [method direct-pass? args]) (p/resolved {:ok true}))] - (p/let [_ (sync-command/execute {:type :sync-ensure-keys} + (p/let [_ (execute-with-runtime-auth {:type :sync-ensure-keys} {:base-url "http://example" :data-dir "/tmp"})] (is (= [] @ensure-calls)) (is (= [[:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] [:thread-api/db-sync-ensure-user-rsa-keys false []]] @invoke-calls)))) @@ -503,16 +555,18 @@ transport/invoke (fn [_ method direct-pass? args] (swap! invoke-calls conj [method direct-pass? args]) (p/resolved {:ok true}))] - (p/let [_ (sync-command/execute {:type :sync-grant-access + (p/let [_ (execute-with-runtime-auth {:type :sync-grant-access :repo "logseq_db_demo" :graph-id "graph-uuid" :email "user@example.com"} {:data-dir "/tmp"})] - (is (= [[{:data-dir "/tmp"} "logseq_db_demo"]] + (is (= [[{:data-dir "/tmp" + :auth-token "runtime-token"} + "logseq_db_demo"]] @ensure-calls)) (is (= [[:thread-api/set-db-sync-config false [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] [:thread-api/db-sync-grant-graph-access false ["logseq_db_demo" "graph-uuid" "user@example.com"]]] @invoke-calls)))) @@ -531,9 +585,9 @@ (swap! invoke-calls conj [method direct-pass? args]) (p/resolved {:ok true}))] (p/let [_ (sync-command/execute {:type :sync-config-get - :config-key :auth-token} + :config-key :ws-url} {:base-url "http://example" - :auth-token "abc" + :ws-url "wss://sync.example.com/sync/%s" :data-dir "/tmp"})] (is (= [] @ensure-calls)) (is (= [] @invoke-calls)))) @@ -552,15 +606,15 @@ (swap! update-calls conj [config updates]) (merge {:ws-url "wss://old.example/sync/%s"} updates))] (p/let [_ (sync-command/execute {:type :sync-config-set - :config-key :auth-token - :config-value "token-value"} + :config-key :ws-url + :config-value "wss://sync.example.com/sync/%s"} {:base-url "http://example" :config-path "/tmp/cli.edn" :data-dir "/tmp"})] (is (= [[{:base-url "http://example" :config-path "/tmp/cli.edn" :data-dir "/tmp"} - {:auth-token "token-value"}]] + {:ws-url "wss://sync.example.com/sync/%s"}]] @update-calls)) (is (= [] @invoke-calls)))) (p/catch (fn [e] @@ -576,18 +630,17 @@ (p/resolved nil)) cli-config/update-config! (fn [config updates] (swap! update-calls conj [config updates]) - (dissoc {:ws-url "wss://old.example/sync/%s" - :auth-token "token-value"} - :auth-token))] + (dissoc {:ws-url "wss://old.example/sync/%s"} + :ws-url))] (p/let [_ (sync-command/execute {:type :sync-config-unset - :config-key :auth-token} + :config-key :ws-url} {:base-url "http://example" :config-path "/tmp/cli.edn" :data-dir "/tmp"})] (is (= [[{:base-url "http://example" :config-path "/tmp/cli.edn" :data-dir "/tmp"} - {:auth-token nil}]] + {:ws-url nil}]] @update-calls)) (is (= [] @invoke-calls)))) (p/catch (fn [e] diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 4414037676..3156023b4b 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -59,6 +59,7 @@ (is (not (string/includes? plain-summary "--retries"))) (is (string/includes? plain-summary "Graph Inspect and Edit")) (is (string/includes? plain-summary "Graph Management")) + (is (string/includes? plain-summary "Authentication")) (is (string/includes? plain-summary "list")) (is (string/includes? plain-summary "upsert")) (is (string/includes? plain-summary "remove")) @@ -68,6 +69,8 @@ (is (string/includes? plain-summary "graph")) (is (string/includes? plain-summary "server")) (is (string/includes? plain-summary "sync")) + (is (string/includes? plain-summary "login")) + (is (string/includes? plain-summary "logout")) (is (string/includes? plain-summary "Path to db-worker data dir (default ~/logseq/graphs)")) (is (contains-bold? summary "list page")) (is (contains-bold? summary "list tag")) @@ -90,6 +93,8 @@ (is (contains-bold? summary "server start")) (is (contains-bold? summary "sync status")) (is (contains-bold? summary "sync start")) + (is (contains-bold? summary "login")) + (is (contains-bold? summary "logout")) (is (contains-bold? summary "--help")) (is (contains-bold? summary "--graph")) (is (re-find #"\u001b\[[0-9;]*mCommands\u001b\[[0-9;]*m:" summary)) @@ -209,6 +214,27 @@ (is (seq lines)) (is (every? #(not (string/includes? % "[options]")) lines))))) +(deftest test-parse-args-help-auth-commands + (testing "login command shows help" + (let [result (binding [style/*color-enabled?* true] + (commands/parse-args ["login" "--help"])) + summary (:summary result) + plain-summary (strip-ansi summary)] + (is (true? (:help? result))) + (is (string/includes? plain-summary "Usage: logseq login")) + (is (string/includes? plain-summary "Global options:")) + (is (string/includes? plain-summary "Command options:")))) + + (testing "logout command shows help" + (let [result (binding [style/*color-enabled?* true] + (commands/parse-args ["logout" "--help"])) + summary (:summary result) + plain-summary (strip-ansi summary)] + (is (true? (:help? result))) + (is (string/includes? plain-summary "Usage: logseq logout")) + (is (string/includes? plain-summary "Global options:")) + (is (string/includes? plain-summary "Command options:"))))) + (deftest test-parse-args-help-sync-group (testing "sync group shows subcommands" (let [result (binding [style/*color-enabled?* true] diff --git a/src/test/logseq/cli/config_test.cljs b/src/test/logseq/cli/config_test.cljs index 1c461500bf..a4bc352d78 100644 --- a/src/test/logseq/cli/config_test.cljs +++ b/src/test/logseq/cli/config_test.cljs @@ -27,21 +27,30 @@ (str "{:graph \"file-repo\" " ":data-dir \"file-data\" " ":timeout-ms 111 " - ":output-format :edn}")) + ":login-timeout-ms 444 " + ":logout-timeout-ms 555 " + ":output-format :edn " + ":auth-token \"file-secret\"}")) env {"LOGSEQ_CLI_GRAPH" "env-repo" "LOGSEQ_CLI_DATA_DIR" "env-data" "LOGSEQ_CLI_TIMEOUT_MS" "222" + "LOGSEQ_CLI_LOGIN_TIMEOUT_MS" "666" + "LOGSEQ_CLI_LOGOUT_TIMEOUT_MS" "777" "LOGSEQ_CLI_OUTPUT" "json"} opts {:config-path cfg-path :graph "cli-repo" :data-dir "cli-data" :timeout-ms 333 + :login-timeout-ms 888 + :logout-timeout-ms 999 :output-format :human} result (with-env env #(config/resolve-config opts))] (is (= cfg-path (:config-path result))) (is (= "cli-repo" (:graph result))) (is (= "cli-data" (:data-dir result))) (is (= 333 (:timeout-ms result))) + (is (= 888 (:login-timeout-ms result))) + (is (= 999 (:logout-timeout-ms result))) (is (nil? (:auth-token result))) (is (nil? (:retries result))) (is (= :human (:output-format result))))) @@ -82,7 +91,10 @@ (let [result (config/resolve-config {}) expected-config-path (node-path/join (.homedir os) "logseq" "cli.edn")] (is (= expected-config-path (:config-path result))) - (is (= "~/logseq/graphs" (:data-dir result))))) + (is (= "~/logseq/graphs" (:data-dir result))) + (is (= 10000 (:timeout-ms result))) + (is (= 300000 (:login-timeout-ms result))) + (is (= 120000 (:logout-timeout-ms result))))) (deftest test-update-config (let [dir (node-helper/create-tmp-dir "cli") @@ -96,7 +108,7 @@ (deftest test-update-config-strips-removed-options (let [dir (node-helper/create-tmp-dir "cli") cfg-path (node-path/join dir "cli.edn") - _ (fs/writeFileSync cfg-path "{:graph \"old\"}") + _ (fs/writeFileSync cfg-path "{:graph \"old\" :auth-token \"legacy-secret\"}") _ (config/update-config! {:config-path cfg-path} {:graph "new" :auth-token "secret" @@ -104,7 +116,7 @@ contents (.toString (fs/readFileSync cfg-path) "utf8") parsed (reader/read-string contents)] (is (= "new" (:graph parsed))) - (is (= "secret" (:auth-token parsed))) + (is (not (contains? parsed :auth-token))) (is (not (contains? parsed :retries))))) (deftest test-update-config-removes-nil-values diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index ca46565244..134af11084 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -341,18 +341,6 @@ (is (string/includes? result "Sync download")) (is (string/includes? result "demo-graph"))))) -(deftest test-human-output-sync-config-get-token-redaction - (testing "sync config get auth-token redacts value in human output" - (let [token "super-secret-token-value" - result (format/format-result {:status :ok - :command :sync-config-get - :data {:key :auth-token - :value token}} - {:output-format nil})] - (is (string/includes? result "auth-token")) - (is (string/includes? result "[REDACTED]")) - (is (not (string/includes? result token)))))) - (deftest test-human-output-sync-config-get-e2ee-password-redaction (testing "sync config get e2ee-password redacts value in human output" (let [password "super-secret-password" @@ -365,6 +353,46 @@ (is (string/includes? result "[REDACTED]")) (is (not (string/includes? result password)))))) +(deftest test-human-output-auth-commands + (testing "login human output reports auth path and user metadata without tokens" + (let [token "secret-token-value" + result (format/format-result {:status :ok + :command :login + :data {:auth-path "/tmp/auth.json" + :email "user@example.com" + :sub "user-123" + :authorize-url "https://example.com/oauth2/authorize?..." + :id-token token}} + {:output-format nil})] + (is (string/includes? result "Login successful")) + (is (string/includes? result "user@example.com")) + (is (string/includes? result "/tmp/auth.json")) + (is (not (string/includes? result token))))) + + (testing "logout human output reports whether auth was removed" + (let [result (format/format-result {:status :ok + :command :logout + :data {:auth-path "/tmp/auth.json" + :deleted? true + :opened? true + :logout-completed? true}} + {:output-format nil})] + (is (string/includes? result "Logged out")) + (is (string/includes? result "/tmp/auth.json")) + (is (string/includes? result "Cognito logout: completed")))) + + (testing "logout human output is still successful when auth file is absent" + (let [result (format/format-result {:status :ok + :command :logout + :data {:auth-path "/tmp/auth.json" + :deleted? false + :opened? true + :logout-completed? true}} + {:output-format nil})] + (is (string/includes? result "Already logged out")) + (is (string/includes? result "/tmp/auth.json")) + (is (string/includes? result "Cognito logout: completed"))))) + (deftest test-human-output-graph-info (testing "graph info includes key metadata lines and kv section" (let [result (format/format-result {:status :ok diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs index fca33a55dd..c9e552e61d 100644 --- a/src/test/logseq/cli/integration_test.cljs +++ b/src/test/logseq/cli/integration_test.cljs @@ -7,6 +7,7 @@ [clojure.string :as string] [frontend.test.node-helper :as node-helper] [frontend.worker.db-worker-node-lock :as db-lock] + [logseq.cli.auth :as cli-auth] [logseq.cli.command.core :as command-core] [logseq.cli.command.show :as show-command] [logseq.cli.config :as cli-config] @@ -185,6 +186,136 @@ [payload] (first (get-in payload [:data :result]))) +(defn- sample-auth + ([] + (sample-auth {})) + ([overrides] + (merge {:provider "cognito" + :id-token "id-token-1" + :access-token "access-token-1" + :refresh-token "refresh-token-1" + :expires-at (+ (js/Date.now) 3600000) + :sub "user-123" + :email "user@example.com" + :updated-at 1735686000000} + overrides))) + +(deftest test-cli-login-integration + (async done + (let [data-dir (node-helper/create-tmp-dir "cli-login-data") + cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") + auth-path (node-path/join (node-helper/create-tmp-dir "cli-auth") "auth.json") + open-calls (atom []) + auth-data (sample-auth)] + (fs/writeFileSync cfg-path "{:output-format :json}") + (let [promise + (p/with-redefs [cli-auth/default-auth-path (fn [] auth-path) + cli-auth/open-browser! (fn [authorize-url] + (swap! open-calls conj authorize-url) + (let [parsed (js/URL. authorize-url) + redirect-uri (.get (.-searchParams parsed) "redirect_uri") + state (.get (.-searchParams parsed) "state")] + (-> (js/fetch (str redirect-uri "?code=integration-code&state=" state)) + (p/then (fn [_] + {:opened? true}))))) + cli-auth/exchange-code-for-auth! (fn [_opts payload] + (is (= "integration-code" (:code payload))) + (p/resolved auth-data))] + (p/let [result (run-cli ["login"] data-dir cfg-path) + payload (parse-json-output-safe result "login") + stored (cli-auth/read-auth-file {:auth-path auth-path})] + (is (= 0 (:exit-code result))) + (is (= "ok" (:status payload))) + (is (= auth-path (get-in payload [:data :auth-path]))) + (is (= "user@example.com" (get-in payload [:data :email]))) + (is (= 1 (count @open-calls))) + (is (= auth-data stored)) + (is (fs/existsSync auth-path))))] + (-> promise + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (done)))))))) + +(deftest test-cli-logout-integration + (async done + (let [data-dir (node-helper/create-tmp-dir "cli-logout-data") + cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") + auth-path (node-path/join (node-helper/create-tmp-dir "cli-auth") "auth.json") + open-calls (atom [])] + (fs/writeFileSync cfg-path "{:output-format :json}") + (cli-auth/write-auth-file! {:auth-path auth-path} (sample-auth)) + (let [promise + (p/with-redefs [cli-auth/default-auth-path (fn [] auth-path) + cli-auth/open-browser! (fn [url] + (swap! open-calls conj url) + (let [parsed (js/URL. url) + logout-uri (.get (.-searchParams parsed) "logout_uri")] + (-> (js/fetch logout-uri) + (p/then (fn [_] + {:opened? true})))))] + (p/let [result (run-cli ["logout"] data-dir cfg-path) + payload (parse-json-output-safe result "logout")] + (is (= 0 (:exit-code result))) + (is (= "ok" (:status payload))) + (is (= 1 (count @open-calls))) + (is (= auth-path (get-in payload [:data :auth-path]))) + (is (= true (get-in payload [:data :deleted?]))) + (is (= true (get-in payload [:data :opened?]))) + (is (= true (get-in payload [:data :logout-completed?]))) + (is (not (fs/existsSync auth-path)))))] + (-> promise + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (done)))))))) + +(deftest test-cli-sync-remote-graphs-refreshes-auth-file-and-injects-runtime-token + (async done + (let [data-dir (node-helper/create-tmp-dir "cli-sync-auth") + cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") + auth-path (node-path/join (node-helper/create-tmp-dir "cli-auth") "auth.json") + invoke-calls (atom []) + expired-auth (sample-auth {:id-token "expired-token" + :access-token "expired-access-token" + :expires-at 0}) + refreshed-auth (sample-auth {:id-token "fresh-token" + :access-token "fresh-access-token" + :expires-at (+ (js/Date.now) 7200000) + :updated-at 1735689600000})] + (fs/writeFileSync cfg-path "{:output-format :json}") + (cli-auth/write-auth-file! {:auth-path auth-path} expired-auth) + (let [promise + (p/with-redefs [cli-auth/default-auth-path (fn [] auth-path) + cli-auth/refresh-auth! (fn [_opts _auth-data] + (p/resolved refreshed-auth)) + cli-server/list-graphs (fn [_config] + ["demo"]) + cli-server/ensure-server! (fn [config _repo] + (p/resolved (assoc config :base-url "http://example"))) + transport/invoke (fn [_ method direct-pass? args] + (swap! invoke-calls conj [method direct-pass? args]) + (case method + :thread-api/set-db-sync-config + (p/resolved nil) + :thread-api/db-sync-list-remote-graphs + (p/resolved []) + (p/resolved nil)))] + (p/let [result (run-cli ["sync" "remote-graphs"] data-dir cfg-path) + payload (parse-json-output-safe result "sync remote-graphs") + stored (cli-auth/read-auth-file {:auth-path auth-path})] + (is (= 0 (:exit-code result))) + (is (= "ok" (:status payload))) + (is (= :thread-api/set-db-sync-config (ffirst @invoke-calls))) + (is (= "fresh-token" (get-in (nth @invoke-calls 0) [2 0 :auth-token]))) + (is (= "fresh-token" (:id-token stored))) + (is (= "fresh-access-token" (:access-token stored)))))] + (-> promise + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (done)))))))) + (deftest test-cli-sync-download-and-start-readiness-with-mocked-sync (async done (let [data-dir (node-helper/create-tmp-dir "db-worker-sync-cli") @@ -199,7 +330,9 @@ _ (is (= 0 (:exit-code create-result))) _ (is (= "ok" (:status create-payload))) [download-result start-result] - (p/with-redefs [cli-server/ensure-server! (fn [config _repo] + (p/with-redefs [cli-auth/resolve-auth-token! (fn [_config] + (p/resolved "runtime-token")) + cli-server/ensure-server! (fn [config _repo] (p/resolved (assoc config :base-url "http://example"))) transport/invoke (fn [_ method _direct-pass? args] (swap! invoke-calls conj [method args]) @@ -268,7 +401,9 @@ _ (is (= 0 (:exit-code create-result))) _ (is (= "ok" (:status create-payload))) upload-result (p/with-redefs - [cli-server/ensure-server! (fn [config _repo] + [cli-auth/resolve-auth-token! (fn [_config] + (p/resolved "runtime-token")) + cli-server/ensure-server! (fn [config _repo] (p/resolved (assoc config :base-url "http://example"))) transport/invoke (fn [_ method _direct-pass? args] (swap! invoke-calls conj [method args]) @@ -287,7 +422,7 @@ (is (= "created-graph-id" (get-in upload-payload [:data :graph-id]))) (is (= [[:thread-api/set-db-sync-config [{:ws-url nil :http-base nil - :auth-token nil + :auth-token "runtime-token" :e2ee-password nil}]] [:thread-api/db-sync-upload-graph ["logseq_db_sync-upload-graph"]]] @invoke-calls))) @@ -308,7 +443,9 @@ _ (is (= 0 (:exit-code create-result))) _ (is (= "ok" (:status create-payload))) [upload-result info-result] - (p/with-redefs [cli-server/ensure-server! (fn [config _repo] + (p/with-redefs [cli-auth/resolve-auth-token! (fn [_config] + (p/resolved "runtime-token")) + cli-server/ensure-server! (fn [config _repo] (p/resolved (assoc config :base-url "http://example"))) transport/invoke (fn [_ method _direct-pass? args] (swap! invoke-calls conj [method args]) diff --git a/src/test/logseq/common/cognito_config_test.cljs b/src/test/logseq/common/cognito_config_test.cljs new file mode 100644 index 0000000000..cea2e91fe1 --- /dev/null +++ b/src/test/logseq/common/cognito_config_test.cljs @@ -0,0 +1,15 @@ +(ns logseq.common.cognito-config-test + (:require [cljs.test :refer [deftest is]] + [frontend.config :as config] + [logseq.common.cognito-config :as cognito-config] + ["fs" :as fs])) + +(deftest test-shared-cognito-config-matches-frontend-config + (is (= config/LOGIN-URL cognito-config/LOGIN-URL)) + (is (= config/COGNITO-CLIENT-ID cognito-config/COGNITO-CLIENT-ID)) + (is (= config/OAUTH-DOMAIN cognito-config/OAUTH-DOMAIN))) + +(deftest test-logseq-cli-build-enables-prod-file-sync-by-default + (let [shadow-config (.toString (fs/readFileSync "shadow-cljs.edn") "utf8")] + (is (re-find #"(?s):logseq-cli\s+\{:target :node-script.*?logseq\.common\.cognito-config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env \[\"ENABLE_FILE_SYNC_PRODUCTION\" :as :bool :default true\]" + shadow-config))))