Skip to content
Open
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
38 changes: 38 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,43 @@ This mode is intended for public read-only MCP servers. It must not be used in
front of a backend that exposes write or administrative tools unless every
publicly callable tool is intentionally allowlisted and tested.

## Optional OAuth Scope-to-Tool ACL

OAuth is optional for public read-only deployments. Anonymous requests can keep
using the public read-only surface, while agents that present a valid bearer
token may be constrained by a scope-to-tool ACL.

Configure the ACL with `AUTHENTICATED_SCOPE_TOOLS`:

```text
AUTHENTICATED_SCOPE_TOOLS=mcp:list_pages|get_page|search_pages|get_recent_posts|list_tags|list_categories|get_sitemap|get_feed|get_site_information
```

Semantics:

- if `AUTHENTICATED_SCOPE_TOOLS` is empty, valid bearer tokens retain the legacy
proxy behavior;
- if a `mcp` mapping is present, valid bearer tokens may call only the listed
`tools/call` names;
- authenticated `tools/list` responses are filtered to advertise only the tools
allowed by the scope;
- protocol setup methods (`initialize`, `notifications/initialized`, `ping`)
remain allowed;
- methods outside that narrow MCP surface are rejected before the backend is
reached.

For `hugo-public-mcp`, the production candidate model is:

- anonymous read-only remains available;
- OAuth is optional and does not unlock private tools yet;
- bearer tokens are limited to the same public read-only tools as anonymous
clients until a separate design introduces private scopes.

Refresh tokens and token revocation are intentionally not part of this model yet.
Short-lived access tokens plus SQLite WAL persistence are sufficient for the
current public read-only staging validation. Add revocation or refresh tokens
only if a future private-tool design requires them.

Important guarantees:

- redirect URIs must match the registered allowlist
Expand Down Expand Up @@ -110,6 +147,7 @@ Common runtime controls:
- `MANDATORY_PKCE`
- `ANONYMOUS_ENABLED`
- `ANONYMOUS_PUBLIC_TOOLS`
- `AUTHENTICATED_SCOPE_TOOLS`

## Security Model

Expand Down
200 changes: 200 additions & 0 deletions docs/HUGO_PUBLIC_MCP_STAGING_OAUTH_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# hugo-public-mcp OAuth Staging Hardening Report

Date: 2026-07-02

Scope: `staging-mcp.arleo.eu` only. Production `mcp.arleo.eu` was not moved
behind `mcp-runtime-go`.

## Auth Model

The production-candidate model is:

- anonymous MCP access remains enabled for public read-only tools;
- OAuth is optional;
- OAuth does not unlock private tools in the current design;
- no write tools, admin tools, Hugo rebuild, shell, filesystem, or private Hugo
MCP access are exposed;
- bearer tokens are constrained by a scope-to-tool ACL before traffic reaches the
backend.

Current staging allowlists:

```text
ANONYMOUS_PUBLIC_TOOLS=list_pages,get_page,search_pages,get_recent_posts,list_tags,list_categories,get_sitemap,get_feed,get_site_information
AUTHENTICATED_SCOPE_TOOLS=mcp:list_pages|get_page|search_pages|get_recent_posts|list_tags|list_categories|get_sitemap|get_feed|get_site_information
```

`TRUSTED_AUTHORIZE_CIDRS` no longer uses `0.0.0.0/0,::/0`. Staging now uses:

```text
82.65.145.189/32,192.168.1.0/24,127.0.0.1/32,::1/128
```

OpenResty staging also has an explicit `/authorize` location with `allow` for
the operator public IP and LAN, then `deny all`.

## Comparison

| Area | `mcp.arleo.eu` production | `staging-mcp.arleo.eu` OAuth staging |
| --- | --- | --- |
| Runtime | `hugo-public-mcp` directly | `mcp-runtime-go` proxying to `hugo-public-mcp` |
| Auth requirement | none | anonymous allowed, OAuth optional |
| OAuth discovery | absent on production | present and valid for staging |
| Public tools | read-only Hugo tools | same read-only tools |
| Private tools | none | none |
| Bearer invalid | not applicable | `401` with `WWW-Authenticate` |
| Bearer valid | not applicable | constrained by `AUTHENTICATED_SCOPE_TOOLS` |
| `/authorize` exposure | not present | restricted to operator/LAN at OpenResty and Go CIDR gate |
| Backend port exposure | production app port remains unchanged | staging app binds `127.0.0.1:8092` only |
| IsItAgentReady role | canonical public MCP | OAuth staging endpoint only |

## Benefits

- Adds real OAuth Authorization Code + PKCE discovery without making OAuth
mandatory for public read-only content.
- Keeps public anonymous tools available for agents that do not need tokens.
- Prevents bearer tokens from becoming implicit broad backend access.
- Provides a staging path to test client interoperability before any production
cutover.

## Risks

- Dynamic Client Registration is still single-tenant: it returns the configured
client identity rather than creating independent durable clients.
- There is no token revocation endpoint.
- There are no refresh tokens.
- The current public read-only use case does not require private scopes; adding
private tools later needs a separate scope design and tests.
- `/authorize` is intentionally operator/LAN restricted. This is safer for
staging, but a fully public OAuth consent model would need a real user-auth or
consent ceremony before production.

## Revocation and Refresh Token Decision

Do not add refresh tokens or revocation for the current public read-only staging
candidate.

Reasoning:

- anonymous read-only access remains available without OAuth;
- OAuth tokens currently unlock only the same public read-only tools;
- short-lived access tokens plus SQLite WAL persistence are sufficient for
staging validation;
- adding refresh/revocation before private scopes would increase surface and
operational burden without clear benefit.

Revisit this only if a future design introduces private scopes or longer-lived
authenticated sessions.

## Validation Evidence

Local/runtime:

```text
systemctl is-active mcp-runtime-staging.service -> active
listener -> 127.0.0.1:8092
GET http://127.0.0.1:8092/healthz -> OK
GET http://127.0.0.1:8092/readyz -> OK
```

Public staging endpoints:

```text
/.well-known/oauth-authorization-server -> 200 application/json
/.well-known/oauth-protected-resource -> 200 application/json
/auth.md -> 200 text/markdown
/healthz -> 200 text/plain
/readyz -> 200 text/plain
```

Security behavior:

```text
Go direct /authorize with X-Forwarded-For: 203.0.113.10 -> 403
OAuth DCR + Authorization Code PKCE + token exchange -> OK
Authenticated tools/list -> filtered to read-only ACL
Authenticated get_site_information -> OK
Authenticated publish_post -> 403 before backend
Anonymous tools/list -> filtered to read-only tools
```

Leak scan over public staging discovery and health endpoints:

```text
No /home/jm
No 192.168.
No .git
No token or secret patterns
```

Project validation:

```text
go test ./... -> PASS
go test -race ./... -> PASS
go vet ./... -> PASS
gitleaks detect -> PASS
```

`golangci-lint run ./...` is not clean because of existing errcheck/staticcheck
debt in unrelated tests and handlers. The new unused symbol found during this
pass was removed.

IsItAgentReady staging:

```text
level: 0 Not Ready
OAuth Discovery: PASS
OAuth Protected Resource: PASS
auth.md: PASS
MCP Server Card: PASS
robots.txt/content-signal: PASS
```

The remaining staging failures are expected because `staging-mcp.arleo.eu` is
not the full Hugo content site:

- sitemap
- DNS-AID
- API Catalog
- Agent Skills
- Markdown negotiation
- A2A
- WebMCP

## Rollback

Disable staging OAuth runtime:

```bash
sudo systemctl disable --now mcp-runtime-staging.service
```

Remove staging vhost:

```bash
sudo rm -f /usr/local/openresty/nginx/conf/sites-enabled/staging-mcp.arleo.eu
sudo openresty -t
sudo systemctl reload openresty
```

Restore saved staging backups if needed:

```bash
sudo cp -a /etc/mcp-runtime-go/mcp-runtime-staging.env.bak-authorize-hardening-YYYYMMDD-HHMMSS /etc/mcp-runtime-go/mcp-runtime-staging.env
sudo cp -a /usr/local/bin/mcp-runtime-staging.bak-YYYYMMDD-HHMMSS /usr/local/bin/mcp-runtime-staging
sudo systemctl restart mcp-runtime-staging.service
```

Production rollback is not required for this pass because production was not
changed.

## Verdict

GO for a future production-candidate PR that documents and reviews this model.

NO-GO for direct production cutover today.

Before production, decide explicitly whether `/authorize` should remain
operator/LAN-only or whether a real public consent/user-auth model is required.
Do not expose OAuth broadly without that decision.
29 changes: 29 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Common production settings:
- `ALLOW_TOKEN_STORE_RECOVERY=false`
- `ANONYMOUS_ENABLED=false`
- `ANONYMOUS_PUBLIC_TOOLS=`
- `AUTHENTICATED_SCOPE_TOOLS=`

Legacy `GRAV_*` variables are supported only as compatibility fallback.

Expand All @@ -66,6 +67,30 @@ Behavior:
Do not enable this mode in front of an administrative MCP backend unless every
write-capable tool is excluded from the public allowlist and separately tested.

## Optional OAuth for Public Read-Only MCP

For a public read-only MCP deployment, OAuth can be enabled without making it
mandatory for anonymous users.

Recommended production-candidate policy for `hugo-public-mcp`:

```bash
ANONYMOUS_ENABLED=true
ANONYMOUS_PUBLIC_TOOLS=list_pages,get_page,search_pages,get_recent_posts,list_tags,list_categories,get_sitemap,get_feed,get_site_information
AUTHENTICATED_SCOPE_TOOLS=mcp:list_pages|get_page|search_pages|get_recent_posts|list_tags|list_categories|get_sitemap|get_feed|get_site_information
```

Behavior:

- requests without an `Authorization` header use the anonymous public allowlist;
- requests with a valid bearer token use the `mcp` scope tool ACL;
- requests with an invalid bearer token receive `401` with `WWW-Authenticate`;
- no private or write tools are exposed by this policy.

This keeps OAuth optional and truthful. It advertises a real Authorization Code
+ PKCE flow and Dynamic Client Registration, but it does not invent private
capabilities or make OAuth a prerequisite for public content access.

## Systemd

The service is expected to run as a hardened unit with:
Expand All @@ -89,6 +114,10 @@ systemctl cat mcp-runtime
- Public traffic flows through Cloudflare and OpenResty.
- CrowdSec / OpenResty controls may block at the edge before Go sees the request.
- `/authorize` is intentionally restricted to trusted operator IPs.
- Do not use `TRUSTED_AUTHORIZE_CIDRS=0.0.0.0/0,::/0` outside short-lived
interoperability tests. A production candidate should restrict `/authorize`
to explicit operator/admin source ranges until a real public consent model is
designed.
- If a request is blocked at the edge, check the OpenResty access/error logs and CrowdSec decisions.

## Health and Metrics
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type OAuthProxyConfig struct {
AllowTokenStoreRecovery bool `env:"ALLOW_TOKEN_STORE_RECOVERY" envDefault:"false"`
AnonymousEnabled bool `env:"ANONYMOUS_ENABLED" envDefault:"false"`
AnonymousPublicTools []string `env:"ANONYMOUS_PUBLIC_TOOLS" envDefault:""`
AuthenticatedScopeTools string `env:"AUTHENTICATED_SCOPE_TOOLS" envDefault:""`
}

type RuntimeConfig struct {
Expand Down
16 changes: 16 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ func TestLoad_AnonymousMCPConfig(t *testing.T) {
}
}

func TestLoad_AuthenticatedScopeToolsConfig(t *testing.T) {
t.Setenv("CLIENT_ID", "test-client")
t.Setenv("CLIENT_SECRET", "test-secret")
t.Setenv("HUGO_TOKEN", "test-token")
t.Setenv("AUTHENTICATED_SCOPE_TOOLS", "mcp:list_pages|get_page|search_pages")

cfg, err := Load()
if err != nil {
t.Fatalf("Load() failed: %v", err)
}

if cfg.OAuthProxy.AuthenticatedScopeTools != "mcp:list_pages|get_page|search_pages" {
t.Fatalf("authenticated scope tools = %q", cfg.OAuthProxy.AuthenticatedScopeTools)
}
}

func TestValidate(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading