Laravel Passport ACL: Managing Passport scopes via Database
- July 26, 2024
- 5 Min Read
Laravel Passport is an excellent OAuth2 server implementation for any Laravel application that not only has authentication but also provides authorization features. Passport's token scopes act as an authorization permission to allow or stop users from performing actions on a resource.
You define all the token scopes in the Passport::tokensCan() method in the boot() method of your application's App\Providers\AuthServiceProvider class.
For more details on setting up and configuring Laravel Passport, you can refer to the official Laravel Passport documentation.
Problems with the Original Approach
There are many issues with this approach, let's look at them closely.
Manual addition of scopes
For every token scope you create, developers have to manually add the scope name in the Passport::tokensCan() method in App\Providers\AuthServiceProvider class. This leaves room for manual error.
Too many scopes
As your application grows, you will create multiple resources (API) and every resource supporting operations i.e. Create, Read, Update, Delete, etc. You will end up creating hundreds of token scopes. This makes it difficult to manage from a single App\Providers\AuthServiceProvider class.
Hitting HTTP authorization header max length
Although HTTP does not define any limit, most web servers do limit the size of headers they accept. Apache's default limit is 8KB, in IIS it's 16K. The server throws a ‘413 Entity Too Large’ error if the header size exceeds that limit.
Most web servers, like Apache, have limits on HTTP header sizes. You can find more details on the default header limits in Apache.
Slower response
Hundreds of token scopes lead to lengthy HTTP authorization header that leads to bulky headers that increase the time to send and receive HTTP requests and responses.
Any of the above challenges could disrupt the functionality of your operational application.
We came across these challenges while working on a SaaS product with 50k+ users. The solution we opted for is to manage the token scopes via database. It eliminates the first problem of manually adding the scopes in App\Providers\AuthServiceProvider class. As for problem 2, we cannot control the number of scopes because it depends on business demands.
Managing Token Scopes via Database
First Step
Write migrations to set up our database managing the token scopes.
NOTE: Assuming, you already have role_id column in the users table. If that's not the case, code changes are required which will be discussed later in the article.
- `role` table
-
`scopes` table
This table contains all the token scopes our application demands.
NOTE: Please ensure the name column is unique. -
`role_scopes` table
This table contains all the token scopes related to a role.
-
`user_scopes` table
This table contains all the extra token scopes related to a specific user.
Execute php artisan migrate to run all the migrations.
Second Step
Create models for the above migrations.
- App\Models\Role
- App\Models\Scope
- Similarly create the App\Models\RoleScope, and App\Models\UserScope models.
Third Step
Fetch the scopes from the database to define them in the Passport::tokensCan() method in App\Providers\AuthServiceProvider class. There are various techniques by which you can achieve this.
- Creating a helper method in the helper file.
- Creating a helper method in the helper file.
We opted to create a battle-tested utility library and a dedicated Facade leveraging Laravel's service container capabilities.
-
Create permission utility class App\Utilities\Permission\Permission.
Note how we utilized the cache()->rememberForever() method to avoid database queries on every request just to fetch the API scopes.
Now, there is a crucial method in the App\Utilities\Permission\Permission class that requires explanation.
- scopesChanged()
Call this method only when a scope is created, updated, or deleted from our database. This will automatically find all the users who are affected by this scope, also destroying and re-creating the user scopes. We will learn its usage later in this article.
NOTE: If your application doesn't have role_id column in the users table, please replace the $user->role_id as per your database setup in the affectedUsersAfterScopesChanged() & refreshUserScopes() methods as discussed in the First Step.
- scopesChanged()
- Create a facade App\Facades\Utilities\Permission.
-
Create and register the App\Providers\PermissionServiceProvider.
Execute php artisan make:provider PermissionServiceProvider to create a new service provider.
Register the PermissionServiceProvider in the config/app.php file.
Fourth Step
Create RoleScopeObserver and UserScopeObserver classes to track the scope changes in the database and take the necessary steps by executing the following commands:
Remember, we declared the scopesChanged() method in the App\Utilities\Permission\Permission class to track the scope changes in the database. Now's the time to call that method from the observers.
- App\Observers\RoleScopeObserver
- App\Observers\UserScopeObserver
-
Register the observers in the boot() method of App\Providers\EventServiceProvider class.
We can now distinguish whether the scope changes were intended for a role or a user by creating separate observers.
Final Step
Call the passportScopes() method from App\Utilities\Permission\Permission library in the AuthServiceProvider class.
After setting up your application with all of the above changes, this is how you will add a new token scope in your application:
- Create migration to create/update/delete a scope and assign it to a role or a user.
- RoleScopeObserver & UserScopeObserver will activate automatically once the scopes are created/updated/deleted in the database.
But, we still have problems to solve.
- How can we expect the frontend team to pass the scopes without knowing the user role and ID, as the user details are revealed once they are authenticated? Answer: Pass an empty value in the scope parameter while hitting the login API /oauth/token
- How will Laravel handle the user authorization as the scope details are not included while creating the Passport access token? Further, we can't even utilize the scope middleware ->middleware('scope:customer-list') in our routes.
Answer: This is a tricky problem, and the solution is equally interesting. But first, let's understand how Laravel determines the scope of an authenticated user:
- Passport access token contains the list of all authorized scopes of an authenticated user that can be decoded by pasting the access token into https://jwt.io website.
- While authorization, Laravel does not match the scope from the route scope middleware with the list of all authorized scopes from the Passport access token of an authenticated user. Instead, it matches the scope with the scopes column from the oauth_access_tokens table.
Deep diving into the Laravel Passport's core files, we discovered an Laravel\Passport\Events\AccessTokenCreated event that is dispatched every time a new access token is created for a user.
Such findings redirected our approach and we figured out that we need to create a listener that listens to the AccessTokenCreated event and update the scopes in the oauth_access_tokens table. So, here comes the actual final step.
Final-Final Step
- Create a Listener class App\Listeners\RefreshUserScopes by executing the php artisan make:listener RefreshUserScopes command.
-
Register the App\Listeners\RefreshUserScopes listener in the boot() method of the App\Providers\EventServiceProvider class.
With this, we solve the 3rd & 4th problems discussed initially of HTTP authorization header max length & slower response as the:
- Access token length does not increase as the scope increases. Now, the scopes are not a part of the access token.
- Faster response because of the lightweight access token.
In conclusion
You've now built a comprehensive ACL or a Permission Management System to manage the passport token scopes via database without losing or compromising Laravel's built-in magic. The beauty of this setup is, that if you ever want to ditch the Laravel Passport's authorization and create your own authorization logic, you already have a database-managed setup and zero dependency on Laravel Passport by simply replacing the scope middleware ->middleware('scope:customer-list') from routes with your own middleware.
Why wait? Try this in your existing or new applications and do share your queries, we are more than happy to work on them with you.
Stay in the Know
Get ahead with TechUp Labs' productivity tips & latest tech trend resources