Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/authserver/server/handlers/dcr.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (h *Handler) RegisterClientHandler(w http.ResponseWriter, req *http.Request
// offline_access) — every DCR-registered client gains the ability to request
// these scopes at /oauth/authorize regardless of what they registered with.
if len(h.config.BaselineClientScopes) > 0 {
effective := unionScopes(scopes, h.config.BaselineClientScopes)
effective := registration.UnionScopes(scopes, h.config.BaselineClientScopes)
if !slices.Equal(effective, scopes) {
// Baseline-driven expansion is the intended behavior whenever
// baseline_client_scopes is configured, so per-registration
Expand Down
34 changes: 0 additions & 34 deletions pkg/authserver/server/handlers/scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,3 @@
// SPDX-License-Identifier: Apache-2.0

package handlers

// unionScopes returns the union of requested and baseline scopes, preserving
// the order of requested first, then appending any baseline scopes not already
// present. Duplicates are removed. Returns nil when the result is empty.
//
// This is used by the DCR registration handler to inject an
// operator-configured scope baseline into the registered client's scope set:
// if a client narrows the scope field at /oauth/register, the baseline scopes
// are still part of the client's registered set so that the client can
// request them later at /oauth/authorize without invalid_scope rejection.
//
// Both inputs must already be validated by the caller (e.g. via ValidateScopes
// for client-supplied scopes). unionScopes does not filter empty strings or
// validate scope syntax — it only deduplicates and merges in stable order.
func unionScopes(requested, baseline []string) []string {
seen := make(map[string]bool, len(requested)+len(baseline))
out := make([]string, 0, len(requested)+len(baseline))
for _, s := range requested {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
for _, s := range baseline {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
4 changes: 3 additions & 1 deletion pkg/authserver/server/handlers/scopes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/stacklok/toolhive/pkg/authserver/server/registration"
)

func TestUnionScopes(t *testing.T) {
Expand Down Expand Up @@ -108,7 +110,7 @@ func TestUnionScopes(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := unionScopes(tt.req, tt.baseline)
got := registration.UnionScopes(tt.req, tt.baseline)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F1 (MEDIUM) — TestUnionScopes lives in the wrong package after the move.

This test now calls registration.UnionScopes across the package boundary, but the file itself stayed in package handlers. Meanwhile handlers/scopes.go was reduced to just the SPDX header and package handlers (no contents). This violates .claude/rules/testing.md ("tests must only test code in the package under test") and leaves an orphaned empty source-file pair in the wrong location.

Anyone running go test ./pkg/authserver/server/registration/... after editing UnionScopes will not exercise these table-driven cases.

Fix: move this test (and the file, or fold into registration/dcr_test.go) to pkg/authserver/server/registration/. Delete the now-empty pkg/authserver/server/handlers/scopes.go and this file. Raised by 3 of 4 specialist agents (gocorr, tests, general).

assert.Equal(t, tt.want, got)
})
}
Expand Down
28 changes: 28 additions & 0 deletions pkg/authserver/server/registration/dcr.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,31 @@ func ValidateScopes(requestedScopes, allowedScopes []string) ([]string, *DCRErro

return scopes, nil
}

// UnionScopes returns the union of requested and baseline scopes, preserving
// the order of requested first, then appending any baseline scopes not already
// present. Duplicates are removed. Returns nil when the result is empty.
//
// Both inputs must already be validated by the caller. UnionScopes does not
// filter empty strings or validate scope syntax — it only deduplicates and
// merges in stable order.
func UnionScopes(requested, baseline []string) []string {
seen := make(map[string]bool, len(requested)+len(baseline))
out := make([]string, 0, len(requested)+len(baseline))
for _, s := range requested {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
for _, s := range baseline {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
9 changes: 8 additions & 1 deletion pkg/authserver/server_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,14 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
// so that GetClient calls for HTTPS client_id values are intercepted at the
// fosite level (not just the handler level).
if cfg.CIMDEnabled {
stor, err = storage.NewCIMDStorageDecorator(stor, true, cfg.CIMDCacheMaxSize, cfg.CIMDCacheFallbackTTL)
if len(cfg.BaselineClientScopes) > 0 {
slog.Warn("CIMD is enabled with baseline_client_scopes configured; "+
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F12 (LOW) — slog.Warn level/clarity for CIMD+baseline interaction.

The warning fires every server start when both CIMDEnabled and BaselineClientScopes are set. Two angles, both raised:

  • General/level: per .claude/rules/go-style.md — "WARN for non-fatal issues (deprecations, fallback behavior, cleanup failures)". This combination isn't a fallback or deprecation; it's a legitimate operator config mirroring DCR behavior. Warning on a deliberate config means operators get a persistent WARN line they can't silence without changing behavior.
  • OAuth/clarity: the message is true but doesn't surface the security-relevant consequence — every dynamically-resolved third-party client URL (which the operator has no a-priori knowledge of) will receive the baseline scopes, potentially including offline_access. For an operator who set BaselineClientScopes mainly for internal DCR clients, this is the moment to learn that the same expansion applies to unknown CIMD clients.

Pick one:

  • (a) Tighten the message to name the consequence: "CIMD is enabled with baseline_client_scopes configured; third-party clients resolved via CIMD will receive these scopes — ensure they are scopes you would grant by default to any unknown client". Keep at Warn.
  • (b) Downgrade to slog.Info and document the interaction on the BaselineClientScopes config-field godoc.

The current shape splits the difference.

"all dynamically resolved CIMD clients will receive the baseline scopes",
"baseline_client_scopes", cfg.BaselineClientScopes)
}
stor, err = storage.NewCIMDStorageDecorator(
stor, true, cfg.CIMDCacheMaxSize, cfg.CIMDCacheFallbackTTL,
cfg.ScopesSupported, cfg.BaselineClientScopes)
if err != nil {
return nil, fmt.Errorf("failed to initialize CIMD storage decorator: %w", err)
}
Expand Down
121 changes: 101 additions & 20 deletions pkg/authserver/storage/cimd_decorator.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import (
// Only GetClient is overridden. DCR clients (opaque IDs) continue to work
// exactly as before.
type CIMDStorageDecorator struct {
Storage // embed full interface — all methods delegate
sf singleflight.Group // deduplicates concurrent fetches for the same URL
cache *lru.Cache[string, *cimdCacheEntry]
ttl time.Duration
Storage // embed full interface — all methods delegate
sf singleflight.Group // deduplicates concurrent fetches for the same URL
cache *lru.Cache[string, *cimdCacheEntry]
ttl time.Duration
scopesSupported []string // AS-configured scopes; nil means accept any
baselineClientScopes []string // unioned into every client's scope set, same as DCR
}

type cimdCacheEntry struct {
Expand All @@ -43,11 +45,20 @@ type cimdCacheEntry struct {
// it returns base unchanged (no allocation). cacheMaxSize must be >= 1;
// fallbackTTL is the fixed TTL applied to every cache entry (Cache-Control
// header parsing is not yet implemented; all entries use this value).
// scopesSupported is the AS-configured scope allowlist; documents that declare
// scopes outside this set are rejected at fetch time. In production this is
// always non-nil because applyDefaults populates ScopesSupported before the
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F8 (LOW) — Doc comments document cross-file invariants not enforced in code.

Two related drift hazards:

  1. Here (line 50): "In production this is always non-nil because applyDefaults populates ScopesSupported before the decorator is constructed." This is true today (verified against pkg/authserver/config.go::applyDefaults), but it depends on call ordering in a different file. A future refactor that reorders or adds an alternate construction path could silently break this without breaking any test — and CIMD would quietly fall back to "unconstrained scope passthrough" in production.

  2. buildFositeClient doc (lines 240–241): "when nil, DefaultScopes is used (unconstrained AS)" frames the fallback as a decorator-state decision, but the actual code is if len(scopes) == 0 — also triggered by, e.g., a whitespace-only doc.Scope on a constrained-AS path. The repo's go-style.md "Keep Comments Synchronized With Code" rule applies.

Suggestions:

  • For (1): either // see authserver.applyDefaults cross-reference, or move the invariant assertion to the production caller in server_impl.go (e.g. if cfg.CIMDEnabled && len(cfg.ScopesSupported) == 0 { return nil, errors.New("...") }).
  • For (2): "Fall back to DefaultScopes when no scopes were resolved by fetch()."

// decorator is constructed. Pass nil only in tests that need unconstrained scope
// passthrough.
// baselineClientScopes mirrors the AS-level baseline: it is unioned into every
// CIMD client's scope set after validation, matching DCR handler behaviour.
func NewCIMDStorageDecorator(
base Storage,
enabled bool,
cacheMaxSize int,
fallbackTTL time.Duration,
scopesSupported []string,
baselineClientScopes []string,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F4 (MEDIUM) — NewCIMDStorageDecorator now has 6 positional args with two adjacent same-typed slices.

The constructor signature is (base, enabled, cacheMaxSize, fallbackTTL, scopesSupported, baselineClientScopes). The last two args are both []string and semantically distinct (allowlist vs. union-set) — silently swappable at every call site with no compile-time protection. The go-style rule on "Package API Surface" recommends introducing a config struct or functional options when runtime shape meaningfully differs from input or there are multiple config sources.

A future addition (e.g., Cache-Control TTL parsing, per-document size cap) would push the signature further past readability limits.

Suggested refactor — introduce a config struct:

type CIMDDecoratorConfig struct {
    Enabled              bool
    CacheMaxSize         int
    FallbackTTL          time.Duration
    ScopesSupported      []string
    BaselineClientScopes []string
}

func NewCIMDStorageDecorator(base Storage, cfg CIMDDecoratorConfig) (Storage, error)

The single production caller in server_impl.go and the test helpers update mechanically. The struct also makes the enabled=false → returns base pattern more obviously a configuration concern.

) (Storage, error) {
if !enabled {
return base, nil
Expand All @@ -63,9 +74,11 @@ func NewCIMDStorageDecorator(
}

return &CIMDStorageDecorator{
Storage: base,
cache: c,
ttl: fallbackTTL,
Storage: base,
cache: c,
ttl: fallbackTTL,
scopesSupported: scopesSupported,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F7 (LOW) — Constructor stores caller-supplied scope slices without defensive slices.Clone.

scopesSupported and baselineClientScopes are stored by slice-header copy. In server_impl.go:187 the same cfg.ScopesSupported slice is also passed to the auth-server config constructor, so multiple components hold views over the same backing array. The repo's go-style.md "Copy Before Mutating Caller Input" rule promotes defensive cloning at constructor boundaries — a one-liner that prevents a class of future bugs.

return &CIMDStorageDecorator{
    Storage:              base,
    cache:                c,
    ttl:                  fallbackTTL,
    scopesSupported:      slices.Clone(scopesSupported),
    baselineClientScopes: slices.Clone(baselineClientScopes),
}, nil

If a future config-reload path mutates these slices in place (e.g., appends a new scope), the decorator's view will not silently drift.

baselineClientScopes: baselineClientScopes,
}, nil
}

Expand Down Expand Up @@ -119,17 +132,75 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
}

// Reject documents that declare an auth method this AS does not support.
// The embedded AS only advertises "none"; accepting a doc that says
// "private_key_jwt" and then silently treating the client as public would
// mislead operators and break clients that actually try to use JWT assertions.
// ErrInvalidClient: the document was fetched successfully but its declared
// metadata violates AS policy (distinct from ErrNotFound which means the
// document could not be fetched at all).
if m := doc.TokenEndpointAuthMethod; m != "" && m != defaultCIMDTokenEndpointAuthMethod {
return nil, fmt.Errorf("%w: CIMD document at %s claims token_endpoint_auth_method %q "+
"but this server only supports %q",
fosite.ErrNotFound.WithHint("unsupported token_endpoint_auth_method"),
fosite.ErrInvalidClient.WithHint("unsupported token_endpoint_auth_method"),
id, m, defaultCIMDTokenEndpointAuthMethod)
}

client := buildFositeClient(doc)
// Reject documents that declare grant_types the embedded AS does not support.
// Mirrors DCR's validateGrantTypes which restricts public clients to
// authorization_code + refresh_token and requires authorization_code to be present.
for _, gt := range doc.GrantTypes {
if !allowedCIMDGrantTypes[gt] {
return nil, fmt.Errorf("%w: CIMD document at %s claims grant_type %q "+
"but this server only supports %v for public clients",
fosite.ErrInvalidClient.WithHint("unsupported grant_type"),
id, gt, defaultCIMDGrantTypes)
}
}
if len(doc.GrantTypes) > 0 && !slices.Contains(doc.GrantTypes, "authorization_code") {
return nil, fmt.Errorf("%w: CIMD document at %s grant_types must include %q",
fosite.ErrInvalidClient.WithHint("grant_types must include authorization_code"),
id, "authorization_code")
}

// Reject documents that declare response_types the embedded AS does not support.
for _, rt := range doc.ResponseTypes {
if !allowedCIMDResponseTypes[rt] {
return nil, fmt.Errorf("%w: CIMD document at %s claims response_type %q "+
"but this server only supports %v",
fosite.ErrInvalidClient.WithHint("unsupported response_type"),
id, rt, defaultCIMDResponseTypes)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F9 (LOW) — DCR-parity gaps around response_types and grant-type validation order.

Two related findings around the parity claim:

  1. Response-type defense-in-depth missing. DCR's validateResponseTypes (registration/dcr.go) enforces slices.Contains(responseTypes, "code") in addition to the allowlist walk. The CIMD code (this loop) only walks the allowlist. Today the allowlist contains only "code", so the inputs are functionally equivalent — but a future PR adding "code id_token" to either allowlist would silently accept docs where "code" is missing on the CIMD path while DCR still rejects.

  2. Grant-type validation order reversed. DCR checks slices.Contains(grantTypes, "authorization_code") first, then walks the allowlist. CIMD (lines 148–160 above) walks the allowlist first, then checks authorization_code presence. For input ["client_credentials"], DCR rejects with "grant_types must include 'authorization_code'", CIMD rejects with "unsupported grant_type". Different operator-visible error for the same input — minor friction in a mixed-mode debugging session.

Cleanest fix: extract ValidatePublicGrantTypes and ValidatePublicResponseTypes helpers in the registration package and call them from both DCR's handler and the CIMD decorator. The PR already does this for scope (registration.ValidateScopes); doing the same for grant_types/response_types would close the parity claim completely and eliminate the duplicated allowedCIMDGrantTypes / allowedCIMDResponseTypes maps.


// Compute and validate the client scope list consistent with DCR.
// When ScopesSupported is configured:
// - Declared scopes are validated via registration.ValidateScopes (same
// function as the DCR handler).
// - When the document omits scope, the client receives ScopesSupported
// rather than DefaultScopes — a CIMD document that doesn't declare scope
// means "whatever the AS supports", not "give me the full default set"
// (which may exceed ScopesSupported).
// When ScopesSupported is not configured: no AS-level validation; declared
// scopes are used directly, or nil to let buildFositeClient apply DefaultScopes.
// In both cases BaselineClientScopes is unioned in after validation,
// matching the DCR handler's behaviour.
var resolvedScopes []string
if len(d.scopesSupported) > 0 {
if doc.Scope != "" {
computed, dcrErr := registration.ValidateScopes(strings.Fields(doc.Scope), d.scopesSupported)
if dcrErr != nil {
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
fosite.ErrInvalidClient.WithHint(dcrErr.Error), id, dcrErr.ErrorDescription)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F6 (LOW) — WithHint(dcrErr.Error) passes the OAuth error code where the descriptive hint belongs.

DCRError has two struct fields: Error string (the OAuth error code, e.g. "invalid_client_metadata") and ErrorDescription string (the human description, e.g. "unsupported scope: foo"). There is no Error() method — dcrErr.Error reads the field, not a stringification.

The current code passes the error code as the fosite hint. The descriptive text ends up in the wrapped error chain via %s but not in the fosite hint that ultimately surfaces in WWW-Authenticate / error_description. Operators / clients see "hint":"invalid_client_metadata" instead of "hint":"unsupported scope: foo".

The other rejection branches in this file (lines 141, 152, 158, 167) pass static descriptive hints like "unsupported grant_type". This branch is inconsistent.

Suggested change
fosite.ErrInvalidClient.WithHint(dcrErr.Error), id, dcrErr.ErrorDescription)
return nil, fmt.Errorf("%w: CIMD document at %s: %s",
fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription)

(Or drop the redundant %s at the end since the hint now carries the description.)

}
resolvedScopes = computed
} else {
resolvedScopes = slices.Clone(d.scopesSupported)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F5 (MEDIUM) — Omitted-scope branch silently diverges from DCR when ScopesSupportedDefaultScopes.

When doc.Scope == "" and ScopesSupported is set, this branch gives the CIMD client the full ScopesSupported. The DCR equivalent (registration.ValidateScopes(nil, allowed)) returns registration.DefaultScopes after subset-checking it against allowed.

In the default config these are equal because applyDefaults populates ScopesSupported = DefaultScopes, so the divergence is invisible. But an operator who configures scopes_supported: ["openid", "profile", "email", "offline_access", "groups"] will see CIMD clients with no declared scope silently get groups, while a DCR client in the same position gets only DefaultScopes. The PR description says "both paths enforce the same scope policy" — this branch contradicts that.

In the worst case the extra scopes include elevated permissions (admin:*, custom resource scopes) that the operator added to ScopesSupported for internal-DCR use only, never intending to extend to dynamically-resolved third-party CIMD clients.

Suggested fix — call the same validator DCR uses, which returns DefaultScopes when the input is empty:

Suggested change
resolvedScopes = slices.Clone(d.scopesSupported)
} else {
computed, _ := registration.ValidateScopes(nil, d.scopesSupported)
resolvedScopes = computed
}

(The error return can't trigger here in production because applyDefaults ensures DefaultScopes ⊆ ScopesSupported; if you'd rather be explicit, propagate the error.)

If the current behavior is intentional, remove the "same scope policy as DCR" claim from the PR description and add a note to the doc comment explaining the intentional divergence.

}
} else if doc.Scope != "" {
resolvedScopes = strings.Fields(doc.Scope)
}
if len(d.baselineClientScopes) > 0 {
resolvedScopes = registration.UnionScopes(resolvedScopes, d.baselineClientScopes)
}

client := buildFositeClient(doc, resolvedScopes)

d.cache.Add(id, &cimdCacheEntry{
client: client,
Expand All @@ -144,10 +215,19 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli
// that use the authorization code flow with refresh token rotation.
var defaultCIMDGrantTypes = []string{"authorization_code", "refresh_token"}

// allowedCIMDGrantTypes is the set of grant_type values a CIMD document may
// declare. Values outside this set are rejected at fetch time, consistent with
// DCR which restricts public clients to authorization_code + refresh_token.
var allowedCIMDGrantTypes = map[string]bool{"authorization_code": true, "refresh_token": true}

// defaultCIMDResponseTypes are the OAuth 2.0 response types applied when the
// CIMD document omits response_types.
var defaultCIMDResponseTypes = []string{"code"}

// allowedCIMDResponseTypes is the set of response_type values a CIMD document
// may declare. Values outside this set are rejected at fetch time.
var allowedCIMDResponseTypes = map[string]bool{"code": true}

// defaultCIMDTokenEndpointAuthMethod is the token endpoint authentication
// method applied when the CIMD document omits token_endpoint_auth_method.
// Documents that declare any other value are rejected by fetch() before
Expand All @@ -157,7 +237,9 @@ const defaultCIMDTokenEndpointAuthMethod = "none"
// buildFositeClient converts a ClientMetadataDocument into a fosite.Client.
// Redirect URIs containing http://localhost are wrapped in a LoopbackClient
// so that RFC 8252 §7.3 dynamic port matching applies.
func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client {
// resolvedScopes is the already-validated scope list computed by fetch() via
// registration.ValidateScopes; when nil, DefaultScopes is used (unconstrained AS).
func buildFositeClient(doc *cimd.ClientMetadataDocument, resolvedScopes []string) fosite.Client {
grantTypes := doc.GrantTypes
if len(grantTypes) == 0 {
grantTypes = defaultCIMDGrantTypes
Expand All @@ -173,13 +255,12 @@ func buildFositeClient(doc *cimd.ClientMetadataDocument) fosite.Client {
tokenEndpointAuthMethod = defaultCIMDTokenEndpointAuthMethod
}

// When the document omits the scope field, apply the same defaults as DCR
// registration so CIMD clients can request openid/profile/email/offline_access
// without needing to enumerate them explicitly in the metadata document.
// Clone to avoid aliasing the package-level DefaultScopes slice.
scopes := slices.Clone(registration.DefaultScopes)
if doc.Scope != "" {
scopes = strings.Fields(doc.Scope)
// Scopes were computed and validated by fetch() via registration.ValidateScopes,
// consistent with the DCR handler. Fall back to DefaultScopes only when the
// decorator has no ScopesSupported restriction (unconstrained AS).
scopes := resolvedScopes
if len(scopes) == 0 {
scopes = slices.Clone(registration.DefaultScopes)
}

defaultClient := &fosite.DefaultClient{
Expand Down
Loading
Loading