oxodao / qne-oauth-bundle
A Quick And Easy OAuth2 opiniated client bundle
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/oxodao/qne-oauth-bundle
Requires
- php: >=8.4
- ext-xml: *
- doctrine/doctrine-bundle: ^2.16|^3.0
- doctrine/orm: ^3.5
- lcobucci/jwt: ^5.6
- lexik/jwt-authentication-bundle: ^3.1
- symfony/framework-bundle: ^7.3
- symfony/http-client: ^7.3
- symfony/security-bundle: ^7.3
- symfony/serializer: ^7.3
Requires (Dev)
This package is auto-updated.
Last update: 2025-10-18 13:30:31 UTC
README
Quick'n'Easy OAuth Bundle is a Symfony bundle to easily add OAuth authentication to your API Platform project.
It piggy-backs on LexikJWTAuthenticationBundle and GesdinetJWTRefreshTokenBundle to provide a seamless integration of OAuth and JWT authentication.
Albeit a not the cleanest way to do it, the OAuth tokens are NO LONGER used once the user is connected. This let you use the same setup as for regular user without having to worry about what's in the token.
It also lets you customize the token for your app however you want (e.g. use Mercure).
Only offline oauth token are used server-side to refresh the user information (like roles) when your refresh token is called.
Currently only tested with Keycloak, it provides the following feature:
- Generate the OAuth login URL (Or directly redirect to it depending on your setup)
- Exchage the OAuth code for a JWT token
- Bring your own user entity
- Role synchronization
- Works in combination with standard password authentication
Warning
Although I tried to stay as close as possible to the OAuth2 standard, this was only tested with Keycloak, so not sure if it work with other providers.
It is also pretty tailored to my use case, so if you have any issue or want a feature, please open a PR.
For now, it only support ONE OAuth provider per project.
It does not support CSRF protection through the state parameter.
For now ROLES SYNCHRONIZATION IS MANDATORY ! At some point I'd like to have an option so that the provider's roles doesn't matter (Which would also stop the need for the user to log in through OAuth every time their offline token expires).
Usage
Keycloak
As an exemple, and because this is the only one currently implemented, let's setup Keycloak.
First we create a client in the master realm:
- Go to the admin interface, clients, "Create a client"
- Fill the ID of the client: my_project and set a name / description.
- Let the default settings on the second screen except "Client authentication" which you should enable.
- On the last screen, fill the values:
- Root URL:
http://localhost
=> Your app frontend base url - Home URL:
/
- Valid Redirect URIs:
/oauth-callback
=> The frontend URL that your OAuth provider will redirect to after login
- Root URL:
- In the roles you can create an "ROLE_ADMIN" role (or any role your app will need)
- Then in "Groups" you can create an "admin" group and assign the "admin" role to it in the mapping tab.
- Finally you can add / remove your users in the member tab of the group.
By default, the user will have to log in again every 30 days because of the offline token expiration.
You can change that by going into the realm settings and updating the "Offline Session Max" value in the "Tokens" tab.
You can get the client_secret
in the "Credentials" tab of your client.
Bundle setup
$ composer require oxodao/qne-oauth-bundle
Then enable the bundle in your config/bundles.php
:
<?php return [ // ... Oxodao\QneOAuthBundle\QneOauthBundle::class => ['all' => true], ];
First we need to setup the bundle, including the role parser, for keycloak here's how to do it in config/packages/qne_oauth.yaml
:
services: kc_role_parser: class: Oxodao\QneOAuthBundle\Service\TokenRoleParser\KeycloakTokenRoleParser arguments: $kcAppName: 'my_project' # The keycloak app name, which is visible in the `resource_access` field of the userinfo response qne_oauth: url: '%env(OAUTH_BASE_URL)%' # The base URL of your OAuth provider; for Keycloak it is something like https://YOUR_KEYCLOAK_URL/auth/realms/master/protocol/openid-connect client_id: '%env(OAUTH_CLIENT_ID)%' # The client ID client_secret: '%env(OAUTH_CLIENT_SECRET)%' # The client secret redirect_url: '%env(PUBLIC_URL)%/login-callback' # The frontend URL where the OAuth provider will redirect after login which will in turn call your API role_parser: 'kc_role_parser' # The role parser we created above user_updater: 'App\Service\OAuthUserUpdater' # A service implementing Oxodao\QneOAuthBundle\Service\OAuthUserUpdaterInterface that will return a non-persisted instance of your user based on the OAuth user infos user_entity: 'App\Entity\User' # Your user entity class login_url_as_json: true # Optional, when true the /login_oauth route will return the login URL as JSON instead of redirecting directly. Defaults to false
We will also setup the routes in config/routes/qne_oauth.yaml
:
qne_oauth_login_url: path: '/api/login_oauth' methods: [GET] controller: Oxodao\QneOAuthBundle\Controller\QneOAuthController::getLoginUrl qne_oauth_callback: path: '/api/login_oauth' methods: [POST] controller: Oxodao\QneOAuthBundle\Controller\QneOAuthController::loginCallback
Finally, in your security.yaml
we'll hook the custom UserChecker that take care of updating the roles on each token creation/refresh
[...] # This is your GesdinetJWTRefreshTokenBundle firewall login_refresh: pattern: ^/api/login_refresh stateless: true provider: user_provider user_checker: Oxodao\QneOAuthBundle\Security\OAuthUserChecker # <--- Add this line refresh_jwt: check_path: /api/login_refresh login: pattern: ^/api/login stateless: true provider: user_provider user_checker: Oxodao\QneOAuthBundle\Security\OAuthUserChecker # <--- Add this line json_login: check_path: /api/login success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure [...]
Once the main config is ready, you'll need to add some helpers on your entity:
class User implements [...], OAuthUserInterface { use OAuthUserTrait; }
Here's an example of the OAuthUserUpdater
:
<?php namespace App\Service; use App\Entity\User; use App\Enum\Language; use Oxodao\QneOAuthBundle\Behavior\OAuthUserInterface; use Oxodao\QneOAuthBundle\Model\OAuthUserInfos; use Oxodao\QneOAuthBundle\Service\OAuthUserUpdaterInterface; readonly class OAuthUserUpdater implements OAuthUserUpdaterInterface { public function __construct( private UniqueUsernameStrategy $uniqueUsernameStrategy, ) { } /** * Here we can update the user entity with the information received from the OAuth provider. * We NEED to setup everything required to persist the entity as it is also * used to create new users. */ public function update(?OAuthUserInterface $user, OAuthUserInfos $infos): OAuthUserInterface { if (null === $user) { $user = new User(); $uniqueUsername = $this->uniqueUsernameStrategy->generate($infos); if (!$uniqueUsername) { throw new \LogicException('Failed to generate a unique username'); } // The username can only be set once, at creation time $user->setUsername($uniqueUsername); } if (!$user instanceof User) { throw new \InvalidArgumentException('User must be an instance of App\Entity\User'); } $user->setEmail($infos->email); $user->setLanguage(Language::fromAlpha2($infos->locale)); return $user; } }
And the basic UniqueUsernameStrategy
used to generate unique usernames:
<?php namespace App\Service; use App\Repository\UserRepository; use Oxodao\QneOAuthBundle\Model\OAuthUserInfos; readonly class UniqueUsernameStrategy { public function __construct( private UserRepository $userRepository, ) { } public function generate(OAuthUserInfos $infos): ?string { $baseUsername = $infos->username ?? ''; if (\strlen($baseUsername) > 0) { $user = $this->userRepository->findOneByUsername($baseUsername); if (null === $user) { return $baseUsername; } } // The OAuth preferred_username is NOT GUARANTEED to be unique, so we try to append a random suffix to it if a user with the same username already exists if (\strlen($baseUsername) > 0) { $tries = 0; while ($tries < 20) { $randomSuffix = \random_int(1, 99999); $username = \sprintf('%s_%05d', $baseUsername, $randomSuffix); $user = $this->userRepository->findOneByUsername($username); if (null === $user) { return $username; } ++$tries; } } return null; } }
This will add the following info on your user:
- OAuth User Id: The unique ID from the OAuth provider
- OAuth Offline Token: An offline refresh token that lets the bundle refresh the user infos
As soon as your user has these info, the bundle will consider them as "OAuth users" and will try to update their infos on each token refresh and login.
/!\ This means that if the user's offline token expires, the user won't be able to login in any way unless they go through the OAuth login once again. /!\
Authenticating
To authenticate through OAuth, you need to do a GET request to /api/login_oauth
.
If you set login_url_as_json
to true, you'll get a JSON response with the login URL, otherwise you'll be redirected directly to the OAuth provider.
Then, the OAuth provider will redirect to your frontend URL with a code
query parameter.
From your frontend, you need to do a POST request to /api/login_oauth
with the body:
{"code": "the_code_from_the_query_parameters"}
You will get a standard token / refresh token response.
License
Copyright 2025 Oxodao
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.