mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
5035168e5c
* 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
181 lines
7.0 KiB
Go
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}
|
|
}
|