beyondbluesky/oauth2-pkce-client
Installation
composer require beyondbluesky/oauth2-pkce-client
Configure the Bundle
Create config/packages/oauth2_pkce_client.yaml with your OAuth2 provider details (e.g., auth/token URIs, client credentials, scopes).
Set Up Database
Run the provided migration to create the oauth2_pkce_state table:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
Create a Controller Define a controller with a route matching your OAuth2 redirect URI:
#[Route('/oauth2/callback', name: 'oauth2_callback')]
public function callback(OAuth2CallbackHandler $handler): Response
{
return $handler->handle();
}
Implement a Custom Authenticator
Extend AbstractOAuth2Authenticator and override getCredentials() to return user credentials (e.g., email from token response):
use BeyondBluesky\OAuth2PkceClient\Auth\AbstractOAuth2Authenticator;
class MyOAuth2Authenticator extends AbstractOAuth2Authenticator
{
public function getCredentials(): array
{
return ['email' => $this->getUser()->getEmail()];
}
}
Configure Security
Add the authenticator to your firewall in config/packages/security.yaml:
firewalls:
main:
custom_authenticators:
- App\Auth\MyOAuth2Authenticator
Trigger Authentication Redirect users to the OAuth2 provider:
return $this->redirect($this->generateUrl('oauth2_authenticate'));
OAuth2Authenticator to handle the OAuth2 flow.Initiate OAuth2 Flow
#[Route('/login/oauth2', name: 'oauth2_authenticate')]
public function initiateOAuth2(OAuth2Authenticator $authenticator): Response
{
return $authenticator->start();
}
Handle Callback
OAuth2CallbackHandler processes the redirect from the provider, exchanges the code for a token, and validates the PKCE challenge.#[Route('/oauth2/callback', name: 'oauth2_callback')]
public function callback(OAuth2CallbackHandler $handler): Response
{
return $handler->handle();
}
User Authentication
AbstractOAuth2Authenticator) maps the token response to a Symfony user (e.g., via UserProvider).getUser() to load or create a user:
public function getUser(): ?UserInterface
{
$email = $this->getCredentials()['email'];
return $this->userProvider->loadUserByIdentifier($email);
}
Token Refresh
OAuth2TokenManager to refresh access tokens silently:
$tokenManager = $this->container->get(OAuth2TokenManager::class);
$refreshedToken = $tokenManager->refreshToken($storedState->getAccessToken());
Custom Token Handling
Extend OAuth2TokenManager to add logic for token validation or custom claims:
class CustomTokenManager extends OAuth2TokenManager
{
protected function validateTokenResponse(array $response): void
{
// Custom validation logic
}
}
Multi-Provider Support
Configure multiple OAuth2 clients in oauth2_pkce_client.yaml under clients and use dependency injection to switch contexts:
oauth2_pkce_client:
clients:
google:
id: '%env(GOOGLE_CLIENT_ID)%'
secret: '%env(GOOGLE_CLIENT_SECRET)%'
# ...
github:
id: '%env(GITHUB_CLIENT_ID)%'
secret: '%env(GITHUB_CLIENT_SECRET)%'
# ...
State Management The bundle stores PKCE states in the database. For high-traffic apps, consider:
StateStorageInterface.state_ttl in config to reduce database load.Error Handling
Catch OAuth2Exception in controllers to provide user-friendly feedback:
try {
return $handler->handle();
} catch (OAuth2Exception $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('home');
}
Testing
Use OAuth2MockClient for unit tests:
$mockClient = new OAuth2MockClient();
$mockClient->setAuthCodeResponse(['access_token' => 'mock_token']);
$this->container->set(OAuth2Client::class, $mockClient);
PKCE State Mismatch
state parameter in the callback doesn’t match the stored state, the request is rejected.redirect_uri in your OAuth2 config matches exactly (including trailing slashes). Use urlencode() for dynamic URIs.CSRF Protection
csrf_token is not conflicting with OAuth2 state tokens.security.yaml:
firewalls:
main:
csrf_protection: ~
pattern: ^/oauth2
csrf_protection: false
Token Expiry
refresh_token is invalid.try {
$refreshedToken = $tokenManager->refreshToken($token);
} catch (OAuth2Exception $e) {
return $this->redirectToRoute('oauth2_authenticate');
}
Database Locking
StateStorage implementation.Provider-Specific Quirks
OAuth2Client to add provider-specific logic:
class GoogleOAuth2Client extends OAuth2Client
{
public function getAuthUri(array $options = []): string
{
$options['access_type'] = 'offline'; // Force refresh token
return parent::getAuthUri($options);
}
}
Enable Verbose Logging
Add this to config/packages/monolog.yaml:
handlers:
oauth2:
type: stream
path: "%kernel.logs_dir%/oauth2.log"
level: debug
channels: ["oauth2"]
Then enable the channel in OAuth2Client:
$client->setLogger($this->container->get('logger')->channel('oauth2'));
Inspect Token Responses Dump the raw token response in your authenticator:
public function getUser(): ?UserInterface
{
$tokenResponse = $this->getTokenResponse();
dump($tokenResponse); // Inspect claims/scopes
return $this->userProvider->loadUserByIdentifier($tokenResponse['email']);
}
Validate PKCE Flow Use PKCE validator tools to test your implementation manually.
BeyondBluesky\OAuth2PkceClient\Storage\StateStorageInterface for non-database storage (e.g., Redis):
class RedisStateStorage implements StateStorageInterface
{
public function saveState(State $state): void
{
$this->redis->set($state->getId(), $state->serialize());
}
public function findState(string $id): ?State
{
$data = $this->redis->get($id);
return $data ? State::unserialize($data) : null;
How can I help you explore Laravel packages today?