Skip to content
Closed
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
1 change: 1 addition & 0 deletions .conformance-catalog-ref
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
d8e8bc3c18c7a8c9f10c6acdc9960214259fdb41
20 changes: 17 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,23 @@ jobs:
- name: Clone shared conformance catalog (out of tree)
if: matrix.module == 'core'
run: |
git -c advice.detachedHead=false clone --depth=1 \
https://github.com/AuthPlane/conformance.git \
"$RUNNER_TEMP/conformance"
# Conformance catalog pinned by SHA (was: clone of the latest default
# branch). The ref is single-sourced from the tracked
# .conformance-catalog-ref file at the repo root — bump it there when
# adopting new catalog cases, together with the SDK-side conformance
# coverage, so a catalog change can never break CI on its own. The
# checkout step above populates $GITHUB_WORKSPACE before this read.
# Source: github.com/AuthPlane/conformance.
CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")"
# Guard against un-pinning: the ref must be a full commit SHA, not a
# branch/tag name (which would silently track a moving target).
grep -Eq '^[0-9a-f]{40}$' <<< "$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; }
git init -q "$RUNNER_TEMP/conformance"
git -C "$RUNNER_TEMP/conformance" \
fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; }
git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD

- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/conformance-catalog-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Conformance Catalog Drift

# The main CI (ci.yml) and release (release.yml) workflows pin the shared
# conformance catalog to a fixed SHA (.conformance-catalog-ref) so a catalog
# change can never break PR CI on its own. The trade-off is that new catalog
# cases stay invisible until someone bumps the pin. This job closes that gap:
# on a weekly schedule it runs the SDK's catalog-alignment check against the
# LATEST (unpinned) default branch of the catalog and FAILS the job on any
# drift, so the scheduled run goes red and GitHub notifies maintainers (the
# same convention as security.yml). This workflow has no pull_request trigger,
# so a failure here can never block a PR.
#
# When this job fails on drift, adopt the new cases in core/conformancetests/
# and bump .conformance-catalog-ref to the new catalog SHA in the same change.

on:
schedule:
# Mondays at 06:00 UTC.
- cron: "0 6 * * 1"
workflow_dispatch:

# Least-privilege default: this workflow only reads the repo.
permissions:
contents: read

jobs:
drift:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# Clone the catalog's DEFAULT branch (latest, unpinned) — deliberately
# NOT the pinned .conformance-catalog-ref — so newly added cases show up.
# Cloned to $RUNNER_TEMP, outside $GITHUB_WORKSPACE, so it stays out of
# the working tree. Source: github.com/AuthPlane/conformance.
- name: Clone latest conformance catalog (out of tree)
run: |
git clone --depth=1 https://github.com/AuthPlane/conformance.git \
"$RUNNER_TEMP/conformance"

- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "core/go.mod"
check-latest: true
cache-dependency-path: "core/go.sum"

# The alignment check runs in TestMain, AFTER m.Run(), so the full suite
# must execute for every Case() registration to fire — the same command
# ci.yml runs, just pointed at the latest catalog. TestMain then fails the
# suite if the latest catalog holds a case ID with no matching Case()
# registration, which fails this step and the job — a red scheduled run is
# the signal GitHub notifies on. This workflow has no pull_request
# trigger, so the failure never blocks a PR.
- name: Run catalog-alignment check against latest catalog
id: align
working-directory: core
env:
CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
run: go test ./conformancetests/ -v

- name: Report drift
if: always()
run: |
if [ "${{ steps.align.outcome }}" = "success" ]; then
echo "Conformance catalog alignment: no drift against the latest catalog." >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::Conformance catalog drift detected — the latest catalog has cases not yet covered by the SDK. Adopt them in core/conformancetests/ and bump .conformance-catalog-ref."
{
echo "## Conformance catalog drift detected"
echo ""
echo "The latest (unpinned) conformance catalog contains cases the SDK does not yet cover, or the alignment check otherwise failed."
echo ""
echo "**Next steps:** adopt the new cases in \`core/conformancetests/\` and bump \`.conformance-catalog-ref\` to the new catalog SHA in the same change."
} >> "$GITHUB_STEP_SUMMARY"
fi
27 changes: 21 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,26 @@ jobs:
token: ${{ steps.app_token.outputs.token }}

- name: Check out shared conformance catalog
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: AuthPlane/conformance
path: conformance
fetch-depth: 1
run: |
# Conformance catalog pinned by SHA (was: clone of the latest default
# branch). The ref is single-sourced from the tracked
# .conformance-catalog-ref file at the repo root — bump it there when
# adopting new catalog cases, together with the SDK-side conformance
# coverage, so a catalog change can never break CI on its own. The
# repo checkout step above populates $GITHUB_WORKSPACE before this read.
# Fetched into $RUNNER_TEMP — outside $GITHUB_WORKSPACE — so `git add
# -A` in the release commit below never stages it as a gitlink.
# Source: github.com/AuthPlane/conformance.
CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")"
# Guard against un-pinning: the ref must be a full commit SHA, not a
# branch/tag name (which would silently track a moving target).
grep -Eq '^[0-9a-f]{40}$' <<< "$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; }
git init -q "$RUNNER_TEMP/conformance"
git -C "$RUNNER_TEMP/conformance" \
fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; }
git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD

- name: Set up Go 1.25
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
Expand Down Expand Up @@ -226,7 +241,7 @@ jobs:

- name: Run tests in all four modules
env:
CONFORMANCE_CATALOG_PATH: ${{ github.workspace }}/conformance/oauth-sdk-conformance-catalog.yaml
CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
run: |
(cd core && go test ./...)
(cd mcp && go test ./...)
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- `http`: the RFC 9728 PRM discovery bypass in the `net/http` adapter now compares `r.URL.EscapedPath()` against the escaped well-known path instead of the decoded `r.URL.Path`. A resource identifier carrying a percent-encoded octet (e.g. `%2F`) yields an escaped well-known path; comparing the decoded path let `%2F` collapse to `/`, the two sides disagreed, and the discovery endpoint stopped being bypassed and returned 401 even though RFC 9728 §3.2 requires it publicly reachable. The check is deliberately stricter than RFC 3986 §6.2.2.1 (a percent-encoded *unreserved* octet won't match its decoded form), an accepted trade-off since a conformant client signs the same octets the operator configured.

### Changed
- **BREAKING** `core/authplane`: `NewClient` now rejects an issuer containing a query or fragment component (RFC 8414 §2 forbids both) instead of passing it straight into metadata discovery. Previously the resource side rejected a fragment but the issuer had no such check, and the two discovery-URL builders diverged when either was present — the RFC 8414 builder silently dropped the issuer's query/fragment while the OIDC builder carried them along, so the two discovery attempts targeted different identities. Construction now fails immediately with a clear error. **Migration:** strip any query or fragment from the issuer you pass to `NewClient`; an issuer identifier never carries one.
- **BREAKING** `core/resource`: `resource.New` now rejects a resource URI containing a `#` (RFC 8707 §2 forbids a fragment in a resource indicator). `url.ParseRequestURI` does not split the fragment, so `https://api.example.com/mcp#frag` previously passed the scheme/host check and leaked the fragment into the derived PRM URL. This is a construction-time change on the exported constructor. **Migration:** remove any fragment from the resource URI you pass to `resource.New`.
- **BREAKING** `core/resource`: the RFC 9728 §3.1 PRM well-known URL now strips any terminating slash following the host component before inserting the well-known path suffix, so a resource identifier ending in `/mcp/` is served at (and derived by a conformant client as) `/.well-known/oauth-protected-resource/mcp` rather than `.../mcp/`. The resource identifier itself is unchanged — only the derived publication URL loses the slash. **Migration:** if you currently serve your PRM document at a trailing-slash well-known path, move it to the slash-stripped path (or route both) so RFC 9728 clients stop 404ing.
- **BREAKING** `core/resource`: `WellKnownPRMPath()` and `PRMURL()` now derive from the resource identifier's escaped path, so a percent-encoded octet (RFC 3986 §3.3 path data, e.g. `%2F`) is carried through verbatim instead of being decoded to `/`. A resource identifier such as `https://api.example.com/mcp%2Fx` therefore yields `.../oauth-protected-resource/mcp%2Fx` where 0.2.0 returned `.../mcp/x` — a visible output change on both exported methods. **Migration:** if you consume these values (routing the PRM handler, advertising `resource_metadata`), ensure your router matches the escaped path.
- **BREAKING** `core/internal/metadata`: the RFC 8414 §3.3 issuer check now compares the configured issuer and the metadata document's `issuer` byte-for-byte (§4: code-point-for-code-point, no normalization) instead of trailing-slash-insensitively. A document whose issuer differs from the configured issuer only by a trailing slash is now rejected as a mismatch. Because discovery is eager, this surfaces at `NewClient` as `metadata: issuer mismatch` — construction fails immediately, not at the first token verification. **Migration:** If your configured issuer differs from your authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them.
- **BREAKING** `core/resource/verifier`: the token verifier stores the issuer passed to `NewTokenVerifier` verbatim and matches a token's `iss` claim byte-for-byte (RFC 8414 §4: code-point-for-code-point, no normalization) instead of trailing-slash-insensitively. A token whose `iss` differs from the configured issuer only by a trailing slash is now an `ErrIssuerMismatch`. **Migration:** If the issuer you pass to `NewTokenVerifier` differs from your authorization server's actual identifier by a trailing slash, correct it — the SDK no longer silently reconciles them.

## [0.2.0] - 2026-07-21

### Added
Expand Down
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ go install golang.org/x/vuln/cmd/govulncheck@latest
(cd mcp && govulncheck ./...)
```

**Conformance catalog:**

The `core` conformance suite maps to the shared [conformance catalog](https://github.com/AuthPlane/conformance). CI pins the catalog to the SHA tracked in [`.conformance-catalog-ref`](.conformance-catalog-ref) at the repo root, so a catalog change can never break CI on its own. To reproduce CI locally, check out that same ref:

```bash
git clone https://github.com/AuthPlane/conformance.git /path/to/catalog
git -C /path/to/catalog checkout "$(cat .conformance-catalog-ref)"
export CONFORMANCE_CATALOG_PATH=/path/to/catalog/oauth-sdk-conformance-catalog.yaml
(cd core && go test ./conformancetests/ -v)
```

A weekly `conformance-catalog-drift` workflow runs the alignment check against the latest catalog and fails when new cases need adopting. When adopting them, update `core/conformancetests/` and bump `.conformance-catalog-ref` in the same change. See [`core/conformancetests/README.md`](core/conformancetests/README.md) for details.

## Pull Request Guidelines

- Branch off `main`. Release branches (`release/v*`, `hotfix/v*`) are managed by the release flow — see [RELEASE_POLICY.md](RELEASE_POLICY.md).
Expand Down
2 changes: 1 addition & 1 deletion RELEASE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ How to ship a new version of the Go SDK (`core`, `http`, `mcp`). All three modul
## Prerequisites

- You are a maintainer on `AuthPlane/go-sdk`.
- **`RELEASE_BOT_APP_ID`** and **`RELEASE_BOT_PRIVATE_KEY`** are set as organization secrets scoped to this repo. The Release Bot GitHub App mints a short-lived token used to push the four annotated tags and check out the conformance catalog. (`ci.yml` does not need these — the conformance repo is public.) `release.yml` fails fast with a clear error if either secret is missing — the workflow will not silently proceed.
- **`RELEASE_BOT_APP_ID`** and **`RELEASE_BOT_PRIVATE_KEY`** are set as organization secrets scoped to this repo. The Release Bot GitHub App mints a short-lived token used to push the five annotated tags. (`ci.yml` does not need these — the conformance repo is public.) `release.yml` fails fast with a clear error if either secret is missing — the workflow will not silently proceed.
- `CHANGELOG.md` on `main` has a populated `## [Unreleased]` section.

There is no registry to configure. `proxy.golang.org` polls public tags and begins serving the new module versions within seconds of the atomic push — **the tag push is the publish**.
Expand Down
20 changes: 20 additions & 0 deletions core/authplane/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,26 @@ func NewClient(ctx context.Context, issuer string, opts ...Option) (*Client, err
opt(cfg)
}

// RFC 8414 §2 forbids both a query and a fragment component in an issuer
// identifier. The resource side already rejects a fragment (resource.New),
// but the issuer flowed straight into metadata.Config with no such check —
// and the two discovery-URL builders disagree when either is present.
// buildOAuthMetadataURL now resolves a well-known reference against the
// issuer, silently dropping the issuer's query and fragment, while
// buildOIDCDiscoveryURL still trims only a trailing slash and concatenates
// the well-known suffix onto the whole string, carrying the query/fragment
// along. For "https://as.example.com/tenant?x=1" the RFC 8414 and OIDC
// discovery attempts would therefore target two different identities.
// Rejecting a query- or fragment-bearing issuer here makes the two helpers
// agree by construction.
//
// This is deliberately scoped to the query/fragment gate. Full issuer
// URL-shape validation (requiring a scheme and host, matching the checks
// resource.New applies to the resource URI) is a tracked follow-up.
if strings.ContainsAny(issuer, "?#") {
return nil, fmt.Errorf("%w: must not contain a query or fragment (RFC 8414 §2), got %q", ErrInvalidIssuer, issuer)
}

// Fetch settings precedence: explicit WithFetchSettings > AUTHPLANE_DEV_MODE env > defaults.
var fetchSettings ssrf.FetchSettings
switch {
Expand Down
33 changes: 33 additions & 0 deletions core/authplane/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
Expand Down Expand Up @@ -85,6 +86,38 @@ func TestNewClient_Success(t *testing.T) {
defer client.Close()
}

func TestNewClient_RejectsIssuerWithQueryOrFragment(t *testing.T) {
// RFC 8414 §2 forbids both a query and a fragment in an issuer identifier.
// NewClient must reject them at construction — before discovery — so the two
// discovery-URL builders cannot diverge on a query/fragment-bearing issuer.
cases := []struct {
name string
issuer string
}{
{"query", "https://as.example.com/tenant?x=1"},
{"fragment", "https://as.example.com/tenant#frag"},
{"both", "https://as.example.com/tenant?x=1#frag"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client, err := authplane.NewClient(context.Background(), tc.issuer,
authplane.WithFetchSettings(authplane.DevModeFetchSettings()))
if err == nil {
if client != nil {
client.Close()
}
t.Fatalf("expected error for issuer %q, got nil", tc.issuer)
}
if !errors.Is(err, authplane.ErrInvalidIssuer) {
t.Fatalf("expected error to wrap ErrInvalidIssuer, got %v", err)
}
if !strings.Contains(err.Error(), "query or fragment") {
t.Fatalf("expected query/fragment rejection message, got %v", err)
}
})
}
}

func TestNewClient_NoCredentials(t *testing.T) {
server, serverURL := mockAS(t)
defer server.Close()
Expand Down
7 changes: 7 additions & 0 deletions core/authplane/types.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package authplane

import (
"errors"

"github.com/authplane/go-sdk/core/internal/oauth"
"github.com/authplane/go-sdk/core/internal/ssrf"
)

// ErrInvalidIssuer is returned by NewClient when the issuer identifier is not
// acceptable — currently, when it carries a query or fragment component, which
// RFC 8414 §2 forbids. Callers can match it with errors.Is.
var ErrInvalidIssuer = errors.New("authplane: invalid issuer")

// TokenResponse is an OAuth 2.0 token endpoint response.
type TokenResponse = oauth.TokenResponse

Expand Down
5 changes: 5 additions & 0 deletions core/conformancetests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,17 @@ Set `CONFORMANCE_CATALOG_PATH` (or `AUTHPLANE_CONFORMANCE_CATALOG`) to the absol
# Clone the catalog repo anywhere
git clone git@github.com:AuthPlane/conformance.git /path/to/catalog

# Check out the same pinned ref CI uses (single-sourced at the repo root)
git -C /path/to/catalog checkout "$(cat /path/to/go-sdk/.conformance-catalog-ref)"

# Point the harness at it
export CONFORMANCE_CATALOG_PATH=/path/to/catalog/oauth-sdk-conformance-catalog.yaml
```

This is useful in CI or when the catalog is not a sibling directory.

CI pins the catalog to the SHA tracked in [`.conformance-catalog-ref`](../../.conformance-catalog-ref) at the repo root, so a catalog change can never break CI on its own. Check out that same ref locally (as above) to match CI exactly. A weekly `conformance-catalog-drift` workflow runs the alignment check against the latest catalog and fails when new cases need adopting; adopt them here and bump `.conformance-catalog-ref` in the same change.

## Running

```bash
Expand Down
Loading
Loading