Commit Graph

14 Commits

Author SHA1 Message Date
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00
xarmian 6365ac08fa fix(oauth): a revoked grant mints nothing, and one refresh token rotates once (BUG-3678) (#2121)
* fix(oauth): a revoked grant mints nothing, and one refresh token rotates once (BUG-3678)

Fosite's refresh validates the presented token, then calls
RotateRefreshToken, CreateAccessTokenSession and
CreateRefreshTokenSession, each committing on its own. Two windows
followed:

- A revoke landing after validation: rotation flipped the family and
  the new pair was inserted active, so a revoked connection came back.
  The account claim and fosite's own RevokeRefreshToken (RFC 7009,
  refresh replay) had the same hole.
- Two concurrent refreshes of one token: both validated, both rotated
  (rotation ignored the presented signature), and both pairs ended up
  minted.

Now:

- RotateRefreshToken spends the presented token conditionally, in one
  transaction with the family flips. A token already inactive fails
  with ErrOAuthRefreshNotActive, which the adapter returns as
  fosite.ErrSerializationFailure (invalid_request, "multiple concurrent
  requests").
- A revoked grant is recorded in oauth_revoked_grants (migration 143).
  RevokeOAuthGrant (connected-apps revoke, adapter RevokeRefreshToken)
  takes the subject's users row FOR NO KEY UPDATE first, then records
  the grant and flips the families. The claim records every grant of
  the account under the users row its first statement holds.
- Issuance refuses a recorded request_id under the users FOR SHARE
  that requireActiveUserTx already takes (ErrOAuthGrantRevoked, mapped
  to fosite.ErrInactiveToken). So a racing refresh either committed
  before the revoke's flips, which then see its rows, or finds the
  record.

Disable, installed-app grants and client deletion were already fenced
(BUG-3349, TASK-3394/3399, the client FK).

Tests: store repros for both windows, the claim and a double rotation,
plus test-pg legs for concurrent refreshes and a revoke racing a
refresh. HTTP tests drive fosite's real token endpoint through a
before-rotation seam. Each production guard was removed in turn, and
the matching tests went red.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): the install barrier refuses a revoked grant; rotation holds no row across the family flips (BUG-3678, codex r1)

P1: an installed app's tokens are minted through installIssuanceBarrierTx,
which returned before the revoked-grant check. The check now runs there
under the install row. RevokeOAuthGrant locks that row (not the bot's
users row, which the install lifecycle takes after the install) for an
install client's grant.

P2: RotateRefreshToken held the presented refresh row in a transaction
while flipping the access family, and the claim and disable flip access
before refresh under the users row, so the two could deadlock on
Postgres. The conditional spend is its own statement again, and the
flips follow it as before.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): a fosite revoke of an installed app's grant takes the app-grant path (BUG-3678, codex r2)

Round 2: RevokeOAuthGrant locked the install row for an installed app's
grant and then flipped refresh before access, while the account claim
holds the person's row and flips access before refresh. Neither shared a
lock with the other, so the two could deadlock on Postgres.

Installed-app grants already have their own tombstone (the binding's
revoked_at, which the issuance barrier refuses) and a lock order that
the barrier, RevokeUserAppGrant, the claim and disable share through the
binding rows. RevokeOAuthGrant now ends a bound grant through that same
path (revokeAppGrantOnce, split out of RevokeUserAppGrant, retried on
deadlock). The round-1 check in the barrier and the install-row lock in
RevokeOAuthGrant are gone. oauth_revoked_grants covers only grants with
no binding, under the subject's users row.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): a tokenless grant's revoke still locks the person (BUG-3678, codex r3)

Round 3: RevokeOAuthGrant took the subject from token rows only, so a
grant whose consent was recorded but whose first tokens were not yet
committed had no person to lock. The revoke could then record the grant
and flip nothing while a first issuance that had already passed its
check committed an active token. The lookup now falls back to the
consent's oauth_connections row. A Postgres test holds the person's row
the way issuance does and requires the revoke to wait on it.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): the claim also ends grants with no token yet (BUG-3678, codex r4)

Round 4: the claim recorded revoked grants from token rows only. A grant
consented to before the claim but whose code was not yet exchanged had
none, so an exchange that validated before the claim minted an active
token after it. The claim now also records the request ids of the
account's oauth_connections rows, read before it deletes them.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(oauth): the revoked-grants table exists on Postgres too (BUG-3678)

Migration 143 added oauth_revoked_grants to SQLite only; pgmigrations is a
separate tree, so every Postgres read and write of it failed with 42P01
(CI Go (PostgreSQL) on #2121). 113 is its twin.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 22:34:03 -04:00
xarmian 7b95dae4f5 fix(oauth): another client's replay of a spent refresh token or code revokes nothing (BUG-3400) (#1807)
* fix(oauth): another client's replay of a spent refresh token or code revokes nothing (BUG-3400)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(oauth): pin the confidential-client case and document BUG-3400's residuals

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 13:22:23 -04:00
xarmian 7396ec42bb feat(oauth): delegated sign-in for installed apps through the issuance barrier, shipped closed (TASK-3399, SPEC-6 U5b-1) (#1779)
* feat(oauth): delegated install grants through the issuance barrier: code, PKCE, access and refresh for a live human member, the consented access bound and capped by the manifest (TASK-3399, U5b-1)

* feat(oauth): delegated app sign-in: a fixed-workspace consent page, code+PKCE for install clients, the person's access choice in the grant, and an ownership check before fosite on code and refresh (TASK-3399, U5b-1)

* feat(oauth): app grants are not MCP connections: the readers skip install chains, the console lists and revokes them through the binding, the sweep clears orphan bindings, and revoking a person reaches their delegated codes (TASK-3399, U5b-1)

* test(server): the console lists an app grant on its own, refuses every connection mutation on it, and revokes it through the binding (TASK-3399, U5b-1)

* test(server): the console revoke ends an app grant through its binding (TASK-3399, U5b-1)

* feat(web): Connected apps lists installed apps acting as you, read-only plus revoke (TASK-3399, U5b-1)

* test(oauth): pin the binding's person and a resource-less sign-in; name the fosite layers behind the consent gate's PKCE and audience checks (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r1: a delegated binding remembers the person's credential epoch and membership and is tombstoned on revoke; issuance share-locks the person on Postgres; app sign-in and grant management work with MCP off (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r2: only a delegated grant's code writes its binding; member removal, disable and claim end a person's delegated grants through their installs; users before installs before tokens in every path (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r3: delegated revocation takes no install lock (the person's row orders it against issuance) and writes child rows in teardown's order, closing two deadlocks with owner-account deletion (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r4: install teardown and a person's delegated revocation lock their bindings in request_id order before any child row (TASK-3399, U5b-1)

* feat(store): bounded retry on a Postgres deadlock for delegated-grant revocation, member removal, account disable and claim, and the OAuth sweep, with a per-site metric (TASK-3399, U5b-1, lead ruling (a))

* fix(oauth): U5b-1 codex r6: the console does not list an app grant the install's epoch has moved past (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r7: a consent carries the person's credential epoch and the barrier refuses it after a disable/re-enable; the console lists a grant not yet exchanged (TASK-3399, U5b-1)

* feat(oauth): delegated app sign-in stays closed (access_denied, not yet available) behind one named guard until U5b-2 opens it with the app API (TASK-3399, U5b-1, lead ruling)

* fix(oauth): U5b-1 codex r8: a consent carries the install epoch and membership its gate read, so a disable/re-enable or removal/re-add after the gate refuses the code (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r9: a delegated grant needs a live workspace, at the consent gate and the barrier (TASK-3399, U5b-1)

* docs(oauth): name the accepted one-second residual of the consent's membership check (TASK-3399, U5b-1)

* test: census the delegated consent's form readers, the app-grant revoke's user agent, and four binding columns that carry no caller text (TASK-3399, U5b-1)
2026-10-04 19:22:21 -04:00
xarmian e5262160e1 feat(oauth): install clients, service tokens and the issuance barrier for installed apps (TASK-3394, SPEC-6 U5a) (#1769)
* feat(store): install clients, the issuance barrier and app_token_bindings (TASK-3394)

* feat(oauth): install-client service tokens, in-process app-token introspection, public introspection hardening, A0 token gate (TASK-3394)

* test(store): the issuance barrier serializes with disable, rotate and re-enable on Postgres (TASK-3394)

* test(server): pin each install-token refusal by its own hint; bearer introspection of another token (TASK-3394)

* fix(oauth): a grant carries the epoch read before client auth; the install-caller check reads credentials as fosite does (TASK-3394, codex r1)

* fix(oauth): classify the token request from the body fosite parses, multipart included (TASK-3394, codex r2)

* test(server): account for the install-client token rules' form readers in the body-reader census (TASK-3394)

* chore(store): renumber the install-client migrations to 117 / pg 091 after #1768 (TASK-3394)
2026-10-04 05:58:33 -04:00
xarmian 66b4a9d477 fix(auth): disabling an account ends every credential, and a disabled account gets no new one (BUG-3349) (#1741)
(b) Admin disable deleted sessions only, so the account's PATs and MCP
grants kept working. DisableUserAndRevokeAccess does all of it in one
transaction:
- disables the account;
- deletes its sessions and API tokens;
- deactivates every OAuth access and refresh token (by subject), and every
  unexchanged authorization code (through its connection's request id);
- deletes its PKCE and OAuth connection rows.
EnableUser restores none of them.

(c) A disabled account acts nowhere and gets no credential:
- requireActiveUserTx is the one gate every credential insert passes. It
  reads the user row in the insert's own transaction, FOR SHARE on
  Postgres, and refuses a disabled or missing account with ErrUserDisabled.
  It covers sessions, API tokens, OAuth codes, access and refresh tokens,
  and OAuth connections. FOR SHARE conflicts with the disable's row lock,
  so a mint admitted before a disable cannot land after it.
  TestBUG3349_MintWaitsForAConcurrentDisable_Postgres proves the wait with
  two connections.
  TestEveryCredentialInsertRequiresAnActiveUser fails on any new credential
  insert that skips the gate.
- RequireAuth checks disabled status before the /api/v1/auth/* exemption.
  Only POST /auth/logout and GET /auth/session stay open, and the session
  check answers account_disabled.
- The MCP door, /oauth/token, /oauth/authorize and /authorize/decide, 2FA
  login-verify, and email verification (before its token is spent) refuse
  a disabled account. The login page shows "Your account has been
  disabled".
- Share links the account created are suspended (lead's ruling):
  byte-identical to an unknown token while it is disabled, serving again
  once it is re-enabled.

Webhooks are unchanged by design; they belong to the workspace. This is
documented in docs/deployment.md, "Disabling an account".

Part (a) shipped in BUG-3336. codex review: two rounds, eleven findings, all
fixed.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 18:34:56 -04:00
xarmian 5c53aae306 feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a) (#1715)
* feat(oauth): a second canonical resource, with every token bound to one (TASK-3321 U2a)

Ruling (i) on TASK-3321: the ChatGPT catalog is served at its own MCP
URL, which is a canonical OAuth resource of its own, and its tokens are
not interchangeable with /mcp's.

- config derives ChatGPTResourceURL from the /mcp resource: its MCP path
  plus /chatgpt, so https://mcp.getpad.dev becomes
  https://mcp.getpad.dev/mcp/chatgpt.
- The authorization server issues tokens for AllowedAudience plus
  AdditionalAudiences. A request must name exactly ONE of them, and every
  requested audience must be the same resource. A request for both, or for
  a foreign one, is refused before a code is issued, and the client's
  hydrated audience must include the one requested. A request naming no
  resource is still bound to /mcp's, the documented default.
- MCPBearerAuth checks the MOUNT's canonical. A route stamps it with
  WithMCPResource (never from the request); an unstamped route is /mcp's.
  A /mcp token is refused at the ChatGPT mount and the reverse.

Nothing is mounted yet (U2b). The cross-refusal test covers both
directions and the no-resource token, and goes red when the middleware
ignores the mount or the strategy allows two resources.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* test(oauth): a refreshed token keeps its one resource (TASK-3321 U2a review)

Codex confirmed fosite's refresh flow restores the original granted
audience; this pins it end to end: refreshing a ChatGPT token (with no
resource, or asking for /mcp's) yields a token refused at /mcp and accepted
at its own mount, and the test fails if no refresh succeeded at all.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 02:06:09 -04:00
xarmian b510d67696 fix(oauth): sweep expired token, code and PKCE rows (BUG-3301) (#1671)
* fix(oauth): sweep expired token, code and PKCE rows (BUG-3301)

Nothing deleted OAuth rows by age. Each refresh leaves the chain's
previous access and refresh rows inactive and inserts a fresh pair,
measured at +1 access and +1 refresh row per refresh (48 rows a day for
one active client at the 1 h access lifetime), forever, on Pad Cloud and
on any https self-host that turns MCP on.

The token reaper now also runs SweepExpiredOAuthRows every hour. A row
goes once its requested_at (its own issuance, measured) is older than
its token's lifespan plus 24 h:
- refresh and authorization codes: the refresh lifespan. A replayed
  rotated token or a reused code keeps revoking the family for as long
  as the token could have been valid, because fosite checks "inactive"
  before "expired".
- access: the access lifespan. Nothing reads an expired access row.
- PKCE: the code lifespan. Only abandoned authorizations leave one.
The lifespans come from the running OAuth server (defaults when OAuth is
not built). No row a live chain or a concurrent exchange could use is
ever below a cutoff.

Deletes run 500 rows per statement with a 20 ms pause between batches.
Measured on SQLite with 50,000 rows and a concurrent token write every
5 ms: with no pause, one write waited 1.55 s; with the pause, the worst
write took 52 ms. The per-tick cap is 200 batches per table.

Connected Apps and the MCP resume count now drop a chain whose refresh
token has been expired for the grace period, so their "upper bound"
comments say how far. The deployment guide's "never swept" gap is gone.

Tests: store cutoffs, zero cutoffs, and batches with the cap, on SQLite
and Postgres. A server end-to-end test through /oauth/token: the live
chain survives and still refreshes; an aged-out chain leaves Connected
Apps and the resume count; a swept token is refused without revoking the
family; a retained rotated token still revokes it. Mutants: inverted age
comparison (store tests fail), refresh cutoff at now (e2e fails: the
live chain's refresh answers invalid_grant).

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* fix(oauth): code retention adds the code lifespan; Capped means "may remain" (BUG-3301, codex r1)

- A code's requested_at is when it was authorized, but the first refresh
  token it yields starts at the exchange, up to a code lifespan later. The
  code cutoff now adds the code lifespan to the refresh lifespan, so reuse
  detection lasts as long as that refresh token could be valid. The
  comment no longer claims "the chain's lifetime": a chain renews forever.
- Capped says the cap was used, so rows MAY remain; the last full batch
  may have been the end.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* fix(oauth): a non-expiring lifespan skips its tables (BUG-3301, codex r2)

fosite reads a negative lifespan as "never expires"; the cutoff math
turned -1 into "now minus the grace", which would delete a live
non-expiring token after a day. A table whose retention depends on a
negative lifespan now gets a zero cutoff, which the store skips (codes
depend on both the code and refresh lifespans).

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-29 19:29:17 -04:00
xarmian 0a1dbc8c73 perf(test): wire remaining store helpers onto storetest fixture (TASK-1915) (#789)
newPadServer (internal/mcp), testStoreOAuth (internal/oauth), and
newMetricsTestServer (internal/server) were left on the slow
per-call migration path after IDEA-1914/#788 wired testServer and
store's white-box testStore onto storetest.NewSQLite. Switch all
three, and add minimal TestMains to internal/mcp and internal/oauth
to release storetest's process-wide template DB, matching
internal/server's existing TestMain.
2026-07-03 12:17:03 -04:00
xarmian 98c8b78d06 feat(metrics): MCP + OAuth observability metrics for /mcp (TASK-961) (#398)
Plug MCP traffic and OAuth flow events into pad's existing
internal/metrics Prometheus surface, plus a Grafana dashboard.

Metrics (all under pad_*):
- Counters: mcp_tool_calls_total{user_id,tool,status},
  mcp_authz_denials_total{reason}, oauth_flows_total{stage},
  oauth_token_revocations_total{reason}
- Histograms: mcp_tool_call_duration_seconds{tool},
  oauth_flow_duration_seconds{stage}, oauth_token_ttl_seconds
- Gauges: mcp_active_sessions, oauth_active_tokens (callback collector)

Wiring seams: MCPAuditLog (per-call), MCPBearerAuth (audience denials),
emitMCPAuditDenied (rate-limit denials), RequireWorkspaceAccess (gated
to MCP-origin via context — workspace_not_in_allowlist + not_a_member),
OAuth handlers (per-stage flow events + per-handler latency), and
internal/oauth/storage.go via a new SetRevocationObserver hook so the
OAuth package stays metrics-naive.

Cmd/pad wires both observers via Server.wireOAuthMetricsObserver(),
called from both SetMetrics and SetOAuthServer for order-independence.

Store helpers added (with full test coverage):
- CountActiveOAuthAccessTokens — backs the active-tokens gauge
- OldestAccessTokenIssuedAtByRequestID — backs the TTL observation

Grafana dashboard at monitoring/grafana/mcp.json: 13 panels across MCP
traffic + OAuth flow rows (rate-by-tool, p50/p95/p99 latency, status
breakdown, denial reasons, active sessions, top-10 users, OAuth flow
events by stage, OAuth handler p95, active tokens, revocations by
reason, TTL p50/p95).

Codex review caught one HIGH issue (round 1, fixed in same commit):
the active-tokens collector originally emitted NewInvalidMetric on
provider error, which propagates through Registry.Gather() and fails
the entire /metrics scrape via promhttp's default error handler.
Switched to log + skip-the-sample so a transient SQLite blip drops
ONE gauge for one scrape rather than the whole observability surface.
Added TestRegisterOAuthActiveTokensCollector_ErrorIsScrapeSafe to pin
the contract.

Tests cover increments, histogram bucket placement, callback collector
freshness across mutations + error path, observer hook firing on user-
initiated revocation + rotation + nil-safety, and per-helper unit tests
for the server-side metric emission.

Verified with `make check` (golangci-lint + go test ./... + web build).
2026-05-03 16:37:49 -04:00
xarmian 229d47e189 fix(oauth): treat empty-path/root trailing slash as equivalent (RFC 3986 §6.2.3) (#382)
Real OAuth clients reconstruct the resource indicator from the URL
the user pasted. URL parsing canonicalizes empty path → "/", so a
client given "https://mcp.getpad.dev" emits
"resource=https://mcp.getpad.dev/" — with a trailing slash that pad's
canonical "https://mcp.getpad.dev" doesn't have. The strict string
compare in audienceMatchingStrategy (and the matching
audienceContains check on the RS side at /mcp) rejected these as
distinct audiences and the connector flow died on
"Requested audience https://mcp.getpad.dev/ is not the canonical
audience https://mcp.getpad.dev."

Per RFC 3986 §6.2.3 (Scheme-Based Normalization) those forms ARE
equivalent for the HTTP scheme. Adds NormalizeAudience(s) and
applies it on both sides of every audience comparison:

  - internal/oauth/audience.go: audienceMatchingStrategy normalizes
    the canonical, then checks each needle and the haystack against
    it via audienceListContainsNormalized.
  - internal/server/middleware_mcp_auth.go: audienceContains (the
    RS-side gate at /mcp) normalizes both sides too. Mirroring the
    rule keeps AS and RS in lockstep — without it, tokens the AS
    minted for a slashed audience would fail validation at /mcp.

Per Codex review #386 round 1, normalization is restricted to URIs
whose path component is exactly the root ("/"). Earlier draft trimmed
ANY trailing "/", which would have made "https://host/mcp" and
"https://host/mcp/" compare equal — distinct HTTP resources collapsing
to one audience is a real audience-confusion attack surface. The
boundary is enforced via url.Parse: only normalize when u.Host is
non-empty AND u.Path == "/" AND there's no query/fragment. Anything
else returns byte-exact.

TestNormalizeAudience pins both branches (root case trims; non-root
paths, hostless strings, queries, fragments, and unparseable inputs
all stay as-is). TestAudienceStrategy_PathSlashIsNotEquivalent
guards the strategy layer directly: even with normalization active,
"/mcp" and "/mcp/" are kept distinct.
2026-05-02 19:01:56 -04:00
xarmian d01bbf6bf1 feat(oauth): live workspace allow-list + role enforcement (TASK-953) (#377)
Closes the third leg of PLAN-943's OAuth permission model:

  (token capability tier) × (live workspace role) × (consent allow-list)

The first two were already in place — TASK-1027 wired the tier
scope check (pad:read / pad:write / pad:admin via tokenScopeAllows)
and RequireWorkspaceAccess does the live role lookup. This PR adds
the third gate: the workspace-allow-list set at consent time
(TASK-952) actually denies workspaces NOT in the user's selection.

## What's new

- `oauth.Session.AllowedWorkspaces()` / `SetAllowedWorkspaces()` —
  typed accessors on session.Extra. Handle BOTH the in-memory
  []string shape (consent-decide path) AND the JSON-decoded
  []interface{} shape (post-storage round-trip path).
- `WithTokenAllowedWorkspaces` / `TokenAllowedWorkspacesFromContext` —
  context helpers in internal/server with defensive copies so
  callers can't corrupt the per-request token state.
- `MCPBearerAuth` (OAuth path) reads the token's allow-list from
  session.Extra and stashes it in context.
- `RequireWorkspaceAccess` checks the allow-list against the
  resolved workspace's slug. Three behaviours match
  TokenAllowedWorkspacesFromContext's return shapes:
    - nil → no token-level gate (PAT auth, pre-TASK-952 OAuth
      tokens). Standard membership applies.
    - ["*"] → wildcard. Every membership the user has passes.
    - [slug-a, slug-b, ...] → only listed slugs. Anything else
      gets 403 permission_denied BEFORE the membership check.

## Live role + revocation

Membership revocation takes effect immediately. RequireWorkspaceAccess
calls GetWorkspaceMember on every request — if the user lost
membership in workspace X, the token's allow-list including X no
longer helps; the request is rejected at the standard membership
gate. Tested explicitly via TestWorkspaceAllowList_LiveMembershipRevocation.

## Tier × role

The natural intersection of tokenScopeAllows (tier-based HTTP-method
gate) and per-handler role checks (e.g. requireEditPermission) handles
the tier × role table from the PLAN-943 spec:

  - pad:write tier passes tokenScopeAllows for POST.
  - But Viewer role fails requireEditPermission's role check.
  - Net: 403 — tested explicitly via
    TestWorkspaceAllowList_TierTimesRole_WriteByViewer.

## Tests

Unit (no I/O):
- TestTokenAllowedWorkspaceMatches — policy table for the helper.
- TestWithTokenAllowedWorkspaces_DefensiveCopy + 1 reader counterpart.
- TestSession_AllowedWorkspaces_*: setter/getter, nil-clear, defensive
  copy, JSON round-trip ([]string + []interface{} branches),
  wildcard JSON round-trip, not-set, nil-session.

Integration (full chain, real OAuth flow):
- TestWorkspaceAllowList_AllowsListedSlug — listed workspace passes.
- TestWorkspaceAllowList_DeniesUnlistedSlug — unlisted gets 403
  even though user is owner.
- TestWorkspaceAllowList_WildcardAllowsAnyMembership — wildcard
  passes for every membership.
- TestWorkspaceAllowList_LiveMembershipRevocation — token works,
  then membership revoked, then same token denied.
- TestWorkspaceAllowList_PATPathUnaffected — PAT regression: PATs
  don't carry an allow-list, must NOT hit the gate.
- TestWorkspaceAllowList_TierTimesRole_WriteByViewer — pad:write
  tier × Viewer role on POST item → 403.
2026-05-02 14:29:17 -04:00
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
xarmian f6eeee4f81 feat(oauth): fosite-backed authorization-server constructor (TASK-1024, sub-PR B of TASK-951) (#371)
* feat(oauth): fosite-backed authorization-server constructor (TASK-1024, sub-PR B of TASK-951)

Second of 5 sub-PRs landing the OAuth 2.1 authorization server in
PLAN-943. Wires fosite v0.49.0 over the storage layer from sub-PR A.
No HTTP routes yet — sub-PR C mounts /authorize, /token, /register;
sub-PR D mounts /revoke and /introspect.

What lands:
- internal/oauth/session.go — pad's *Session embedding fosite.DefaultSession
  with typed UserID() accessor + Clone override returning *Session
  (so handler-side type-assertions don't lose the concrete type
  during refresh-token rotation).
- internal/oauth/storage.go — Storage adapter satisfying:
    fosite.ClientManager
    handler/oauth2.AuthorizeCodeStorage
    handler/oauth2.AccessTokenStorage
    handler/oauth2.RefreshTokenStorage
    handler/oauth2.TokenRevocationStorage
    handler/pkce.PKCERequestStorage
  Compile-time guards in server.go assert each interface remains
  satisfied. Translation: fosite.Requester ⇄ models.OAuthRequest
  via JSON-encoded session_data + URL-encoded form. Sentinel errors
  from sub-PR A map to fosite.ErrNotFound /
  ErrInvalidatedAuthorizeCode / ErrInactiveToken.
- internal/oauth/audience.go — RFC 8707 custom AudienceMatchingStrategy.
  fosite has no native RFC 8707; we close over a canonical audience
  and reject any request that doesn't carry exactly that resource.
  Belt-and-suspenders haystack check defends against fixtures /
  migrations that register a client without setting Audience.
  Plus ValidateAudienceParam (HTTP-handler entry helper) and
  audienceForNewClient (DCR seed for sub-PR C).
- internal/oauth/server.go — NewServer(Config) → *Server returning
  fosite.OAuth2Provider configured for:
    - PKCE-S256 required (EnforcePKCE + EnablePKCEPlainChallengeMethod=false)
    - Opaque HMAC tokens (compose.NewOAuth2HMACStrategy)
    - Refresh rotation with grant-family revocation (sub-PR A round-2 fix)
    - Audience binding via the custom strategy
  Sensible default lifespans (1h access, 30d refresh, 15m authcode);
  overridable via Config. Excluded by design: client-credentials,
  implicit, ROPC (deprecated in OAuth 2.1), OpenID factories
  (we're not OIDC), PAR (not needed for v1).
- go.mod — github.com/ory/fosite pinned at v0.49.0 (direct dep).

Tests (20):
- NewServer required-field validation (3) + default-lifespan path
- audienceMatchingStrategy: empty needle, mismatch, client-without-canonical,
  canonical-only happy path, multi-audience rejection, no-canonical=ServerError
- ValidateAudienceParam (5 sub-cases) + audienceForNewClient
- Session: clone returns concrete *Session (not *DefaultSession);
  nil-safe accessors
- Storage adapter: auth-code round-trip + invalidated-code error,
  GetClient not-found mapping, access-token-inactive mapping,
  rotation-revokes-entire-grant (end-to-end), PKCE round-trip,
  requester-to-OAuthRequest session encoding + missing-client guard

Out of scope (subsequent sub-PRs):
- HTTP route handlers + DCR endpoint (sub-PR C / TASK-1025)
- /revoke + /introspect endpoints (sub-PR D / TASK-1026)
- MCPBearerAuth OAuth introspection branch (sub-PR E / TASK-1027)

* fix(oauth): inject canonical audience into hydrated clients per Codex review (round 1)

Codex round 1 caught a P1 in the storage adapter: modelClientToFosite
returned fosite.DefaultClient.Audience=nil for every persisted
client, but audienceMatchingStrategy's haystack-side check requires
client.GetAudience() to contain the canonical audience. Net result:
every authorize / token / refresh flow would fail with invalid_request
once the strategy ran, regardless of how the client was registered.

Fix: thread the canonical audience through Storage. NewStorage now
takes a canonicalAudience string; modelClientToFosite (now a method
on Storage) injects [canonicalAudience] into the hydrated client's
Audience field. The audience isn't persisted as a column —
single-resource AS for v1 (PLAN-943) means every client implicitly
allows the same audience, so storing what we'd always set to the
same value is pure write amplification.

Threading:
  cfg.AllowedAudience → NewServer → NewStorage(store, audience)
                                    └─ Storage.canonicalAudience
                                       └─ modelClientToFosite injects

Misconfigured Storage (empty canonicalAudience — caught earlier by
NewServer's required-field check, but tests pin the fail-loud branch
in case Storage is ever constructed directly): produces clients
with Audience=nil so audienceMatchingStrategy rejects every request
with ServerError, surfacing the misconfiguration fast rather than
silently issuing wide-open tokens.

Tests:
- TestStorage_GetClient_InjectsCanonicalAudience — pins the
  injection contract; without the fix this fails.
- TestStorage_NewStorage_EmptyCanonicalLeavesAudienceNil — pins the
  fail-loud branch for misconfigured Storage.
- 6 existing tests updated to pass canonical audience to NewStorage
  (mechanical sed update; behaviour unchanged).

* fix(oauth): hydrate request payload on inactive token Get*Session per Codex review (round 2)

Codex round 2 caught a HIGH-severity gap: GetRefreshTokenSession
returned (nil, fosite.ErrInactiveToken) for revoked rows, but
fosite's handleRefreshTokenReuse (flow_refresh.go:178-204) derefs
req.GetID() to drive the family revocation that's the OAuth 2.1
BCP §4.14 replay-detection rule. Returning nil nil-derefs that
flow and defeats replay detection — the very thing rotation exists
to enable.

Fix: hydrate the stored row even on the inactive path and return
(req, fosite.ErrInactiveToken). Mirrors the pattern already used
by GetAuthorizeCodeSession's invalidated-code branch. If
hydration itself fails (client deleted between issuance and use),
return the underlying error rather than masking it — replay
detection loses but the failure is observable.

Symmetric fix applied to GetAccessTokenSession even though
no fosite caller currently derefs on inactive there. Defense in
depth + uniform contract makes the adapter resilient to future
fosite changes (e.g. an introspector that wants req.GetID() for
audit-log enrichment).

Tests:
- TestStorage_GetRefreshTokenSession_InactiveReturnsPayload —
  pins the refresh-side contract; without the fix this fails
  on the nil-check.
- TestStorage_GetAccessTokenSession_InactiveReturnsPayload —
  same pattern for access tokens.

* fix(oauth): set RefreshTokenScopes=[] so authorize-code grants issue refresh per Codex review (round 3)

Codex round 3 caught a P1: fosite defaults
Config.RefreshTokenScopes to ["offline", "offline_access"]. fosite
only mints refresh tokens when one of the listed scopes is granted.
PLAN-943's scope vocabulary is pad:read / pad:write / pad:admin —
no "offline" scope — so the default silently disabled refresh
issuance for every Pad grant, defeating the entire refresh-rotation +
family-revocation machinery this PR adds.

Fix: explicitly set RefreshTokenScopes: []string{} in NewServer's
fosite.Config. fosite reads the empty slice as "issue refresh on
every authorize-code grant whose client allows the refresh_token
grant type, no scope predicate" — matches fosite's own tests
(flow_authorize_code_token_test.go:129).

Pin: TestNewServer_RefreshTokenScopesIsEmpty documents the decision
+ smoke-checks that the constructor still returns a usable provider.
The actual "refresh issued on authorize-code grant" assertion lands
in sub-PR C's /token endpoint test — that's where fosite's
flow_authorize_code_token.go reads the field.

* fix(oauth): bump go.opentelemetry.io/otel{,/sdk} to v1.40.0 to resolve GO-2026-4394

CI govulncheck job rejected the build: fosite v0.49.0 transitively
pulls in go.opentelemetry.io/otel/sdk@v1.21.0 which has known
vulnerability GO-2026-4394 (Arbitrary Code Execution via PATH
Hijacking in go.opentelemetry.io/otel/sdk). Affected
init-time call sites:
  internal/oauth/audience.go:8 → fosite.init → otel resource.init
  internal/server/middleware_ratelimit.go:80 → sync.Once.Do → resource.Default
  internal/cli/client.go:415,794,798 → otelhttp.* → trace.*

Fix: bump otel core + sdk + metric + trace to v1.40.0 (the fixed
version per GO-2026-4394's advisory). go mod tidy also pulled in
go.opentelemetry.io/auto/sdk@v1.2.1 as a new transitive.

Verified locally:
  govulncheck ./... → "No vulnerabilities found"
  go test ./...     → all green
  go build ./...    → clean
2026-05-02 10:59:07 -04:00