Skip to content
Closed
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
4 changes: 3 additions & 1 deletion doc/02_Installation_and_Configuration/06_OAuth_Server.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,9 @@ for the full rules.

`scopes_supported` does two jobs. It caps what a token for that resource may carry, so a client asking for
more is narrowed to the intersection and one asking **only** for scopes the resource does not declare is
refused with `invalid_scope`. And it is **how a scope comes to exist at all**: the server's catalogue, which
refused with `invalid_scope`. A client naming no scope at all is given every scope the resource declares, so
the consent screen always shows what the token will carry. And it is **how a scope comes to exist at all**:
the server's catalogue, which
the authorization endpoint accepts, dynamic clients may register and the metadata advertises, is the union of
the `scopes_supported` of every registered resource. There is nothing else to declare, and nothing that can
disagree with it.
Expand Down
8 changes: 6 additions & 2 deletions doc/04_Development_Details/07_OAuth_Protected_Applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,8 @@ document 404s, its scopes vanish from the catalogue, and a client requesting its
declared. If your resource is missing, check the tag first.

The scopes are not decoration. They cap what a token for this resource may carry, a client asking for more is
narrowed to them before consent is shown, and they are how a scope comes to exist at all: the server's
catalogue is the union of what every resource supports.
narrowed to them before consent is shown, a client asking for none is given all of them, and they are how a
scope comes to exist at all: the server's catalogue is the union of what every resource supports.

Providers are read lazily and only once. Symfony's tagged iterator does not instantiate anything until the
registry is first read, and the registry memoises what it resolved, so a request touching neither OAuth nor
Expand Down Expand Up @@ -377,6 +377,10 @@ on the token and reported back, but nothing compares a granted scope against an
is therefore an upper bound the server maintains, not a check anyone performs: treat a scope as a label shown
at consent time, not a guarantee, and enforce it yourself if your operations differ in privilege.

Check for your scope even when every operation has the same privilege. The authorization request always leads to
a consent screen listing your scopes, but a client refreshing a token may ask for fewer scopes than were granted,
including none, and no screen is shown then. Requiring your scope keeps such a token from reaching your resource.
Comment on lines +380 to +382

## Related

- [OAuth 2.1 Authorization Server](../02_Installation_and_Configuration/06_OAuth_Server.md) - enabling and
Expand Down
36 changes: 32 additions & 4 deletions src/OAuth/Server/Grant/LoopbackAuthCodeGrant.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,20 @@ private function narrowToResource(AuthorizationRequestInterface $request, string
$supported = $this->resourceRegistry->get($resource)->scopesSupported ?? [];
$requested = $request->getScopes();

// A resource that declares no scopes constrains nothing, and a request that names
// none has nothing to narrow. Neither is an error: both are reachable today, and
// refusing them would turn working clients away over a token nobody checks.
if ($supported === [] || $requested === []) {
// A resource that declares no scopes constrains nothing.
if ($supported === []) {
return $requested;
}

// RFC 6749 section 3.3: a request that omits `scope` is processed with a default
// value or refused. Refusing would turn away clients that work today, so the default
// is everything the resource declares. Passing the empty set through instead issued a
// token with no scopes at all, whose consent screen told the user that nothing was
// requested while the token still reached the resource.
if ($requested === []) {
return $this->scopesOf($supported);
}

$narrowed = array_values(
array_filter(
$requested,
Expand Down Expand Up @@ -163,6 +170,27 @@ private function narrowToResource(AuthorizationRequestInterface $request, string
return $narrowed;
}

/**
* The catalogue is derived from the same resources, so every declared scope resolves;
* the null check only keeps the return type honest.
*
* @param list<string> $identifiers
*
* @return ScopeEntityInterface[]
*/
private function scopesOf(array $identifiers): array
{
$scopes = [];
foreach ($identifiers as $identifier) {
$scope = $this->scopeRepository->getScopeEntityByIdentifier($identifier);
if ($scope !== null) {
$scopes[] = $scope;
}
}

return $scopes;
}

/**
* The redirect league itself would have used for an `invalid_scope`, so a refusal
* raised here reaches the client the same way rather than as a bare response.
Expand Down
26 changes: 22 additions & 4 deletions tests/Unit/OAuth/Server/Grant/LoopbackAuthCodeGrantTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -409,12 +409,30 @@ public function testScopesAreNarrowedToWhatTheResourceSupports(): void

/**
* The production configuration: no default scope is ever set, so a client that names
* none arrives with an empty list. Narrowing must leave it alone rather than read it
* as "asked for nothing this resource supports" and refuse a working client.
* none arrives with an empty list. It is still accepted, but given everything the
* resource declares: passing the empty set through issued a token whose consent screen
* said nothing was requested while the token still reached the resource.
*/
public function testARequestNamingNoScopeIsAccepted(): void
public function testARequestNamingNoScopeIsGivenEveryScopeTheResourceDeclares(): void
{
$authRequest = $this->grant(defaultScope: '', supportedScopes: ['mcp:read'])
$authRequest = $this->grant(defaultScope: '', supportedScopes: ['mcp:read', 'mcp:write'])
->validateAuthorizationRequest($this->authorizeRequest([
'code_challenge' => self::CODE_CHALLENGE,
'code_challenge_method' => 'S256',
'resource' => self::KNOWN_RESOURCE,
'scope' => null,
]));

$this->assertSame(['mcp:read', 'mcp:write'], $this->scopeIdentifiers($authRequest));
}

/**
* Without declared scopes there is no default to apply, so the request keeps its empty
* list rather than being refused.
*/
public function testARequestNamingNoScopeForAResourceDeclaringNoneStaysEmpty(): void
{
$authRequest = $this->grant(defaultScope: '', supportedScopes: [])
->validateAuthorizationRequest($this->authorizeRequest([
'code_challenge' => self::CODE_CHALLENGE,
'code_challenge_method' => 'S256',
Expand Down
25 changes: 21 additions & 4 deletions tests/Unit/OAuth/Server/ResourceBindingLifecycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ public function testTheAudienceSurvivesIssuanceAndRefresh(): void
$this->assertSame([self::RESOURCE], $this->claims($refreshed['access_token'])->claims()->get('aud'));
}

/**
* A client that omits `scope` gets the resource's declared scopes on the token, not an
* empty set. Driven end to end because the scopes travel from the authorization request
* through the encrypted code to the JWT claim, and the claim is what an application reads.
*/
public function testARequestWithoutScopeIsIssuedTheScopesTheResourceDeclares(): void
{
$issued = $this->exchange($this->authorize(null));

$this->assertSame(self::SCOPE, $this->claims($issued['access_token'])->claims()->get('scope'));
$this->assertSame(self::SCOPE, $issued['scope'] ?? null);
}

/**
* league validates a refresh token from its own encrypted payload and reads an unknown
* identifier as "not revoked", so nothing but this refuses a token whose record is gone.
Expand Down Expand Up @@ -181,17 +194,21 @@ private function expectRefusal(callable $call): void
/**
* Runs the authorization leg and returns the authorization code it redirects with.
*/
private function authorize(): string
private function authorize(?string $scope = self::SCOPE): string
{
$request = (new ServerRequest('GET', self::ISSUER . '/pimcore-oauth/authorize'))->withQueryParams([
$query = [
'client_id' => self::CLIENT_ID,
'response_type' => 'code',
'redirect_uri' => self::REDIRECT_URI,
'scope' => self::SCOPE,
'resource' => self::RESOURCE,
'code_challenge' => $this->codeChallenge(),
'code_challenge_method' => 'S256',
]);
];
if ($scope !== null) {
$query['scope'] = $scope;
}

$request = (new ServerRequest('GET', self::ISSUER . '/pimcore-oauth/authorize'))->withQueryParams($query);

$authorizationRequest = $this->server->validateAuthorizationRequest($request);
$authorizationRequest->setUser(new UserEntity('21'));
Expand Down
Loading