weldist / laravel-passport-client-scopes
Per-client default scopes and RFC 6750 bearer challenges for Laravel Passport client credentials tokens.
Package info
github.com/weldist/laravel-passport-client-scopes
pkg:composer/weldist/laravel-passport-client-scopes
Requires
- php: ^8.4
- laravel/passport: ^13.0
Requires (Dev)
- orchestra/testbench: ^9.15|^10.8|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 12:39:38 UTC
README
Per-client default scopes and standard insufficient_scope responses for Laravel Passport client credentials tokens. Grant a service its scopes once, on the server, and stop maintaining the same list in every client's .env.
A weld.ist project.
Unofficial. Not affiliated with Laravel.
The Problem
Passport lets you restrict what a client may receive through the oauth_clients.scopes column, but that list is only a ceiling. A client credentials token carries exactly the scopes the client asks for:
POST /oauth/token grant_type=client_credentials → token with no scopes → every scoped route answers 403
POST /oauth/token grant_type=client_credentials scope=... → token with the requested scopes
So every machine client has to keep a copy of its own permission list and send it on each token request. Every time the server grants a new scope, each client needs a config change and a redeploy. Asking for * doesn't help either: once the client's scopes column is set, * is not in it and gets filtered out.
Passport's token response doesn't include the scope field either, so a client can't tell which scopes it was actually issued.
When a scope is missing, Passport answers with a generic 403. A client cannot tell it apart from any other authorization failure, so it cannot react, for example by fetching a fresh token. A missing or invalid token gets a 401 without the WWW-Authenticate header that RFC 6750 §3 requires.
A client whose scopes column is null is not restricted at all: it receives any scope it asks for.
The Solution
This package welds the missing pieces onto Passport:
- Default scopes per client. A client credentials request without a
scopeparameter receives every scope granted to the client inoauth_clients.scopes. RFC 6749 §3.3 explicitly allows this pre-defined default, and it's how Auth0, GitHub App installation tokens, Microsoft Entra (/.default) and Keycloak behave. - Issued scopes in the token response. The response includes the
scopefield, so the client knows what it received without inspecting the token. insufficient_scoperesponses. A token without a required scope receives the RFC 6750 §3.1 challenge, so clients can tell exactly which scope is missing:
HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", scope="venues:create" {"message": "Insufficient scope: venues:create"}
- Authentication challenges. A request rejected by Passport for a missing token receives
WWW-Authenticate: Bearer; an invalid or expired token receivesWWW-Authenticate: Bearer error="invalid_token". - Unconfigured clients receive nothing. A client credentials client whose
scopescolumn isnullreceives no scopes, requested or not, until you fill its column.
A client that does send scope still gets the narrower token it asked for. Other grant types are unaffected.
Requirements
- PHP
^8.4 laravel/passport ^13.0
Installation
composer require weldist/laravel-passport-client-scopes
The service provider is auto-discovered — no manual registration.
Setup
Passport's default oauth_clients migration has no scopes column. If your table doesn't have one yet, publish and run this package's migration:
php artisan vendor:publish --tag=passport-client-scopes-migrations php artisan migrate
Then grant scopes to a client:
use Laravel\Passport\ClientRepository; $client = app(ClientRepository::class)->createClientCredentialsGrantClient('Billing Service'); $client->forceFill(['scopes' => ['invoices:read', 'invoices:create']])->save();
The client now requests a token without any scope and receives both.
Warning
Clients that existed before you installed the package have a null scopes column and receive no scopes from now on. Fill the column of every client credentials client before you deploy.
Configuration
Publish the config file to change the defaults:
php artisan vendor:publish --tag=passport-client-scopes-config
| Key | Default | Effect |
|---|---|---|
allow_unconfigured_clients |
false |
true restores Passport's behavior for a null column: the client receives any scope it requests. |
authentication_challenge |
true |
false leaves 401 responses without the WWW-Authenticate header. |
How It Works
oauth_clients.scopes |
Token request without scope |
Token request with scope |
|---|---|---|
null |
no scopes | no scopes (any requested scope with allow_unconfigured_clients) |
[] |
no scopes | no scopes |
["a", "b"] |
a b |
requested scopes that are in the list |
- Scopes in the column that aren't defined with
Passport::tokensCan()are dropped. - The
nullrule applies to client credentials tokens only. Authorization code, password and other grants keep Passport's behavior. - The token response always carries the issued scopes in its
scopefield, as RFC 6749 §5.1 requires whenever they differ from the requested ones. Clients should read their scopes from there and treat the access token itself as opaque (RFC 9068 §6).
{"scope": "invoices:read invoices:create", "token_type": "Bearer", "expires_in": 31536000, "access_token": "..."}
Minimal override
ScopeRepositoryextends Passport'sBridge\ScopeRepositoryand overrides onlyfinalizeScopes(): for aclient_credentialsgrant it returns no scopes when the client's column isnull, substitutes the client's granted scopes when nothing was requested, then hands over to Passport's own filtering.BearerTokenResponseextends league'sBearerTokenResponseand only adds thescopefield. It is installed throughPassport::$authorizationServerResponseTypeunless your application already set its own response type; in that case add thescopefield there.MissingScopeExceptionis mapped to anHttpExceptioncarrying theWWW-Authenticateheader; the rest of your exception rendering stays as it is.AuthenticationChallengeis a global middleware that adds the header to a401response when the exception behind it comes from Passport's middleware or a guard with thepassportdriver. Your own rendering ofAuthenticationExceptionstays in place, and aWWW-Authenticateheader you set yourself is kept.
Testing
# Build first (once per PHP version) DOCKER_BUILDKIT=0 docker compose --profile php84 build # Run tests docker compose --profile php84 up --abort-on-container-exit --exit-code-from php84 # PHP 8.4 docker compose --profile php85 up --abort-on-container-exit --exit-code-from php85 # PHP 8.5 # Run a single test class or method docker compose --profile php84 run --rm php84 vendor/bin/phpunit --filter DefaultScopesTest
CI runs PHP 8.4–8.5 via .github/workflows/tests.yml.
License
This package is open-sourced software licensed under the MIT license.