Files
pad/internal/oauth/session.go
T
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

181 lines
7.0 KiB
Go

// Package oauth contains pad's OAuth 2.1 authorization-server
// integration with github.com/ory/fosite (PLAN-943 TASK-951 sub-PR B).
//
// This package wires fosite's compose pattern over pad's storage layer
// (internal/store/oauth.go from sub-PR A), adds an RFC 8707 audience-
// binding hook, and exposes NewServer returning a fosite.OAuth2Provider
// that sub-PR C's HTTP handlers consume.
//
// fosite version: pinned to v0.49.0 in go.mod. Bump deliberately —
// compose APIs are stable but storage interface signatures have
// changed across major versions.
package oauth
import (
"github.com/ory/fosite"
)
// Session is pad's concrete fosite.Session. We embed fosite.DefaultSession
// (which already implements the interface — SetExpiresAt / GetExpiresAt /
// GetUsername / GetSubject / Clone) and add typed accessors for the few
// pad-specific fields we care about.
//
// Why a wrapper rather than using DefaultSession directly:
//
// - Subject in OAuth context = pad user ID (UUID). DefaultSession.Subject
// is fine for that, but having a typed UserID() method makes call sites
// in sub-PR C's handlers + sub-PR E's MCPBearerAuth introspection branch
// read cleanly: token.UserID() instead of token.GetSession().GetSubject()
// with type assertions.
// - Future-proofs the place where extra OAuth-only fields live (workspace
// allow-list arrives in TASK-953; we'll add a WorkspaceIDs []string
// field here without touching every adapter call site).
// - JSON round-trip is via DefaultSession's existing tags, so the storage
// layer's session_data column doesn't need to know about pad-specific
// fields — they ride along in DefaultSession.Extra.
//
// fosite.Session contract:
//
// fosite calls Session.Clone() between requests; deepcopy on the embedded
// DefaultSession handles that correctly. fosite calls SetExpiresAt /
// GetExpiresAt to track per-token-type lifetimes; same handling.
type Session struct {
*fosite.DefaultSession
}
// NewSession constructs a Session with the given subject (pad user ID).
// Returns a non-nil DefaultSession so the embedded methods don't panic
// on a zero-value receiver.
func NewSession(subject string) *Session {
return &Session{
DefaultSession: &fosite.DefaultSession{
Subject: subject,
Extra: map[string]interface{}{},
},
}
}
// UserID returns the pad user ID this session was issued to. Equivalent
// to GetSubject() under our model where Subject is always the user ID;
// kept as a typed accessor so future fields (workspace allow-list,
// capability tier from TASK-953) can land without sprinkling
// GetSubject() across handler code.
func (s *Session) UserID() string {
if s == nil || s.DefaultSession == nil {
return ""
}
return s.DefaultSession.Subject
}
// allowedWorkspacesExtraKey is the session.Extra map key under which
// the consent UI (TASK-952) stores the workspace allow-list. Defined
// as a package constant so producers (handlers_oauth.go's
// /authorize/decide) and consumers (TASK-953's MCPBearerAuth gate)
// agree on the wire form.
const allowedWorkspacesExtraKey = "allowed_workspaces"
// AllowedWorkspaces returns the workspace allow-list stored in
// session.Extra at consent time (TASK-952). Three return shapes
// matter to callers:
//
// - nil — no allow-list set. Either a non-OAuth session (PAT auth
// never goes through this code path; a workspace-pinned PAT gets its
// one-slug list from the server, BUG-3982) or a pre-TASK-952 token
// issued before the consent UI shipped. Callers should treat
// this as "no token-level workspace constraint" and rely on the
// standard membership gate.
// - []string{"*"} — wildcard. The user explicitly granted access
// to any workspace they currently or later have access to;
// standard membership still applies, no extra restriction.
// - []string{"slug-a", "slug-b", ...} — explicit allow-list. Each
// workspace request must hit a slug in this set OR be denied
// before the membership check runs.
//
// JSON round-trip handling: fosite serializes session.Extra via
// json.Marshal and deserializes back through json.Unmarshal into a
// map[string]interface{}. After a round-trip the value is
// []interface{} (Go's untyped JSON array shape), not []string.
// We accept both so the helper works whether the session was just
// created in memory (handlers_oauth.go's decide flow) or hydrated
// from storage (sub-PR D's introspection path).
func (s *Session) AllowedWorkspaces() []string {
if s == nil || s.DefaultSession == nil || s.DefaultSession.Extra == nil {
return nil
}
raw, ok := s.DefaultSession.Extra[allowedWorkspacesExtraKey]
if !ok {
return nil
}
switch v := raw.(type) {
case []string:
out := make([]string, len(v))
copy(out, v)
return out
case []interface{}:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
// Distinguish "explicit empty list" from "no key set" — the
// former is unusual but if a future change persists []string{}
// for some reason we don't want a phantom nil meaning "no
// constraint." Return non-nil empty so callers see "list is
// set, but contains nothing"; current MCPBearerAuth would
// reject the request as no workspace can match.
if out == nil {
out = []string{}
}
return out
}
return nil
}
// SetAllowedWorkspaces is the symmetric writer used by
// /oauth/authorize/decide (TASK-952). Centralizing the key string
// here keeps producers + consumers in sync; nil clears the entry.
func (s *Session) SetAllowedWorkspaces(workspaces []string) {
if s == nil || s.DefaultSession == nil {
return
}
if s.DefaultSession.Extra == nil {
s.DefaultSession.Extra = map[string]interface{}{}
}
if workspaces == nil {
delete(s.DefaultSession.Extra, allowedWorkspacesExtraKey)
return
}
// Defensive copy — caller mutating the slice after the call
// shouldn't bleed through into the persisted session.
cp := make([]string, len(workspaces))
copy(cp, workspaces)
s.DefaultSession.Extra[allowedWorkspacesExtraKey] = cp
}
// Clone overrides DefaultSession.Clone so the returned value is a
// *Session, not a *DefaultSession. Without this override, fosite's
// internal Clone() calls during refresh-token rotation would lose the
// concrete Session type and subsequent type-assertions in handler code
// would fail.
//
// Implementation: deep-copy the embedded DefaultSession (its Clone
// already does the deep copy via mohae/deepcopy) and re-wrap.
func (s *Session) Clone() fosite.Session {
if s == nil {
return nil
}
if s.DefaultSession == nil {
return &Session{}
}
cloned, ok := s.DefaultSession.Clone().(*fosite.DefaultSession)
if !ok {
// Unreachable in practice — DefaultSession.Clone always
// returns *DefaultSession. Guard the assertion so a future
// fosite version-bump that changes the return type produces
// a clean nil rather than a panic.
return &Session{DefaultSession: &fosite.DefaultSession{}}
}
return &Session{DefaultSession: cloned}
}