mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
924d82dae4
* feat(oauth): MCPBearerAuth OAuth integration + public-info endpoint (TASK-1027, sub-PR E of TASK-951)
Closes the OAuth server build-out by connecting sub-PRs A-D to the MCP
transport from TASK-950 and shipping the consent-screen support endpoint.
## MCPBearerAuth OAuth path
middleware_mcp_auth.go now branches on token shape:
- pad_<60-hex> → existing PAT validation (TASK-950 path)
- anything else → fosite.IntrospectToken via the new
internal/oauth.Server.IntrospectToken wrapper (server-side, no
HTTP roundtrip — pad-cloud is both auth server and resource
server, so the public /oauth/introspect endpoint is for external
clients only).
OAuth path validation gates:
- Token must be active (fosite returns ErrInactiveToken / ErrNotFound
on revoked / unknown / expired tokens).
- tokenUse must be access_token; refresh tokens explicitly rejected
(RFC 6749 §1.5 — refresh tokens aren't bearers for resource calls).
- Granted audience MUST contain the canonical MCP URL (RFC 8707
anti-replay; resource-server-side check defends against compromised
or shared auth servers).
- Subject must resolve to a real user row.
Successful path stashes user + scopes via WithCurrentUser /
WithTokenScopes. Scopes are translated from fosite's space-separated
form to JSON-array form via oauthScopesToJSON.
## tokenScopeAllows pad:* extension
Extended to recognize the OAuth scope vocabulary alongside PAT scopes:
- pad:read ↔ read (GET/HEAD/OPTIONS only)
- pad:write ↔ write (all methods)
- pad:admin ↔ * (all methods)
So MCP tool authorization stays uniform regardless of which transport
issued the bearer.
## /api/v1/oauth/clients/{id}/public-info
New read-only endpoint for the consent screen (TASK-952) and the
OAuth-intent banner (TASK-1001, already shipped). Returns four
non-sensitive fields: client_id, client_name, logo_uri, redirect_uris.
- Auth-required (any logged-in user).
- Cloud-mode-gated (404s outside cloud).
- 404 for unknown clients.
- Whitelisted leak surface — explicit fields, no embedded
models.OAuthClient, so a future field addition (e.g. a confidential-
client secret) doesn't accidentally appear here.
## Tests
- TestMCP_OAuthAccessToken_Authenticates — happy path: full flow
yields a token that authenticates against /mcp.
- TestMCP_OAuthAccessToken_AudienceMismatch_Rejected — RFC 8707
resource-server check; mints a token, swaps the OAuth server
for one with a different canonical, confirms 401.
- TestMCP_OAuthRefreshToken_RejectedAtMCP — refresh tokens MUST
NOT authenticate.
- TestMCP_RevokedOAuthToken_Rejected — revocation takes effect at
the resource server.
- TestMCP_PATPath_StillWorks — regression for sub-PR D's coexistence
with the OAuth path.
- TestMCP_OAuthScopeReadOnly_StashesPadReadScope — scope round-trip.
- TestOAuthClientPublicInfo_HappyPath / UnknownClient_404 /
Unauthenticated_401 / NotMountedOutsideCloudMode — full coverage
of the new endpoint.
- TestE2E_ClaudeDesktopFlow — simulates the full sequence
(discovery → DCR → authorize → token → /mcp call) Claude Desktop
walks on first connect.
- TestTokenScopeAllows extended with pad:* coverage.
## TASK-951 status
Closes TASK-951 when this lands (5/5 sub-PRs done):
- A: schema + storage layer (#370 / 2a00775)
- B: fosite-backed authorization-server constructor (#371 / f6eeee4)
- C: DCR + authorize + token endpoints + populated discovery (#372 / 48776a3)
- D: revoke + introspect endpoints (#373 / 4250fb1)
- E: MCPBearerAuth + public-info (this PR)
* fix(oauth): fail-closed on empty OAuth scopes per Codex review (round 1)
Codex caught a high-severity bug in oauthScopesToJSON: the helper
mapped empty granted scopes to `[]`, which tokenScopeAllows interprets
as the legacy "unrestricted" PAT shape (allow all methods). Combined
with OAuth's RFC 6749 §3.3 rule that the `scope` parameter is
OPTIONAL, this meant a client could:
1. Run the auth-code flow without requesting scopes.
2. Get back a token with empty granted_scopes.
3. Drive write MCP tools because MCPBearerAuth stashed `[]` and
tokenScopeAllows fell through to the legacy unrestricted path.
Fix: map empty OAuth scopes to JSON `null` instead. tokenScopeAllows
denies on the "scopes == nil" branch (existing TASK-667 behavior),
so the entire write surface is denied for empty-scope OAuth tokens.
In production this path is hard to hit — sub-PR C's DCR handler
defaults registered clients to `pad:read pad:write` when omitted,
and audienceMatchingStrategy enforces canonical-audience matching at
grant time. Defense-in-depth at the resource server is the right
policy regardless.
Test: TestOAuthScopesToJSON_FailClosedOnEmpty asserts both halves of
the contract — the helper produces "null" for empty input, and
tokenScopeAllows denies every method when fed that value.
108 lines
5.6 KiB
Go
108 lines
5.6 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
func TestTokenScopeAllows(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
scopes string
|
|
method string
|
|
path string
|
|
want bool
|
|
}{
|
|
// Wildcard scope
|
|
{"wildcard allows GET", `["*"]`, http.MethodGet, "/api/v1/test", true},
|
|
{"wildcard allows POST", `["*"]`, http.MethodPost, "/api/v1/test", true},
|
|
{"wildcard allows DELETE", `["*"]`, http.MethodDelete, "/api/v1/test", true},
|
|
|
|
// Empty/default scopes
|
|
{"empty string allows all", "", http.MethodPost, "/api/v1/test", true},
|
|
{"default wildcard allows all", `["*"]`, http.MethodPatch, "/api/v1/test", true},
|
|
|
|
// Read scope
|
|
{"read allows GET", `["read"]`, http.MethodGet, "/api/v1/test", true},
|
|
{"read allows HEAD", `["read"]`, http.MethodHead, "/api/v1/test", true},
|
|
{"read allows OPTIONS", `["read"]`, http.MethodOptions, "/api/v1/test", true},
|
|
{"read blocks POST", `["read"]`, http.MethodPost, "/api/v1/test", false},
|
|
{"read blocks DELETE", `["read"]`, http.MethodDelete, "/api/v1/test", false},
|
|
{"read blocks PATCH", `["read"]`, http.MethodPatch, "/api/v1/test", false},
|
|
|
|
// Write scope
|
|
{"write allows GET", `["write"]`, http.MethodGet, "/api/v1/test", true},
|
|
{"write allows POST", `["write"]`, http.MethodPost, "/api/v1/test", true},
|
|
{"write allows DELETE", `["write"]`, http.MethodDelete, "/api/v1/test", true},
|
|
|
|
// Invalid/unparseable JSON — now DENY (TASK-667 deny-by-default).
|
|
// Data corruption / tampering should not fall open.
|
|
{"invalid json denies all", "not-json", http.MethodPost, "/api/v1/test", false},
|
|
{"invalid json denies GET", "not-json", http.MethodGet, "/api/v1/test", false},
|
|
|
|
// JSON null MUST NOT fall through the legacy-unrestricted path.
|
|
// json.Unmarshal("null", &[]string) succeeds and leaves nil slice —
|
|
// without an explicit raw-string check we'd grant full access.
|
|
{"json null denies POST", "null", http.MethodPost, "/api/v1/test", false},
|
|
{"json null denies GET", "null", http.MethodGet, "/api/v1/test", false},
|
|
|
|
// Multiple scopes
|
|
{"read+write allows POST", `["read","write"]`, http.MethodPost, "/api/v1/test", true},
|
|
{"read only blocks PUT", `["read"]`, http.MethodPut, "/api/v1/test", false},
|
|
|
|
// Empty array still allows all — legacy "unrestricted" form. New
|
|
// tokens should use ["*"]. Whitespace-padded variants that clients
|
|
// might serialize also count as empty arrays.
|
|
{"empty array allows all", `[]`, http.MethodGet, "/api/v1/test", true},
|
|
{"empty array allows POST", `[]`, http.MethodPost, "/api/v1/test", true},
|
|
{"empty array with spaces", `[ ]`, http.MethodPost, "/api/v1/test", true},
|
|
{"empty array with newline", "[\n]", http.MethodPost, "/api/v1/test", true},
|
|
{"empty array with tab", "[\t]", http.MethodGet, "/api/v1/test", true},
|
|
{"wildcard with spaces", `[ "*" ]`, http.MethodPost, "/api/v1/test", true},
|
|
|
|
// Unrecognized scopes — TASK-667 deny-by-default. A typo like
|
|
// "read-only" must NOT silently grant full access.
|
|
{"unknown scope only denies GET", `["docs"]`, http.MethodGet, "/api/v1/test", false},
|
|
{"unknown scope only denies POST", `["repo"]`, http.MethodPost, "/api/v1/test", false},
|
|
{"read-only typo denies GET", `["read-only"]`, http.MethodGet, "/api/v1/test", false},
|
|
{"unknown+read allows GET", `["docs","read"]`, http.MethodGet, "/api/v1/test", true},
|
|
{"unknown+read blocks POST", `["docs","read"]`, http.MethodPost, "/api/v1/test", false},
|
|
{"unknown+wildcard still allows POST", `["docs","*"]`, http.MethodPost, "/api/v1/test", true},
|
|
{"unknown+write still allows DELETE", `["docs","write"]`, http.MethodDelete, "/api/v1/test", true},
|
|
|
|
// OAuth scope vocabulary (sub-PR E, TASK-1027). MCPBearerAuth
|
|
// stashes fosite-issued scopes as JSON arrays alongside PAT
|
|
// scopes, so the same policy applies. Asserts the read/write/
|
|
// admin mappings hold under the OAuth namespace.
|
|
{"pad:read allows GET", `["pad:read"]`, http.MethodGet, "/api/v1/test", true},
|
|
{"pad:read allows HEAD", `["pad:read"]`, http.MethodHead, "/api/v1/test", true},
|
|
{"pad:read allows OPTIONS", `["pad:read"]`, http.MethodOptions, "/api/v1/test", true},
|
|
{"pad:read blocks POST", `["pad:read"]`, http.MethodPost, "/api/v1/test", false},
|
|
{"pad:read blocks PATCH", `["pad:read"]`, http.MethodPatch, "/api/v1/test", false},
|
|
{"pad:read blocks DELETE", `["pad:read"]`, http.MethodDelete, "/api/v1/test", false},
|
|
{"pad:write allows GET", `["pad:write"]`, http.MethodGet, "/api/v1/test", true},
|
|
{"pad:write allows POST", `["pad:write"]`, http.MethodPost, "/api/v1/test", true},
|
|
{"pad:write allows DELETE", `["pad:write"]`, http.MethodDelete, "/api/v1/test", true},
|
|
{"pad:write allows PATCH", `["pad:write"]`, http.MethodPatch, "/api/v1/test", true},
|
|
{"pad:admin allows POST", `["pad:admin"]`, http.MethodPost, "/api/v1/test", true},
|
|
{"pad:admin allows DELETE", `["pad:admin"]`, http.MethodDelete, "/api/v1/test", true},
|
|
// Multi-scope OAuth grants (the realistic shape — DCR clients
|
|
// usually request all scopes they might need).
|
|
{"pad:read+pad:write allows POST", `["pad:read","pad:write"]`, http.MethodPost, "/api/v1/test", true},
|
|
{"pad:read+pad:write allows GET", `["pad:read","pad:write"]`, http.MethodGet, "/api/v1/test", true},
|
|
// PAT + OAuth scope mixed (defensive — shouldn't happen in
|
|
// practice but the policy should still work).
|
|
{"read+pad:write allows POST", `["read","pad:write"]`, http.MethodPost, "/api/v1/test", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := tokenScopeAllows(tt.scopes, tt.method, tt.path)
|
|
if got != tt.want {
|
|
t.Errorf("tokenScopeAllows(%q, %q, %q) = %v, want %v",
|
|
tt.scopes, tt.method, tt.path, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|