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
5 changes: 5 additions & 0 deletions .github/workflows/backport-fixes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ concurrency:
group: backport-${{ inputs.fromBranch }}-to-${{ inputs.toBranch }}
cancel-in-progress: false

# Least-privilege default; the backport job below elevates to the writes
# it needs (push the backport branch, open a PR).
permissions:
contents: read

jobs:
backport:
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
branches:
- main

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

jobs:
quality:
runs-on: ubuntu-latest
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: CodeQL

# Static analysis (SAST) for the Go modules in this repo. Results are
# uploaded to the GitHub Security tab.
#
# Gated to public repositories only: CodeQL scanning requires GitHub
# Advanced Security, which is free on public repos but not enabled on the
# private fork — running it there would fail every PR. The `if:` guard
# keys off the repo's actual visibility, so this works for any public
# clone and stays a no-op on private ones. Checking visibility == 'public'
# (rather than != 'private') also fails safe: if the field is ever absent
# the job is skipped rather than run.

on:
push:
branches:
- main
pull_request:
branches:
- main
schedule:
# Wednesdays 06:00 UTC.
- cron: "0 6 * * 3"
workflow_dispatch:

# Least-privilege default; the job below grants only the writes CodeQL needs.
permissions:
contents: read

jobs:
analyze:
name: Analyze (Go)
# Public repos only — see the header comment. Skips cleanly on the fork.
if: github.event.repository.visibility == 'public'
runs-on: ubuntu-latest
permissions:
# Required for CodeQL to upload results to the Security tab.
security-events: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.25.x"
check-latest: true

- name: Initialize CodeQL
uses: github/codeql-action/init@c35d1b164463ee62a100735382aaaa525c5d3496 # codeql-bundle-v2.25.6
with:
languages: go
# This repo is a Go workspace of four independent modules; the
# default autobuild can't span them, so we build each module
# manually below.
build-mode: manual

- name: Build modules
# Compile every module so the extractor sees all packages. go.work
# ties the modules together for local dev; here we build each one
# in its own directory to keep the extractor's view complete.
run: |
for module in core http mcp mark3labs; do
echo "::group::build $module"
(cd "$module" && go build ./...)
echo "::endgroup::"
done

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c35d1b164463ee62a100735382aaaa525c5d3496 # codeql-bundle-v2.25.6
with:
category: "/language:go"
5 changes: 5 additions & 0 deletions .github/workflows/cut-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ concurrency:
group: cut-release
cancel-in-progress: false

# Least-privilege default; the cut job below elevates to contents: write
# to push the new release branch.
permissions:
contents: read

jobs:
cut:
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ name: Dependency Review
on:
pull_request:

# Least-privilege default; the job below grants itself the writes it needs.
permissions:
contents: read

jobs:
dependency-review:
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

# Least-privilege default; the release job below elevates to contents: write
# to push tags/branch and create the GitHub Release.
permissions:
contents: read

jobs:
release:
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ jobs:
cache-dependency-path: "${{ matrix.module }}/go.sum"

- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
# Pinned to a tagged release rather than @latest so the toolchain is
# reproducible and the build can't be moved by an upstream retag.
run: go install golang.org/x/vuln/cmd/govulncheck@v1.3.0

- name: Run govulncheck
# Exits non-zero on any known vulnerability in the module's call graph
Expand Down
64 changes: 64 additions & 0 deletions .github/workflows/workflows-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Lint workflows

# 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.

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

permissions:
contents: read

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

# Pulls the matching actionlint binary release from GitHub Releases
# via the upstream download script. The script is fetched by commit
# SHA (not a mutable tag) and sha256-verified before it runs — this
# closes Scorecard's "downloadThenRun not pinned by hash" gap. The
# script then checksum-verifies the actionlint binary it pulls from
# the matching release.
#
# To bump: change ACTIONLINT_VERSION, set ACTIONLINT_SCRIPT_SHA to the
# commit the new tag points at (`gh api repos/rhysd/actionlint/commits/vX.Y.Z -q .sha`),
# and update ACTIONLINT_SCRIPT_SHA256 to that file's sha256.
#
# Install dir is passed explicitly as the script's second positional
# arg so the workflow doesn't couple to the script's internal default
# of $PWD (which happens to be $GITHUB_WORKSPACE after checkout —
# a coincidence, not a contract).
- name: Install actionlint
env:
ACTIONLINT_VERSION: "1.7.7"
ACTIONLINT_SCRIPT_SHA: "03d0035246f3e81f36aed592ffb4bebf33a03106"
ACTIONLINT_SCRIPT_SHA256: "221d1d16c03e4e4fcd867de34104e8d479bdce20ccdfa553b9a5c0dc29bf6af2"
ACTIONLINT_INSTALL_DIR: ${{ runner.temp }}/actionlint
run: |
mkdir -p "${ACTIONLINT_INSTALL_DIR}"
script="${ACTIONLINT_INSTALL_DIR}/download-actionlint.bash"
curl -fsSL -o "${script}" \
"https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_SCRIPT_SHA}/scripts/download-actionlint.bash"
echo "${ACTIONLINT_SCRIPT_SHA256} ${script}" | sha256sum -c -
bash "${script}" "${ACTIONLINT_VERSION}" "${ACTIONLINT_INSTALL_DIR}"
echo "${ACTIONLINT_INSTALL_DIR}" >> "${GITHUB_PATH}"
"${ACTIONLINT_INSTALL_DIR}/actionlint" -version

# `-shellcheck=shellcheck` makes the shellcheck dependency explicit
# rather than relying on actionlint's implicit lookup against the
# runner image's $PATH; if the Ubuntu image ever drops shellcheck the
# job fails loudly instead of silently degrading.
- name: Run actionlint
run: actionlint -color -shellcheck=shellcheck
11 changes: 4 additions & 7 deletions core/resource/verifier/claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,10 @@ func (c *VerifiedClaims) HasScope(scope string) bool {
// Thin wrapper over [RequireScopes] so the singular helper carries the
// same enriched `required scope "X"; token has scopes: …` shape the
// plural one does. The wire body produced from this path is byte-identical
// to a `claims.RequireScopes(scope)` call. Note that the adapter middleware
// at github.com/authplane/go-sdk/http/pkg/authplanehttp.Adapter.RequireScopes
// still loops over scopes and returns on the first miss, so a multi-scope
// adapter failure names only one scope; a direct
// `claims.RequireScopes("a", "b", ...)` call names every missing scope.
// The two paths converge only on the single-missing-scope case until the
// adapter is switched over to the plural helper.
// to a `claims.RequireScopes(scope)` call, and the adapter middleware at
// github.com/authplane/go-sdk/http/pkg/authplanehttp.Adapter.RequireScopes
// delegates to RequireScopes too, so middleware-enforced and code-enforced
// paths produce the same error shape on a multi-scope failure.
func (c *VerifiedClaims) RequireScope(scope string) error {
return c.RequireScopes(scope)
}
Expand Down
18 changes: 10 additions & 8 deletions http/pkg/authplanehttp/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ func (a *Adapter) Middleware() func(http.Handler) http.Handler {
// for all required scopes. Returns 403 with RFC 6750 WWW-Authenticate header
// (including scope= parameter) if any scope is missing. Returns 401 if no claims
// are in context (i.e., Middleware was not applied upstream).
//
// On failure the error_description names every missing scope (not just the first),
// matching the shape produced by a direct claims.RequireScopes call so middleware-
// enforced and code-enforced paths surface the same diagnostic to clients.
func (a *Adapter) RequireScopes(scopes ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -207,14 +211,12 @@ func (a *Adapter) RequireScopes(scopes ...string) func(http.Handler) http.Handle
a.writeAuthError(w, verifier.ErrTokenMissing)
return
}
for _, scope := range scopes {
if err := claims.RequireScope(scope); err != nil {
a.writeAuthError(w, &resource.ScopeError{
RequiredScopes: scopes,
Err: err,
})
return
}
if err := claims.RequireScopes(scopes...); err != nil {
a.writeAuthError(w, &resource.ScopeError{
RequiredScopes: scopes,
Err: err,
})
return
}
next.ServeHTTP(w, r)
})
Expand Down
25 changes: 25 additions & 0 deletions http/pkg/authplanehttp/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,31 @@ func TestRequireScopesMultipleOneMissing(t *testing.T) {
}
}

// TestRequireScopesMultipleAllMissingNamesEveryScope verifies the middleware
// surfaces all missing scopes (not just the first) in the error_description,
// matching the shape of a direct claims.RequireScopes call. The
// WWW-Authenticate header carries the scopes space-separated per RFC 6750 §3;
// the enriched quoted-list shape lives in the JSON body's error_description.
func TestRequireScopesMultipleAllMissingNamesEveryScope(t *testing.T) {
e := newTestEnv(t)
handler := e.adapter.Middleware()(e.adapter.RequireScopes("tools/admin", "tools/superuser")(okHandler()))
token := e.makeToken(t, []string{"tools/add"}, time.Now().Add(time.Hour))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/mcp/admin", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, `scope="tools/admin tools/superuser"`) {
t.Errorf("WWW-Authenticate = %q, want scope=\"tools/admin tools/superuser\"", got)
}
body := rec.Body.String()
if !strings.Contains(body, `\"tools/admin\"`) || !strings.Contains(body, `\"tools/superuser\"`) {
t.Errorf("body = %s, want error_description to name every missing scope (not just the first)", body)
}
}

// Case-insensitive scheme tests

func TestMiddleware_BearerCaseInsensitive(t *testing.T) {
Expand Down
25 changes: 18 additions & 7 deletions mark3labs/pkg/authplanemark3labs/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ type Adapter struct {

client *authplane.Client
prmHandler http.Handler // mark3labs PRM handler, built once at construction
ownsClient bool // true when this Adapter constructed the client and must close it
}

// ClaimsFromContext returns the VerifiedClaims injected by Middleware (and
Expand Down Expand Up @@ -138,13 +139,15 @@ func NewAdapter(ctx context.Context, options Options) (*Adapter, error) {
Adapter: authplanehttp.New(res),
client: client,
prmHandler: server.NewProtectedResourceMetadataHandler(prmConfigFromResource(res)),
ownsClient: true,
}, nil
}

// NewAdapterFromClientAndResource creates an Adapter from an already-configured
// authplane.Client and resource.Resource. Adapter.Close() still calls client.Close();
// when sharing a client across adapters, manage client lifecycle yourself and let
// the adapters go out of scope.
// authplane.Client and resource.Resource. The caller retains ownership of the
// client — adapter.Close() is a no-op for adapters created this way, so a
// client shared across multiple adapters keeps running even after one of them
// is closed.
//
// Returns an error if client or res is nil. The signature matches the sibling
// mcp adapter — neither constructor panics, and both surface programming
Expand All @@ -160,6 +163,7 @@ func NewAdapterFromClientAndResource(client *authplane.Client, res *resource.Res
Adapter: authplanehttp.New(res),
client: client,
prmHandler: server.NewProtectedResourceMetadataHandler(prmConfigFromResource(res)),
ownsClient: false,
}, nil
}

Expand Down Expand Up @@ -325,18 +329,25 @@ func prmConfigFromResource(res *resource.Resource) server.ProtectedResourceMetad
// Client returns the underlying authplane.Client, providing access to all SDK
// operations: TokenExchange, Revoke, Introspect, ClientCredentials, DPoPSigner, etc.
//
// Do not call Close() on the returned client directly — call adapter.Close() instead,
// as the adapter owns the client lifecycle.
// For adapters built with NewAdapter, do not call Close() on the returned client
// directly — call adapter.Close() instead, as the adapter owns the client lifecycle.
// For adapters built with NewAdapterFromClientAndResource, the caller owns the
// client and is responsible for closing it.
func (a *Adapter) Client() *authplane.Client {
return a.client
}

// Close stops all background goroutines and releases resources held by the
// underlying client. It is safe to call multiple times.
//
// When using NewAdapterFromClientAndResource and sharing a client across multiple
// adapters, call client.Close() directly instead of adapter.Close().
// For adapters built with NewAdapter, Close closes the underlying client.
// For adapters built with NewAdapterFromClientAndResource, the caller owns the
// client; Close is a no-op so a client shared across multiple adapters keeps
// running even after one of them is closed.
func (a *Adapter) Close() error {
if !a.ownsClient {
return nil
}
return a.client.Close()
}

Expand Down
26 changes: 26 additions & 0 deletions mark3labs/pkg/authplanemark3labs/constructor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ func TestNewAdapterFromClientAndResource(t *testing.T) {
}
}

// TestNewAdapterFromClientAndResource_CloseDoesNotCloseSharedClient verifies
// that closing an adapter built from an externally-owned client leaves the
// shared client running — the lifecycle contract documented on
// NewAdapterFromClientAndResource and Close.
func TestNewAdapterFromClientAndResource_CloseDoesNotCloseSharedClient(t *testing.T) {
e := newTestEnv(t)

adapter2, err := authplanemark3labs.NewAdapterFromClientAndResource(e.adapter.Client(), e.adapter.Resource())
if err != nil {
t.Fatalf("NewAdapterFromClientAndResource: %v", err)
}
if err := adapter2.Close(); err != nil {
t.Fatalf("adapter2.Close: %v", err)
}

// The shared client must still serve requests through the original adapter.
token := e.makeToken(t, []string{"tools/add"}, time.Now().Add(time.Hour))
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/mcp", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
e.adapter.AuthMiddleware(okHandler()).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200; shared client must remain usable after adapter2.Close()", rec.Code)
}
}

// TestNewAdapterFromClientAndResourceNilClient verifies that a nil client
// yields a clear error rather than a panic — the constructor's signature
// matches the sibling mcp adapter.
Expand Down
2 changes: 1 addition & 1 deletion mcp/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/sys v0.44.0 // indirect
)

replace github.com/authplane/go-sdk/core => ../core
Loading
Loading