Skip to content
Merged
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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
```
Expand Down
25 changes: 25 additions & 0 deletions src/Identity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, list<string>>
*/
public static array $scopes = [];

/**
* Register the view name for the RP-initiated logout confirmation screen.
* Receives: ['client' => Client, 'request' => LogoutRequest, 'state' => ?string].
Expand Down Expand Up @@ -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<string> $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`,
Expand Down Expand Up @@ -97,5 +121,6 @@ public static function reset(): void
static::$frontChannelLogoutLayout = null;
static::$firstPartyClientResolver = null;
static::$defaultSigningAlgorithm = Algorithm::RS256;
static::$scopes = [];
}
}
26 changes: 24 additions & 2 deletions src/LaravelIdentityServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
44 changes: 44 additions & 0 deletions tests/Feature/CustomScopeRegistrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php declare(strict_types=1);

namespace Mindtwo\LaravelIdentity\Tests\Feature;

use Laravel\Passport\Passport;
use Mindtwo\LaravelIdentity\Contracts\ScopeRegistrar;
use Mindtwo\LaravelIdentity\Tests\Fixtures\LateScopeServiceProvider;
use Mindtwo\LaravelIdentity\Tests\TestCase;

class CustomScopeRegistrationTest extends TestCase
{
public function test_scope_from_a_later_booting_provider_is_requestable_in_passport(): void
{
$scopes = Passport::scopes()->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];
}
}
22 changes: 22 additions & 0 deletions tests/Fixtures/LateScopeServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types=1);

namespace Mindtwo\LaravelIdentity\Tests\Fixtures;

use Illuminate\Support\ServiceProvider;
use Mindtwo\LaravelIdentity\Identity;

/**
* A host-app-style provider that registers a custom scope from boot(). When
* loaded after LaravelIdentityServiceProvider it proves scope registration is
* order-independent.
*/
class LateScopeServiceProvider extends ServiceProvider
{
public const SCOPE = 'org';
public const CLAIM = 'https://auth.example.com/department';

public function boot(): void
{
Identity::registerScope(self::SCOPE, [self::CLAIM]);
}
}
Loading