Files
pad/internal/server/token_scopes_test.go
T
xarmian 924d82dae4 feat(oauth): MCPBearerAuth OAuth integration + public-info (TASK-1027) — closes TASK-951 (#375)
* 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.
2026-05-02 13:33:53 -04:00

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)
}
})
}
}