Master advanced Laravel Passport customizations for multi-tenant applications, custom grant types, and complex authentication workflows in enterprise environments.
Table Of Contents
- Understanding Laravel Passport Architecture
- Custom Grant Types
- Advanced Client Management
- Custom Scope Management
- Token Customization
- Security Enhancements
Understanding Laravel Passport Architecture
Laravel Passport provides a full OAuth2 server implementation built on the League OAuth2 server package. While it works excellently out of the box for standard authentication flows, real-world enterprise applications often require customizations for specific business requirements, multi-tenant architectures, and complex user authorization scenarios.
Understanding Passport's internal architecture is crucial for implementing custom authentication flows that maintain security while meeting unique business needs. This is particularly important when building SaaS applications with Laravel that require sophisticated access control mechanisms.
Custom Grant Types
Implementing Custom Grant Types
Create custom grant types for specialized authentication scenarios:
<?php
namespace App\Passport\Grants;
use Laravel\Passport\Bridge\User;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\UserEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Grant\AbstractGrant;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use League\OAuth2\Server\Repositories\UserRepositoryInterface;
use League\OAuth2\Server\RequestEvent;
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
use Psr\Http\Message\ServerRequestInterface;
class TwoFactorGrant extends AbstractGrant
{
protected UserRepositoryInterface $userRepository;
public function __construct(
UserRepositoryInterface $userRepository,
RefreshTokenRepositoryInterface $refreshTokenRepository
) {
$this->userRepository = $userRepository;
$this->refreshTokenRepository = $refreshTokenRepository;
$this->refreshTokenTTL = new \DateInterval('P1M');
}
public function respondToAccessTokenRequest(
ServerRequestInterface $request,
ResponseTypeInterface $responseType,
\DateInterval $accessTokenTTL
): ResponseTypeInterface {
// Validate the client
$client = $this->validateClient($request);
// Validate request parameters
$parameters = $this->getRequestParameters($request);
$this->validateParameters($parameters);
// Authenticate user with email/password
$user = $this->validateUserCredentials($parameters);
// Validate 2FA token
$this->validateTwoFactorToken($user, $parameters['two_factor_token']);
// Generate access token
$accessToken = $this->issueAccessToken(
$accessTokenTTL,
$client,
$user->getIdentifier(),
$this->validateScopes($parameters['scope'] ?? '')
);
// Generate refresh token
$refreshToken = $this->issueRefreshToken($accessToken);
$responseType->setAccessToken($accessToken);
$responseType->setRefreshToken($refreshToken);
return $responseType;
}
protected function getRequestParameters(ServerRequestInterface $request): array
{
$parameters = (array) $request->getParsedBody();
$requiredParams = ['email', 'password', 'two_factor_token'];
foreach ($requiredParams as $param) {
if (!isset($parameters[$param])) {
throw OAuthServerException::invalidRequest($param);
}
}
return $parameters;
}
protected function validateParameters(array $parameters): void
{
if (empty($parameters['email']) || !filter_var($parameters['email'], FILTER_VALIDATE_EMAIL)) {
throw OAuthServerException::invalidRequest('email', 'Invalid email address');
}
if (empty($parameters['password'])) {
throw OAuthServerException::invalidRequest('password', 'Password is required');
}
if (empty($parameters['two_factor_token']) || !preg_match('/^\d{6}$/', $parameters['two_factor_token'])) {
throw OAuthServerException::invalidRequest('two_factor_token', 'Invalid 2FA token format');
}
}
protected function validateUserCredentials(array $parameters): UserEntityInterface
{
$user = $this->userRepository->getUserEntityByUserCredentials(
$parameters['email'],
$parameters['password'],
'two_factor',
$this->getClientEntity()
);
if ($user instanceof UserEntityInterface === false) {
$this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));
throw OAuthServerException::invalidCredentials();
}
return $user;
}
protected function validateTwoFactorToken(UserEntityInterface $user, string $token): void
{
$appUser = \App\Models\User::find($user->getIdentifier());
if (!$appUser->hasTwoFactorEnabled()) {
throw OAuthServerException::invalidRequest('two_factor_token', '2FA is not enabled for this user');
}
if (!$appUser->verifyTwoFactorToken($token)) {
throw OAuthServerException::invalidRequest('two_factor_token', 'Invalid 2FA token');
}
}
public function getIdentifier(): string
{
return 'two_factor';
}
}
Register the custom grant type in a service provider:
<?php
namespace App\Providers;
use Laravel\Passport\PassportServiceProvider;
use League\OAuth2\Server\AuthorizationServer;
use App\Passport\Grants\TwoFactorGrant;
class PassportGrantServiceProvider extends PassportServiceProvider
{
public function boot(): void
{
parent::boot();
app(AuthorizationServer::class)->enableGrantType(
new TwoFactorGrant(
app(\Laravel\Passport\Bridge\UserRepository::class),
app(\Laravel\Passport\Bridge\RefreshTokenRepository::class)
),
\DateInterval::createFromDateString('1 hour')
);
}
}
Multi-Tenant Grant Type
Implement tenant-aware authentication:
<?php
namespace App\Passport\Grants;
use Laravel\Passport\Bridge\User;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Grant\PasswordGrant;
use Psr\Http\Message\ServerRequestInterface;
class TenantPasswordGrant extends PasswordGrant
{
public function respondToAccessTokenRequest(
ServerRequestInterface $request,
ResponseTypeInterface $responseType,
\DateInterval $accessTokenTTL
): ResponseTypeInterface {
// Validate tenant context first
$parameters = (array) $request->getParsedBody();
$tenant = $this->validateTenant($parameters);
// Set tenant context for user lookup
app()->instance('auth.tenant', $tenant);
return parent::respondToAccessTokenRequest($request, $responseType, $accessTokenTTL);
}
protected function validateTenant(array $parameters): \App\Models\Tenant
{
if (!isset($parameters['tenant_id'])) {
throw OAuthServerException::invalidRequest('tenant_id', 'Tenant ID is required');
}
$tenant = \App\Models\Tenant::find($parameters['tenant_id']);
if (!$tenant) {
throw OAuthServerException::invalidRequest('tenant_id', 'Invalid tenant');
}
if (!$tenant->is_active) {
throw OAuthServerException::accessDenied('Tenant account is suspended');
}
return $tenant;
}
}
Advanced Client Management
Dynamic Client Registration
Implement dynamic client registration for multi-tenant applications:
<?php
namespace App\Services;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Client;
use App\Models\Tenant;
class TenantClientManager
{
protected ClientRepository $clientRepository;
public function __construct(ClientRepository $clientRepository)
{
$this->clientRepository = $clientRepository;
}
public function createTenantClient(Tenant $tenant, array $clientData): Client
{
$client = $this->clientRepository->create(
$tenant->id, // Use tenant ID as user ID
$this->generateClientName($tenant, $clientData),
$this->generateRedirectUris($tenant, $clientData),
null, // No specific provider
false, // Not a personal access client
false, // Not a password client
true // Is a confidential client
);
// Store additional tenant-specific metadata
$client->update([
'tenant_id' => $tenant->id,
'allowed_scopes' => $this->getTenantAllowedScopes($tenant),
'client_metadata' => json_encode([
'tenant_name' => $tenant->name,
'environment' => $clientData['environment'] ?? 'production',
'contact_email' => $clientData['contact_email'] ?? $tenant->email,
'webhook_url' => $clientData['webhook_url'] ?? null,
]),
]);
return $client;
}
protected function generateClientName(Tenant $tenant, array $clientData): string
{
$environment = $clientData['environment'] ?? 'production';
return "{$tenant->name} - {$environment}";
}
protected function generateRedirectUris(Tenant $tenant, array $clientData): string
{
$baseUris = [
$tenant->primary_domain . '/auth/callback',
$tenant->primary_domain . '/auth/mobile-callback',
];
if (isset($clientData['additional_uris'])) {
$baseUris = array_merge($baseUris, $clientData['additional_uris']);
}
return implode(',', $this->validateRedirectUris($baseUris));
}
protected function validateRedirectUris(array $uris): array
{
$validated = [];
foreach ($uris as $uri) {
if (filter_var($uri, FILTER_VALIDATE_URL) && $this->isAllowedScheme($uri)) {
$validated[] = $uri;
}
}
if (empty($validated)) {
throw new \InvalidArgumentException('At least one valid redirect URI is required');
}
return $validated;
}
protected function isAllowedScheme(string $uri): bool
{
$scheme = parse_url($uri, PHP_URL_SCHEME);
$allowedSchemes = ['https', 'http', 'myapp']; // Include custom schemes for mobile
return in_array($scheme, $allowedSchemes);
}
protected function getTenantAllowedScopes(Tenant $tenant): array
{
$baseScopes = ['read-profile', 'write-profile'];
// Add tenant-specific scopes based on subscription
if ($tenant->hasFeature('api_access')) {
$baseScopes[] = 'api-access';
}
if ($tenant->hasFeature('webhook_access')) {
$baseScopes[] = 'webhook-access';
}
if ($tenant->subscription_tier === 'enterprise') {
$baseScopes[] = 'admin-access';
}
return $baseScopes;
}
}
Client Authentication Customization
Implement custom client authentication methods:
<?php
namespace App\Passport\Authenticators;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use Psr\Http\Message\ServerRequestInterface;
class CustomClientAuthenticator
{
protected ClientRepositoryInterface $clientRepository;
public function __construct(ClientRepositoryInterface $clientRepository)
{
$this->clientRepository = $clientRepository;
}
public function authenticateClient(ServerRequestInterface $request): ClientEntityInterface
{
$method = $this->getAuthenticationMethod($request);
return match($method) {
'client_secret_basic' => $this->authenticateBasicAuth($request),
'client_secret_post' => $this->authenticatePostAuth($request),
'client_secret_jwt' => $this->authenticateJwtAuth($request),
'private_key_jwt' => $this->authenticatePrivateKeyJwt($request),
default => throw OAuthServerException::invalidClient($request),
};
}
protected function getAuthenticationMethod(ServerRequestInterface $request): string
{
$headers = $request->getHeaders();
$body = $request->getParsedBody();
if (isset($headers['Authorization'])) {
return 'client_secret_basic';
}
if (isset($body['client_assertion_type'])) {
if ($body['client_assertion_type'] === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer') {
return isset($body['client_secret']) ? 'client_secret_jwt' : 'private_key_jwt';
}
}
return 'client_secret_post';
}
protected function authenticateJwtAuth(ServerRequestInterface $request): ClientEntityInterface
{
$body = $request->getParsedBody();
if (!isset($body['client_assertion'])) {
throw OAuthServerException::invalidRequest('client_assertion');
}
try {
$jwt = new \Firebase\JWT\JWT();
$jwt->leeway = 60; // Allow 60 seconds leeway for clock skew
$payload = $jwt::decode(
$body['client_assertion'],
new \Firebase\JWT\Key($this->getClientSecret($body['client_id']), 'HS256')
);
$this->validateJwtClaims($payload, $body['client_id']);
return $this->clientRepository->getClientEntity($body['client_id']);
} catch (\Exception $e) {
throw OAuthServerException::invalidClient($request);
}
}
protected function authenticatePrivateKeyJwt(ServerRequestInterface $request): ClientEntityInterface
{
$body = $request->getParsedBody();
if (!isset($body['client_assertion'])) {
throw OAuthServerException::invalidRequest('client_assertion');
}
try {
$client = $this->clientRepository->getClientEntity($body['client_id']);
$publicKey = $this->getClientPublicKey($client);
$jwt = new \Firebase\JWT\JWT();
$payload = $jwt::decode(
$body['client_assertion'],
new \Firebase\JWT\Key($publicKey, 'RS256')
);
$this->validateJwtClaims($payload, $body['client_id']);
return $client;
} catch (\Exception $e) {
throw OAuthServerException::invalidClient($request);
}
}
protected function validateJwtClaims(\stdClass $payload, string $clientId): void
{
// Validate issuer
if (!isset($payload->iss) || $payload->iss !== $clientId) {
throw new \InvalidArgumentException('Invalid issuer');
}
// Validate subject
if (!isset($payload->sub) || $payload->sub !== $clientId) {
throw new \InvalidArgumentException('Invalid subject');
}
// Validate audience
if (!isset($payload->aud) || !in_array(config('app.url') . '/oauth/token', (array)$payload->aud)) {
throw new \InvalidArgumentException('Invalid audience');
}
// Validate expiration
if (!isset($payload->exp) || $payload->exp < time()) {
throw new \InvalidArgumentException('Token expired');
}
// Validate not before
if (isset($payload->nbf) && $payload->nbf > time()) {
throw new \InvalidArgumentException('Token not yet valid');
}
}
}
Custom Scope Management
Dynamic Scope Resolution
Implement context-aware scope resolution:
<?php
namespace App\Passport\Scopes;
use Laravel\Passport\Passport;
use App\Models\User;
use App\Models\Tenant;
class DynamicScopeResolver
{
public function resolveScopes(User $user, array $requestedScopes, ?Tenant $tenant = null): array
{
$availableScopes = $this->getAvailableScopes($user, $tenant);
$resolvedScopes = [];
foreach ($requestedScopes as $scope) {
if ($this->canAccessScope($user, $scope, $availableScopes, $tenant)) {
$resolvedScopes[] = $scope;
}
}
return $resolvedScopes;
}
protected function getAvailableScopes(User $user, ?Tenant $tenant = null): array
{
$baseScopes = ['read-profile'];
// User-level scopes
if ($user->hasVerifiedEmail()) {
$baseScopes[] = 'write-profile';
}
if ($user->hasRole('admin')) {
$baseScopes = array_merge($baseScopes, ['admin-users', 'admin-settings']);
}
// Tenant-level scopes
if ($tenant) {
$tenantScopes = $this->getTenantScopes($user, $tenant);
$baseScopes = array_merge($baseScopes, $tenantScopes);
}
return $baseScopes;
}
protected function getTenantScopes(User $user, Tenant $tenant): array
{
$scopes = [];
$membership = $user->tenantMemberships()->where('tenant_id', $tenant->id)->first();
if (!$membership) {
return $scopes;
}
// Role-based scopes
switch ($membership->role) {
case 'owner':
$scopes = array_merge($scopes, [
'tenant-admin',
'manage-billing',
'manage-users',
'manage-settings'
]);
break;
case 'admin':
$scopes = array_merge($scopes, [
'manage-users',
'manage-settings'
]);
break;
case 'member':
$scopes[] = 'tenant-access';
break;
}
// Feature-based scopes
if ($tenant->hasFeature('api_access')) {
$scopes[] = 'api-access';
}
if ($tenant->hasFeature('webhook_management') && in_array($membership->role, ['owner', 'admin'])) {
$scopes[] = 'manage-webhooks';
}
return $scopes;
}
protected function canAccessScope(User $user, string $scope, array $availableScopes, ?Tenant $tenant = null): bool
{
// Check if scope is in available scopes
if (!in_array($scope, $availableScopes)) {
return false;
}
// Additional business logic checks
if ($scope === 'manage-billing' && $tenant) {
// Only allow billing access if tenant has active subscription
return $tenant->hasActiveSubscription();
}
if (str_starts_with($scope, 'admin-') && $user->hasRole('suspended')) {
return false;
}
return true;
}
}
Hierarchical Scopes
Implement hierarchical scope inheritance:
<?php
namespace App\Passport\Scopes;
class HierarchicalScopeManager
{
protected array $scopeHierarchy = [
'tenant-owner' => [
'tenant-admin',
'manage-billing',
'manage-users',
'manage-settings',
'tenant-access'
],
'tenant-admin' => [
'manage-users',
'manage-settings',
'tenant-access'
],
'tenant-member' => [
'tenant-access'
],
'api-admin' => [
'api-write',
'api-read'
],
'api-write' => [
'api-read'
]
];
public function expandScopes(array $scopes): array
{
$expandedScopes = [];
foreach ($scopes as $scope) {
$expandedScopes[] = $scope;
if (isset($this->scopeHierarchy[$scope])) {
$childScopes = $this->expandScopes($this->scopeHierarchy[$scope]);
$expandedScopes = array_merge($expandedScopes, $childScopes);
}
}
return array_unique($expandedScopes);
}
public function hasScope(array $userScopes, string $requiredScope): bool
{
$expandedScopes = $this->expandScopes($userScopes);
return in_array($requiredScope, $expandedScopes);
}
public function getMinimalScopes(array $scopes): array
{
$minimal = [];
foreach ($scopes as $scope) {
$isRedundant = false;
foreach ($scopes as $otherScope) {
if ($scope !== $otherScope && $this->isChildScope($scope, $otherScope)) {
$isRedundant = true;
break;
}
}
if (!$isRedundant) {
$minimal[] = $scope;
}
}
return $minimal;
}
protected function isChildScope(string $childScope, string $parentScope): bool
{
if (!isset($this->scopeHierarchy[$parentScope])) {
return false;
}
$childScopes = $this->expandScopes($this->scopeHierarchy[$parentScope]);
return in_array($childScope, $childScopes);
}
}
Token Customization
Custom Token Claims
Add custom claims to access tokens:
<?php
namespace App\Passport;
use Laravel\Passport\Token;
use Laravel\Passport\PersonalAccessTokenResult;
use App\Models\User;
use Firebase\JWT\JWT;
class CustomTokenGenerator
{
public function generateTokenWithClaims(User $user, array $scopes, array $customClaims = []): PersonalAccessTokenResult
{
$token = $user->createToken('API Token', $scopes);
// Add custom claims to the token
$this->addCustomClaims($token->token, $user, $customClaims);
return $token;
}
protected function addCustomClaims(Token $token, User $user, array $customClaims): void
{
$claims = array_merge([
'user_id' => $user->id,
'email' => $user->email,
'email_verified' => $user->hasVerifiedEmail(),
'roles' => $user->roles->pluck('name')->toArray(),
'tenant_id' => $user->current_tenant_id,
'subscription_tier' => $user->currentTenant?->subscription_tier,
'last_login' => $user->last_login_at?->toISOString(),
'created_at' => $user->created_at->toISOString(),
], $customClaims);
// Store claims in token's attributes or separate table
$token->update([
'custom_claims' => json_encode($claims)
]);
}
public function createJwtFromToken(Token $token): string
{
$payload = [
'iss' => config('app.url'),
'aud' => config('app.url'),
'iat' => now()->timestamp,
'exp' => $token->expires_at->timestamp,
'sub' => $token->user_id,
'jti' => $token->id,
'scopes' => $token->scopes,
];
// Add custom claims
if ($token->custom_claims) {
$customClaims = json_decode($token->custom_claims, true);
$payload = array_merge($payload, $customClaims);
}
return JWT::encode($payload, config('passport.jwt_key'), 'RS256');
}
}
Token Middleware Enhancement
Create enhanced token validation middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Laravel\Passport\Http\Middleware\CheckClientCredentials;
use Laravel\Passport\Token;
use Laravel\Passport\TokenRepository;
class EnhancedTokenValidation
{
protected TokenRepository $tokenRepository;
public function __construct(TokenRepository $tokenRepository)
{
$this->tokenRepository = $tokenRepository;
}
public function handle(Request $request, Closure $next, ...$scopes)
{
$token = $request->user()->token();
// Validate token hasn't been revoked
if ($token->revoked) {
return response()->json(['error' => 'Token has been revoked'], 401);
}
// Validate token hasn't expired
if ($token->expires_at->isPast()) {
return response()->json(['error' => 'Token has expired'], 401);
}
// Validate hierarchical scopes
if (!$this->hasRequiredScopes($token, $scopes)) {
return response()->json(['error' => 'Insufficient scope'], 403);
}
// Validate tenant context
if (!$this->validateTenantContext($request, $token)) {
return response()->json(['error' => 'Invalid tenant context'], 403);
}
// Update token usage statistics
$this->updateTokenUsage($token);
return $next($request);
}
protected function hasRequiredScopes(Token $token, array $requiredScopes): bool
{
if (empty($requiredScopes)) {
return true;
}
$scopeManager = app(HierarchicalScopeManager::class);
$tokenScopes = $token->scopes;
foreach ($requiredScopes as $scope) {
if (!$scopeManager->hasScope($tokenScopes, $scope)) {
return false;
}
}
return true;
}
protected function validateTenantContext(Request $request, Token $token): bool
{
$currentTenant = app('current_tenant');
if (!$currentTenant) {
return true; // No tenant context required
}
$customClaims = json_decode($token->custom_claims ?? '{}', true);
$tokenTenantId = $customClaims['tenant_id'] ?? null;
return $tokenTenantId === $currentTenant->id;
}
protected function updateTokenUsage(Token $token): void
{
// Update usage statistics asynchronously
dispatch(function () use ($token) {
$token->increment('usage_count');
$token->update(['last_used_at' => now()]);
})->afterResponse();
}
}
Security Enhancements
Rate Limiting by Scope
Implement scope-based rate limiting:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Cache\RateLimiter;
use Symfony\Component\HttpFoundation\Response;
class ScopeBasedRateLimit
{
protected RateLimiter $limiter;
protected array $scopeLimits = [
'api-read' => ['attempts' => 1000, 'decay' => 3600], // 1000 per hour
'api-write' => ['attempts' => 100, 'decay' => 3600], // 100 per hour
'admin-api' => ['attempts' => 10000, 'decay' => 3600], // 10000 per hour
'webhook-access' => ['attempts' => 50, 'decay' => 60], // 50 per minute
];
public function __construct(RateLimiter $limiter)
{
$this->limiter = $limiter;
}
public function handle(Request $request, Closure $next, string $scope): Response
{
$token = $request->user()->token();
$key = $this->resolveRequestSignature($request, $token, $scope);
$limit = $this->getScopeLimit($scope);
if ($this->limiter->tooManyAttempts($key, $limit['attempts'])) {
return $this->buildRateLimitResponse($key, $limit['attempts']);
}
$this->limiter->hit($key, $limit['decay']);
$response = $next($request);
return $this->addHeaders(
$response,
$limit['attempts'],
$this->calculateRemainingAttempts($key, $limit['attempts'])
);
}
protected function resolveRequestSignature(Request $request, $token, string $scope): string
{
return sha1(implode('|', [
$token->id,
$scope,
$request->ip(),
]));
}
protected function getScopeLimit(string $scope): array
{
return $this->scopeLimits[$scope] ?? ['attempts' => 60, 'decay' => 3600];
}
protected function buildRateLimitResponse(string $key, int $maxAttempts): Response
{
$retryAfter = $this->limiter->availableIn($key);
return response()->json([
'error' => 'Rate limit exceeded',
'message' => "Too many requests. Limit: {$maxAttempts} requests per hour.",
'retry_after' => $retryAfter,
], 429)->withHeaders([
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => 0,
'Retry-After' => $retryAfter,
]);
}
protected function calculateRemainingAttempts(string $key, int $maxAttempts): int
{
return max(0, $maxAttempts - $this->limiter->attempts($key));
}
protected function addHeaders(Response $response, int $maxAttempts, int $remainingAttempts): Response
{
return $response->withHeaders([
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => $remainingAttempts,
]);
}
}
Customizing Laravel Passport for complex authentication flows requires deep understanding of OAuth2 specifications and careful consideration of security implications. These patterns enable building sophisticated authentication systems that meet enterprise requirements while maintaining security and performance. Whether you're building multi-tenant applications or complex API ecosystems, these customizations provide the flexibility needed for advanced use cases.
Add Comment
No comments yet. Be the first to comment!