From a430f2e5fef58dfd1105420a19796b7581768026 Mon Sep 17 00:00:00 2001 From: Elisha Date: Tue, 16 Jun 2026 21:54:55 +0200 Subject: [PATCH] Register custom OIDC scopes order-independently Snapshotting the scope registry into Passport::tokensCan() ran during the package boot, so a host app could only add custom scopes by racing provider boot order. Defer the snapshot to app booted() and add Identity::registerScope() so scopes registered from any provider reach Passport, discovery, and claim filtering regardless of order. --- README.md | 22 +++++++++- src/Identity.php | 25 +++++++++++ src/LaravelIdentityServiceProvider.php | 26 ++++++++++- tests/Feature/CustomScopeRegistrationTest.php | 44 +++++++++++++++++++ tests/Fixtures/LateScopeServiceProvider.php | 22 ++++++++++ 5 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/CustomScopeRegistrationTest.php create mode 100644 tests/Fixtures/LateScopeServiceProvider.php diff --git a/README.md b/README.md index 24d1f26..822e20f 100644 --- a/README.md +++ b/README.md @@ -320,10 +320,28 @@ models, all client/user lookups go through `Passport::clientModel()` and the con | `SessionIdResolver` | `SidManager` | `sid` issuance and revocation | | `DiscoveryDocumentBuilder` | `DefaultDiscoveryBuilder` | The discovery document | +To add a custom scope and the claims it grants, call `Identity::registerScope()` from +any service provider's `register()` or `boot()` — order relative to this package does +not matter, and the scope reaches Passport, discovery, and claim filtering automatically: + +```php +use Mindtwo\LaravelIdentity\Identity; + +// AppServiceProvider::boot() +Identity::registerScope('org', [ + 'https://issuer.example/department', + 'https://issuer.example/team', +]); +``` + +The claim names must match the keys your `ClaimProvider` emits; namespace any +non-standard claims with a URI you own so they cannot collide with standard ones. + +To replace a collaborator entirely, swap its container binding: + ```php -// Override any default, e.g. add custom scopes/claims: $this->app->extend(ScopeRegistrar::class, function ($registrar) { - $registrar->register('roles', ['roles']); + // return your own ScopeRegistrar implementation return $registrar; }); ``` diff --git a/src/Identity.php b/src/Identity.php index 321d9b3..3aac13c 100644 --- a/src/Identity.php +++ b/src/Identity.php @@ -29,6 +29,15 @@ class Identity */ public static Algorithm $defaultSigningAlgorithm = Algorithm::RS256; + /** + * Custom scopes registered via registerScope(), keyed by scope name. Flushed + * into the ScopeRegistrar after every provider has booted, so the calling + * provider's boot order relative to this package does not matter. + * + * @var array> + */ + public static array $scopes = []; + /** * Register the view name for the RP-initiated logout confirmation screen. * Receives: ['client' => Client, 'request' => LogoutRequest, 'state' => ?string]. @@ -64,6 +73,21 @@ public static function signingAlgorithm(Algorithm $algorithm): void static::$defaultSigningAlgorithm = $algorithm; } + /** + * Register a custom OIDC scope and the claim names it grants. Safe to call from + * any service provider's register() or boot(): the scope is applied to the + * ScopeRegistrar after all providers have booted, so it always reaches Passport, + * discovery, and claim filtering regardless of provider order. Claim names must + * match the keys the corresponding ClaimProvider emits (namespace your custom + * claims, e.g. https://issuer.example/department). + * + * @param list $claims + */ + public static function registerScope(string $scope, array $claims): void + { + static::$scopes[$scope] = array_values(array_unique([...static::$scopes[$scope] ?? [], ...$claims])); + } + /** * The OpenID Provider issuer identifier. Single source of truth so the value * is byte-for-byte identical across the discovery document, id_token `iss`, @@ -97,5 +121,6 @@ public static function reset(): void static::$frontChannelLogoutLayout = null; static::$firstPartyClientResolver = null; static::$defaultSigningAlgorithm = Algorithm::RS256; + static::$scopes = []; } } diff --git a/src/LaravelIdentityServiceProvider.php b/src/LaravelIdentityServiceProvider.php index 9dbaeb5..41ddd37 100644 --- a/src/LaravelIdentityServiceProvider.php +++ b/src/LaravelIdentityServiceProvider.php @@ -105,8 +105,30 @@ public function packageBooted(): void // Swap Passport's BearerTokenResponse so token endpoint responses include id_token. Passport::useAuthorizationServerResponseType($this->app->make(IdTokenResponseType::class)); - if (config('identity.register_openid_scope', true)) { - $this->registerOidcScopes(); + // Deferred until every provider has booted: host apps register custom scopes + // (via Identity::registerScope() or the ScopeRegistrar directly) from their own + // providers, and we cannot assume they boot before us. Running here guarantees + // the registry is complete before it is snapshotted into Passport. + $this->app->booted(function (): void { + $this->applyCustomScopes(); + + if (config('identity.register_openid_scope', true)) { + $this->registerOidcScopes(); + } + }); + } + + /** + * Flush scopes buffered via Identity::registerScope() into the ScopeRegistrar. + * The registrar backs discovery and claim filtering, so this runs regardless of + * the register_openid_scope toggle (which only governs the Passport snapshot). + */ + private function applyCustomScopes(): void + { + $registrar = $this->app->make(ScopeRegistrar::class); + + foreach (Identity::$scopes as $scope => $claims) { + $registrar->register($scope, $claims); } } diff --git a/tests/Feature/CustomScopeRegistrationTest.php b/tests/Feature/CustomScopeRegistrationTest.php new file mode 100644 index 0000000..f0e67f1 --- /dev/null +++ b/tests/Feature/CustomScopeRegistrationTest.php @@ -0,0 +1,44 @@ +pluck('id')->all(); + + $this->assertContains(LateScopeServiceProvider::SCOPE, $scopes); + } + + public function test_scope_and_its_claims_are_advertised_in_discovery(): void + { + $response = $this->getJson('/.well-known/openid-configuration'); + + $this->assertContains(LateScopeServiceProvider::SCOPE, $response->json('scopes_supported')); + $this->assertContains(LateScopeServiceProvider::CLAIM, $response->json('claims_supported')); + } + + public function test_registrar_maps_the_scope_to_its_claims_for_filtering(): void + { + $claims = app(ScopeRegistrar::class)->claimsFor([LateScopeServiceProvider::SCOPE]); + + $this->assertContains(LateScopeServiceProvider::CLAIM, $claims); + } + + /** + * Load the host-app provider AFTER laravel-identity so the test fails if scope + * registration is coupled to provider boot order. + * + * @param mixed $app + */ + protected function getPackageProviders($app): array + { + return [...parent::getPackageProviders($app), LateScopeServiceProvider::class]; + } +} diff --git a/tests/Fixtures/LateScopeServiceProvider.php b/tests/Fixtures/LateScopeServiceProvider.php new file mode 100644 index 0000000..62a1cfb --- /dev/null +++ b/tests/Fixtures/LateScopeServiceProvider.php @@ -0,0 +1,22 @@ +