Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1dcaf3fb3 |
||
|
|
73aebc1526 |
||
|
|
cabf0607b6 |
||
|
|
c7411aeaba |
||
|
|
739b70e8bc |
||
|
|
dd4b2939e3 |
||
|
|
068a97a2f8 |
||
|
|
d3fbdc61ce |
||
|
|
5271ba5075 |
||
|
|
974a523857 |
||
|
|
1d1eb91e51 |
||
|
|
99c9439d6f |
||
|
|
30ca36e42e |
||
|
|
f98e367dd9 |
||
|
|
e166f76f06 |
||
|
|
d08e70b32a |
||
|
|
00d5aaad79 |
||
|
|
57b19e27d2 |
||
|
|
da98ceced3 |
||
|
|
71f3a1b62e |
||
|
|
6036804f1c |
||
|
|
3c1ee7d272 |
||
|
|
5022e04399 |
||
|
|
8759d9d8c0 |
||
|
|
4ec2ed3f9f |
||
|
|
17dec17a41 |
||
|
|
91cc4aed20 |
||
|
|
a25f166b4b |
||
|
|
3e463e52bb |
||
|
|
988b078e02 |
||
|
|
43402d8f6f |
||
|
|
43fbc1dcbd |
||
|
|
69374b08de |
||
|
|
03aeca562f |
||
|
|
4508c48f80 |
||
|
|
f36b7dd4af |
||
|
|
f52dc8525a |
||
|
|
2b09bde534 |
||
|
|
5c19656cb9 |
||
|
|
0f78916eee |
||
|
|
34f0bb99f3 |
||
|
|
ac340e0b88 |
||
|
|
4e1f96a716 |
||
|
|
2a28830156 |
||
|
|
4000b09953 |
||
|
|
0b5cc0f6a9 |
||
|
|
b25ab9b93a |
||
|
|
8d3517519f |
||
|
|
5cbf3320e8 |
||
|
|
2271214bd8 |
||
|
|
29e32c39dc |
77 changed files with 1732 additions and 3445 deletions
|
|
@ -11,6 +11,11 @@ MCP_REGISTRY_DATABASE_URL=postgres://username:password@localhost:5432/mcp-regist
|
||||||
# For offline development, use: data/seed.json
|
# For offline development, use: data/seed.json
|
||||||
MCP_REGISTRY_SEED_FROM=https://registry.modelcontextprotocol.io/v0/servers
|
MCP_REGISTRY_SEED_FROM=https://registry.modelcontextprotocol.io/v0/servers
|
||||||
|
|
||||||
|
# Validate seed data against the registry rules on import (default: true)
|
||||||
|
# Set to false for offline development when seeding from a local file, since
|
||||||
|
# data/seed.json is not guaranteed to pass validation
|
||||||
|
MCP_REGISTRY_ENABLE_REGISTRY_VALIDATION=true
|
||||||
|
|
||||||
# GitHub OAuth configuration
|
# GitHub OAuth configuration
|
||||||
# These creds are for local development with the 'MCP Registry Login (Local)' GitHub App
|
# These creds are for local development with the 'MCP Registry Login (Local)' GitHub App
|
||||||
# They don't provide any real privileged access, hence why it's okay that they're here
|
# They don't provide any real privileged access, hence why it's okay that they're here
|
||||||
|
|
|
||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
|
|
@ -9,5 +9,5 @@ To publish an MCP server to the registry, use the `mcp-publisher` CLI tool. See
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
- Use `make` targets where possible (run `make help` to see available targets)
|
- Use `make` targets where possible (run `make help` to see available targets)
|
||||||
- Run `make check` to run lint, unit tests, and integration tests
|
- Run `make check` to run lint, schema validation, and all tests (this also stops any running dev-compose environment)
|
||||||
- Run `make dev-compose` to start the local development environment
|
- Run `make dev-compose` to start the local development environment
|
||||||
16
.github/dependabot.yml
vendored
16
.github/dependabot.yml
vendored
|
|
@ -3,28 +3,28 @@ updates:
|
||||||
- package-ecosystem: gomod
|
- package-ecosystem: gomod
|
||||||
directory: /
|
directory: /
|
||||||
groups:
|
groups:
|
||||||
opentelemetry:
|
go-dependencies:
|
||||||
patterns:
|
patterns:
|
||||||
- "go.opentelemetry.io/*"
|
- "*"
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: weekly
|
||||||
- package-ecosystem: gomod
|
- package-ecosystem: gomod
|
||||||
directory: /deploy
|
directory: /deploy
|
||||||
groups:
|
groups:
|
||||||
opentelemetry:
|
deploy-go-dependencies:
|
||||||
patterns:
|
patterns:
|
||||||
- "go.opentelemetry.io/*"
|
- "*"
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: weekly
|
||||||
- package-ecosystem: github-actions
|
- package-ecosystem: github-actions
|
||||||
directory: /
|
directory: /
|
||||||
groups:
|
groups:
|
||||||
actions:
|
github-actions:
|
||||||
patterns:
|
patterns:
|
||||||
- "*"
|
- "*"
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: weekly
|
||||||
- package-ecosystem: "docker"
|
- package-ecosystem: docker
|
||||||
directory: "/"
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: weekly
|
||||||
|
|
|
||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a
|
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a
|
||||||
with:
|
with:
|
||||||
version: v2.11.4
|
version: v2.13.1
|
||||||
|
|
||||||
- name: Validate schemas and examples
|
- name: Validate schemas and examples
|
||||||
run: make validate
|
run: make validate
|
||||||
|
|
@ -51,7 +51,7 @@ jobs:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
|
||||||
13
.github/workflows/close-invalid-publish-prs.yml
vendored
13
.github/workflows/close-invalid-publish-prs.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
COMMENT_BODY: |
|
COMMENT_BODY: |
|
||||||
Hi @__AUTHOR__ 👋 — thanks for your interest in the MCP Registry!
|
Hi @__AUTHOR__ 👋 — thanks for your interest in the MCP Registry!
|
||||||
|
|
||||||
It looks like this PR is trying to publish an MCP server by adding or editing files in this repository (under `servers/` or in `data/seed.json`). That isn't how servers get published, so I'm closing this PR automatically.
|
It looks like this PR is trying to publish an MCP server by adding or editing server files in this repository (under `servers/`, `data/servers/`, or in `data/seed.json`). That isn't how servers get published, so I'm closing this PR automatically.
|
||||||
|
|
||||||
**Servers are published with the [`mcp-publisher`](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx) CLI**, not by opening a pull request against this repo. The CLI verifies that you own your namespace and submits your `server.json` directly to the live registry API.
|
**Servers are published with the [`mcp-publisher`](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx) CLI**, not by opening a pull request against this repo. The CLI verifies that you own your namespace and submits your `server.json` directly to the live registry API.
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ jobs:
|
||||||
|
|
||||||
- name: Download flagged PR number
|
- name: Download flagged PR number
|
||||||
if: steps.check.outputs.found == 'true'
|
if: steps.check.outputs.found == 'true'
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: flagged-pr
|
name: flagged-pr
|
||||||
run-id: ${{ github.event.workflow_run.id }}
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
|
@ -121,10 +121,17 @@ jobs:
|
||||||
echo "No files for PR #$PR_NUMBER; skipping."
|
echo "No files for PR #$PR_NUMBER; skipping."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# Keep this list in sync with the copy in stage 1.
|
||||||
publish_attempt=0
|
publish_attempt=0
|
||||||
other=0
|
other=0
|
||||||
for f in "${FILES[@]}"; do
|
for f in "${FILES[@]}"; do
|
||||||
if [[ "$f" == servers/* || "$f" == "data/seed.json" ]]; then
|
if [[ "$f" == servers/* \
|
||||||
|
|| "$f" == data/servers/* \
|
||||||
|
|| "$f" == data/data/servers/* \
|
||||||
|
|| "$f" =~ ^data/[^/]*server\.json$ \
|
||||||
|
|| "$f" == "data/seed.json" \
|
||||||
|
|| "$f" == "server.json" \
|
||||||
|
|| "$f" == "servers.json" ]]; then
|
||||||
publish_attempt=1
|
publish_attempt=1
|
||||||
else
|
else
|
||||||
other=1
|
other=1
|
||||||
|
|
|
||||||
2
.github/workflows/deploy-production.yml
vendored
2
.github/workflows/deploy-production.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
|
||||||
6
.github/workflows/deploy-staging.yml
vendored
6
.github/workflows/deploy-staging.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
uses: ko-build/setup-ko@v0.10
|
uses: ko-build/setup-ko@v0.10
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|
@ -73,7 +73,7 @@ jobs:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
|
||||||
20
.github/workflows/detect-invalid-publish-prs.yml
vendored
20
.github/workflows/detect-invalid-publish-prs.yml
vendored
|
|
@ -48,12 +48,26 @@ jobs:
|
||||||
mapfile -t FILES < <(gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')
|
mapfile -t FILES < <(gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')
|
||||||
if [ "${#FILES[@]}" -gt 0 ]; then
|
if [ "${#FILES[@]}" -gt 0 ]; then
|
||||||
# Flag only if the PR touches *exclusively* publish-attempt files
|
# Flag only if the PR touches *exclusively* publish-attempt files
|
||||||
# (servers/** and/or data/seed.json) and nothing else, so that
|
# and nothing else, so that legit PRs touching seed data
|
||||||
# legit PRs touching seed data alongside real code are left alone.
|
# alongside real code are left alone.
|
||||||
|
#
|
||||||
|
# Keep this list in sync with the copy in stage 2. It stays an
|
||||||
|
# explicit list rather than a broad `**/server.json` glob:
|
||||||
|
# because we require *every* file to match, an over-broad
|
||||||
|
# pattern would auto-close a legitimate single-file PR.
|
||||||
|
# data/servers/** is here because #1524 put its server.json
|
||||||
|
# there - a natural guess next to data/seed.json - and slipped
|
||||||
|
# through the original servers/**-only list.
|
||||||
publish_attempt=0
|
publish_attempt=0
|
||||||
other=0
|
other=0
|
||||||
for f in "${FILES[@]}"; do
|
for f in "${FILES[@]}"; do
|
||||||
if [[ "$f" == servers/* || "$f" == "data/seed.json" ]]; then
|
if [[ "$f" == servers/* \
|
||||||
|
|| "$f" == data/servers/* \
|
||||||
|
|| "$f" == data/data/servers/* \
|
||||||
|
|| "$f" =~ ^data/[^/]*server\.json$ \
|
||||||
|
|| "$f" == "data/seed.json" \
|
||||||
|
|| "$f" == "server.json" \
|
||||||
|
|| "$f" == "servers.json" ]]; then
|
||||||
publish_attempt=1
|
publish_attempt=1
|
||||||
else
|
else
|
||||||
echo "Non-publish file changed: $f"
|
echo "Non-publish file changed: $f"
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
@ -28,7 +28,7 @@ jobs:
|
||||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Install Syft
|
- name: Install Syft
|
||||||
uses: anchore/sbom-action/download-syft@v0.24.0
|
uses: anchore/sbom-action/download-syft@v0.24.2
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94
|
||||||
|
|
@ -48,7 +48,7 @@ jobs:
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
uses: ko-build/setup-ko@v0.10
|
uses: ko-build/setup-ko@v0.10
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ linters:
|
||||||
- gocyclo
|
- gocyclo
|
||||||
- godox
|
- godox
|
||||||
- gomoddirectives
|
- gomoddirectives
|
||||||
- gomodguard
|
- gomodguard_v2
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
- gosec
|
- gosec
|
||||||
- grouper
|
- grouper
|
||||||
|
|
@ -55,6 +55,10 @@ linters:
|
||||||
max-complexity: 20
|
max-complexity: 20
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 300
|
threshold: 300
|
||||||
|
goconst:
|
||||||
|
# Repeated literals in tests are fixture data ("1.0.0", "Test server", ...);
|
||||||
|
# hoisting them into constants hurts readability more than it helps.
|
||||||
|
ignore-tests: true
|
||||||
gocognit:
|
gocognit:
|
||||||
min-complexity: 51
|
min-complexity: 51
|
||||||
nestif:
|
nestif:
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ See the [README](README.md#quick-start) for prerequisites and instructions on ru
|
||||||
### Running checks
|
### Running checks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run lint, unit tests and integration tests
|
# Run lint, schema validation, and all tests
|
||||||
|
# Note: this also runs `make dev-down`, stopping any running dev-compose environment
|
||||||
make check
|
make check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -11,8 +11,8 @@ The MCP registry provides MCP clients with a list of MCP servers, like an app st
|
||||||
**2025-09-08 update**: The registry has launched in preview 🎉 ([announcement blog post](https://blog.modelcontextprotocol.io/posts/2025-09-08-mcp-registry-preview/)). While the system is now more stable, this is still a preview release and breaking changes or data resets may occur. A general availability (GA) release will follow later. We'd love your feedback in [GitHub discussions](https://github.com/modelcontextprotocol/registry/discussions/new?category=ideas) or in the [#registry-dev Discord](https://discord.com/channels/1358869848138059966/1369487942862504016) ([joining details here](https://modelcontextprotocol.io/community/communication)).
|
**2025-09-08 update**: The registry has launched in preview 🎉 ([announcement blog post](https://blog.modelcontextprotocol.io/posts/2025-09-08-mcp-registry-preview/)). While the system is now more stable, this is still a preview release and breaking changes or data resets may occur. A general availability (GA) release will follow later. We'd love your feedback in [GitHub discussions](https://github.com/modelcontextprotocol/registry/discussions/new?category=ideas) or in the [#registry-dev Discord](https://discord.com/channels/1358869848138059966/1369487942862504016) ([joining details here](https://modelcontextprotocol.io/community/communication)).
|
||||||
|
|
||||||
Registry Working Group:
|
Registry Working Group:
|
||||||
|
- **Radoslav (Rado) Dimitrov** (Stacklok) [@rdimitrov](https://github.com/rdimitrov) - WG Lead
|
||||||
- **Tadas Antanavicius** (PulseMCP) [@tadasant](https://github.com/tadasant)
|
- **Tadas Antanavicius** (PulseMCP) [@tadasant](https://github.com/tadasant)
|
||||||
- **Radoslav (Rado) Dimitrov** (Stacklok) [@rdimitrov](https://github.com/rdimitrov)
|
|
||||||
- **Bob Dickinson** (TeamSpark) [@BobDickinson](https://github.com/BobDickinson)
|
- **Bob Dickinson** (TeamSpark) [@BobDickinson](https://github.com/BobDickinson)
|
||||||
- **Preeti (Pree) Dewani** (Ravenmail) [@pree-dew](https://github.com/pree-dew)
|
- **Preeti (Pree) Dewani** (Ravenmail) [@pree-dew](https://github.com/pree-dew)
|
||||||
|
|
||||||
|
|
@ -32,9 +32,9 @@ Often (but not always) ideas flow through this pipeline:
|
||||||
#### Pre-requisites
|
#### Pre-requisites
|
||||||
|
|
||||||
- **Docker**
|
- **Docker**
|
||||||
- **Go 1.24.x**
|
- **Go** - version per the `go` directive in [go.mod](./go.mod); the Go toolchain fetches it for you
|
||||||
- **ko** - Container image builder for Go ([installation instructions](https://ko.build/install/))
|
- **ko** - Container image builder for Go ([installation instructions](https://ko.build/install/))
|
||||||
- **golangci-lint v2.4.0**
|
- **golangci-lint** - version per the `golangci-lint-action` pin in [ci.yml](./.github/workflows/ci.yml), so local lint matches CI
|
||||||
|
|
||||||
#### Running the server
|
#### Running the server
|
||||||
|
|
||||||
|
|
@ -94,7 +94,8 @@ See [the publisher guide](./docs/modelcontextprotocol-io/quickstart.mdx) for mor
|
||||||
#### Other commands
|
#### Other commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run lint, unit tests and integration tests
|
# Run lint, schema validation, and all tests
|
||||||
|
# Note: this also runs `make dev-down`, stopping any running dev-compose environment
|
||||||
make check
|
make check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -110,7 +111,8 @@ For Claude and other AI tools: Always prefer make targets over custom commands w
|
||||||
|
|
||||||
```
|
```
|
||||||
├── cmd/ # Application entry points
|
├── cmd/ # Application entry points
|
||||||
│ └── publisher/ # Server publishing tool
|
│ ├── publisher/ # Server publishing tool
|
||||||
|
│ └── registry/ # Registry API server
|
||||||
├── data/ # Seed data
|
├── data/ # Seed data
|
||||||
├── deploy/ # Deployment configuration (Pulumi)
|
├── deploy/ # Deployment configuration (Pulumi)
|
||||||
├── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
|
|
@ -119,6 +121,7 @@ For Claude and other AI tools: Always prefer make targets over custom commands w
|
||||||
│ ├── auth/ # Authentication (GitHub OAuth, JWT, namespace blocking)
|
│ ├── auth/ # Authentication (GitHub OAuth, JWT, namespace blocking)
|
||||||
│ ├── config/ # Configuration management
|
│ ├── config/ # Configuration management
|
||||||
│ ├── database/ # Data persistence (PostgreSQL)
|
│ ├── database/ # Data persistence (PostgreSQL)
|
||||||
|
│ ├── importer/ # Seed data import
|
||||||
│ ├── service/ # Business logic
|
│ ├── service/ # Business logic
|
||||||
│ ├── telemetry/ # Metrics and monitoring
|
│ ├── telemetry/ # Metrics and monitoring
|
||||||
│ └── validators/ # Input validation
|
│ └── validators/ # Input validation
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@ make publisher
|
||||||
make dev-compose # Start local registry
|
make dev-compose # Start local registry
|
||||||
./bin/mcp-publisher init
|
./bin/mcp-publisher init
|
||||||
./bin/mcp-publisher login none --registry=http://localhost:8080
|
./bin/mcp-publisher login none --registry=http://localhost:8080
|
||||||
./bin/mcp-publisher publish --registry=http://localhost:8080
|
./bin/mcp-publisher publish
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`publish` takes the registry URL from the token saved by `login`, so it accepts no `--registry`
|
||||||
|
flag — passing one would be read as the `server.json` path.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
@ -24,6 +27,7 @@ make dev-compose # Start local registry
|
||||||
- **`login`** - Handle authentication (github, dns, http, none)
|
- **`login`** - Handle authentication (github, dns, http, none)
|
||||||
- **`publish`** - Validate and upload servers to registry
|
- **`publish`** - Validate and upload servers to registry
|
||||||
- **`status`** - Update server lifecycle status (active, deprecated, deleted)
|
- **`status`** - Update server lifecycle status (active, deprecated, deleted)
|
||||||
|
- **`validate`** - Validate server.json without publishing
|
||||||
- **`logout`** - Clear stored credentials
|
- **`logout`** - Clear stored credentials
|
||||||
|
|
||||||
### Authentication Providers
|
### Authentication Providers
|
||||||
|
|
@ -44,4 +48,3 @@ Optional: enables `dns` and `http` methods to sign out-of-process without direct
|
||||||
- **`main.go`** - CLI setup and command routing
|
- **`main.go`** - CLI setup and command routing
|
||||||
- **`commands/`** - Command implementations with auto-detection logic
|
- **`commands/`** - Command implementations with auto-detection logic
|
||||||
- **`auth/`** - Authentication provider implementations
|
- **`auth/`** - Authentication provider implementations
|
||||||
- **`build.sh`** - Cross-platform build script
|
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ func (d Signer) GetSignedTimestamp(ctx context.Context) (*string, []byte, error)
|
||||||
fmt.Fprintln(os.Stdout, "Successfully read the public key from Key Vault.")
|
fmt.Fprintln(os.Stdout, "Successfully read the public key from Key Vault.")
|
||||||
auth.PrintEcdsaP384KeyInfo(ecdsa.PublicKey{
|
auth.PrintEcdsaP384KeyInfo(ecdsa.PublicKey{
|
||||||
Curve: elliptic.P384(),
|
Curve: elliptic.P384(),
|
||||||
X: new(big.Int).SetBytes(keyResp.Key.X),
|
X: new(big.Int).SetBytes(keyResp.Key.X), //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
|
||||||
Y: new(big.Int).SetBytes(keyResp.Key.Y),
|
Y: new(big.Int).SetBytes(keyResp.Key.Y), //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
|
||||||
})
|
})
|
||||||
|
|
||||||
timestamp := auth.GetTimestamp()
|
timestamp := auth.GetTimestamp()
|
||||||
|
|
|
||||||
|
|
@ -164,10 +164,10 @@ func parseRawPrivateKey(curve elliptic.Curve, privateKeyBytes []byte) (*ecdsa.Pr
|
||||||
return &ecdsa.PrivateKey{
|
return &ecdsa.PrivateKey{
|
||||||
PublicKey: ecdsa.PublicKey{
|
PublicKey: ecdsa.PublicKey{
|
||||||
Curve: curve,
|
Curve: curve,
|
||||||
X: x,
|
X: x, //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
|
||||||
Y: y,
|
Y: y, //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
|
||||||
},
|
},
|
||||||
D: d,
|
D: d, //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,31 @@ const (
|
||||||
GitHubAccessTokenURL = "https://github.com/login/oauth/access_token" // #nosec:G101
|
GitHubAccessTokenURL = "https://github.com/login/oauth/access_token" // #nosec:G101
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultPollInterval is the initial device-flow polling interval in
|
||||||
|
// seconds, per RFC 8628 §3.5.
|
||||||
|
defaultPollInterval = 5
|
||||||
|
// maxPollInterval caps how large the polling interval may grow. RFC 8628
|
||||||
|
// §3.5 mandates a 5-second increase on each slow_down but leaves the
|
||||||
|
// maximum to the implementation; this prevents a misbehaving auth server
|
||||||
|
// from growing the interval unboundedly.
|
||||||
|
maxPollInterval = 60
|
||||||
|
// defaultExpiresIn is the device-code lifetime in seconds used when the
|
||||||
|
// device-code response omits expires_in.
|
||||||
|
defaultExpiresIn = 900
|
||||||
|
// maxExpiresIn caps the device-code lifetime a response may claim. It sits
|
||||||
|
// well past GitHub's documented 15 minutes; the cap stops a misbehaving or
|
||||||
|
// hostile response from pushing the deadline (and the polling loop) far
|
||||||
|
// into the future.
|
||||||
|
maxExpiresIn = 3600
|
||||||
|
// incorrectDeviceCodeGraceRetries is the total number of extra polls a
|
||||||
|
// single login may spend on incorrect_device_code before giving up — a
|
||||||
|
// budget for the whole login, not per occurrence. cli/cli#9302 reports the
|
||||||
|
// same error for a code that had just been issued; a genuinely invalid code
|
||||||
|
// still fails, only a few seconds later.
|
||||||
|
incorrectDeviceCodeGraceRetries = 2
|
||||||
|
)
|
||||||
|
|
||||||
// DeviceCodeResponse represents the response from GitHub's device code endpoint
|
// DeviceCodeResponse represents the response from GitHub's device code endpoint
|
||||||
type DeviceCodeResponse struct {
|
type DeviceCodeResponse struct {
|
||||||
DeviceCode string `json:"device_code"`
|
DeviceCode string `json:"device_code"`
|
||||||
|
|
@ -32,6 +57,38 @@ type AccessTokenResponse struct {
|
||||||
TokenType string `json:"token_type"`
|
TokenType string `json:"token_type"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
// ErrorDescription and ErrorURI accompany Error in GitHub's responses;
|
||||||
|
// surfacing them turns opaque failures like "incorrect_device_code" into
|
||||||
|
// diagnosable reports.
|
||||||
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
|
ErrorURI string `json:"error_uri,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorDetail renders Error together with any error_description and error_uri
|
||||||
|
// GitHub returned, so failures reach the user diagnosable rather than opaque.
|
||||||
|
func (r AccessTokenResponse) errorDetail() string {
|
||||||
|
detail := r.Error
|
||||||
|
if r.ErrorDescription != "" {
|
||||||
|
detail += ": " + r.ErrorDescription
|
||||||
|
}
|
||||||
|
if r.ErrorURI != "" {
|
||||||
|
detail += " (" + r.ErrorURI + ")"
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAccessTokenResponse(statusCode int, body []byte) (AccessTokenResponse, error) {
|
||||||
|
var tokenResp AccessTokenResponse
|
||||||
|
// OAuth errors may use HTTP 400 (RFC 6749 §5.2), not just HTTP 200.
|
||||||
|
if statusCode != http.StatusOK && statusCode != http.StatusBadRequest {
|
||||||
|
return tokenResp, fmt.Errorf("token endpoint returned %d: %s", statusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(body, &tokenResp)
|
||||||
|
if statusCode == http.StatusBadRequest && (err != nil || tokenResp.Error == "") {
|
||||||
|
return tokenResp, fmt.Errorf("token endpoint returned %d: %s", statusCode, body)
|
||||||
|
}
|
||||||
|
return tokenResp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistryTokenResponse represents the response from registry's token exchange endpoint
|
// RegistryTokenResponse represents the response from registry's token exchange endpoint
|
||||||
|
|
@ -46,6 +103,24 @@ type GitHubATProvider struct {
|
||||||
registryURL string
|
registryURL string
|
||||||
providedToken string // Token provided via --token flag or MCP_GITHUB_TOKEN env var
|
providedToken string // Token provided via --token flag or MCP_GITHUB_TOKEN env var
|
||||||
githubToken string // In-memory GitHub token set by Login()
|
githubToken string // In-memory GitHub token set by Login()
|
||||||
|
|
||||||
|
// accessTokenURL is the GitHub access-token polling endpoint. It is a field
|
||||||
|
// (rather than the package constant) so tests can point it at a mock server.
|
||||||
|
accessTokenURL string
|
||||||
|
// deviceCodeURL is the GitHub device-code endpoint, a field for the same
|
||||||
|
// reason as accessTokenURL.
|
||||||
|
deviceCodeURL string
|
||||||
|
// pollInterval is the initial polling interval in seconds. Defaults to
|
||||||
|
// defaultPollInterval; overridable in tests to avoid real delays. Updated
|
||||||
|
// from the device-code response's interval when GitHub returns one.
|
||||||
|
pollInterval int
|
||||||
|
// expiresIn is the device-code lifetime in seconds. Defaults to
|
||||||
|
// defaultExpiresIn; updated from the device-code response's expires_in
|
||||||
|
// when GitHub returns one.
|
||||||
|
expiresIn int
|
||||||
|
// sleep abstracts time.Sleep so tests can run without real delays and
|
||||||
|
// assert the back-off sequence. Defaults to time.Sleep.
|
||||||
|
sleep func(time.Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerHealthResponse represents the response from the health endpoint
|
// ServerHealthResponse represents the response from the health endpoint
|
||||||
|
|
@ -62,8 +137,13 @@ func NewGitHubATProvider(registryURL, token string) Provider {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &GitHubATProvider{
|
return &GitHubATProvider{
|
||||||
registryURL: registryURL,
|
registryURL: registryURL,
|
||||||
providedToken: token,
|
providedToken: token,
|
||||||
|
accessTokenURL: GitHubAccessTokenURL,
|
||||||
|
deviceCodeURL: GitHubDeviceCodeURL,
|
||||||
|
pollInterval: defaultPollInterval,
|
||||||
|
expiresIn: defaultExpiresIn,
|
||||||
|
sleep: time.Sleep,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +229,7 @@ func (g *GitHubATProvider) requestDeviceCode(ctx context.Context) (string, strin
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubDeviceCodeURL, bytes.NewBuffer(jsonData))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.deviceCodeURL, bytes.NewBuffer(jsonData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +258,21 @@ func (g *GitHubATProvider) requestDeviceCode(ctx context.Context) (string, strin
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per RFC 8628 §3.2 interval and expires_in are bound to this device code,
|
||||||
|
// so reset any pacing left over from a previous code before adopting them.
|
||||||
|
// Clamp both: a hostile or buggy response must not grow the interval past
|
||||||
|
// maxPollInterval (the invariant maxPollInterval exists for) nor push the
|
||||||
|
// deadline unboundedly into the future, and clamping also keeps the
|
||||||
|
// derived time.Duration well clear of int64 overflow.
|
||||||
|
g.pollInterval = defaultPollInterval
|
||||||
|
g.expiresIn = defaultExpiresIn
|
||||||
|
if deviceCodeResp.Interval > 0 {
|
||||||
|
g.pollInterval = min(deviceCodeResp.Interval, maxPollInterval)
|
||||||
|
}
|
||||||
|
if deviceCodeResp.ExpiresIn > 0 {
|
||||||
|
g.expiresIn = min(deviceCodeResp.ExpiresIn, maxExpiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
return deviceCodeResp.DeviceCode, deviceCodeResp.UserCode, deviceCodeResp.VerificationURI, nil
|
return deviceCodeResp.DeviceCode, deviceCodeResp.UserCode, deviceCodeResp.VerificationURI, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,13 +293,28 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default polling interval and expiration time
|
// Pacing comes from the device-code response (via requestDeviceCode),
|
||||||
interval := 5 // seconds
|
// falling back to GitHub's documented defaults.
|
||||||
expiresIn := 900 // 15 minutes
|
interval := g.pollInterval
|
||||||
|
expiresIn := g.expiresIn
|
||||||
|
// Zero-value fallback: providers built directly rather than through
|
||||||
|
// requestDeviceCode (the test seam) carry no lifetime. Keep this guard.
|
||||||
|
if expiresIn <= 0 {
|
||||||
|
expiresIn = defaultExpiresIn
|
||||||
|
}
|
||||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||||
|
|
||||||
|
graceRetries := 0
|
||||||
|
// serverErrorInterval grows the wait between consecutive 5xx retries and is
|
||||||
|
// reset by any other response.
|
||||||
|
serverErrorInterval := interval
|
||||||
|
// lastErr holds the most recent retryable failure so a deadline reached
|
||||||
|
// mid-retry reports that diagnostic instead of a bare "timed out". It is
|
||||||
|
// cleared whenever polling recovers, so a genuine user timeout after a
|
||||||
|
// recovered blip still reports plainly.
|
||||||
|
var lastErr error
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubAccessTokenURL, bytes.NewBuffer(jsonData))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.accessTokenURL, bytes.NewBuffer(jsonData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -223,20 +333,54 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenResp AccessTokenResponse
|
// A transient 5xx must not abandon a login the user may already have
|
||||||
err = json.Unmarshal(body, &tokenResp)
|
// approved in the browser. Each consecutive 5xx grows the wait by 5s up
|
||||||
|
// to maxPollInterval, so a sustained outage costs a couple of dozen
|
||||||
|
// requests rather than hundreds; the deadline bounds the number of
|
||||||
|
// these retries.
|
||||||
|
if resp.StatusCode >= http.StatusInternalServerError {
|
||||||
|
lastErr = fmt.Errorf("token endpoint returned %d", resp.StatusCode)
|
||||||
|
serverErrorInterval = min(serverErrorInterval+5, maxPollInterval)
|
||||||
|
g.sleep(time.Duration(serverErrorInterval) * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serverErrorInterval = interval
|
||||||
|
|
||||||
|
tokenResp, err := decodeAccessTokenResponse(resp.StatusCode, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenResp.Error == "authorization_pending" {
|
// Per RFC 8628 §3.5, both authorization_pending and slow_down indicate
|
||||||
// User hasn't authorized yet, wait and retry
|
// the client should keep polling. slow_down additionally requires that
|
||||||
time.Sleep(time.Duration(interval) * time.Second)
|
// the polling interval be increased by 5 seconds.
|
||||||
|
if tokenResp.Error == "authorization_pending" || tokenResp.Error == "slow_down" {
|
||||||
|
if tokenResp.Error == "slow_down" {
|
||||||
|
interval += 5
|
||||||
|
if interval > maxPollInterval {
|
||||||
|
interval = maxPollInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastErr = nil
|
||||||
|
g.sleep(time.Duration(interval) * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenResp.Error != "" {
|
if tokenResp.Error != "" {
|
||||||
return "", fmt.Errorf("token request failed: %s", tokenResp.Error)
|
failure := fmt.Errorf("token request failed: %s", tokenResp.errorDetail())
|
||||||
|
|
||||||
|
// incorrect_device_code has been reported for codes that were in
|
||||||
|
// fact valid (cli/cli#9302; cause never confirmed), so spend a
|
||||||
|
// couple of grace polls before abandoning a login the user may
|
||||||
|
// have approved.
|
||||||
|
if tokenResp.Error == "incorrect_device_code" && graceRetries < incorrectDeviceCodeGraceRetries {
|
||||||
|
graceRetries++
|
||||||
|
lastErr = failure
|
||||||
|
g.sleep(time.Duration(interval) * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", failure
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenResp.AccessToken != "" {
|
if tokenResp.AccessToken != "" {
|
||||||
|
|
@ -247,6 +391,9 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
|
||||||
return "", fmt.Errorf("failed to obtain access token")
|
return "", fmt.Errorf("failed to obtain access token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", fmt.Errorf("device code authorization timed out; last response: %w", lastErr)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("device code authorization timed out")
|
return "", fmt.Errorf("device code authorization timed out")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
532
cmd/publisher/auth/github_at_device_flow_internal_test.go
Normal file
532
cmd/publisher/auth/github_at_device_flow_internal_test.go
Normal file
|
|
@ -0,0 +1,532 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seqTokenServer serves a fixed sequence of (status, body) responses, one per
|
||||||
|
// request, reusing the last one once the sequence is exhausted. Unlike
|
||||||
|
// newMockTokenServer it can serve non-200 statuses and non-JSON bodies, which
|
||||||
|
// the transient-5xx path needs.
|
||||||
|
type seqTokenServer struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
seen int
|
||||||
|
items []seqResp
|
||||||
|
}
|
||||||
|
|
||||||
|
type seqResp struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSeqTokenServer(t *testing.T, items ...seqResp) *seqTokenServer {
|
||||||
|
t.Helper()
|
||||||
|
s := &seqTokenServer{items: items}
|
||||||
|
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
item := s.items[len(s.items)-1]
|
||||||
|
if s.seen < len(s.items) {
|
||||||
|
item = s.items[s.seen]
|
||||||
|
}
|
||||||
|
s.seen++
|
||||||
|
s.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(item.status)
|
||||||
|
_, _ = w.Write([]byte(item.body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(s.srv.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *seqTokenServer) requests() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.seen
|
||||||
|
}
|
||||||
|
|
||||||
|
func okResp(body string) seqResp { return seqResp{status: http.StatusOK, body: body} }
|
||||||
|
|
||||||
|
// serverErrorResp is a 502 carrying an HTML body rather than JSON, the shape a
|
||||||
|
// transient edge failure takes.
|
||||||
|
func serverErrorResp() seqResp {
|
||||||
|
return seqResp{status: http.StatusBadGateway, body: `<html>Server Error</html>`}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
testClientID = "test-client-id"
|
||||||
|
|
||||||
|
pendingBody = `{"error":"authorization_pending"}`
|
||||||
|
incorrectBody = `{"error":"incorrect_device_code"}`
|
||||||
|
// The full shape GitHub sends for this error, description and URI included.
|
||||||
|
incorrectVerboseBody = `{"error":"incorrect_device_code","error_description":"The device_code provided is not valid.","error_uri":"https://docs.github.com/developers/apps/authorizing-oauth-apps#error-codes-for-the-device-flow"}`
|
||||||
|
tokenBody = `{"access_token":"gho_test_token","token_type":"bearer","scope":"read:org,read:user"}` // #nosec G101 -- test fixture, not a real secret
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPollForToken_IncorrectDeviceCodeIsTerminalAfterGrace confirms a code that
|
||||||
|
// stays invalid past the grace polls still fails, and fails with the error
|
||||||
|
// GitHub reported rather than a rewritten one.
|
||||||
|
func TestPollForToken_IncorrectDeviceCodeIsTerminalAfterGrace(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t, okResp(pendingBody), okResp(pendingBody), okResp(incorrectBody))
|
||||||
|
p, _ := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
_, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, "token request failed: incorrect_device_code", err.Error())
|
||||||
|
// 2 pending + 1 incorrect + 2 grace polls that also answer incorrect.
|
||||||
|
assert.Equal(t, 5, gh.requests())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_TransientIncorrectDeviceCodeRecovered covers the case the
|
||||||
|
// grace retries exist for: one incorrect_device_code between a pending poll and
|
||||||
|
// a successful one must not discard a token the user already authorised.
|
||||||
|
func TestPollForToken_TransientIncorrectDeviceCodeRecovered(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t, okResp(pendingBody), okResp(incorrectBody), okResp(tokenBody))
|
||||||
|
p, _ := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.NoError(t, err, "one transient incorrect_device_code must not be fatal")
|
||||||
|
assert.Equal(t, "gho_test_token", token)
|
||||||
|
assert.Equal(t, 3, gh.requests())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollForToken_OAuthErrorsRetryOnBadRequest(t *testing.T) {
|
||||||
|
for _, status := range []int{http.StatusOK, http.StatusBadRequest} {
|
||||||
|
for _, code := range []string{"authorization_pending", "slow_down", "incorrect_device_code"} {
|
||||||
|
t.Run(fmt.Sprintf("%d/%s", status, code), func(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t,
|
||||||
|
seqResp{status: status, body: fmt.Sprintf(`{"error":%q}`, code)},
|
||||||
|
okResp(tokenBody),
|
||||||
|
)
|
||||||
|
p, slept := newPollTestProvider(gh.srv.URL)
|
||||||
|
p.pollInterval = defaultPollInterval
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "gho_test_token", token)
|
||||||
|
assert.Equal(t, 2, gh.requests())
|
||||||
|
wait := time.Duration(defaultPollInterval) * time.Second
|
||||||
|
if code == "slow_down" {
|
||||||
|
wait += 5 * time.Second
|
||||||
|
}
|
||||||
|
assert.Equal(t, []time.Duration{wait}, *slept)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollForToken_BadRequestOAuthErrorsRemainTerminal(t *testing.T) {
|
||||||
|
for _, code := range []string{"access_denied", "expired_token", "unknown_error", "incorrect_device_code"} {
|
||||||
|
t.Run(code, func(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t, seqResp{
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: fmt.Sprintf(`{"error":%q,"error_description":"Request rejected","error_uri":"https://github.com/error"}`, code),
|
||||||
|
})
|
||||||
|
p, slept := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.EqualError(t, err, "token request failed: "+code+": Request rejected (https://github.com/error)")
|
||||||
|
assert.Empty(t, token)
|
||||||
|
retries := 0
|
||||||
|
if code == "incorrect_device_code" {
|
||||||
|
retries = incorrectDeviceCodeGraceRetries
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1+retries, gh.requests())
|
||||||
|
assert.Len(t, *slept, retries)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_ErrorDescriptionAndURISurfaced confirms the grace budget is
|
||||||
|
// spent in full against a persistently invalid code, and that GitHub's
|
||||||
|
// error_description and error_uri reach the user instead of being discarded.
|
||||||
|
func TestPollForToken_ErrorDescriptionAndURISurfaced(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t, okResp(incorrectVerboseBody))
|
||||||
|
p, _ := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
_, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "incorrect_device_code")
|
||||||
|
assert.Contains(t, err.Error(), "The device_code provided is not valid.")
|
||||||
|
assert.Contains(t, err.Error(), "error-codes-for-the-device-flow")
|
||||||
|
assert.Equal(t, 1+incorrectDeviceCodeGraceRetries, gh.requests())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_TransientServerErrorRetried confirms a 5xx (which carries an
|
||||||
|
// HTML body, not JSON) is retried rather than ending the login.
|
||||||
|
func TestPollForToken_TransientServerErrorRetried(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t,
|
||||||
|
serverErrorResp(),
|
||||||
|
okResp(tokenBody),
|
||||||
|
)
|
||||||
|
p, slept := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "gho_test_token", token)
|
||||||
|
assert.Equal(t, 2, gh.requests())
|
||||||
|
assert.Len(t, *slept, 1, "the 5xx must be followed by one back-off")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_FirstPollIsImmediate pins the polling schedule: the first
|
||||||
|
// request goes out without a preceding sleep, so a user who authorises quickly
|
||||||
|
// is not made to wait an interval for nothing.
|
||||||
|
func TestPollForToken_FirstPollIsImmediate(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t, okResp(tokenBody))
|
||||||
|
p, slept := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
_, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, gh.requests())
|
||||||
|
assert.Empty(t, *slept, "an immediately successful poll must not sleep")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestDeviceCode_HonoursServerIntervalAndExpiry confirms the interval and
|
||||||
|
// expires_in in the device-code response are adopted rather than dropped.
|
||||||
|
func TestRequestDeviceCode_HonoursServerIntervalAndExpiry(t *testing.T) {
|
||||||
|
device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"device_code":"dc-1","user_code":"AAAA-BBBB","verification_uri":"https://github.com/login/device","expires_in":60,"interval":7}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(device.Close)
|
||||||
|
|
||||||
|
p := &GitHubATProvider{
|
||||||
|
clientID: testClientID,
|
||||||
|
deviceCodeURL: device.URL,
|
||||||
|
pollInterval: defaultPollInterval,
|
||||||
|
expiresIn: defaultExpiresIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceCode, userCode, uri, err := p.requestDeviceCode(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "dc-1", deviceCode)
|
||||||
|
assert.Equal(t, "AAAA-BBBB", userCode)
|
||||||
|
assert.Equal(t, "https://github.com/login/device", uri)
|
||||||
|
assert.Equal(t, 7, p.pollInterval, "interval from the response must be adopted")
|
||||||
|
assert.Equal(t, 60, p.expiresIn, "expires_in from the response must be adopted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestDeviceCode_KeepsDefaultsWhenOmitted confirms a response without
|
||||||
|
// interval or expires_in leaves the documented defaults in place.
|
||||||
|
func TestRequestDeviceCode_KeepsDefaultsWhenOmitted(t *testing.T) {
|
||||||
|
device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"device_code":"dc-2","user_code":"EEEE-FFFF","verification_uri":"https://github.com/login/device"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(device.Close)
|
||||||
|
|
||||||
|
p := &GitHubATProvider{
|
||||||
|
clientID: testClientID,
|
||||||
|
deviceCodeURL: device.URL,
|
||||||
|
pollInterval: defaultPollInterval,
|
||||||
|
expiresIn: defaultExpiresIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, err := p.requestDeviceCode(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, defaultPollInterval, p.pollInterval)
|
||||||
|
assert.Equal(t, defaultExpiresIn, p.expiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceCodeServerReturning serves a single device-code response with the given
|
||||||
|
// raw JSON body, for exercising interval/expires_in handling.
|
||||||
|
func deviceCodeServerReturning(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestDeviceCode_ClampsExcessiveInterval confirms an interval far above
|
||||||
|
// maxPollInterval is clamped, protecting the same invariant maxPollInterval
|
||||||
|
// enforces on the slow_down path.
|
||||||
|
func TestRequestDeviceCode_ClampsExcessiveInterval(t *testing.T) {
|
||||||
|
url := deviceCodeServerReturning(t,
|
||||||
|
`{"device_code":"dc","user_code":"AAAA-BBBB","verification_uri":"https://github.com/login/device","expires_in":900,"interval":86400}`)
|
||||||
|
|
||||||
|
p := &GitHubATProvider{clientID: testClientID, deviceCodeURL: url, pollInterval: defaultPollInterval, expiresIn: defaultExpiresIn}
|
||||||
|
_, _, _, err := p.requestDeviceCode(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, maxPollInterval, p.pollInterval, "interval above the cap must be clamped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestDeviceCode_ClampsOverflowValues covers the overflow pair: values
|
||||||
|
// large enough that time.Duration(n)*time.Second wraps negative would make
|
||||||
|
// sleep return instantly (spinning the loop) and put the deadline in the past
|
||||||
|
// (yielding zero polls). Clamping keeps both within safe bounds.
|
||||||
|
func TestRequestDeviceCode_ClampsOverflowValues(t *testing.T) {
|
||||||
|
url := deviceCodeServerReturning(t,
|
||||||
|
`{"device_code":"dc","user_code":"AAAA-BBBB","verification_uri":"https://github.com/login/device","expires_in":10000000000,"interval":10000000000}`)
|
||||||
|
|
||||||
|
p := &GitHubATProvider{clientID: testClientID, deviceCodeURL: url, pollInterval: defaultPollInterval, expiresIn: defaultExpiresIn}
|
||||||
|
_, _, _, err := p.requestDeviceCode(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, maxPollInterval, p.pollInterval)
|
||||||
|
assert.Equal(t, maxExpiresIn, p.expiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestDeviceCode_IgnoresNegativeValues confirms negative interval and
|
||||||
|
// expires_in are rejected in favour of the defaults, so neither can produce a
|
||||||
|
// negative sleep or a deadline already in the past.
|
||||||
|
func TestRequestDeviceCode_IgnoresNegativeValues(t *testing.T) {
|
||||||
|
url := deviceCodeServerReturning(t,
|
||||||
|
`{"device_code":"dc","user_code":"AAAA-BBBB","verification_uri":"https://github.com/login/device","expires_in":-1,"interval":-1}`)
|
||||||
|
|
||||||
|
p := &GitHubATProvider{clientID: testClientID, deviceCodeURL: url, pollInterval: defaultPollInterval, expiresIn: defaultExpiresIn}
|
||||||
|
_, _, _, err := p.requestDeviceCode(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, defaultPollInterval, p.pollInterval, "negative interval must fall back to the default")
|
||||||
|
assert.Equal(t, defaultExpiresIn, p.expiresIn, "negative expires_in must fall back to the default")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_TimeoutSurfacesLastError covers F2: when the deadline lands
|
||||||
|
// mid-retry, the timeout must carry the last retryable diagnostic instead of a
|
||||||
|
// bare "timed out". The grace poll records the full-shape incorrect_device_code
|
||||||
|
// and its following sleep crosses the (1-second) deadline.
|
||||||
|
func TestPollForToken_TimeoutSurfacesLastError(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t, okResp(incorrectVerboseBody))
|
||||||
|
p := &GitHubATProvider{
|
||||||
|
clientID: testClientID,
|
||||||
|
accessTokenURL: gh.srv.URL,
|
||||||
|
pollInterval: 1,
|
||||||
|
expiresIn: 1, // deadline ~1s out
|
||||||
|
sleep: func(time.Duration) { time.Sleep(1100 * time.Millisecond) },
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "timed out")
|
||||||
|
assert.Contains(t, err.Error(), "The device_code provided is not valid.",
|
||||||
|
"the last retryable diagnostic must survive the timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_TimeoutAfterRecoveryIsBare confirms lastErr is cleared when
|
||||||
|
// polling recovers: one transient 502 followed by healthy pending polls and
|
||||||
|
// then a genuine user timeout must report plainly, not blame the stale 502.
|
||||||
|
func TestPollForToken_TimeoutAfterRecoveryIsBare(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t,
|
||||||
|
serverErrorResp(),
|
||||||
|
okResp(pendingBody),
|
||||||
|
)
|
||||||
|
p := &GitHubATProvider{
|
||||||
|
clientID: testClientID,
|
||||||
|
accessTokenURL: gh.srv.URL,
|
||||||
|
pollInterval: 1,
|
||||||
|
expiresIn: 1, // deadline ~1s out
|
||||||
|
sleep: func(time.Duration) { time.Sleep(400 * time.Millisecond) },
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, "device code authorization timed out", err.Error(),
|
||||||
|
"a recovered 502 must not be reported as the cause of a user timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-OAuth failures retain HTTP diagnostics rather than JSON parsing errors.
|
||||||
|
func TestPollForToken_NonOKStatusSurfacesStatusAndBody(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
resp seqResp
|
||||||
|
}{
|
||||||
|
{"rate_limited", seqResp{status: http.StatusTooManyRequests, body: "<html>rate limited</html>"}},
|
||||||
|
{"bad_request_html", seqResp{status: http.StatusBadRequest, body: "<html>bad request</html>"}},
|
||||||
|
{"bad_request_invalid_json", seqResp{status: http.StatusBadRequest, body: `{"error":`}},
|
||||||
|
{"bad_request_empty_error", seqResp{status: http.StatusBadRequest, body: `{"error":""}`}},
|
||||||
|
{"bad_request_token", seqResp{status: http.StatusBadRequest, body: tokenBody}},
|
||||||
|
{"pending_on_other_status", seqResp{status: http.StatusForbidden, body: pendingBody}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gh := newSeqTokenServer(t, tt.resp)
|
||||||
|
p, slept := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.EqualError(t, err, fmt.Sprintf("token endpoint returned %d: %s", tt.resp.status, tt.resp.body))
|
||||||
|
assert.Empty(t, token)
|
||||||
|
assert.Equal(t, 1, gh.requests(), "a non-retryable status must stop polling")
|
||||||
|
assert.Empty(t, *slept)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_ConsecutiveServerErrorsBackOff confirms the 5xx wait grows by
|
||||||
|
// 5s per consecutive failure and is capped, so a sustained outage costs a
|
||||||
|
// bounded number of requests rather than hammering the endpoint.
|
||||||
|
func TestPollForToken_ConsecutiveServerErrorsBackOff(t *testing.T) {
|
||||||
|
responses := make([]seqResp, 0, 4)
|
||||||
|
for range [3]struct{}{} {
|
||||||
|
responses = append(responses, serverErrorResp())
|
||||||
|
}
|
||||||
|
responses = append(responses, okResp(tokenBody))
|
||||||
|
|
||||||
|
gh := newSeqTokenServer(t, responses...)
|
||||||
|
p, slept := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "gho_test_token", token)
|
||||||
|
|
||||||
|
// Base interval is 0 in the test provider, so the waits are 5, 10, 15.
|
||||||
|
require.Len(t, *slept, 3)
|
||||||
|
assert.Equal(t, 5*time.Second, (*slept)[0])
|
||||||
|
assert.Equal(t, 10*time.Second, (*slept)[1])
|
||||||
|
assert.Equal(t, 15*time.Second, (*slept)[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_ServerErrorBackOffResetsOnRecovery confirms the 5xx growth is
|
||||||
|
// reset by any non-5xx response, so an intermittent endpoint does not
|
||||||
|
// accumulate an ever-longer wait across separate blips.
|
||||||
|
func TestPollForToken_ServerErrorBackOffResetsOnRecovery(t *testing.T) {
|
||||||
|
serverError := serverErrorResp()
|
||||||
|
gh := newSeqTokenServer(t,
|
||||||
|
serverError,
|
||||||
|
okResp(pendingBody),
|
||||||
|
serverError,
|
||||||
|
okResp(tokenBody),
|
||||||
|
)
|
||||||
|
p, slept := newPollTestProvider(gh.srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "issued-device-code")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "gho_test_token", token)
|
||||||
|
|
||||||
|
// 5s for the first 5xx, 0s for the pending poll (base interval), then 5s
|
||||||
|
// again — not 10s — because the pending response reset the growth.
|
||||||
|
require.Len(t, *slept, 3)
|
||||||
|
assert.Equal(t, 5*time.Second, (*slept)[0])
|
||||||
|
assert.Equal(t, 0*time.Second, (*slept)[1])
|
||||||
|
assert.Equal(t, 5*time.Second, (*slept)[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestDeviceCode_PacingDoesNotLeakAcrossCodes covers F4: interval and
|
||||||
|
// expires_in are bound to the code they were issued with, so a second request
|
||||||
|
// that omits them must fall back to defaults rather than inherit the first.
|
||||||
|
func TestRequestDeviceCode_PacingDoesNotLeakAcrossCodes(t *testing.T) {
|
||||||
|
var call int
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if call == 0 {
|
||||||
|
_, _ = w.Write([]byte(`{"device_code":"dc1","user_code":"AAAA-BBBB","verification_uri":"https://github.com/login/device","expires_in":120,"interval":55}`))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(`{"device_code":"dc2","user_code":"CCCC-DDDD","verification_uri":"https://github.com/login/device"}`))
|
||||||
|
}
|
||||||
|
call++
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
p := &GitHubATProvider{clientID: testClientID, deviceCodeURL: srv.URL, pollInterval: defaultPollInterval, expiresIn: defaultExpiresIn}
|
||||||
|
|
||||||
|
_, _, _, err := p.requestDeviceCode(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 55, p.pollInterval)
|
||||||
|
assert.Equal(t, 120, p.expiresIn)
|
||||||
|
|
||||||
|
_, _, _, err = p.requestDeviceCode(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, defaultPollInterval, p.pollInterval, "second code must not inherit the first's interval")
|
||||||
|
assert.Equal(t, defaultExpiresIn, p.expiresIn, "second code must not inherit the first's expires_in")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogin_ClientIDStableAcrossDeviceCodeAndPolls covers the whole device flow
|
||||||
|
// end to end and pins the one invariant that makes incorrect_device_code
|
||||||
|
// meaningful: the health endpoint is read once, and the client_id the device
|
||||||
|
// code was issued under is the one every poll carries. The mock token endpoint
|
||||||
|
// answers incorrect_device_code for any other pairing.
|
||||||
|
func TestLogin_ClientIDStableAcrossDeviceCodeAndPolls(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
healthCalls := 0
|
||||||
|
deviceClientID := ""
|
||||||
|
pollClientIDs := []string{}
|
||||||
|
pollCount := 0
|
||||||
|
const issued = "dc-login"
|
||||||
|
|
||||||
|
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v0/health" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
healthCalls++
|
||||||
|
id := fmt.Sprintf("Iv23liCLIENT%04d", healthCalls)
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status":"ok","github_client_id":"%s"}`, id)
|
||||||
|
}))
|
||||||
|
t.Cleanup(registry.Close)
|
||||||
|
|
||||||
|
device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]string
|
||||||
|
// assert, not require: t.FailNow from a handler goroutine is unsupported.
|
||||||
|
if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) {
|
||||||
|
http.Error(w, "bad request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
deviceClientID = req["client_id"]
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = fmt.Fprintf(w, `{"device_code":"%s","user_code":"CCCC-DDDD","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}`, issued)
|
||||||
|
}))
|
||||||
|
t.Cleanup(device.Close)
|
||||||
|
|
||||||
|
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]string
|
||||||
|
// assert, not require: t.FailNow from a handler goroutine is unsupported.
|
||||||
|
if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) {
|
||||||
|
http.Error(w, "bad request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
pollClientIDs = append(pollClientIDs, req["client_id"])
|
||||||
|
bound := req["client_id"] == deviceClientID && req["device_code"] == issued
|
||||||
|
pollCount++
|
||||||
|
first := pollCount == 1
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case !bound:
|
||||||
|
_, _ = w.Write([]byte(incorrectBody))
|
||||||
|
case first:
|
||||||
|
_, _ = w.Write([]byte(pendingBody))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(tokenBody))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(tokenSrv.Close)
|
||||||
|
|
||||||
|
p := &GitHubATProvider{
|
||||||
|
registryURL: registry.URL,
|
||||||
|
deviceCodeURL: device.URL,
|
||||||
|
accessTokenURL: tokenSrv.URL,
|
||||||
|
pollInterval: defaultPollInterval,
|
||||||
|
expiresIn: defaultExpiresIn,
|
||||||
|
sleep: func(time.Duration) {},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, p.Login(context.Background()))
|
||||||
|
assert.Equal(t, "gho_test_token", p.githubToken)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
assert.Equal(t, 1, healthCalls, "Login must read the health endpoint exactly once")
|
||||||
|
assert.Equal(t, "Iv23liCLIENT0001", deviceClientID)
|
||||||
|
for _, id := range pollClientIDs {
|
||||||
|
assert.Equal(t, deviceClientID, id,
|
||||||
|
"every poll must carry the client_id the device code was issued under")
|
||||||
|
}
|
||||||
|
}
|
||||||
131
cmd/publisher/auth/github_at_poll_internal_test.go
Normal file
131
cmd/publisher/auth/github_at_poll_internal_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMockTokenServer returns an httptest.Server that serves the given sequence
|
||||||
|
// of AccessTokenResponse values, one per request. Once the sequence is
|
||||||
|
// exhausted the final response is reused for any extra requests.
|
||||||
|
func newMockTokenServer(t *testing.T, responses []AccessTokenResponse) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
var i int
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
resp := responses[len(responses)-1]
|
||||||
|
if i < len(responses) {
|
||||||
|
resp = responses[i]
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp) // #nosec G117 -- test fixture token, not a real secret
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPollTestProvider builds a provider wired to a mock token endpoint. The
|
||||||
|
// initial poll interval is 0 (so the back-off starts from a known base) and
|
||||||
|
// sleeps are captured rather than performed, making the tests deterministic and
|
||||||
|
// instant. The returned slice records every interval passed to sleep.
|
||||||
|
func newPollTestProvider(tokenURL string) (*GitHubATProvider, *[]time.Duration) {
|
||||||
|
slept := &[]time.Duration{}
|
||||||
|
p := &GitHubATProvider{
|
||||||
|
clientID: "test-client-id",
|
||||||
|
accessTokenURL: tokenURL,
|
||||||
|
pollInterval: 0,
|
||||||
|
sleep: func(d time.Duration) { *slept = append(*slept, d) },
|
||||||
|
}
|
||||||
|
return p, slept
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_SlowDownThenSuccess confirms that a slow_down response is
|
||||||
|
// treated as retriable: polling continues and the success path is reached.
|
||||||
|
func TestPollForToken_SlowDownThenSuccess(t *testing.T) {
|
||||||
|
srv := newMockTokenServer(t, []AccessTokenResponse{
|
||||||
|
{Error: "slow_down"},
|
||||||
|
{AccessToken: "gh-token"},
|
||||||
|
})
|
||||||
|
p, slept := newPollTestProvider(srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "device-code")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "gh-token", token)
|
||||||
|
|
||||||
|
// One slow_down => one back-off, interval bumped 0 -> 5.
|
||||||
|
require.Len(t, *slept, 1)
|
||||||
|
assert.Equal(t, 5*time.Second, (*slept)[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_MixedRetryableErrorsCompoundInterval confirms the interval
|
||||||
|
// increment compounds correctly across mixed retryable errors. Per RFC 8628
|
||||||
|
// §3.5 the interval is increased by 5 seconds on each slow_down and is NOT
|
||||||
|
// reset between slow_down instances, while authorization_pending leaves it
|
||||||
|
// unchanged.
|
||||||
|
func TestPollForToken_MixedRetryableErrorsCompoundInterval(t *testing.T) {
|
||||||
|
srv := newMockTokenServer(t, []AccessTokenResponse{
|
||||||
|
{Error: "slow_down"}, // 0 -> 5
|
||||||
|
{Error: "authorization_pending"}, // stays 5
|
||||||
|
{Error: "slow_down"}, // 5 -> 10
|
||||||
|
{AccessToken: "gh-token"},
|
||||||
|
})
|
||||||
|
p, slept := newPollTestProvider(srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "device-code")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "gh-token", token)
|
||||||
|
|
||||||
|
require.Len(t, *slept, 3)
|
||||||
|
assert.Equal(t, 5*time.Second, (*slept)[0]) // after first slow_down
|
||||||
|
assert.Equal(t, 5*time.Second, (*slept)[1]) // authorization_pending: unchanged
|
||||||
|
assert.Equal(t, 10*time.Second, (*slept)[2]) // second slow_down: compounded
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_IntervalCappedAtMax confirms the polling interval is capped
|
||||||
|
// so a misbehaving auth server returning slow_down repeatedly cannot grow it
|
||||||
|
// without bound.
|
||||||
|
func TestPollForToken_IntervalCappedAtMax(t *testing.T) {
|
||||||
|
// 13 slow_downs starting from interval 0 would otherwise reach 65s
|
||||||
|
// (5, 10, ..., 60, 65); the cap holds it at 60.
|
||||||
|
responses := make([]AccessTokenResponse, 0, 14)
|
||||||
|
for range [13]struct{}{} {
|
||||||
|
responses = append(responses, AccessTokenResponse{Error: "slow_down"})
|
||||||
|
}
|
||||||
|
responses = append(responses, AccessTokenResponse{AccessToken: "gh-token"})
|
||||||
|
|
||||||
|
srv := newMockTokenServer(t, responses)
|
||||||
|
p, slept := newPollTestProvider(srv.URL)
|
||||||
|
|
||||||
|
token, err := p.pollForToken(context.Background(), "device-code")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "gh-token", token)
|
||||||
|
|
||||||
|
require.Len(t, *slept, 13)
|
||||||
|
for _, d := range *slept {
|
||||||
|
assert.LessOrEqual(t, d, time.Duration(maxPollInterval)*time.Second)
|
||||||
|
}
|
||||||
|
// The last two back-offs are both pinned at the cap.
|
||||||
|
assert.Equal(t, time.Duration(maxPollInterval)*time.Second, (*slept)[11])
|
||||||
|
assert.Equal(t, time.Duration(maxPollInterval)*time.Second, (*slept)[12])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollForToken_FatalErrorStopsPolling confirms a non-retryable error ends
|
||||||
|
// polling immediately with an error.
|
||||||
|
func TestPollForToken_FatalErrorStopsPolling(t *testing.T) {
|
||||||
|
srv := newMockTokenServer(t, []AccessTokenResponse{
|
||||||
|
{Error: "access_denied"},
|
||||||
|
})
|
||||||
|
p, slept := newPollTestProvider(srv.URL)
|
||||||
|
|
||||||
|
_, err := p.pollForToken(context.Background(), "device-code")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "access_denied")
|
||||||
|
assert.Empty(t, *slept, "fatal error should not trigger a back-off sleep")
|
||||||
|
}
|
||||||
|
|
@ -64,7 +64,7 @@ func PublishCommand(args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish to registry
|
// Publish to registry
|
||||||
_, _ = fmt.Fprintf(os.Stdout, "Publishing to %s...\n", registryURL)
|
_, _ = fmt.Fprintf(os.Stdout, "Publishing %s@%s to %s...\n", serverJSON.Name, serverJSON.Version, registryURL)
|
||||||
response, statusCode, err := publishToRegistry(registryURL, serverData, token)
|
response, statusCode, err := publishToRegistry(registryURL, serverData, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If publish failed with 422, call validate endpoint to show detailed errors
|
// If publish failed with 422, call validate endpoint to show detailed errors
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue