Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,25 @@ jobs:
- core
- mcp
- http
- mark3labs
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# Only core consumes the catalog (mcp/http have no conformance suite).
- name: Check out shared conformance catalog
# AuthPlane/conformance is the public sibling repo carrying the shared
# oauth-sdk-conformance-catalog.yaml. Cloned to $RUNNER_TEMP — outside
# $GITHUB_WORKSPACE — so the catalog stays out of the working tree and
# never trips `go list ./...` or coverage glob patterns. Plain git
# clone is enough: the repo is public read-only, no auth or
# persist-credentials features needed.
# Only `core` consumes the catalog (mcp/http/mark3labs have no
# conformance suite).
- name: Clone shared conformance catalog (out of tree)
if: matrix.module == 'core'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: AuthPlane/conformance
path: conformance
fetch-depth: 1
run: |
git -c advice.detachedHead=false clone --depth=1 \
https://github.com/AuthPlane/conformance.git \
"$RUNNER_TEMP/conformance"

- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
Expand Down Expand Up @@ -60,7 +67,7 @@ jobs:
- name: Test
working-directory: ${{ matrix.module }}
env:
CONFORMANCE_CATALOG_PATH: ${{ github.workspace }}/conformance/oauth-sdk-conformance-catalog.yaml
CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
run: |
# Per-module coverage floors. Adapters (http, mcp) are thinner than
# core so their floors are lower, but they still need a floor to
Expand Down
11 changes: 6 additions & 5 deletions .github/workflows/cut-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,19 @@ jobs:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 0

- name: Refuse if branch or any of the four tags already exist
# Go SDK publishes four tags per release: per-module tags
# (core/vX.Y.Z, mcp/vX.Y.Z, http/vX.Y.Z) plus an umbrella vX.Y.Z
# tag for the GitHub Release. Any one already present aborts.
- name: Refuse if branch or any of the five tags already exist
# Go SDK publishes five tags per release: per-module tags
# (core/vX.Y.Z, mcp/vX.Y.Z, http/vX.Y.Z, mark3labs/vX.Y.Z) plus
# an umbrella vX.Y.Z tag for the GitHub Release. Any one already
# present aborts.
run: |
v="${{ steps.validate.outputs.release }}"
branch="${{ steps.validate.outputs.branch }}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
echo "::error::Branch $branch already exists. Aborting."
exit 1
fi
for tag in "core/v$v" "mcp/v$v" "http/v$v" "v$v"; do
for tag in "core/v$v" "mcp/v$v" "http/v$v" "mark3labs/v$v" "v$v"; do
if git ls-remote --exit-code --tags origin "$tag" >/dev/null; then
echo "::error::Tag $tag already exists. Aborting."
exit 1
Expand Down
81 changes: 48 additions & 33 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
name: Release

# Step 2 of the single-branch release: take the stabilized release/v* or
# hotfix/v* branch, bump `require core vX.Y.Z` in mcp/http, run
# `go mod tidy`, run tests (incl. conformance), commit, tag the four
# refs, atomic-push the branch + tags, create the GitHub Release, and
# delete the source branch on success.
# hotfix/v* branch, bump `require core vX.Y.Z` in mcp/http/mark3labs (and
# `require http vX.Y.Z` in mark3labs, which wraps http), run `go mod
# tidy`, run tests (incl. conformance), commit, tag the five refs,
# atomic-push the branch + tags, create the GitHub Release, and delete
# the source branch on success.
#
# There is no merge into the default branch and no external-registry
# publish step. `proxy.golang.org` polls tags and serves the new module
# versions within seconds of the push — the atomic tag push IS the
# publish.
#
# The require-bump commit lives only on the release branch (now
# deleted). It is reachable from the four annotated tags, so the proxy
# deleted). It is reachable from the five annotated tags, so the proxy
# resolves byte-identical hashes when consumers `go get` any of them.
# The default branch keeps its `require core v0.0.0` placeholder; the
# go.work + replace directive cover local dev.
# The default branch keeps its `require core v0.0.0` (and
# `require http v0.0.0` for mark3labs) placeholders; the go.work +
# replace directive cover local dev.
#
# Branch conventions:
# - release/v<X.Y.Z>: current-line release off the default branch.
Expand Down Expand Up @@ -116,15 +118,17 @@ jobs:
echo "coreTag=core/v$version"
echo "mcpTag=mcp/v$version"
echo "httpTag=http/v$version"
echo "mark3labsTag=mark3labs/v$version"
echo "versionTag=v$version"
} >> "$GITHUB_OUTPUT"

- name: Refuse if any of the four tags already exist
- name: Refuse if any of the five tags already exist
run: |
for tag in \
"${{ steps.version.outputs.coreTag }}" \
"${{ steps.version.outputs.mcpTag }}" \
"${{ steps.version.outputs.httpTag }}" \
"${{ steps.version.outputs.mark3labsTag }}" \
"${{ steps.version.outputs.versionTag }}"; do
if git ls-remote --exit-code --tags origin "$tag" >/dev/null; then
echo "::error::Tag $tag already exists. Aborting."
Expand Down Expand Up @@ -178,7 +182,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

# Install golangci-lint and vet/lint all three modules before any
# Install golangci-lint and vet/lint all four modules before any
# state-mutating steps. A linting failure here leaves the workspace
# clean (no partial go.mod edits to roll back).
- name: Install golangci-lint
Expand All @@ -187,37 +191,47 @@ jobs:
version: v2.11.4
install-only: true

- name: Vet and lint all three modules
- name: Vet and lint all four modules
run: |
for mod in core mcp http; do
for mod in core mcp http mark3labs; do
(cd "$mod" && go vet ./...)
(cd "$mod" && golangci-lint run ./...)
done

# Bump require core in mcp and http, then go mod tidy. go.work + the
# replace directive mean go mod tidy resolves core locally, so the
# hash it writes matches what proxy.golang.org will later serve from
# the about-to-be-created core/vX.Y.Z tag. Consumers get
# byte-identical checksums.
- name: Bump require core in mcp and http, then go mod tidy
# Bump require core in mcp, http, and mark3labs, then go mod tidy.
# mark3labs also depends on http, so its go.mod gets a second
# require-bump in the step below. go.work + the replace directive
# mean go mod tidy resolves siblings locally, so the hash it writes
# matches what proxy.golang.org will later serve from the
# about-to-be-created tags. Consumers get byte-identical checksums.
- name: Bump require core in mcp, http, and mark3labs, then go mod tidy
run: |
v="${{ steps.version.outputs.version }}"
for mod in mcp http; do
for mod in mcp http mark3labs; do
(cd "$mod" && go mod edit -require=github.com/authplane/go-sdk/core@v"$v" && go mod tidy)
done

- name: Run tests in all three modules
# mark3labs wraps the http adapter, so it also needs require http
# pinned to the matching vX.Y.Z. Done after the core-require loop
# so the previous `go mod tidy` already wrote a stable core hash.
- name: Bump require http in mark3labs, then go mod tidy
run: |
v="${{ steps.version.outputs.version }}"
(cd mark3labs && go mod edit -require=github.com/authplane/go-sdk/http@v"$v" && go mod tidy)

- name: Run tests in all four modules
env:
CONFORMANCE_CATALOG_PATH: ${{ github.workspace }}/conformance/oauth-sdk-conformance-catalog.yaml
run: |
(cd core && go test ./...)
(cd mcp && go test ./...)
(cd http && go test ./...)
(cd core && go test ./...)
(cd mcp && go test ./...)
(cd http && go test ./...)
(cd mark3labs && go test ./...)

# Single release commit on the release/hotfix branch containing the
# require bumps. Tag points at this commit. Lives only on the
# release branch (deleted after publish) — never reaches the default
# branch. Reachable from the four annotated tags so proxy.golang.org
# branch. Reachable from the five annotated tags so proxy.golang.org
# serves it correctly.
- name: Commit require-bumps on the release branch
run: |
Expand All @@ -228,34 +242,35 @@ jobs:
id: sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

- name: Create four annotated tags locally
- name: Create five annotated tags locally
run: |
v="${{ steps.version.outputs.version }}"
git tag -a "core/v$v" -m "core v$v"
git tag -a "mcp/v$v" -m "mcp v$v"
git tag -a "http/v$v" -m "http v$v"
git tag -a "v$v" -m "Release v$v"
git tag -a "core/v$v" -m "core v$v"
git tag -a "mcp/v$v" -m "mcp v$v"
git tag -a "http/v$v" -m "http v$v"
git tag -a "mark3labs/v$v" -m "mark3labs v$v"
git tag -a "v$v" -m "Release v$v"

# --- Everything below this point mutates the remote or external state. ---
# Dry-run short-circuit: report and skip the mutating phase. The local
# state (release commit + four tags on the runner) is verified but
# state (release commit + five tags on the runner) is verified but
# thrown away when the runner tears down.
- name: Dry run short-circuit notice
if: ${{ inputs.dryRun }}
run: |
echo "::notice::Dry run — skipping atomic push, GitHub Release, and branch deletion. Require-bump, tidy, tests, and local tag creation all succeeded. Would-be released commit: ${{ steps.sha.outputs.sha }}"

# Atomic push of the release/hotfix branch tip (with the release
# commit) + four tags. Either everything lands or nothing does.
# commit) + five tags. Either everything lands or nothing does.
# The branch is listed first so the commit referenced by the tags
# exists on the remote after the transaction.
- name: Atomic push of release branch and four tags
- name: Atomic push of release branch and five tags
if: ${{ !inputs.dryRun }}
run: |
v="${{ steps.version.outputs.version }}"
git push --atomic origin \
"$GITHUB_REF_NAME" \
"core/v$v" "mcp/v$v" "http/v$v" "v$v"
"core/v$v" "mcp/v$v" "http/v$v" "mark3labs/v$v" "v$v"
echo "::notice::Released commit: ${{ steps.sha.outputs.sha }}"

# --------- From here on, the release is live on proxy.golang.org. ---------
Expand Down Expand Up @@ -329,7 +344,7 @@ jobs:
echo ""
echo "- **Version**: \`$v\`"
echo "- **Released commit**: \`$sha\`"
echo "- **Tags pushed**: \`core/v$v\`, \`mcp/v$v\`, \`http/v$v\`, \`v$v\`"
echo "- **Tags pushed**: \`core/v$v\`, \`mcp/v$v\`, \`http/v$v\`, \`mark3labs/v$v\`, \`v$v\`"
echo "- **Go module proxy**: https://proxy.golang.org/github.com/authplane/go-sdk/core/@v/v$v.info"
echo "- **GitHub Release**: ${{ github.server_url }}/${{ github.repository }}/releases/tag/v$v"
if [[ "${{ steps.delete.outcome }}" != "success" ]]; then
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
- core
- http
- mcp
- mark3labs
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `core/resource/verifier`: `(*VerifiedClaims).RequireScopes(scopes ...string) error` plural helper. Returns `nil` on empty input, and on failure wraps `ErrInsufficientScope` naming every missing scope plus the scopes the token does carry, so an adapter can surface it verbatim in the `WWW-Authenticate` `error_description`.
- `core/resource/verifier`: `ErrMultipleDpopProofs` sentinel for RFC 9449 §4.3 #1 violations. `core/resource.AuthErrorResponse` maps it to a `DPoP error="invalid_dpop_proof"` 401 challenge per RFC 9449 §7.1.
- `core/resource/verifier`: `NewDPoPContext(method, url, dpopHeaderValues []string) (*DPoPContext, error)` factory — the canonical §4.3 #1 enforcement boundary. Filters blanks, splits on `,` defensively for proxies that pre-join duplicate headers, and returns `ErrMultipleDpopProofs` on more than one non-blank value.
- `core/resource/verifier`: `(*DPoPContext).Proof()` nil-safe accessor returning the single proof (or `""` when none).
- `mark3labs` module: adapter for [`mark3labs/mcp-go`](https://github.com/mark3labs/mcp-go). Wraps `*authplanehttp.Adapter` so consumers get bearer + DPoP auth, RFC 9728 PRM, and `HTTPContextFunc` integration for the mark3labs HTTP server. `HTTPContextFunc` takes `WithForwardedContextKeys(keys ...any)` and `WithContextForwarding(fn)` options to propagate values (request IDs, tracing spans, …) from the upstream request context onto the per-tool-call MCP context. See `mark3labs/docs/user-guide.md`.
- `core/resource`: `Resource.PRMURL()` returns the precomputed absolute Protected Resource Metadata URL as a single infallible source of truth.
- `core/resource`: `Resource.PRMConfig()` returns the Protected Resource Metadata as a typed `PRMConfig` struct, for feeding into third-party PRM-serving handlers (e.g. mark3labs/mcp-go's `NewProtectedResourceMetadataHandler`) without going through the dynamic `PRMResponse` map.

### Fixed
- `core/resource/verifier`: `validateHTU` compares `EscapedPath()` instead of `Path`, so an encoded `%2F` is no longer treated as equivalent to a literal `/`. The previous comparison conflated distinct request targets, weakening the RFC 9449 §4.3 `htu` binding (RFC 3986 §6.2.2.2 only permits decoding *unreserved* characters when comparing URLs).
- `core/resource/verifier`: `validateHTU` strips an explicit default port (`:80` for http, `:443` for https) on both sides before comparing (RFC 9110 §7.2), so a resource configured as `http://api.example.com:80/mcp` no longer mismatches every client that signs the port-less form.
- `core/resource/verifier`: `validateHTU` collapses an empty path to `/` on both sides before comparing, so a client signing a bare-origin htu (e.g. `https://host`) no longer fails against a server where every inbound `r.URL.EscapedPath()` is at least `/`.

### Changed
- **BREAKING (pre-1.0)** `http`: DPoP `htu` reconstruction in the `net/http` adapter no longer reads the inbound `Host` header, `r.TLS`, or `r.URL.RawQuery`. Both `Host` and `r.TLS` are proxy-controlled and would otherwise let a misconfigured edge — or an attacker forging `Host` — shift the `htu` binding to a different origin or downgrade `https` to `http` behind a TLS-terminating proxy. The adapter now sources scheme + authority from the operator-configured resource URI and contributes only the request path in raw `EscapedPath` form (query and fragment are dropped per RFC 9449 §4.3 #5). **Migration:** mount the middleware **before** any `http.StripPrefix` so `r.URL.EscapedPath()` still reflects the path the client signed; apps relying on `Host`-derived htu (or `r.TLS`-derived scheme) will see proof rejections until the resource URI is corrected to match the canonical origin.
- `core/resource/verifier`: `(*VerifiedClaims).RequireScope` delegates to `RequireScopes`, so the singular helper also emits the enriched `required scope "X"; token has scopes: …` `error_description`. Behaviour (`errors.Is(err, ErrInsufficientScope)`, 403 status, `scope="…"` parameter) is unchanged; only the error string is enriched.
- **BREAKING** `core/resource/verifier`: `DPoPContext` no longer exposes the raw proof through a public field — the previous `DPoPContext.Proof string` is replaced with an unexported slice plus the `(*DPoPContext).Proof()` accessor and the `NewDPoPContext` constructor. Route through the factory so RFC 9449 §4.3 #1 enforcement stays single-source and invalid (multi-proof) states stay unconstructable.
- `http`: HTTP adapter middleware reads `r.Header.Values("DPoP")` and routes the slice through `NewDPoPContext`. A request carrying more than one `DPoP` header returns HTTP 401 + `WWW-Authenticate: DPoP error="invalid_dpop_proof"` (RFC 9449 §4.3 #1, §7.1); the previous `r.Header.Get("DPoP")` silently picked only the first copy.
- `http`: `WWW-Authenticate` now uses a space (not a comma) before `resource_metadata` when no prior auth-param is present, matching RFC 7235 §2.1 (`Bearer resource_metadata="…"` instead of `Bearer, resource_metadata="…"`).
- `mcp.NewAdapterFromClientAndResource` now returns `(*Adapter, error)` and rejects a nil client with a typed error instead of panicking, matching the shape of its discovery-driven `NewAdapter` sibling.

## [0.1.1] - 2026-05-22

### Fixed
Expand Down
Loading
Loading