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
1 change: 1 addition & 0 deletions .conformance-catalog-ref
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf
53 changes: 53 additions & 0 deletions .github/scripts/fetch-conformance-catalog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
#
# Fetch the conformance catalog at the revision this repo pins.
#
# The catalog lives in github.com/AuthPlane/conformance and is updated
# independently of this repo, so cloning its default branch would let a catalog
# change turn an unrelated PR red here. The ref is pinned instead, single-sourced
# from the tracked .conformance-catalog-ref at the repo root — bump it there when
# adopting new catalog cases, together with the coverage for them, so a catalog
# change can never break CI on its own.
#
# This script exists because the read/guard/fetch sequence is needed by more than
# one workflow (ci.yml and release.yml). Keeping it inline in both meant the
# guard could be tightened in one and not the other; the pin was single-sourced
# but the logic reading it was not.
#
# Clones into $RUNNER_TEMP — outside $GITHUB_WORKSPACE — so the catalog stays out
# of the working tree: it must never trip `go list ./...` or a coverage glob, and
# `git add -A` in the release commit must never stage it as a gitlink.
#
# Requires: GITHUB_WORKSPACE, RUNNER_TEMP.

set -euo pipefail

: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE must be set}"
: "${RUNNER_TEMP:?RUNNER_TEMP must be set}"

REF_FILE="$GITHUB_WORKSPACE/.conformance-catalog-ref"
DEST="$RUNNER_TEMP/conformance"
CATALOG_REPO="https://github.com/AuthPlane/conformance.git"

if [[ ! -f "$REF_FILE" ]]; then
echo "::error::$REF_FILE is missing; the conformance catalog revision is unpinned"
exit 1
fi

CONFORMANCE_CATALOG_REF="$(tr -d '[:space:]' < "$REF_FILE")"

# Guard against un-pinning: the ref must be a full commit SHA, not a branch or
# tag name, either of which would silently track a moving target.
if ! grep -Eq '^[0-9a-f]{40}$' <<< "$CONFORMANCE_CATALOG_REF"; then
echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA, got '$CONFORMANCE_CATALOG_REF'"
exit 1
fi

git init -q "$DEST"
if ! git -C "$DEST" fetch --depth=1 "$CATALOG_REPO" "$CONFORMANCE_CATALOG_REF"; then
echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"
exit 1
fi
git -C "$DEST" checkout -q FETCH_HEAD

echo "Conformance catalog checked out at $CONFORMANCE_CATALOG_REF"
6 changes: 2 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,8 @@ jobs:
# conformance suite).
- 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"
shell: bash
run: .github/scripts/fetch-conformance-catalog.sh

- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
Expand Down
103 changes: 103 additions & 0 deletions .github/workflows/conformance-catalog-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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
# `shell: bash` is load-bearing, not decoration. The default shell for a
# `run:` step on Linux is `bash -e {0}`, which does NOT set pipefail, so
# the pipeline below would exit with tee's status — always 0 — and this
# step would report success no matter what `go test` did. `shell: bash`
# is what adds `-o pipefail`.
shell: bash
run: go test ./conformancetests/ -v 2>&1 | tee "$RUNNER_TEMP/align.log"

- 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"
elif ! grep -qE "has no conformance test|registers unknown case" "$RUNNER_TEMP/align.log" 2>/dev/null; then
# Match the two messages that actually mean drift, not the bare
# "CATALOG ALIGNMENT:" prefix. verifyCatalogAlignment emits that
# prefix for three distinct outcomes (catalog_alignment_test.go):
#
# catalog case %q has no conformance test -> drift
# conformance test registers unknown case %q -> drift
# load catalog: read catalog: ... -> harness problem
#
# The third fires when the catalog clone is missing or the path is
# wrong. Grepping the prefix would classify that as drift, which is
# exactly the case this branch exists to separate out.
echo "::warning::The catalog-alignment step failed without a drift message. This is a build or harness problem — a compile error, a failed module download, an unreadable catalog clone, or an unrelated conformance assertion — not catalog drift. Read the step log before touching .conformance-catalog-ref."
{
echo "## Conformance alignment check failed for another reason"
echo ""
echo "The step failed, but its output carries neither drift message"
echo "(\`has no conformance test\` / \`registers unknown case\`)."
echo "That points at a build or harness problem rather than a catalog change."
} >> "$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
9 changes: 3 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,8 @@ 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
shell: bash
run: .github/scripts/fetch-conformance-catalog.sh

- name: Set up Go 1.25
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
Expand Down Expand Up @@ -226,7 +223,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
26 changes: 23 additions & 3 deletions .github/workflows/workflows-lint.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
name: Lint workflows
name: Release tooling

# Catches workflow YAML / shell-in-`run:` regressions at PR time so a
# typo can't reach a release tag and surface only when a publish run
# fails. Scoped to changes under `.github/workflows/**` to keep CI
# overhead off unrelated PRs.
# fails. The shell scripts at the top of scripts/ are in the same category
# — a break in them surfaces only when someone reaches for them after a
# release, which is the worst moment to discover it — so they are linted
# and tested here too.
#
# Scoped to `.github/workflows/**` and `scripts/*.sh` to keep CI overhead
# off unrelated PRs. `.github/scripts/*.sh` is deliberately not in scope:
# it is driven by conformance-catalog-drift.yml, not by the release flow
# this job guards, and pulling it in would widen the trigger to every PR
# touching `.github/**`.

on:
pull_request:
paths:
- ".github/workflows/**"
- "scripts/*.sh"
push:
branches:
- main
paths:
- ".github/workflows/**"
- "scripts/*.sh"

permissions:
contents: read
Expand Down Expand Up @@ -62,3 +72,13 @@ jobs:
# job fails loudly instead of silently degrading.
- name: Run actionlint
run: actionlint -color -shellcheck=shellcheck

- name: Shellcheck the release scripts
run: shellcheck scripts/*.sh

# backport-fixes.sh accepts a branch or a tag as --from, and only the
# branch form has a remote-tracking ref. The tag form is what the release
# flow tells you to use once release.yml has deleted the branch, so it is
# the form least likely to be exercised before it is needed.
- name: Test backport-fixes.sh
run: scripts/backport-fixes.test.sh
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `core/resource/verifier`: `ValidateIssuer(issuer string) error` — the RFC 8414 §2 issuer-shape rule, exported so every construction boundary applies one implementation rather than a copy. Rejects a query or fragment component, and requires an absolute URL with a scheme and host. `NewTokenVerifier`, `resource.New` and `authplane.NewClient` all route through it.
- `core/resource/verifier`: `ErrInvalidIssuer` sentinel, returned by everything that validates an issuer identifier. Match it with `errors.Is`.

### Fixed
- `core/resource/verifier`, `core/authplane`: an issuer rejected at construction is no longer echoed verbatim into the error. The query/fragment branch fires for exactly the shape that can carry a credential (`https://as.example.com?access_token=…`), and `net/url.Error` prints its URL field without redacting, so the raw identifier — query, fragment and any userinfo — reached whatever log the construction error landed in. Messages now carry scheme and host only. Parse failures are still wrapped with `%w`, so `errors.As(err, new(*url.Error))` keeps working; only the URL the error prints is substituted.
- `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/resource/verifier`, `core/resource`: `NewTokenVerifier` and `resource.New` now reject an issuer carrying a query or fragment component, and require the identifier to be an absolute URL with a scheme and host (RFC 8414 §2). Construction that succeeded in 0.2.0 — a relative reference such as `/tenant`, or an issuer with `?x=1` — now fails. `url.ParseRequestURI` alone accepted both: it takes a path-only reference, and it folds a fragment into `Path` rather than splitting it. **Migration:** pass the authorization server's issuer identifier exactly as published — absolute, `https`, no query, no fragment.
- **BREAKING** `core/authplane`: `NewClient` additionally requires the issuer to be absolute with a scheme and host, beyond the query/fragment rule below. This gate is not redundant with the verifier's: a `*Client` used only for token, introspection and revocation calls never constructs a `TokenVerifier`, so it is the only thing keeping a relative reference out of eager discovery. **Migration:** as above.
- **BREAKING** `core/authplane`: `ErrInvalidIssuer` is now an alias of `verifier.ErrInvalidIssuer` rather than its own sentinel. Two consequences for code that inspects it: the message changes from `authplane: invalid issuer` to `verifier: invalid issuer`, and `errors.Is(err, authplane.ErrInvalidIssuer)` now returns true for a rejection raised by the verifier, where it previously returned false. **Migration:** if you relied on the two sentinels being distinct to tell which layer rejected an identifier, that distinction is gone — both boundaries now apply the same rule, so match on the single sentinel and read the message for the specific violation. Code that only did `errors.Is(err, authplane.ErrInvalidIssuer)` on a `NewClient` error is unaffected.
- **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
Loading
Loading