From 2ba8464d4e1cf115b2f187dcf83294fabb00c894 Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Thu, 25 Jun 2026 10:21:47 +0200 Subject: [PATCH] Fix advertised scopes for oauth protected resources Signed-off-by: Ludovic Vielle --- contrib/claude/authorization.md | 2 +- e2e/console/oauth2_test.go | 3 ++- pkg/iam/oauth2/protected_resource_metadata.go | 4 ++-- pkg/iam/oauth2/protected_resource_metadata_test.go | 6 ++++-- pkg/iam/oauth2/scopes.go | 4 ++-- pkg/iam/service.go | 2 +- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/contrib/claude/authorization.md b/contrib/claude/authorization.md index 68ef71709..b48a41184 100644 --- a/contrib/claude/authorization.md +++ b/contrib/claude/authorization.md @@ -289,7 +289,7 @@ Scopes are namespace- or product-level only — no resource segments (e.g. `v1:p **Discovery:** - Authorization server (RFC 8414): `scopes_supported` on `/.well-known/oauth-authorization-server` lists OIDC + all API scopes; `protected_resources` links to the resource metadata document -- Protected resource (RFC 9728): `scopes_supported` on `/.well-known/oauth-protected-resource` lists `openid` plus API scopes +- Protected resource (RFC 9728): `scopes_supported` on `/.well-known/oauth-protected-resource` lists `openid` plus write API scopes only (no `:read` suffix); matches CIMD client registration **Enforcement:** OAuth2 bearer-token requests carry the validated access token on the request context (`pkg/iam/oauth2/request_context.go`). Before IAM policy evaluation, `iam.Authorizer` checks registered `oauth2scope.Registry` mappings via `Registry.Allows`. Each domain package exports `OAuth2ScopeMappings` in its `oauth2_scopes.go`; `probod` registers all domain mappings on the shared registry before `iam.NewService`. The check uses explicit scope→action lists — no `:read` / `:get` heuristics at enforcement time. Session, personal API key, and SCIM auth skip the check (no access token on context). Unmapped IAM actions **deny** OAuth requests (fail closed). Enforcement reads scopes from the access token directly. diff --git a/e2e/console/oauth2_test.go b/e2e/console/oauth2_test.go index ef14e5c69..de0ab3167 100644 --- a/e2e/console/oauth2_test.go +++ b/e2e/console/oauth2_test.go @@ -114,7 +114,8 @@ func TestOAuth2_ProtectedResourceMetadata(t *testing.T) { assert.Contains(t, metadata.AuthorizationServers, expectedResource) assert.Contains(t, metadata.BearerMethodsSupported, "header") assert.Contains(t, metadata.ScopesSupported, "openid") - assert.Contains(t, metadata.ScopesSupported, "v1:document:read") + assert.Contains(t, metadata.ScopesSupported, "v1:document") + assert.NotContains(t, metadata.ScopesSupported, "v1:document:read") assert.NotContains(t, metadata.ScopesSupported, "profile") } diff --git a/pkg/iam/oauth2/protected_resource_metadata.go b/pkg/iam/oauth2/protected_resource_metadata.go index 5d9ffe507..a4b8a6b63 100644 --- a/pkg/iam/oauth2/protected_resource_metadata.go +++ b/pkg/iam/oauth2/protected_resource_metadata.go @@ -31,7 +31,7 @@ type ProtectedResourceMetadata struct { func NewProtectedResourceMetadata( resource uri.URI, authorizationServer uri.URI, - registeredScopes []coredata.OAuth2Scope, + writeScopes []coredata.OAuth2Scope, ) *ProtectedResourceMetadata { return &ProtectedResourceMetadata{ Resource: resource, @@ -39,6 +39,6 @@ func NewProtectedResourceMetadata( BearerMethodsSupported: []string{ "header", }, - ScopesSupported: protectedResourceScopes(registeredScopes), + ScopesSupported: protectedResourceScopes(writeScopes), } } diff --git a/pkg/iam/oauth2/protected_resource_metadata_test.go b/pkg/iam/oauth2/protected_resource_metadata_test.go index 2d7c85d66..036703d9f 100644 --- a/pkg/iam/oauth2/protected_resource_metadata_test.go +++ b/pkg/iam/oauth2/protected_resource_metadata_test.go @@ -32,19 +32,21 @@ func TestNewProtectedResourceMetadata(t *testing.T) { reg := oauth2scope.NewRegistry().Register( map[coredata.OAuth2Scope][]string{ probo.ScopeV1DocumentRead: {"core:document:get"}, + probo.ScopeV1Document: {"core:document:create"}, }, ) resource := uri.URI("https://app.example.com") authorizationServer := uri.URI("https://app.example.com") - metadata := oauth2.NewProtectedResourceMetadata(resource, authorizationServer, reg.RegisteredScopes()) + metadata := oauth2.NewProtectedResourceMetadata(resource, authorizationServer, reg.AllWriteScopes()) require.NotNil(t, metadata) assert.Equal(t, resource, metadata.Resource) assert.Equal(t, []uri.URI{authorizationServer}, metadata.AuthorizationServers) assert.Equal(t, []string{"header"}, metadata.BearerMethodsSupported) assert.Contains(t, metadata.ScopesSupported, oauth2.ScopeOpenID) - assert.Contains(t, metadata.ScopesSupported, probo.ScopeV1DocumentRead) + assert.Contains(t, metadata.ScopesSupported, probo.ScopeV1Document) + assert.NotContains(t, metadata.ScopesSupported, probo.ScopeV1DocumentRead) assert.NotContains(t, metadata.ScopesSupported, oauth2.ScopeProfile) } diff --git a/pkg/iam/oauth2/scopes.go b/pkg/iam/oauth2/scopes.go index 1d24190df..4eaa26f69 100644 --- a/pkg/iam/oauth2/scopes.go +++ b/pkg/iam/oauth2/scopes.go @@ -32,9 +32,9 @@ func authorizationServerScopes(registeredScopes []coredata.OAuth2Scope) []coreda ) } -func protectedResourceScopes(registeredScopes []coredata.OAuth2Scope) []coredata.OAuth2Scope { +func protectedResourceScopes(writeScopes []coredata.OAuth2Scope) []coredata.OAuth2Scope { return slices.Concat( []coredata.OAuth2Scope{ScopeOpenID}, - registeredScopes, + writeScopes, ) } diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 81f9b6785..ccfb748fd 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -233,7 +233,7 @@ func (s *Service) OAuth2ServerMetadata(endpoints oauth2.Endpoints) *oauth2.Serve // OAuth2ProtectedResourceMetadata returns the RFC 9728 protected resource metadata document. func (s *Service) OAuth2ProtectedResourceMetadata(resource uri.URI) *oauth2.ProtectedResourceMetadata { - return oauth2.NewProtectedResourceMetadata(resource, resource, s.OAuth2ScopeRegistry.RegisteredScopes()) + return oauth2.NewProtectedResourceMetadata(resource, resource, s.OAuth2ScopeRegistry.AllWriteScopes()) } func (s *Service) IsSignUpEnabled() bool {