diff --git a/.changeset/fresh-mails-refresh.md b/.changeset/fresh-mails-refresh.md new file mode 100644 index 0000000..9067cf5 --- /dev/null +++ b/.changeset/fresh-mails-refresh.md @@ -0,0 +1,7 @@ +--- +"@razroo/gmail-mcp": patch +--- + +Restore complete file-based OAuth credentials, prefer user-provided OAuth client files over the embedded default, and update the Google auth/API transport for Node.js 24. This fixes Gmail authorization and automatic token refresh while preserving owner-only credential permissions. + +Users who already have `gcp-oauth.keys.json` but originally authorized with the embedded client should run `npx @razroo/gmail-mcp auth` once so the stored refresh token matches their custom OAuth client. diff --git a/.changeset/gmail-mcp-http-listener-resilient.md b/.changeset/gmail-mcp-http-listener-resilient.md deleted file mode 100644 index 5e9beea..0000000 --- a/.changeset/gmail-mcp-http-listener-resilient.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@razroo/gmail-mcp": patch ---- - -Make the Streamable HTTP listener resilient to port conflicts. Previously, if `PORT` (default `3000`) was already bound — typically by a zombie `gmail-mcp` from a prior session, or a second instance running in another project — `app.listen(PORT)` threw `EADDRINUSE` and the whole process crashed, which killed the stdio MCP transport too even though stdio doesn't need the port. Now we attach an `error` handler to the HTTP server: on `EADDRINUSE` we log a warning and continue, so stdio MCP clients (Claude Desktop, opencode, etc.) keep working. Also added a `DISABLE_HTTP=true` env var for consumers who only use stdio and want to skip the HTTP listener entirely. diff --git a/.github/workflows/on-pr-feature-branch.yml b/.github/workflows/on-pr-feature-branch.yml index 5621de6..de621b4 100644 --- a/.github/workflows/on-pr-feature-branch.yml +++ b/.github/workflows/on-pr-feature-branch.yml @@ -51,3 +51,24 @@ jobs: run: npm install -g pnpm - name: Run validation script run: .github/scripts/validate-build.sh + + test-node: + name: Test on Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['22', '24.17.0', '24'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Install pnpm + run: npm install -g pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Run tests + run: pnpm test:ci diff --git a/.github/workflows/on-push-main.yml b/.github/workflows/on-push-main.yml index f504c38..a1cb69f 100644 --- a/.github/workflows/on-push-main.yml +++ b/.github/workflows/on-push-main.yml @@ -52,6 +52,9 @@ jobs: runs-on: ubuntu-latest needs: [check-version, check-changesets] if: needs.check-changesets.outputs.hasChangesets == 'true' && needs.check-version.outputs.version_changed == 'false' + permissions: + contents: write + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -77,31 +80,6 @@ jobs: env: PATH_TO_FILE: 'src/index.ts' - gh-release: - name: GH Release - runs-on: ubuntu-latest - needs: check-version - if: needs.check-version.outputs.version_changed == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - name: Get release version - id: get-version - run: | - echo "result=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT - - name: Get release body - run: | - # Get the PR body to use in the GH release body - # Use empty body if no merged PR found (e.g. direct push to main) - gh pr list --search "$(git rev-parse HEAD)" --state merged --json number,body --jq '"This release was merged in PR #" + (.[0].number | tostring) + "\n\n" + (.[0].body | split("# Releases")[1:] | join("# Releases"))' > pr_body.tmp 2>/dev/null || echo "Direct push to main" > pr_body.tmp - - name: Create release - uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1 - with: - tag_name: v${{ steps.get-version.outputs.result }} - name: Release v${{ steps.get-version.outputs.result }} - body_path: pr_body.tmp - - # npm publishing is handled by .github/workflows/publish.yml - # which triggers on GitHub releases created by the gh-release job above + # Releases are created explicitly with `gh release create`. The release event + # then triggers publish.yml, whose npm provenance job cannot be triggered by a + # release created with this workflow's GITHUB_TOKEN. diff --git a/package.json b/package.json index f02300c..454854d 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,21 @@ "build": "tsc -p tsconfig.json", "start": "node ./dist/index.js", "auth": "node ./dist/index.js auth", - "test": "jest" + "test": "jest", + "test:unit": "jest test/oauth2.test.ts test/auth-error.test.ts --runInBand", + "test:oauth": "node --test test/oauth2.node.test.mjs", + "test:ci": "pnpm build && pnpm test:unit && pnpm test:oauth" + }, + "jest": { + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + } }, "dependencies": { "@modelcontextprotocol/sdk": "1.16.0", "@smithery/sdk": "1.4.3", - "google-auth-library": "9.15.1", - "googleapis": "129.0.0", + "google-auth-library": "10.5.0", + "googleapis": "173.0.0", "open": "9.1.0", "ts-node": "10.9.2", "zod": "3.22.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57fa32d..8f654ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,11 +15,11 @@ importers: specifier: 1.4.3 version: 1.4.3(react@19.1.0) google-auth-library: - specifier: 9.15.1 - version: 9.15.1 + specifier: 10.5.0 + version: 10.5.0 googleapis: - specifier: 129.0.0 - version: 129.0.0 + specifier: 173.0.0 + version: 173.0.0 open: specifier: 9.1.0 version: 9.1.0 @@ -741,6 +741,10 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -857,6 +861,10 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -982,6 +990,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -990,6 +1002,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1075,6 +1091,9 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1209,6 +1228,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -1281,6 +1304,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -1297,6 +1323,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1412,6 +1441,10 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1424,6 +1457,10 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} @@ -1435,6 +1472,10 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1462,13 +1503,21 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - gaxios@6.7.1: - resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} - engines: {node: '>=14'} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} - gcp-metadata@6.1.1: - resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} - engines: {node: '>=14'} + gaxios@7.2.0: + resolution: {integrity: sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + gcp-metadata@8.1.3: + resolution: {integrity: sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w==} + engines: {node: '>=18'} gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} @@ -1498,29 +1547,38 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} - google-auth-library@9.15.1: - resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} - engines: {node: '>=14'} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} - google-logging-utils@0.0.2: - resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + google-auth-library@10.9.0: + resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} - googleapis-common@7.2.0: - resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} - engines: {node: '>=14.0.0'} + googleapis-common@8.0.2: + resolution: {integrity: sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==} + engines: {node: '>=18.0.0'} - googleapis@129.0.0: - resolution: {integrity: sha512-gFatrzby+oh/GxEeMhJOKzgs9eG7yksRcTon9b+kPie4ZnDSgGQ85JgtUaBtLSBkcKpUKukdSP6Km1aCjs4y4Q==} - engines: {node: '>=14.0.0'} + googleapis@173.0.0: + resolution: {integrity: sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==} + engines: {node: '>=18'} gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} @@ -1529,9 +1587,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - gtoken@7.1.0: - resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} - engines: {node: '>=14.0.0'} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -1699,6 +1757,9 @@ packages: resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1900,6 +1961,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1963,6 +2027,14 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1996,6 +2068,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -2087,6 +2163,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} @@ -2117,6 +2196,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.2.0: resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} engines: {node: '>=16'} @@ -2244,6 +2327,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2355,10 +2442,18 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2516,10 +2611,6 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2534,6 +2625,10 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -2553,6 +2648,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3536,6 +3635,15 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -3774,6 +3882,9 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -3924,12 +4035,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -4059,6 +4174,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -4183,6 +4302,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + data-uri-to-buffer@4.0.1: {} + debug@4.4.1: dependencies: ms: 2.1.3 @@ -4231,6 +4352,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -4243,6 +4366,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} enquirer@2.4.1: @@ -4389,6 +4514,11 @@ snapshots: dependencies: bser: 2.1.1 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -4409,6 +4539,11 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} form-data@4.0.4: @@ -4424,6 +4559,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -4447,24 +4586,37 @@ snapshots: function-bind@1.1.2: {} - gaxios@6.7.1: + gaxios@7.1.3: dependencies: extend: 3.0.2 https-proxy-agent: 7.0.6 - is-stream: 2.0.1 - node-fetch: 2.7.0 - uuid: 9.0.1 + node-fetch: 3.3.2 + rimraf: 5.0.10 transitivePeerDependencies: - - encoding - supports-color - gcp-metadata@6.1.1: + gaxios@7.2.0: dependencies: - gaxios: 6.7.1 - google-logging-utils: 0.0.2 + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.2.0 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.3: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 json-bigint: 1.0.0 transitivePeerDependencies: - - encoding - supports-color gensync@1.0.0-beta.2: {} @@ -4497,6 +4649,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -4515,50 +4676,58 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - google-auth-library@9.15.1: + google-auth-library@10.5.0: dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1 - gcp-metadata: 6.1.1 - gtoken: 7.1.0 + gaxios: 7.2.0 + gcp-metadata: 8.1.3 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.0 + transitivePeerDependencies: + - supports-color + + google-auth-library@10.9.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.2.0 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 jws: 4.0.0 transitivePeerDependencies: - - encoding - supports-color - google-logging-utils@0.0.2: {} + google-logging-utils@1.1.3: {} - googleapis-common@7.2.0: + googleapis-common@8.0.2: dependencies: extend: 3.0.2 - gaxios: 6.7.1 - google-auth-library: 9.15.1 + gaxios: 7.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 qs: 6.14.0 url-template: 2.0.8 - uuid: 9.0.1 transitivePeerDependencies: - - encoding - supports-color - googleapis@129.0.0: + googleapis@173.0.0: dependencies: - google-auth-library: 9.15.1 - googleapis-common: 7.2.0 + google-auth-library: 10.9.0 + googleapis-common: 8.0.2 transitivePeerDependencies: - - encoding - supports-color gopd@1.2.0: {} graceful-fs@4.2.11: {} - gtoken@7.1.0: + gtoken@8.0.0: dependencies: - gaxios: 6.7.1 + gaxios: 7.2.0 jws: 4.0.0 transitivePeerDependencies: - - encoding - supports-color has-flag@4.0.0: {} @@ -4711,6 +4880,12 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -5080,6 +5255,8 @@ snapshots: lodash@4.17.21: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5129,6 +5306,12 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minipass@7.1.3: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -5145,6 +5328,12 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-int64@0.4.0: {} node-releases@2.0.19: {} @@ -5226,6 +5415,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@0.2.11: dependencies: quansync: 0.2.10 @@ -5249,6 +5440,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@8.2.0: {} path-type@4.0.0: {} @@ -5356,6 +5552,10 @@ snapshots: reusify@1.1.0: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + router@2.2.0: dependencies: debug: 4.4.1 @@ -5486,10 +5686,20 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-bom@4.0.0: {} @@ -5611,8 +5821,6 @@ snapshots: uuid@11.1.0: {} - uuid@9.0.1: {} - v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -5627,6 +5835,8 @@ snapshots: dependencies: makeerror: 1.0.12 + web-streams-polyfill@3.3.3: {} + web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} @@ -5646,6 +5856,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@4.0.2: diff --git a/src/oauth2.ts b/src/oauth2.ts index 29aa04d..f2ca3a7 100644 --- a/src/oauth2.ts +++ b/src/oauth2.ts @@ -1,5 +1,5 @@ -import { AUTH_SERVER_PORT, CLIENT_ID, CLIENT_SECRET, GMAIL_CREDENTIALS_PATH, GMAIL_OAUTH_PATH, REFRESH_TOKEN } from "./config.js" -import { OAuth2Client } from "google-auth-library" +import { AUTH_SERVER_PORT, CLIENT_ID, CLIENT_SECRET, GMAIL_CREDENTIALS_PATH, GMAIL_OAUTH_PATH, MCP_CONFIG_DIR, REFRESH_TOKEN } from "./config.js" +import { OAuth2Client, type Credentials } from "google-auth-library" import fs from "fs" import http from "http" import open from "open" @@ -12,27 +12,61 @@ const AUTH_SCOPES = [ 'https://www.googleapis.com/auth/gmail.settings.sharing' ] -const getRefreshTokenFromFile = () => { +type ResolvedOAuthCredentials = { + clientId: string + clientSecret: string + tokens: Credentials +} + +const getStoredCredentials = (): Credentials | null => { if (fs.existsSync(GMAIL_CREDENTIALS_PATH)) { try { - const gmailCredentialsFile = JSON.parse(fs.readFileSync(GMAIL_CREDENTIALS_PATH, 'utf8')) - return gmailCredentialsFile?.refresh_token || null + const storedCredentials = JSON.parse(fs.readFileSync(GMAIL_CREDENTIALS_PATH, 'utf8')) + return storedCredentials && typeof storedCredentials === 'object' ? storedCredentials : null } catch { return null } } return null } -const getEnvBasedCredentials = (queryConfig?: Record) => { +const getTokens = (explicitRefreshToken?: string): Credentials => { + if (explicitRefreshToken) return { refresh_token: explicitRefreshToken } + return getStoredCredentials() || {} +} + +const writeStoredCredentials = (tokens: Credentials) => { + const previousCredentials = getStoredCredentials() || {} + const credentials = { + ...previousCredentials, + ...tokens, + refresh_token: tokens.refresh_token || previousCredentials.refresh_token + } + + fs.mkdirSync(MCP_CONFIG_DIR, { recursive: true, mode: 0o700 }) + fs.writeFileSync(GMAIL_CREDENTIALS_PATH, JSON.stringify(credentials, null, 2), { mode: 0o600 }) + try { fs.chmodSync(GMAIL_CREDENTIALS_PATH, 0o600) } catch { /* best effort on non-POSIX filesystems */ } +} + +const getEnvBasedCredentials = (queryConfig?: Record): ResolvedOAuthCredentials | null => { + const hasExplicitClientCredentials = Boolean( + queryConfig?.CLIENT_ID || + queryConfig?.CLIENT_SECRET || + process.env.CLIENT_ID || + process.env.CLIENT_SECRET + ) + + // A user-provided OAuth key file must override the embedded default client. + if (!hasExplicitClientCredentials && fs.existsSync(GMAIL_OAUTH_PATH)) return null + const clientId = queryConfig?.CLIENT_ID || CLIENT_ID const clientSecret = queryConfig?.CLIENT_SECRET || CLIENT_SECRET - const refreshToken = queryConfig?.REFRESH_TOKEN || REFRESH_TOKEN || getRefreshTokenFromFile() + const explicitRefreshToken = queryConfig?.REFRESH_TOKEN || REFRESH_TOKEN if (!clientId || !clientSecret) return null - return { clientId, clientSecret, refreshToken: refreshToken || null } + return { clientId, clientSecret, tokens: getTokens(explicitRefreshToken) } } -const getFileBasedCredentials = () => { +const getFileBasedCredentials = (): ResolvedOAuthCredentials | null => { const oauthFilePresent = fs.existsSync(GMAIL_OAUTH_PATH) if (!oauthFilePresent) return null @@ -42,9 +76,9 @@ const getFileBasedCredentials = () => { const clientId = parsedKeys?.installed?.client_id || parsedKeys?.web?.client_id const clientSecret = parsedKeys?.installed?.client_secret || parsedKeys?.web?.client_secret - const refreshToken = getRefreshTokenFromFile() + if (!clientId || !clientSecret) return null - return { clientId, clientSecret, refreshToken } + return { clientId, clientSecret, tokens: getTokens(REFRESH_TOKEN) } } export const createOAuth2Client = (queryConfig?: Record) => { @@ -63,7 +97,7 @@ export const createOAuth2Client = (queryConfig?: Record) => { redirectUri: `http://localhost:${AUTH_SERVER_PORT}/oauth2callback` }) - if (credentials.refreshToken) oauth2Client.setCredentials({ refresh_token: credentials.refreshToken }) + if (Object.keys(credentials.tokens).length > 0) oauth2Client.setCredentials(credentials.tokens) return oauth2Client } catch (error: any) { @@ -108,7 +142,7 @@ export const launchAuthServer = async (oauth2Client: OAuth2Client) => new Promis try { const { tokens } = await oauth2Client.getToken(code) oauth2Client.setCredentials(tokens) - fs.writeFileSync(GMAIL_CREDENTIALS_PATH, JSON.stringify(tokens, null, 2)) + writeStoredCredentials(tokens) res.writeHead(200) res.end(`Authentication successful! Go to ${GMAIL_CREDENTIALS_PATH} to view your REFRESH_TOKEN. You can close this window.`) @@ -128,16 +162,17 @@ export const validateCredentials = async (oauth2Client: OAuth2Client) => { if (!credentials) return false const expiryDate = credentials.expiry_date - const needsRefresh = !expiryDate || expiryDate <= Date.now() + const needsRefresh = !credentials.access_token || !expiryDate || expiryDate <= Date.now() if (!needsRefresh) return true if (!credentials.refresh_token) return false const { credentials: tokens } = await oauth2Client.refreshAccessToken() - oauth2Client.setCredentials(tokens) + if (!tokens.access_token) return false - fs.writeFileSync(GMAIL_CREDENTIALS_PATH, JSON.stringify(tokens, null, 2)) + oauth2Client.setCredentials(tokens) + writeStoredCredentials(tokens) return true } catch (error: any) { return false diff --git a/test/oauth2.node.test.mjs b/test/oauth2.node.test.mjs new file mode 100644 index 0000000..fa418bd --- /dev/null +++ b/test/oauth2.node.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +test('restores fresh credentials and refreshes expired credentials on native Node.js', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gmail-mcp-native-oauth-')) + const credentialsPath = path.join(configDir, 'credentials.json') + fs.writeFileSync(path.join(configDir, 'gcp-oauth.keys.json'), JSON.stringify({ + installed: { + client_id: 'fixture-client', + client_secret: 'fixture-secret' + } + })) + fs.writeFileSync(credentialsPath, JSON.stringify({ + access_token: 'fresh-access', + refresh_token: 'fixture-refresh', + expiry_date: Date.now() + 3_600_000 + }), { mode: 0o600 }) + + let requestBody = '' + let requestCount = 0 + const tokenServer = http.createServer((request, response) => { + requestCount += 1 + const chunks = [] + request.on('data', chunk => chunks.push(Buffer.from(chunk))) + request.on('end', () => { + requestBody = Buffer.concat(chunks).toString('utf8') + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + access_token: 'fixture-access', + expires_in: 3600, + token_type: 'Bearer' + })) + }) + }) + await new Promise(resolve => tokenServer.listen(0, '127.0.0.1', resolve)) + + try { + process.env.MCP_CONFIG_DIR = configDir + const { createOAuth2Client, validateCredentials } = await import('../dist/oauth2.js') + const { port } = tokenServer.address() + const tokenUrl = `http://127.0.0.1:${port}/token` + + const freshClient = createOAuth2Client() + freshClient.endpoints.oauth2TokenUrl = tokenUrl + assert.equal(await validateCredentials(freshClient), true) + assert.equal(requestCount, 0, 'fresh credentials should not call the token endpoint') + + fs.writeFileSync(credentialsPath, JSON.stringify({ + access_token: 'expired-access', + refresh_token: 'fixture-refresh', + expiry_date: 1 + }), { mode: 0o600 }) + + const expiredClient = createOAuth2Client() + expiredClient.endpoints.oauth2TokenUrl = tokenUrl + assert.equal(await validateCredentials(expiredClient), true) + assert.equal(requestCount, 1, 'expired credentials should refresh exactly once') + + const requestParameters = new URLSearchParams(requestBody) + assert.equal(requestParameters.get('grant_type'), 'refresh_token') + assert.equal(requestParameters.get('refresh_token'), 'fixture-refresh') + assert.equal(requestParameters.get('client_id'), 'fixture-client') + assert.equal(requestParameters.get('client_secret'), 'fixture-secret') + + const storedCredentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')) + assert.equal(storedCredentials.access_token, 'fixture-access') + assert.equal(storedCredentials.refresh_token, 'fixture-refresh') + assert.ok(storedCredentials.expiry_date > Date.now()) + assert.equal(fs.statSync(credentialsPath).mode & 0o777, 0o600) + } finally { + delete process.env.MCP_CONFIG_DIR + await new Promise((resolve, reject) => tokenServer.close(error => error ? reject(error) : resolve())) + fs.rmSync(configDir, { recursive: true, force: true }) + } +}) diff --git a/test/oauth2.test.ts b/test/oauth2.test.ts new file mode 100644 index 0000000..ff349f3 --- /dev/null +++ b/test/oauth2.test.ts @@ -0,0 +1,128 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' + +jest.mock('open', () => jest.fn()) + +const originalEnvironment = { + MCP_CONFIG_DIR: process.env.MCP_CONFIG_DIR, + CLIENT_ID: process.env.CLIENT_ID, + CLIENT_SECRET: process.env.CLIENT_SECRET, + REFRESH_TOKEN: process.env.REFRESH_TOKEN +} + +const createConfigDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'gmail-mcp-oauth-')) + +const loadOAuthModule = async (configDir: string) => { + process.env.MCP_CONFIG_DIR = configDir + jest.resetModules() + return import('../src/oauth2') +} + +describe('OAuth credential persistence', () => { + const configDirs: string[] = [] + + afterEach(() => { + for (const configDir of configDirs.splice(0)) fs.rmSync(configDir, { recursive: true, force: true }) + + for (const [name, value] of Object.entries(originalEnvironment)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + jest.resetModules() + }) + + test('restores the complete stored token set', async () => { + const configDir = createConfigDir() + configDirs.push(configDir) + const expiryDate = Date.now() + 3_600_000 + fs.writeFileSync(path.join(configDir, 'credentials.json'), JSON.stringify({ + access_token: 'stored-access-token', + refresh_token: 'stored-refresh-token', + expiry_date: expiryDate, + token_type: 'Bearer' + })) + + const { createOAuth2Client } = await loadOAuthModule(configDir) + const oauth2Client = createOAuth2Client() + + expect(oauth2Client?.credentials).toMatchObject({ + access_token: 'stored-access-token', + refresh_token: 'stored-refresh-token', + expiry_date: expiryDate, + token_type: 'Bearer' + }) + }) + + test('uses an unexpired stored access token without refreshing', async () => { + const configDir = createConfigDir() + configDirs.push(configDir) + fs.writeFileSync(path.join(configDir, 'credentials.json'), JSON.stringify({ + access_token: 'stored-access-token', + refresh_token: 'stored-refresh-token', + expiry_date: Date.now() + 3_600_000 + })) + + const { createOAuth2Client, validateCredentials } = await loadOAuthModule(configDir) + const oauth2Client = createOAuth2Client() + const refreshAccessToken = jest.spyOn(oauth2Client!, 'refreshAccessToken') + + await expect(validateCredentials(oauth2Client!)).resolves.toBe(true) + expect(refreshAccessToken).not.toHaveBeenCalled() + }) + + test('prefers a user-provided OAuth key file over the embedded client', async () => { + const configDir = createConfigDir() + configDirs.push(configDir) + fs.writeFileSync(path.join(configDir, 'gcp-oauth.keys.json'), JSON.stringify({ + installed: { + client_id: 'custom-client-id', + client_secret: 'custom-client-secret' + } + })) + fs.writeFileSync(path.join(configDir, 'credentials.json'), JSON.stringify({ + refresh_token: 'custom-refresh-token' + })) + + const { createOAuth2Client } = await loadOAuthModule(configDir) + const oauth2Client = createOAuth2Client() + const authUrl = oauth2Client?.generateAuthUrl({ scope: ['test-scope'] }) + + expect(new URL(authUrl || '').searchParams.get('client_id')).toBe('custom-client-id') + }) + + test('refreshes explicitly and persists owner-only credentials', async () => { + const configDir = createConfigDir() + configDirs.push(configDir) + const { validateCredentials } = await loadOAuthModule(configDir) + const expiryDate = Date.now() + 3_600_000 + const oauth2Client = { + credentials: { refresh_token: 'stored-refresh-token' }, + refreshAccessToken: jest.fn(async () => { + const credentials = { + access_token: 'refreshed-access-token', + refresh_token: 'stored-refresh-token', + expiry_date: expiryDate + } + return { credentials } + }), + setCredentials: jest.fn(function (this: any, credentials: any) { + this.credentials = credentials + }) + } + + await expect(validateCredentials(oauth2Client as any)).resolves.toBe(true) + expect(oauth2Client.refreshAccessToken).toHaveBeenCalledTimes(1) + expect(oauth2Client.setCredentials).toHaveBeenCalledTimes(1) + + const credentialsPath = path.join(configDir, 'credentials.json') + const storedCredentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8')) + expect(storedCredentials).toMatchObject({ + access_token: 'refreshed-access-token', + refresh_token: 'stored-refresh-token', + expiry_date: expiryDate + }) + expect(fs.statSync(credentialsPath).mode & 0o777).toBe(0o600) + }) + +})