From 960999bb157880ecaa9c4c5c3de9fe24592e487a Mon Sep 17 00:00:00 2001 From: Steve Date: Mon, 2 Feb 2026 10:25:39 -0500 Subject: [PATCH] feat: initial oauth implementation --- bun.lock | 68 +++++- packages/cli/package.json | 4 +- packages/cli/src/commands/auth.ts | 1 + packages/cli/src/commands/login.ts | 296 +++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/lib/atproto.ts | 76 +++++-- packages/cli/src/lib/credentials.ts | 159 +++++++++++--- packages/cli/src/lib/oauth-client.ts | 91 ++++++++ packages/cli/src/lib/oauth-store.ts | 124 +++++++++++ packages/cli/src/lib/types.ts | 35 +++- 10 files changed, 812 insertions(+), 44 deletions(-) create mode 100644 packages/cli/src/commands/login.ts create mode 100644 packages/cli/src/lib/oauth-client.ts create mode 100644 packages/cli/src/lib/oauth-store.ts diff --git a/bun.lock b/bun.lock index 8bf2b89..48c7f1f 100644 --- a/bun.lock +++ b/bun.lock @@ -24,17 +24,19 @@ }, "packages/cli": { "name": "sequoia-cli", - "version": "0.2.0", + "version": "0.2.1", "bin": { "sequoia": "dist/index.js", }, "dependencies": { "@atproto/api": "^0.18.17", + "@atproto/oauth-client-node": "^0.3.16", "@clack/prompts": "^1.0.0", "cmd-ts": "^0.14.3", "glob": "^13.0.0", "mime-types": "^2.1.35", "minimatch": "^10.1.1", + "open": "^11.0.0", }, "devDependencies": { "@biomejs/biome": "^2.3.13", @@ -49,16 +51,48 @@ "packages": { "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.6", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], + + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], + + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], + + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], + + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.25", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.6", "@atproto/did": "0.3.0" } }, "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw=="], + + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], + + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], + + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], + + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], + "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], + + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], + + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], + + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], + "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], + + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver-node": "0.1.25", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="], + + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], + "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], "@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], @@ -615,6 +649,8 @@ "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -663,6 +699,8 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], "create-vocs": ["create-vocs@1.0.0", "", { "dependencies": { "@clack/prompts": "^0.7.0", "cac": "^6.7.14", "detect-package-manager": "^3.0.2", "fs-extra": "^11.3.0", "picocolors": "^1.1.1" }, "bin": { "create-vocs": "_lib/bin.js" } }, "sha512-Lv1Bd3WZEgwG4nrogkM54m8viW+TWPlGivLyEi7aNb3cuKPsEfMDZ/kTbo87fzOGtsZ2yh7scO54ZmVhhgBgTw=="], @@ -761,6 +799,12 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -921,14 +965,22 @@ "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], @@ -937,6 +989,8 @@ "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], @@ -945,6 +999,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -1167,6 +1223,8 @@ "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], @@ -1209,6 +1267,8 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], @@ -1283,6 +1343,8 @@ "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1375,6 +1437,8 @@ "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], @@ -1445,6 +1509,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index cab2d2d..a6a97d9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,10 +30,12 @@ }, "dependencies": { "@atproto/api": "^0.18.17", + "@atproto/oauth-client-node": "^0.3.16", "@clack/prompts": "^1.0.0", "cmd-ts": "^0.14.3", "glob": "^13.0.0", "mime-types": "^2.1.35", - "minimatch": "^10.1.1" + "minimatch": "^10.1.1", + "open": "^11.0.0" } } diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 3fea184..10334a6 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -158,6 +158,7 @@ export const authCommand = command({ // Save credentials await saveCredentials({ + type: "app-password", pdsUrl, identifier: identifier, password: appPassword, diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts new file mode 100644 index 0000000..715d5ca --- /dev/null +++ b/packages/cli/src/commands/login.ts @@ -0,0 +1,296 @@ +import * as http from "node:http"; +import { log, note, select, spinner, text } from "@clack/prompts"; +import { command, flag, option, optional, string } from "cmd-ts"; +import { resolveHandleToDid } from "../lib/atproto"; +import { + getCallbackPort, + getCallbackUrl, + getOAuthClient, + getOAuthScope, +} from "../lib/oauth-client"; +import { + deleteOAuthSession, + getOAuthStorePath, + listOAuthSessions, +} from "../lib/oauth-store"; +import { exitOnCancel } from "../lib/prompts"; + +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export const loginCommand = command({ + name: "login", + description: "Login with OAuth (browser-based authentication)", + args: { + logout: option({ + long: "logout", + description: "Remove OAuth session for a specific DID", + type: optional(string), + }), + list: flag({ + long: "list", + description: "List all stored OAuth sessions", + }), + }, + handler: async ({ logout, list }) => { + // List sessions + if (list) { + const sessions = await listOAuthSessions(); + if (sessions.length === 0) { + log.info("No OAuth sessions stored"); + } else { + log.info("OAuth sessions:"); + for (const did of sessions) { + console.log(` - ${did}`); + } + } + return; + } + + // Logout + if (logout !== undefined) { + const did = logout || undefined; + + if (!did) { + // No DID provided - show available and prompt + const sessions = await listOAuthSessions(); + if (sessions.length === 0) { + log.info("No OAuth sessions found"); + return; + } + if (sessions.length === 1) { + const deleted = await deleteOAuthSession(sessions[0]!); + if (deleted) { + log.success(`Removed OAuth session for ${sessions[0]}`); + } + return; + } + // Multiple sessions - prompt + const selected = exitOnCancel( + await select({ + message: "Select session to remove:", + options: sessions.map((d) => ({ value: d, label: d })), + }), + ); + const deleted = await deleteOAuthSession(selected); + if (deleted) { + log.success(`Removed OAuth session for ${selected}`); + } + return; + } + + const deleted = await deleteOAuthSession(did); + if (deleted) { + log.success(`Removed OAuth session for ${did}`); + } else { + log.info(`No OAuth session found for ${did}`); + } + return; + } + + // OAuth login flow + note( + "OAuth login will open your browser to authenticate.\n\n" + + "This is more secure than app passwords and tokens refresh automatically.", + "OAuth Login", + ); + + const handle = exitOnCancel( + await text({ + message: "Handle or DID:", + placeholder: "yourhandle.bsky.social", + }), + ); + + if (!handle) { + log.error("Handle is required"); + process.exit(1); + } + + const s = spinner(); + s.start("Resolving identity..."); + + let did: string; + try { + did = await resolveHandleToDid(handle); + s.stop(`Identity resolved`); + } catch (error) { + s.stop("Failed to resolve identity"); + if (error instanceof Error) { + log.error(`Error: ${error.message}`); + } else { + log.error(`Error: ${error}`); + } + process.exit(1); + } + + s.start("Initializing OAuth..."); + + try { + const client = await getOAuthClient(); + + // Generate authorization URL using the resolved DID + const authUrl = await client.authorize(did, { + scope: getOAuthScope(), + }); + + log.info(`Login URL: ${authUrl}`); + + s.message("Opening browser..."); + + // Try to open browser + let browserOpened = true; + try { + const open = (await import("open")).default; + await open(authUrl.toString()); + } catch { + browserOpened = false; + } + + s.message("Waiting for authentication..."); + + // Show URL info + if (!browserOpened) { + s.stop("Could not open browser automatically"); + log.warn("Please open the following URL in your browser:"); + log.info(authUrl.toString()); + s.start("Waiting for authentication..."); + } + + // Start HTTP server to receive callback + const result = await waitForCallback(); + + if (!result.success) { + s.stop("Authentication failed"); + log.error(result.error || "OAuth callback failed"); + process.exit(1); + } + + s.message("Completing authentication..."); + + // Exchange code for tokens + const { session } = await client.callback( + new URLSearchParams(result.params!), + ); + + // Try to get the handle for display (use the original handle input as fallback) + let displayName = handle; + try { + // The session should have the DID, we can use the original handle they entered + // or we could fetch the profile to get the current handle + displayName = handle.startsWith("did:") ? session.did : handle; + } catch { + displayName = session.did; + } + + s.stop(`Logged in as ${displayName}`); + + log.success(`OAuth session saved to ${getOAuthStorePath()}`); + log.info("Your session will refresh automatically when needed."); + + // Exit cleanly - the OAuth client may have background processes + process.exit(0); + } catch (error) { + s.stop("OAuth login failed"); + if (error instanceof Error) { + log.error(`Error: ${error.message}`); + } else { + log.error(`Error: ${error}`); + } + process.exit(1); + } + }, +}); + +interface CallbackResult { + success: boolean; + params?: Record; + error?: string; +} + +function waitForCallback(): Promise { + return new Promise((resolve) => { + const port = getCallbackPort(); + let timeoutId: ReturnType | undefined; + + const server = http.createServer((req, res) => { + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); + + if (url.pathname === "/oauth/callback") { + const params: Record = {}; + url.searchParams.forEach((value, key) => { + params[key] = value; + }); + + // Clear the timeout + if (timeoutId) clearTimeout(timeoutId); + + // Check for error + if (params.error) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + +

Authentication Failed

+

${params.error_description || params.error}

+

You can close this window.

+ + + `); + server.close(() => { + resolve({ + success: false, + error: params.error_description || params.error, + }); + }); + return; + } + + // Success + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + +

Authentication Successful

+

You can close this window and return to the terminal.

+ + + `); + server.close(() => { + resolve({ success: true, params }); + }); + return; + } + + // Not the callback path + res.writeHead(404); + res.end("Not found"); + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + if (timeoutId) clearTimeout(timeoutId); + if (err.code === "EADDRINUSE") { + resolve({ + success: false, + error: `Port ${port} is already in use. Please close the application using that port and try again.`, + }); + } else { + resolve({ + success: false, + error: `Server error: ${err.message}`, + }); + } + }); + + server.listen(port, "127.0.0.1"); + + // Timeout after 5 minutes + timeoutId = setTimeout(() => { + server.close(() => { + resolve({ + success: false, + error: "Timeout waiting for OAuth callback. Please try again.", + }); + }); + }, CALLBACK_TIMEOUT_MS); + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0539690..45be9c6 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,7 @@ import { run, subcommands } from "cmd-ts"; import { authCommand } from "./commands/auth"; import { initCommand } from "./commands/init"; import { injectCommand } from "./commands/inject"; +import { loginCommand } from "./commands/login"; import { publishCommand } from "./commands/publish"; import { syncCommand } from "./commands/sync"; @@ -38,6 +39,7 @@ Publish evergreen content to the ATmosphere auth: authCommand, init: initCommand, inject: injectCommand, + login: loginCommand, publish: publishCommand, sync: syncCommand, }, diff --git a/packages/cli/src/lib/atproto.ts b/packages/cli/src/lib/atproto.ts index 3f3c604..2f0e258 100644 --- a/packages/cli/src/lib/atproto.ts +++ b/packages/cli/src/lib/atproto.ts @@ -1,8 +1,9 @@ -import { AtpAgent } from "@atproto/api"; +import { Agent, AtpAgent } from "@atproto/api"; import * as mimeTypes from "mime-types"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { stripMarkdownForText } from "./markdown"; +import { getOAuthClient } from "./oauth-client"; import type { BlobObject, BlogPost, @@ -10,6 +11,7 @@ import type { PublisherConfig, StrongRef, } from "./types"; +import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; async function fileExists(filePath: string): Promise { try { @@ -20,22 +22,27 @@ async function fileExists(filePath: string): Promise { } } -export async function resolveHandleToPDS(handle: string): Promise { - // First, resolve the handle to a DID - let did: string; - +/** + * Resolve a handle to a DID + */ +export async function resolveHandleToDid(handle: string): Promise { if (handle.startsWith("did:")) { - did = handle; - } else { - // Try to resolve handle via Bluesky API - const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; - const resolveResponse = await fetch(resolveUrl); - if (!resolveResponse.ok) { - throw new Error("Could not resolve handle"); - } - const resolveData = (await resolveResponse.json()) as { did: string }; - did = resolveData.did; + return handle; + } + + // Try to resolve handle via Bluesky API + const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; + const resolveResponse = await fetch(resolveUrl); + if (!resolveResponse.ok) { + throw new Error("Could not resolve handle"); } + const resolveData = (await resolveResponse.json()) as { did: string }; + return resolveData.did; +} + +export async function resolveHandleToPDS(handle: string): Promise { + // First, resolve the handle to a DID + const did = await resolveHandleToDid(handle); // Now resolve the DID to get the PDS URL from the DID document let pdsUrl: string | undefined; @@ -90,6 +97,45 @@ export interface CreatePublicationOptions { } export async function createAgent(credentials: Credentials): Promise { + if (isOAuthCredentials(credentials)) { + // OAuth flow - restore session from stored tokens + const client = await getOAuthClient(); + try { + const oauthSession = await client.restore(credentials.did); + // Wrap the OAuth session in an Agent which provides the atproto API + const agent = new Agent(oauthSession) as unknown as AtpAgent; + + // The Agent class doesn't have session.did like AtpAgent does + // We need to set up a compatible session object for the rest of our code + agent.session = { + did: oauthSession.did, + handle: credentials.handle, + accessJwt: "", + refreshJwt: "", + active: true, + }; + + return agent; + } catch (error) { + if (error instanceof Error) { + // Check for common OAuth errors + if ( + error.message.includes("expired") || + error.message.includes("revoked") + ) { + throw new Error( + `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`, + ); + } + } + throw error; + } + } + + // App password flow + if (!isAppPasswordCredentials(credentials)) { + throw new Error("Invalid credential type"); + } const agent = new AtpAgent({ service: credentials.pdsUrl }); await agent.login({ diff --git a/packages/cli/src/lib/credentials.ts b/packages/cli/src/lib/credentials.ts index a68b69b..bf70cfa 100644 --- a/packages/cli/src/lib/credentials.ts +++ b/packages/cli/src/lib/credentials.ts @@ -1,13 +1,22 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import type { Credentials } from "./types"; +import { getOAuthSession, listOAuthSessions } from "./oauth-store"; +import type { + AppPasswordCredentials, + Credentials, + LegacyCredentials, + OAuthCredentials, +} from "./types"; const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); -// Stored credentials keyed by identifier -type CredentialsStore = Record; +// Stored credentials keyed by identifier (can be legacy or typed) +type CredentialsStore = Record< + string, + AppPasswordCredentials | LegacyCredentials +>; async function fileExists(filePath: string): Promise { try { @@ -19,8 +28,24 @@ async function fileExists(filePath: string): Promise { } /** - * Load all stored credentials + * Normalize credentials to have explicit type */ +function normalizeCredentials( + creds: AppPasswordCredentials | LegacyCredentials, +): AppPasswordCredentials { + // If it already has type, return as-is + if ("type" in creds && creds.type === "app-password") { + return creds; + } + // Migrate legacy format + return { + type: "app-password", + pdsUrl: creds.pdsUrl, + identifier: creds.identifier, + password: creds.password, + }; +} + async function loadCredentialsStore(): Promise { if (!(await fileExists(CREDENTIALS_FILE))) { return {}; @@ -32,7 +57,7 @@ async function loadCredentialsStore(): Promise { // Handle legacy single-credential format (migrate on read) if (parsed.identifier && parsed.password) { - const legacy = parsed as Credentials; + const legacy = parsed as LegacyCredentials; return { [legacy.identifier]: legacy }; } @@ -51,14 +76,44 @@ async function saveCredentialsStore(store: CredentialsStore): Promise { await fs.chmod(CREDENTIALS_FILE, 0o600); } +/** + * Try to load OAuth credentials for a given profile (DID or handle) + */ +async function tryLoadOAuthCredentials( + profile: string, +): Promise { + // If it looks like a DID, try to get the session directly + if (profile.startsWith("did:")) { + const session = await getOAuthSession(profile); + if (session) { + return { + type: "oauth", + did: profile, + handle: profile, // We don't have the handle stored, use DID + pdsUrl: "https://bsky.social", // Will be resolved from DID doc + }; + } + } + + // Otherwise, check all OAuth sessions to find a matching handle + // (This is a fallback - handle matching isn't perfect without storing handles) + const sessions = await listOAuthSessions(); + for (const did of sessions) { + // Could enhance this by storing handle with session, but for now + // just return null if profile isn't a DID + } + + return null; +} + /** * Load credentials for a specific identity or resolve which to use. * * Priority: * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) - * 2. SEQUOIA_PROFILE env var - selects from stored credentials + * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) * 3. projectIdentity parameter (from sequoia.json) - * 4. If only one identity stored, use it + * 4. If only one identity stored (app-password or OAuth), use it * 5. Return null (caller should prompt user) */ export async function loadCredentials( @@ -71,6 +126,7 @@ export async function loadCredentials( if (envIdentifier && envPassword) { return { + type: "app-password", identifier: envIdentifier, password: envPassword, pdsUrl: envPdsUrl || "https://bsky.social", @@ -78,44 +134,71 @@ export async function loadCredentials( } const store = await loadCredentialsStore(); - const identifiers = Object.keys(store); - - if (identifiers.length === 0) { - return null; - } + const appPasswordIds = Object.keys(store); + const oauthDids = await listOAuthSessions(); // 2. SEQUOIA_PROFILE env var const profileEnv = process.env.SEQUOIA_PROFILE; - if (profileEnv && store[profileEnv]) { - return store[profileEnv]; + if (profileEnv) { + // Try app-password credentials first + if (store[profileEnv]) { + return normalizeCredentials(store[profileEnv]); + } + // Try OAuth session (profile could be a DID) + const oauth = await tryLoadOAuthCredentials(profileEnv); + if (oauth) { + return oauth; + } } // 3. Project-specific identity (from sequoia.json) - if (projectIdentity && store[projectIdentity]) { - return store[projectIdentity]; + if (projectIdentity) { + if (store[projectIdentity]) { + return normalizeCredentials(store[projectIdentity]); + } + const oauth = await tryLoadOAuthCredentials(projectIdentity); + if (oauth) { + return oauth; + } } - // 4. If only one identity, use it - if (identifiers.length === 1 && identifiers[0]) { - return store[identifiers[0]] ?? null; + // 4. If only one identity total, use it + const totalIdentities = appPasswordIds.length + oauthDids.length; + if (totalIdentities === 1) { + if (appPasswordIds.length === 1 && appPasswordIds[0]) { + return normalizeCredentials(store[appPasswordIds[0]]!); + } + if (oauthDids.length === 1 && oauthDids[0]) { + const session = await getOAuthSession(oauthDids[0]); + if (session) { + return { + type: "oauth", + did: oauthDids[0], + handle: oauthDids[0], + pdsUrl: "https://bsky.social", + }; + } + } } - // Multiple identities exist but none selected + // Multiple identities exist but none selected, or no identities return null; } /** - * Get a specific identity by identifier + * Get a specific identity by identifier (app-password only) */ export async function getCredentials( identifier: string, -): Promise { +): Promise { const store = await loadCredentialsStore(); - return store[identifier] || null; + const creds = store[identifier]; + if (!creds) return null; + return normalizeCredentials(creds); } /** - * List all stored identities + * List all stored app-password identities */ export async function listCredentials(): Promise { const store = await loadCredentialsStore(); @@ -123,9 +206,33 @@ export async function listCredentials(): Promise { } /** - * Save credentials for an identity (adds or updates) + * List all credentials (both app-password and OAuth) + */ +export async function listAllCredentials(): Promise< + Array<{ id: string; type: "app-password" | "oauth" }> +> { + const store = await loadCredentialsStore(); + const oauthDids = await listOAuthSessions(); + + const result: Array<{ id: string; type: "app-password" | "oauth" }> = []; + + for (const id of Object.keys(store)) { + result.push({ id, type: "app-password" }); + } + + for (const did of oauthDids) { + result.push({ id: did, type: "oauth" }); + } + + return result; +} + +/** + * Save app-password credentials for an identity (adds or updates) */ -export async function saveCredentials(credentials: Credentials): Promise { +export async function saveCredentials( + credentials: AppPasswordCredentials, +): Promise { const store = await loadCredentialsStore(); store[credentials.identifier] = credentials; await saveCredentialsStore(store); diff --git a/packages/cli/src/lib/oauth-client.ts b/packages/cli/src/lib/oauth-client.ts new file mode 100644 index 0000000..66464f1 --- /dev/null +++ b/packages/cli/src/lib/oauth-client.ts @@ -0,0 +1,91 @@ +import { + NodeOAuthClient, + type NodeOAuthClientOptions, +} from "@atproto/oauth-client-node"; +import { sessionStore, stateStore } from "./oauth-store"; + +const CALLBACK_PORT = 4000; +const CALLBACK_HOST = "127.0.0.1"; +const CALLBACK_URL = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/oauth/callback`; + +// OAuth scope for Sequoia CLI - includes atproto base scope plus our collections +const OAUTH_SCOPE = + "atproto repo:site.standard.document repo:site.standard.publication repo:app.bsky.feed.post blob:*/*"; + +let oauthClient: NodeOAuthClient | null = null; + +// Simple lock implementation for CLI (single process, no contention) +// This prevents the "No lock mechanism provided" warning +const locks = new Map>(); + +async function requestLock(key: string, fn: () => Promise): Promise { + // Wait for any existing lock on this key + while (locks.has(key)) { + await locks.get(key); + } + + // Create our lock + let resolve: () => void; + const lockPromise = new Promise((r) => { + resolve = r; + }); + locks.set(key, lockPromise); + + try { + await fn(); + } finally { + locks.delete(key); + resolve!(); + } +} + +/** + * Get or create the OAuth client singleton + */ +export async function getOAuthClient(): Promise { + if (oauthClient) { + return oauthClient; + } + + // Build client_id with required parameters + const clientIdParams = new URLSearchParams(); + clientIdParams.append("redirect_uri", CALLBACK_URL); + clientIdParams.append("scope", OAUTH_SCOPE); + + const clientOptions: NodeOAuthClientOptions = { + clientMetadata: { + client_id: `http://localhost?${clientIdParams.toString()}`, + client_name: "Sequoia CLI", + client_uri: "https://github.com/stevedylandev/sequoia", + redirect_uris: [CALLBACK_URL], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + application_type: "web", + scope: OAUTH_SCOPE, + dpop_bound_access_tokens: false, + }, + stateStore, + sessionStore, + // Configure identity resolution + plcDirectoryUrl: "https://plc.directory", + // Provide lock mechanism to prevent warning + requestLock, + }; + + oauthClient = new NodeOAuthClient(clientOptions); + + return oauthClient; +} + +export function getOAuthScope(): string { + return OAUTH_SCOPE; +} + +export function getCallbackUrl(): string { + return CALLBACK_URL; +} + +export function getCallbackPort(): number { + return CALLBACK_PORT; +} diff --git a/packages/cli/src/lib/oauth-store.ts b/packages/cli/src/lib/oauth-store.ts new file mode 100644 index 0000000..db74030 --- /dev/null +++ b/packages/cli/src/lib/oauth-store.ts @@ -0,0 +1,124 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { + NodeSavedSession, + NodeSavedSessionStore, + NodeSavedState, + NodeSavedStateStore, +} from "@atproto/oauth-client-node"; + +const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); +const OAUTH_FILE = path.join(CONFIG_DIR, "oauth.json"); + +interface OAuthStore { + states: Record; + sessions: Record; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function loadOAuthStore(): Promise { + if (!(await fileExists(OAUTH_FILE))) { + return { states: {}, sessions: {} }; + } + + try { + const content = await fs.readFile(OAUTH_FILE, "utf-8"); + return JSON.parse(content) as OAuthStore; + } catch { + return { states: {}, sessions: {} }; + } +} + +async function saveOAuthStore(store: OAuthStore): Promise { + await fs.mkdir(CONFIG_DIR, { recursive: true }); + await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2)); + await fs.chmod(OAUTH_FILE, 0o600); +} + +/** + * State store for PKCE flow (temporary, used during auth) + */ +export const stateStore: NodeSavedStateStore = { + async set(key: string, state: NodeSavedState): Promise { + const store = await loadOAuthStore(); + store.states[key] = state; + await saveOAuthStore(store); + }, + + async get(key: string): Promise { + const store = await loadOAuthStore(); + return store.states[key]; + }, + + async del(key: string): Promise { + const store = await loadOAuthStore(); + delete store.states[key]; + await saveOAuthStore(store); + }, +}; + +/** + * Session store for OAuth tokens (persistent) + */ +export const sessionStore: NodeSavedSessionStore = { + async set(sub: string, session: NodeSavedSession): Promise { + const store = await loadOAuthStore(); + store.sessions[sub] = session; + await saveOAuthStore(store); + }, + + async get(sub: string): Promise { + const store = await loadOAuthStore(); + return store.sessions[sub]; + }, + + async del(sub: string): Promise { + const store = await loadOAuthStore(); + delete store.sessions[sub]; + await saveOAuthStore(store); + }, +}; + +/** + * List all stored OAuth session DIDs + */ +export async function listOAuthSessions(): Promise { + const store = await loadOAuthStore(); + return Object.keys(store.sessions); +} + +/** + * Get an OAuth session by DID + */ +export async function getOAuthSession( + did: string, +): Promise { + const store = await loadOAuthStore(); + return store.sessions[did]; +} + +/** + * Delete an OAuth session by DID + */ +export async function deleteOAuthSession(did: string): Promise { + const store = await loadOAuthStore(); + if (!store.sessions[did]) { + return false; + } + delete store.sessions[did]; + await saveOAuthStore(store); + return true; +} + +export function getOAuthStorePath(): string { + return OAUTH_FILE; +} diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 220a248..16888be 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -37,12 +37,45 @@ export interface PublisherConfig { bluesky?: BlueskyConfig; // Optional Bluesky posting configuration } -export interface Credentials { +// Legacy credentials format (for backward compatibility during migration) +export interface LegacyCredentials { pdsUrl: string; identifier: string; password: string; } +// App password credentials (explicit type) +export interface AppPasswordCredentials { + type: "app-password"; + pdsUrl: string; + identifier: string; + password: string; +} + +// OAuth credentials (references stored OAuth session) +export interface OAuthCredentials { + type: "oauth"; + did: string; + handle: string; + pdsUrl: string; +} + +// Union type for all credential types +export type Credentials = AppPasswordCredentials | OAuthCredentials; + +// Helper to check credential type +export function isOAuthCredentials( + creds: Credentials, +): creds is OAuthCredentials { + return creds.type === "oauth"; +} + +export function isAppPasswordCredentials( + creds: Credentials, +): creds is AppPasswordCredentials { + return creds.type === "app-password"; +} + export interface PostFrontmatter { title: string; description?: string; -- 2.51.2