├── LICENSE ├── README.md ├── UPGRADE.md ├── composer.json ├── docs └── ms-entra-id.md ├── phpstan-baseline.php ├── phpstan.dist.neon └── src ├── DependencyInjection ├── Configuration.php └── DrensoOidcExtension.php ├── DrensoOidcBundle.php ├── Enum └── OidcTokenType.php ├── EventListener └── OidcEndSessionSubscriber.php ├── Exception ├── OidcClientNotFoundException.php ├── OidcCodeChallengeMethodNotSupportedException.php ├── OidcConfigurationDisableUserInfoNotSupportedException.php ├── OidcConfigurationException.php ├── OidcConfigurationResolveException.php └── OidcException.php ├── Model ├── OidcIntrospectionData.php ├── OidcTokens.php ├── OidcUserData.php └── UnvalidatedOidcTokens.php ├── OidcClient.php ├── OidcClientInterface.php ├── OidcClientLocator.php ├── OidcJwtHelper.php ├── OidcSessionStorage.php ├── OidcTokenConstraintProviderInterface.php ├── OidcUrlFetcher.php ├── OidcWellKnownParserInterface.php ├── Resources └── config │ └── services.php └── Security ├── Exception ├── InvalidJwtTokenException.php ├── OidcAuthenticationException.php ├── OidcUserNotFoundException.php └── UnsupportedManagerException.php ├── Factory └── OidcFactory.php ├── OidcAuthenticator.php ├── Token └── OidcToken.php └── UserProvider └── OidcUserProviderInterface.php /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 intern 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony OIDC bundle 2 | 3 | This bundle can be used to add OIDC support to any Symfony application. We have only tested it with 4 | SURFconext OIDC, but it should work with any OIDC provider! 5 | 6 | Many thanks to https://github.com/jumbojett/OpenID-Connect-PHP for the implementation which this bundle uses although it has been modified to fix within an object oriented approach. 7 | 8 | > Note that this repository is automatically mirrored from our own Gitlab instance. 9 | > We will accept issues and merge requests here though! 10 | 11 | ### Version notes 12 | 13 | Since version 2 this bundle only supports Symfony's new authentication manager, introduced in Symfony 5.3. As the security 14 | manager matured in Symfony 5.4, that is the first version this bundle supports. Using the new authentication manager is 15 | required for Symfony 6! 16 | 17 | We also require the use of PHP8, as that significantly reduces the maintenance complexity. 18 | 19 | Do you need this bundle, but you cannot enable the new authentication manager or use PHP8? Check out 20 | the [v1.x](https://github.com/Drenso/symfony-oidc/tree/v1.x) branch and its documentation! 21 | 22 | ### Works with the following IdPs 23 | 24 | The following IdPs are known to work with this bundle: 25 | 26 | | IdP | Status | Remarks | 27 | |--------------------|--------|------------------------------------------------------------------------------------------------------------------------------------------------------| 28 | | Auth0 by Okta | ✅ | | 29 | | OpenConext | ✅ | Used by SURFconext in the Netherlands | 30 | | Keycloak | ✅ | | 31 | | Microsoft Entra ID | ✅ | Will not work with default configuration, make sure to follow [these steps.](https://github.com/Drenso/symfony-oidc/blob/master/docs/ms-entra-id.md) | 32 | 33 | If you are using this bundle with any other IdP, please submit a PR to add it! 34 | 35 | ### Migrate from older versions 36 | 37 | Take a look at [UPGRADE.md](https://github.com/Drenso/symfony-oidc/blob/master/UPGRADE.md)! 38 | 39 | ### Installation 40 | 41 | You can add this bundle by simply requiring it with composer: 42 | 43 | ```php 44 | composer require drenso/symfony-oidc-bundle 45 | ``` 46 | 47 | If you're using Symfony Flex, your `.env` file should have been appended with some environment variables and 48 | a `drenso_oidc.yaml` file should have been created in your configuration directory! 49 | 50 | ### Setup 51 | 52 | ##### OIDC Clients 53 | 54 | Make sure to configure at least the default OIDC client in the `drenso_oidc.yaml` in your `config/packages` directory. 55 | This can be done using the environment variables already added to your application by Symfony flex, or by updating the 56 | configuration file. You can configure more clients, they will be available under the `drenso.oidc.client.{name}`, and are 57 | autowirable by using `OidcClientInterface ${name}OidcClient`, for example `OidcClientInterface $defaultOidcClient`. If 58 | the name does not match with one of the configured clients, the default client will be autowired. 59 | 60 | Configuration example: 61 | 62 | ```yaml 63 | drenso_oidc: 64 | #default_client: default # The default client, will be aliased to OidcClientInterface 65 | clients: 66 | default: # The client name, each client will be aliased to its name (for example, $defaultOidcClient) 67 | # Required OIDC client configuration 68 | well_known_url: '%env(OIDC_WELL_KNOWN_URL)%' 69 | client_id: '%env(OIDC_CLIENT_ID)%' 70 | client_secret: '%env(OIDC_CLIENT_SECRET)%' 71 | 72 | # Extra configuration options 73 | #well_known_parser: ~ # Service id for a custom well-known configuration parser 74 | #well_known_cache_time: 3600 # Time in seconds, will only be used when symfony/cache is available 75 | #jwks_cache_time: 3600 # Time in seconds, will only be used when symfony/cache is available 76 | #token_leeway_seconds: 300 # Leeway time in seconds when validating token validity 77 | #redirect_route: '/login_check' 78 | #custom_client_headers: [] 79 | #code_challenge_method: ~ # Code challenge method, can be null, 'S256' or 'plain' 80 | #disable_nonce: false # Set to true when nonce verification should not be used 81 | 82 | # Add any extra client 83 | #link: # Will be accessible using $linkOidcClient 84 | #well_known_url: '%env(LINK_WELL_KNOWN_URL)%' 85 | #client_id: '%env(LINK_CLIENT_ID)%' 86 | #client_secret: '%env(LINK_CLIENT_SECRET)%' 87 | ``` 88 | 89 | ##### User provider 90 | 91 | You will need to update your User Provider to implement the methods from the `OidcUserProviderInterface`. Two methods 92 | need to be implemented: 93 | 94 | - `ensureUserExists(string $userIdentifier, OidcUserData $userData, OidcTokens $tokens)`: 95 | Implement this method to bootstrap a new account using the data available from the passed `OidcUserData` object. 96 | The identifier is a configurable property from the user data, which defaults to `sub`. 97 | If the account cannot be bootstrapped, authentication will be impossible as the 98 | User Provider will not be capable of retrieving the user. 99 | - `loadOidcUser(string $userIdentifier): UserInterface`: Implement this method to retrieve the user based on the 100 | identifier. We use a dedicated method instead of Symfony's default `loadUserByIdentifier` to allow you to detect where 101 | the login is coming from, without the need of creating a dedicated user provider. If the OIDC user identifiers are 102 | unique, a forward to the `loadUserByIdentifier` should be sufficient. 103 | 104 | ##### Firewall configuration 105 | 106 | If you are using Symfony <6, make sure to enable the new authentication manager in the `security.yaml`: 107 | 108 | ```yaml 109 | security: 110 | enable_authenticator_manager: true 111 | ``` 112 | 113 | Enable the `oidc` listener in the `security.yml` for your firewall: 114 | 115 | ```yaml 116 | security: 117 | firewalls: 118 | main: 119 | pattern: ^/ 120 | oidc: ~ 121 | ``` 122 | 123 | There are a couple of options available for the `oidc` listener. 124 | 125 | | Option | Default | Description | 126 | |----------------------------------|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------| 127 | | `check_path` | `/login_check` | Only on this path the authenticator will accept authentication. Note that this should match with the redirect configured for the OIDC client. | 128 | | `login_path` | `/login` | The path to forward to when authentication is required | 129 | | `client` | `default` | The configured OIDC client to use | 130 | | `user_identifier_property` | `sub` | The OidcUserData property to use as unique user identifier | 131 | | `user_identifier_from_idtoken` | `false` | The identifier is fetched from the id_token instead of userinfo endpoint | 132 | | `enable_remember_me` | `false` | Enable "remember me" functionality for authenticator | 133 | | `enable_end_session_listener` | `false` | Enable "logout" functionality for authenticator through the "LogoutEvent" | 134 | | `use_logout_target_path` | `true` | Used for the end session event subscriber | 135 | | `always_use_default_target_path` | `false` | Used for the success handler | 136 | | `default_target_path` | `/` | Used for the success handler | 137 | | `target_path_parameter` | `_target_path` | Used for the success handler | 138 | | `use_referer` | `false` | Used for the success handler | 139 | | `failure_path` | `null` | Used for the failure handler | 140 | | `failure_forward` | `false` | Used for the failure handler | 141 | | `failure_path_parameter` | `_failure_path` | Used for the failure handler | 142 | | `enable_retrieve_user_info` | `true` | Enable fetching data from the user info endpoint. *Required:* If disabled, `user_identifier_from_idtoken` must be set to `true`. | 143 | 144 | You can configure them directly under the `oidc` listener in your firewall, for example the `user_identifier_property`: 145 | 146 | ```yaml 147 | security: 148 | firewalls: 149 | main: 150 | oidc: 151 | user_identifier_property: email 152 | ``` 153 | 154 | ##### Start the authentication 155 | 156 | Use the controller example below to forward a user to the OIDC service: 157 | 158 | ```php 159 | /** 160 | * This controller forwards the user to the OIDC login 161 | * 162 | * @throws \Drenso\OidcBundle\Exception\OidcException 163 | */ 164 | #[Route('/login_oidc', name: 'login_oidc')] 165 | #[IsGranted('PUBLIC_ACCESS')] 166 | public function surfconext(OidcClientInterface $oidcClient): RedirectResponse 167 | { 168 | // Redirect to authorization @ OIDC provider 169 | return $oidcClient->generateAuthorizationRedirect(); 170 | } 171 | ``` 172 | 173 | > It is possible to supply prompt, scopes and additional query parameters to the `generateAuthorizationRedirect` method. 174 | 175 | > It is also possible to force remember me mode for the redirect. 176 | 177 | That should be all! 178 | 179 | ### User identifier 180 | 181 | By default, this bundle uses the `sub` property as user identifier, but any property from the retrieved user data can be used. Just configure the `user_identifier_property` with an property path string compatible with the [Symfony Property Accessor](https://symfony.com/doc/current/components/property_access.html) to retrieve the value you need. 182 | 183 | > Note that the object based access method is used to retrieve the properties from the user data. 184 | 185 | ### Remember me 186 | 187 | If you want to enable remember me functionality make sure that you add the `_remember_me=1` query parameter to the route being used to generate the redirect forward (the one that calls `generateAuthorizationRedirect`). 188 | 189 | You can override the `_remember_me` parameter per OIDC client. Just update the `remember_me_parameter` value in the client configuration. 190 | 191 | Lastly, make sure the Symfony remember me authenticator is enabled, and that you set the `enable_remember_me` option to true for the `oidc` authenticator in `security.yaml`. 192 | 193 | When a user is authenticated, you will see the `REMEMBERME` cookie. You can remove the `PHPSESSID` cookie to check whether remember me is working. 194 | 195 | ### Logout 196 | 197 | It is possible to enable "logout" through the `end_session_support` functionality of the Identity Provider, if the `end_session_endpoint` parameter is present in the .well-known endpoint it can be used. 198 | 199 | As logging out is fundamentally broken when using single sign-on, this option is disabled by default. This is due to the fact that logging out at the identity provider (for example: Azure, Facebook, etc) cannot guarantee the user is logged out of any other service that the user has authenticated with using the same identity provider. 200 | 201 | If you want to enable the "logout" support, simply add `enable_end_session_listener: true` to your oidc listener in the firewall config. It will only work of you enabled the default Symfony `logout: true` setting in your firewall. 202 | 203 | By default, the listener will pass the logout `target_path` to the OpenID Provider, so the user gets redirected back to your application after logging out. If you don't want this and want the user to remain at the logout confirmation page of your OpenID Provider, enable the `use_logout_target_path: false` setting. 204 | 205 | **_Example: default logout path_** 206 | 207 | ```yaml 208 | security: 209 | firewalls: 210 | main: 211 | logout: true 212 | oidc: 213 | enable_end_session_listener: true 214 | ``` 215 | 216 | **_Example: custom logout target path_** 217 | 218 | ```yaml 219 | security: 220 | firewalls: 221 | main: 222 | logout: 223 | target: /my_custom_target_path 224 | oidc: 225 | enable_end_session_listener: true 226 | ``` 227 | 228 | **_Example: disable redirect to logout `target_path`_** 229 | 230 | This will keep the user at the OpenID provider after login out. 231 | ```yaml 232 | security: 233 | firewalls: 234 | main: 235 | logout: true 236 | oidc: 237 | enable_end_session_listener: true 238 | use_logout_target_path: false 239 | ``` 240 | 241 | 242 | ### Client locator 243 | 244 | If for some reason you have several OIDC clients configured and need to retrieve them dynamically, you can use the `OidcClientLocator`. 245 | 246 | ```php 247 | public function surfconext(OidcClientLocator $clientLocator): RedirectResponse 248 | { 249 | return $clientLocator->getClient('your_client_id')->generateAuthorizationRedirect(); 250 | } 251 | ``` 252 | 253 | The locator will throw an OidcClientNotFoundException when the requested client is not found. When called without an argument, it will return the configured default client. 254 | 255 | ### Leeway 256 | 257 | This bundle uses a 300 seconds leeway when validating the access tokens. This value can be configured with the `token_leeway_seconds` client option. 258 | 259 | ### Cache 260 | 261 | When you have `symfony/cache` available in your project, this library will automatically cache the well known and jwks results. By default, it will be cached for `3600` seconds. 262 | 263 | You can disable the caches separately by passing `null` to the `well_known_cache_time` or `jwks_cache_time` client options. 264 | 265 | ### Refreshing tokens 266 | 267 | Currently, the firewall implementation provided by this bundle does not offer refresh tokens (as it should not be necessary). 268 | However, if you need to refresh the tokens yourself for your implementation, you can use the `refreshTokens` method on the `OidcClientInterface`! 269 | 270 | ### Additional token claim validation 271 | 272 | If you need to validate additional token claims, you can create a service which implements `OidcTokenConstraintProviderInterface` and add its service id to the OIDC client of your choice. 273 | 274 | Sample configuration: 275 | 276 | ```yaml 277 | drenso_oidc: 278 | clients: 279 | default: 280 | additional_token_constraints_provider: App\Security\AdditionalTokenConstraintProvider 281 | ``` 282 | 283 | Sample constraint provider: 284 | 285 | ```php 286 | namespace App\Security; 287 | 288 | use App\Security\Constraint\HasAudienceContaining; 289 | use Drenso\OidcBundle\Enum\OidcTokenType; 290 | use Drenso\OidcBundle\OidcTokenConstraintProviderInterface; 291 | 292 | class AdditionalTokenConstraintProvider implements OidcTokenConstraintProviderInterface 293 | { 294 | public function getAdditionalConstraints(OidcTokenType $tokenType): array 295 | { 296 | if (OidcTokenType::ID === $tokenType) { 297 | return [ 298 | new HasAudienceContaining('abc123'), 299 | ]; 300 | } 301 | 302 | if (OidcTokenType::ACCESS === $tokenType) { 303 | return [ 304 | new HasAudienceContaining('def456'), 305 | ]; 306 | } 307 | 308 | return []; 309 | } 310 | } 311 | ``` 312 | 313 | ### Parsing well-known information 314 | 315 | Some providers return incorrect or incomplete well known information. You can configure a custom well-known parser for the `OidcClient` by setting the `well_known_parser` to a service id which implements the `OidcWellKnownParserInterface`. 316 | 317 | ### OAuth 2.0 Token Exchange [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 318 | 319 | This bundle support Token Exchange: you can use the `exchangeTokens` on the `OidcClient` to do so. This was added with https://github.com/Drenso/symfony-oidc/pull/66, which contains some more background information regarding the procedure as well. 320 | 321 | ## Known usages 322 | 323 | A list of open source projects that use this bundle: 324 | 325 | - Zitadel's [example-symfony-oidc](https://github.com/zitadel/example-symfony-oidc): A template repository with basic OIDC authentication, user model, roles and example pages. 326 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | This file describes the steps you will need to make when upgrading this bundle. 2 | 3 | # From 3.x to 4.0 4 | 5 | - `OidcTokens` is added as a parameter to `Drenso\OidcBundle\Security\UserProvider\OidcUserProviderInterface::ensureUserExists()` method. To upgrade please add this parameter to your implementation of `OidcUserProviderInterface::ensureUserExists()`. 6 | - Explicit return types have been added where they were missing. You will need to adjust your implementations accordingly. 7 | - PHPStan typing has been added: this can impact you if you are using static analysis. 8 | 9 | # From 3.0 to 3.1 10 | 11 | - The `iss`, `iat`, `nbf` and `exp` claims set in the access token are only validated when they are actually available. While this is strictly speaking not according to the JWT specification, we have decided to handle these cases gracefully. 12 | 13 | # From 2.0 to 3.0 14 | 15 | - ES signing algorithms are now supported 16 | - Token validity verification now uses a configured leeway seconds value, which by default is 300 17 | - Configurable with `token_leeway_seconds` client option 18 | - The `iat` claim is now also taken into account 19 | - Additional cache has been added for JWK results caching 20 | - Cache can be disabled by setting the `jwks_cache_time` client option to `null` 21 | - Internal cache keys have been updated 22 | - `OidcJwtHelper` has been rewritten: if you were not calling this class yourself, you should be fine 23 | - Only a new `verifyTokens` function is now available which combines the old `verifyJwtSignature` and `verifyJwtClaims` methods 24 | - `decodeJwt` and `getIdTokenClaims` are no longer available, but you can get a decoded token object with `JwtHelper::parseToken()` 25 | - Support for `phpseclib/phpseclib` 2 has been dropped 26 | - The `getJwktUri` method typo in `OidcClient` has been fixed, it is now `getJwksUri` 27 | - The `OidcJwtHelper` is no longer passed to the `OidcAuthenticator` instance 28 | 29 | # From 2.0 to 2.1 30 | 31 | The following has changed: 32 | 33 | - Constructor parameters of a couple of classes (should all be managed by the bundle dependency injection) 34 | - Drenso\OidcBundle\OidcClient 35 | - Drenso\OidcBundle\OidcJwtHelper 36 | - Drenso\OidcBundle\Security\OidcAuthenticator 37 | - Session information is now stored per configured client, using a dedicated class 38 | - Remember me support has been added (disabled by default) 39 | 40 | # From 1.x to 2.x 41 | 42 | As 2.x is a major rewrite which actually leverages the Symfony configuration component, you will need to remove most of 43 | your previous configuration. 44 | 45 | ### What hasn't changed? 46 | 47 | The `oidc` key and its options remain the same in your firewall configuration 🎉 48 | 49 | ### Configuration to remove 50 | 51 | Remove the `OidcClient` registration from your `services.yaml` which passed the configuration values: 52 | 53 | ```yaml 54 | parameters: 55 | oidc.well_known_url: '%env(OIDC_WELL_KNOWN)%' 56 | oidc.client_id: '%env(OIDC_CLIENT_ID)%' 57 | oidc.client_secret: "%env(string:key:oidc:json:file:resolve:SECRETS_FILE)%" 58 | 59 | services: 60 | Drenso\OidcBundle\OidcClient: 61 | arguments: 62 | $wellKnownUrl: '%oidc.well_known_url%' 63 | $clientId: '%oidc.client_id%' 64 | $clientSecret: '%oidc.client_secret%' 65 | ``` 66 | 67 | Also, remove the security listeners: 68 | 69 | ```php 70 | services: 71 | security.authentication.provider.oidc: 72 | class: Drenso\OidcBundle\Security\Authentication\Provider\OidcProvider 73 | arguments: 74 | - '' 75 | - '@security.user_checker' 76 | - '@security.token_storage' 77 | - '@logger' 78 | 79 | security.authentication.listener.oidc: 80 | class: Drenso\OidcBundle\Security\Firewall\OidcListener 81 | arguments: 82 | - '@security.token_storage' 83 | - '@security.authentication.manager' 84 | - '@security.authentication.session_strategy' 85 | - '@security.http_utils' 86 | - '' 87 | - '' 88 | - '' 89 | - { } 90 | - '@logger' 91 | - '@Drenso\OidcBundle\OidcClient' 92 | ``` 93 | 94 | Remove the OidcFactory from the `Kernel.php` build method: 95 | 96 | ```php 97 | /** 98 | * @param ContainerBuilder $container 99 | */ 100 | protected function build(ContainerBuilder $container) 101 | { 102 | // Register the OIDC factory 103 | $extension = $container->getExtension('security'); 104 | assert($extension instanceof SecurityExtension); 105 | $extension->addSecurityListenerFactory(new OidcFactory()); 106 | } 107 | ``` 108 | 109 | ### Configuration to update 110 | 111 | First, make sure to let Flex install/update the recipe: 112 | 113 | ``` 114 | composer sync-recipes drenso/symfony-oidc-bundle --force 115 | ``` 116 | 117 | This will add the required configuration. Now, simply set a value for the newly added environment variables, or update 118 | the configuration file with your variables/parameters. 119 | 120 | ### Implementations to update 121 | 122 | First, you will need to update your User Provider to implement the new methods from the `OidcUserProviderInterface`, as 123 | the `loadUserByToken` is no longer used. Instead, now two methods need to be implemented: 124 | 125 | - `ensureUserExists(string $userIdentifier, OidcUserData $userData)`: Implement this method to bootstrap a new account 126 | using the data available from the passed `OidcUserData` object. The identifier is a configurable property from the 127 | user data, which defaults to `sub`. If the account cannot be bootstrapped, authentication will be impossible as the 128 | User Provider will not be capable of retrieving the user. 129 | - `loadOidcUser(string $userIdentifier): UserInterface`: Implement this method to retrieve the user based on the 130 | identifier. We use a dedicated method instead of Symfony's default `loadUserByIdentifier` to allow you to detect where 131 | the login is coming from, without the need of creating a dedicated user provider. If the OIDC user identifiers are 132 | unique, a forward to the `loadUserByIdentifier` should be sufficient. 133 | 134 | Secondly, it is no longer required to manually clear the security session when starting the OIDC authorization. You can remove the following lines from you redirect controller (if you had them): 135 | 136 | ```php 137 | // Remove errors from state 138 | $session->remove(Security::AUTHENTICATION_ERROR); 139 | $session->remove(Security::LAST_USERNAME); 140 | ``` 141 | 142 | ### login_check route 143 | 144 | The `login_check` route (see https://github.com/Drenso/symfony-oidc/issues/5) is no longer needed, we now default to the `/login_check` path as is normally used by the Symfony form login method as well. 145 | 146 | ### Caching 147 | 148 | By default, this bundle now caches the well known response for an hour. The cache time is configurable, but note that caching will only work when `symfony/cache` is available in your container. 149 | 150 | ### Breaking changes 151 | 152 | Most breaking changes are related to classes having been renamed or removed. If you did not rely on any of the 153 | internals, these changes should not be problematic for you. 154 | 155 | - `Drenso\OidcBundle\OidcTokens` has moved to `Drenso\OidcBundle\Model\OidcTokens`. The functionality has not changed. 156 | - `Drenso\OidcBundle\Security\Authentication\Token\OidcToken` has been replaced with two classes: 157 | - `Drenso\OidcBundle\Model\OidcUserData`: Contains the user data as retrieved from the OIDC provider 158 | - `Drenso\OidcBundle\Security\Token\OidcToken`: Contains the same data as the old `OidcToken`, but it is required to first retrieve the new `OidcUserData` value before being able to access the user data. Use `getAuthData` and `getUserData` on the token when you require them. 159 | - The `getUsername` method is removed. 160 | - It is no longer possible to override the retrieved OIDC data with the `setAuthData` and `setUserData` methods, but you can still do it using the token attributes (not recommended). 161 | - `Drenso\OidcBundle\OidcClient`: No longer returns `NULL` in case of missing query parameters of invalid state, instead it throws an `OidcException`. 162 | - The `retrieveUserInfo` method now return the user data wrapped in a `Drenso\OidcBundle\Model\OidcUserData` for easy access. 163 | - It is no longer possible to autowire the client with the `OidcClient` class, you will need to use the `OidcClientInterface` 164 | - `Drenso\OidcBundle\Security\Authentication\Provider\OidcProvider` is no longer available 165 | - `Drenso\OidcBundle\Security\EntryPoint\OidcEntryPoint` is no longer available 166 | - `Drenso\OidcBundle\Security\Firewall\OidcListener` is no longer available 167 | - `Drenso\OidcBundle\Security\Exception\OidcUsernameNotFoundException` has been renamed to `Drenso\OidcBundle\Security\Exception\OidcUserNotFoundException` 168 | - `Drenso\OidcBundle\Security\UserProvider\OidcUserProviderInterface` has been changed, see above. 169 | 170 | We've also annotated all methods using their actual return types, and removed doc block definitions where possible. 171 | 172 | ### Missing something? 173 | 174 | Feel free to open an issue! 175 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drenso/symfony-oidc-bundle", 3 | "description": "OpenID connect bundle for Symfony", 4 | "keywords": [ 5 | "symfony", 6 | "oidc", 7 | "openid connect" 8 | ], 9 | "type": "symfony-bundle", 10 | "homepage": "https://gitlab.drenso.nl/intern/symfony-oidc", 11 | "license": "Apache-2.0", 12 | "authors": [ 13 | { 14 | "name": "Bob van de Vijver", 15 | "email": "bob@drenso.nl" 16 | }, 17 | { 18 | "name": "Tobias Feijten", 19 | "email": "tobias@drenso.nl" 20 | } 21 | ], 22 | "require": { 23 | "php": ">=8.1", 24 | "ext-curl": "*", 25 | "ext-filter": "*", 26 | "ext-hash": "*", 27 | "ext-json": "*", 28 | "ext-mbstring": "*", 29 | "lcobucci/jwt": "^5.0", 30 | "phpseclib/phpseclib": "^3.0.36", 31 | "psr/container": "^1.1 || ^2.0", 32 | "psr/clock": "^1.0", 33 | "psr/log": "^1.1 || ^2.0 || ^3.0", 34 | "symfony/config": "^5.4 || ^6.3 || ^7.0", 35 | "symfony/dependency-injection": "^5.4 || ^6.3 || ^7.0", 36 | "symfony/event-dispatcher": "^5.4 || ^6.3 || ^7.0", 37 | "symfony/http-foundation": "^5.4 || ^6.3 || ^7.0", 38 | "symfony/http-kernel": "^5.4 || ^6.3 || ^7.0", 39 | "symfony/property-access": "^5.4 || ^6.3 || ^7.0", 40 | "symfony/security-bundle": "^5.4 || ^6.3 || ^7.0", 41 | "symfony/security-core": "^5.4 || ^6.3 || ^7.0", 42 | "symfony/security-http": "^5.4 || ^6.3 || ^7.0", 43 | "symfony/string": "^5.4 || ^6.3 || ^7.0" 44 | }, 45 | "require-dev": { 46 | "friendsofphp/php-cs-fixer": "3.75.0", 47 | "phpstan/extension-installer": "1.4.3", 48 | "phpstan/phpstan": "2.1.17", 49 | "phpstan/phpstan-deprecation-rules": "^2.0", 50 | "rector/rector": "2.0.17", 51 | "symfony/cache": "^5.4 || ^6.3 || ^7.0", 52 | "symfony/translation-contracts": "^2.0 || ^3.0" 53 | }, 54 | "suggest": { 55 | "symfony/cache": "When installed, IdP information will be automatically cached" 56 | }, 57 | "autoload": { 58 | "psr-4": { 59 | "Drenso\\OidcBundle\\": "src" 60 | } 61 | }, 62 | "config": { 63 | "sort-packages": true, 64 | "allow-plugins": { 65 | "phpstan/extension-installer": true 66 | } 67 | }, 68 | "extra": { 69 | "branch-alias": { 70 | "dev-master": "v3.x-dev" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /docs/ms-entra-id.md: -------------------------------------------------------------------------------- 1 | # Microsoft Entra ID 2 | 3 | While Microsoft Entra ID supports OpenID Connect, it is by default inconsistent with the tokens that are being generated. This is because Entra ID uses two tokens versions, and even when the V2 token endpoint is used it will return a V1 access token. 4 | 5 | ### Identify 6 | 7 | You can identify this issue using xdebug (break on the `verifyTokens` method call in the `OidcJwtHelper` class) to inspect the access token contents. If it contains the claim `ver` with value `1`, you are getting the wrong access token version. You can use the following statement when paused in that method: 8 | 9 | ```php 10 | self::parseToken($tokens->getTokenByType(OidcTokenType::ACCESS))->claims()->get('ver') 11 | ``` 12 | 13 | ### Solutions 14 | 15 | Luckily, with some simple configuration you can solve this issue. 16 | 17 | #### Option 1: Fully configure V2 tokens 18 | 19 | 1. According to the [Microsoft documentation](https://learn.microsoft.com/en-gb/entra/identity-platform/reference-app-manifest?WT.mc_id=Portal-Microsoft_AAD_RegisteredApps#accesstokenacceptedversion-attribute), you can set the `accessTokenAcceptedVersion` to `2` in the app manifest. It might take some time to propagate. 20 | 2. If that doesn't work, you can configure a custom scope. See [this comment](https://github.com/Drenso/symfony-oidc/issues/62#issuecomment-2151759001) for some more details on how that can look. Make sure to add your custom scope to your `generateAuthorizationRedirect` call, so Entra ID is forced to generate a V2 token. 21 | 22 | #### Option 2: Use V1 tokens 23 | 24 | > This is not the recommended solution, only use this if option 1 doesn't work for you! 25 | 26 | You can fully revert to the V1 tokens. This can be achieved by removing `/v2.0` from the well-known URL. 27 | 28 | ### Future 29 | 30 | According to Microsoft: 31 | 32 | > Starting August 2024, new Microsoft Entra applications created using any interface (including the Microsoft Entra admin center, Azure portal, Powershell/CLI, or the Microsoft Graph application API) will have the default value of the 'requestedAccessTokenVersion' property in the app registration set to '2'; this is a change from the previous default of 'null' (meaning '1'). This means that new resource applications receive v2 access tokens instead of v1 by default. This improves the security of apps. 33 | 34 | This might solve the issue completely for new applications. 35 | 36 | ### References 37 | 38 | - https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#v10-and-v20-tokens 39 | - https://learn.microsoft.com/en-gb/entra/identity-platform/reference-app-manifest?WT.mc_id=Portal-Microsoft_AAD_RegisteredApps#accesstokenacceptedversion-attribute 40 | - https://stackoverflow.com/questions/59790209/access-token-issuer-from-azure-ad-is-sts-windows-net-instead-of-login-microsofto 41 | - https://stackoverflow.com/questions/66693477/wrong-version-of-access-token-expect-v2-received-v1 42 | -------------------------------------------------------------------------------- /phpstan-baseline.php: -------------------------------------------------------------------------------- 1 | '#^Method Drenso\\\\OidcBundle\\\\Model\\\\OidcUserData\\:\\:getUserDataArray\\(\\) return type has no value type specified in iterable type array\\.$#', 6 | 'identifier' => 'missingType.iterableValue', 7 | 'count' => 1, 8 | 'path' => __DIR__ . '/src/Model/OidcUserData.php', 9 | ]; 10 | $ignoreErrors[] = [ 11 | 'message' => '#^Access to constant AUTHENTICATION_ERROR on an unknown class Symfony\\\\Component\\\\Security\\\\Core\\\\Security\\.$#', 12 | 'identifier' => 'class.notFound', 13 | 'count' => 1, 14 | 'path' => __DIR__ . '/src/OidcClient.php', 15 | ]; 16 | $ignoreErrors[] = [ 17 | 'message' => '#^Access to constant LAST_USERNAME on an unknown class Symfony\\\\Component\\\\Security\\\\Core\\\\Security\\.$#', 18 | 'identifier' => 'class.notFound', 19 | 'count' => 1, 20 | 'path' => __DIR__ . '/src/OidcClient.php', 21 | ]; 22 | $ignoreErrors[] = [ 23 | 'message' => '#^Access to undefined constant Symfony\\\\Bundle\\\\SecurityBundle\\\\Security\\:\\:AUTHENTICATION_ERROR\\.$#', 24 | 'identifier' => 'classConstant.notFound', 25 | 'count' => 1, 26 | 'path' => __DIR__ . '/src/OidcClient.php', 27 | ]; 28 | $ignoreErrors[] = [ 29 | 'message' => '#^Access to undefined constant Symfony\\\\Bundle\\\\SecurityBundle\\\\Security\\:\\:LAST_USERNAME\\.$#', 30 | 'identifier' => 'classConstant.notFound', 31 | 'count' => 1, 32 | 'path' => __DIR__ . '/src/OidcClient.php', 33 | ]; 34 | $ignoreErrors[] = [ 35 | 'message' => '#^Method Drenso\\\\OidcBundle\\\\OidcUrlFetcher\\:\\:fetchUrl\\(\\) should return string but returns string\\|true\\.$#', 36 | 'identifier' => 'return.type', 37 | 'count' => 1, 38 | 'path' => __DIR__ . '/src/OidcUrlFetcher.php', 39 | ]; 40 | $ignoreErrors[] = [ 41 | 'message' => '#^Parameter \\#2 \\$option of function curl_setopt expects int, string given\\.$#', 42 | 'identifier' => 'argument.type', 43 | 'count' => 1, 44 | 'path' => __DIR__ . '/src/OidcUrlFetcher.php', 45 | ]; 46 | $ignoreErrors[] = [ 47 | 'message' => '#^Method Drenso\\\\OidcBundle\\\\Security\\\\Factory\\\\OidcFactory\\:\\:createAuthProvider\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#', 48 | 'identifier' => 'missingType.iterableValue', 49 | 'count' => 1, 50 | 'path' => __DIR__ . '/src/Security/Factory/OidcFactory.php', 51 | ]; 52 | $ignoreErrors[] = [ 53 | 'message' => '#^Parameter \\$passport of method Drenso\\\\OidcBundle\\\\Security\\\\OidcAuthenticator\\:\\:createAuthenticatedToken\\(\\) has invalid type Symfony\\\\Component\\\\Security\\\\Http\\\\Authenticator\\\\Passport\\\\PassportInterface\\.$#', 54 | 'identifier' => 'class.notFound', 55 | 'count' => 1, 56 | 'path' => __DIR__ . '/src/Security/OidcAuthenticator.php', 57 | ]; 58 | 59 | return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; 60 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.php 3 | 4 | parameters: 5 | level: 8 6 | tmpDir: var/cache/phpstan 7 | paths: 8 | - src/ 9 | excludePaths: 10 | - src/DependencyInjection/Configuration.php 11 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 15 | ->fixXmlConfig('client') 16 | ->children() 17 | ->scalarNode('default_client') 18 | ->info('The default client to use') 19 | ->defaultValue('default') 20 | ->end() 21 | ->arrayNode('clients') 22 | ->useAttributeAsKey('name') 23 | ->requiresAtLeastOneElement() 24 | ->arrayPrototype() 25 | ->children() 26 | ->scalarNode('well_known_url') 27 | ->isRequired() 28 | ->end() // well_known_url 29 | ->scalarNode('well_known_parser') 30 | ->defaultNull() 31 | ->end() // well_known_parser 32 | ->scalarNode('additional_token_constraints_provider') 33 | ->defaultNull() 34 | ->end() // additional_token_constraints_provider 35 | ->scalarNode('well_known_cache_time') 36 | ->defaultValue(3600) 37 | ->validate() 38 | ->ifTrue(fn ($value) => $value !== null && !is_int($value)) 39 | ->thenInvalid('Must be either null or an integer value') 40 | ->end() 41 | ->end() // well_known_cache_time 42 | ->scalarNode('jwks_cache_time') 43 | ->defaultValue(3600) 44 | ->validate() 45 | ->ifTrue(fn ($value) => $value !== null && !is_int($value)) 46 | ->thenInvalid('Must be either null or an integer value') 47 | ->end() 48 | ->end() // jwks_cache_time 49 | ->scalarNode('token_leeway_seconds') 50 | ->defaultValue(300) 51 | ->validate() 52 | ->ifTrue(fn ($value) => !is_int($value) || $value < 0) 53 | ->thenInvalid('Must be an integer value and greater or equal to zero') 54 | ->end() 55 | ->end() // token_leeway_seconds 56 | ->scalarNode('client_id') 57 | ->isRequired() 58 | ->end() // client_id 59 | ->scalarNode('client_secret') 60 | ->isRequired() 61 | ->end() // client_secret 62 | ->scalarNode('redirect_route') 63 | ->defaultValue('/login_check') 64 | ->end() // redirect_route 65 | ->arrayNode('custom_client_headers') 66 | ->scalarPrototype()->end() 67 | ->end() // custom_client_headers 68 | ->arrayNode('custom_client_options') 69 | ->scalarPrototype()->end() 70 | ->end() // custom_client_options 71 | ->scalarNode('remember_me_parameter') 72 | ->defaultValue('_remember_me') 73 | ->end() // remember_me_parameter 74 | ->scalarNode('code_challenge_method') 75 | ->defaultNull() 76 | ->validate() 77 | ->ifNotInArray([null, 'S256', 'plain']) 78 | ->thenInvalid('Invalid code challenge method %s') 79 | ->end() 80 | ->end() // code_challenge_method 81 | ->booleanNode('disable_nonce') 82 | ->defaultFalse() 83 | ->end() // disable_nonce 84 | ->end() // array prototype children 85 | ->end() // array prototype 86 | ->end() // clients 87 | ->end(); // root children 88 | 89 | return $treeBuilder; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/DependencyInjection/DrensoOidcExtension.php: -------------------------------------------------------------------------------- 1 | $mergedConfig */ 26 | public function loadInternal(array $mergedConfig, ContainerBuilder $container): void 27 | { 28 | // Autoload configured services 29 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 30 | $loader->load('services.php'); 31 | 32 | // Load the configured clients 33 | $clientServices = []; 34 | foreach ($mergedConfig['clients'] as $clientName => $clientConfig) { 35 | $clientServices[$clientName] = $this->registerClient($container, $clientName, $clientConfig); 36 | } 37 | 38 | // Setup default alias 39 | $container 40 | ->setAlias(OidcClientInterface::class, sprintf('drenso.oidc.client.%s', $mergedConfig['default_client'])); 41 | 42 | // Configure client locator 43 | $container 44 | ->getDefinition(self::CLIENT_LOCATOR_ID) 45 | ->addArgument(ServiceLocatorTagPass::register($container, $clientServices)) 46 | ->addArgument($mergedConfig['default_client']); 47 | } 48 | 49 | /** @param array $config */ 50 | private function registerClient(ContainerBuilder $container, string $name, array $config): Reference 51 | { 52 | $urlFetcherId = sprintf('%s.%s', self::URL_FETCHER_ID, $name); 53 | $container 54 | ->setDefinition($urlFetcherId, new ChildDefinition(self::URL_FETCHER_ID)) 55 | ->addArgument($config['custom_client_headers']) 56 | ->addArgument($config['custom_client_options']); 57 | 58 | $sessionStorageId = sprintf('%s.%s', self::SESSION_STORAGE_ID, $name); 59 | $container 60 | ->setDefinition($sessionStorageId, new ChildDefinition(self::SESSION_STORAGE_ID)) 61 | ->addArgument($name); 62 | 63 | $jwtHelperId = sprintf('%s.%s', self::JWT_HELPER_ID, $name); 64 | $additionalTokenConstraintsProviderId = $config['additional_token_constraints_provider']; 65 | $container 66 | ->setDefinition($jwtHelperId, new ChildDefinition(self::JWT_HELPER_ID)) 67 | ->addArgument(new Reference($urlFetcherId)) 68 | ->addArgument(new Reference($sessionStorageId)) 69 | ->addArgument($config['client_id']) 70 | ->addArgument($config['jwks_cache_time']) 71 | ->addArgument($config['token_leeway_seconds']) 72 | ->addArgument($additionalTokenConstraintsProviderId ? new Reference($additionalTokenConstraintsProviderId) : null); 73 | 74 | $clientId = sprintf('%s.%s', self::CLIENT_ID, $name); 75 | $wellKnownParserId = $config['well_known_parser']; 76 | $container 77 | ->setDefinition($clientId, new ChildDefinition(self::CLIENT_ID)) 78 | ->addArgument(new Reference($urlFetcherId)) 79 | ->addArgument(new Reference($sessionStorageId)) 80 | ->addArgument(new Reference($jwtHelperId)) 81 | ->addArgument($config['well_known_url']) 82 | ->addArgument($config['well_known_cache_time']) 83 | ->addArgument($config['client_id']) 84 | ->addArgument($config['client_secret']) 85 | ->addArgument($config['redirect_route']) 86 | ->addArgument($config['remember_me_parameter']) 87 | ->addArgument($wellKnownParserId ? new Reference($wellKnownParserId) : null) 88 | ->addArgument($config['code_challenge_method']) 89 | ->addArgument($config['disable_nonce']); 90 | 91 | $container 92 | ->registerAliasForArgument($clientId, OidcClientInterface::class, sprintf('%sOidcClient', $name)); 93 | 94 | return new Reference($clientId); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/DrensoOidcBundle.php: -------------------------------------------------------------------------------- 1 | getExtension('security'); 18 | assert($extension instanceof SecurityExtension); 19 | $extension->addAuthenticatorFactory(new OidcFactory()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Enum/OidcTokenType.php: -------------------------------------------------------------------------------- 1 | getToken(); 31 | 32 | if (!$token instanceof OidcToken) { 33 | return; 34 | } 35 | 36 | $postLogoutRedirectUrl = null !== $this->logoutTarget 37 | ? $this->httpUtils->generateUri($event->getRequest(), $this->logoutTarget) 38 | : null; 39 | 40 | $event->setResponse($this->oidcClient->generateEndSessionEndpointRedirect( 41 | $token->getAuthData(), 42 | $postLogoutRedirectUrl 43 | )); 44 | } 45 | 46 | public static function getSubscribedEvents(): array 47 | { 48 | return [LogoutEvent::class => 'onLogout']; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Exception/OidcClientNotFoundException.php: -------------------------------------------------------------------------------- 1 | $introspectionData */ 15 | public function __construct(array $introspectionData) 16 | { 17 | // Cast the array data to a stdClass for easy access 18 | $this->introspectionData = (object)$introspectionData; 19 | } 20 | 21 | public function isActive(): bool 22 | { 23 | return $this->getIntrospectionDataBoolean('active'); 24 | } 25 | 26 | /** Get the OIDC scope claim */ 27 | public function getScope(): string 28 | { 29 | return $this->getIntrospectionDataString('scope'); 30 | } 31 | 32 | /** Get the OIDC client_id claim */ 33 | public function getClientId(): string 34 | { 35 | return $this->getIntrospectionDataString('client_id'); 36 | } 37 | 38 | /** Get the OIDC username claim */ 39 | public function getUsername(): string 40 | { 41 | return $this->getIntrospectionDataString('username'); 42 | } 43 | 44 | /** Get the OIDC token_type claim */ 45 | public function getTokenType(): string 46 | { 47 | return $this->getIntrospectionDataString('token_type'); 48 | } 49 | 50 | /** Get the OIDC exp claim */ 51 | public function getExp(): ?int 52 | { 53 | return $this->getIntrospectionDataInteger('exp'); 54 | } 55 | 56 | /** Get the OIDC iat claim */ 57 | public function getIat(): ?int 58 | { 59 | return $this->getIntrospectionDataInteger('iat'); 60 | } 61 | 62 | /** Get the OIDC nbf claim */ 63 | public function getNbf(): ?int 64 | { 65 | return $this->getIntrospectionDataInteger('nbf'); 66 | } 67 | 68 | /** Get the OIDC sub claim */ 69 | public function getSub(): string 70 | { 71 | return $this->getIntrospectionDataString('sub'); 72 | } 73 | 74 | /** 75 | * Get the OIDC aud claim. 76 | * 77 | * @return string|string[] 78 | */ 79 | public function getAud(): string|array 80 | { 81 | return $this->getIntrospectionDataStringOrArray('aud'); 82 | } 83 | 84 | /** Get the OIDC iss claim */ 85 | public function getIss(): string 86 | { 87 | return $this->getIntrospectionDataString('iss'); 88 | } 89 | 90 | /** Get the OIDC jti claim */ 91 | public function getJti(): string 92 | { 93 | return $this->getIntrospectionDataString('jti'); 94 | } 95 | 96 | /** Get a boolean property from the introspection data */ 97 | public function getIntrospectionDataBoolean(string $key): bool 98 | { 99 | return $this->getIntrospectionData($key) ?: false; 100 | } 101 | 102 | /** Get a string property from the introspection data */ 103 | public function getIntrospectionDataString(string $key): string 104 | { 105 | return $this->getIntrospectionData($key) ?: ''; 106 | } 107 | 108 | /** 109 | * Get a string property from the introspection data. 110 | * 111 | * @return string|string[] 112 | */ 113 | public function getIntrospectionDataStringOrArray(string $key): string|array 114 | { 115 | return $this->getIntrospectionData($key) ?: ''; 116 | } 117 | 118 | /** Get a integer property from the introspection data */ 119 | public function getIntrospectionDataInteger(string $key): ?int 120 | { 121 | return $this->getIntrospectionData($key) ?: null; 122 | } 123 | 124 | public function getIntrospectionData(string $propertyPath): mixed 125 | { 126 | self::$accessor ??= PropertyAccess::createPropertyAccessorBuilder() 127 | ->disableExceptionOnInvalidIndex() 128 | ->disableExceptionOnInvalidPropertyPath() 129 | ->getPropertyAccessor(); 130 | 131 | // Cast the introspection data to a stdClass 132 | return self::$accessor->getValue($this->introspectionData, $propertyPath); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Model/OidcTokens.php: -------------------------------------------------------------------------------- 1 | idToken) || (null === $tokens->accessToken)) { 17 | throw new OidcException('Invalid token object.'); 18 | } 19 | } else { 20 | if (!isset($tokens->id_token) || !isset($tokens->access_token)) { 21 | throw new OidcException('Invalid token object.'); 22 | } 23 | } 24 | 25 | parent::__construct($tokens); 26 | } 27 | 28 | public function getAccessToken(): string 29 | { 30 | return $this->accessToken ?? throw new OidcException('Access token not available'); 31 | } 32 | 33 | public function getIdToken(): string 34 | { 35 | return $this->idToken ?? throw new OidcException('ID token not available'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Model/OidcUserData.php: -------------------------------------------------------------------------------- 1 | $userData */ 15 | public function __construct(array $userData) 16 | { 17 | // Cast the array data to a stdClass for easy access 18 | $this->userData = (object)$userData; 19 | } 20 | 21 | /** Get the OIDC sub claim */ 22 | public function getSub(): string 23 | { 24 | return $this->getUserDataString('sub'); 25 | } 26 | 27 | /** Get the OIDC preferred_username claim */ 28 | public function getDisplayName(): string 29 | { 30 | return $this->getUserDataString('preferred_username'); 31 | } 32 | 33 | /** Get the OIDC family_name claim */ 34 | public function getFamilyName(): string 35 | { 36 | return $this->getUserDataString('family_name'); 37 | } 38 | 39 | /** Get the OIDC name claim */ 40 | public function getFullName(): string 41 | { 42 | return $this->getUserDataString('name'); 43 | } 44 | 45 | /** Get the OIDC given_name claim */ 46 | public function getGivenName(): string 47 | { 48 | return $this->getUserDataString('given_name'); 49 | } 50 | 51 | /** Get the OIDC email claim */ 52 | public function getEmail(): string 53 | { 54 | return $this->getUserDataString('email'); 55 | } 56 | 57 | /** Get the OIDC email verified claim */ 58 | public function getEmailVerified(): bool 59 | { 60 | return $this->getUserDataBoolean('email_verified'); 61 | } 62 | 63 | /** Get the OIDC schac_home_organization claim */ 64 | public function getOrganisation(): string 65 | { 66 | return $this->getUserDataString('schac_home_organization'); 67 | } 68 | 69 | /** 70 | * Get the OIDC edu_person_affiliations claim. 71 | * 72 | * @return string[] 73 | */ 74 | public function getAffiliations(): array 75 | { 76 | return $this->getUserDataArray('eduperson_affiliation'); 77 | } 78 | 79 | /** 80 | * Get the OIDC uids claim. 81 | * 82 | * @return string[] 83 | */ 84 | public function getUids(): array 85 | { 86 | return $this->getUserDataArray('uids'); 87 | } 88 | 89 | /** Get a boolean property from the user data */ 90 | public function getUserDataBoolean(string $key): bool 91 | { 92 | return $this->getUserData($key) ?: false; 93 | } 94 | 95 | /** Get a string property from the user data */ 96 | public function getUserDataString(string $key): string 97 | { 98 | return $this->getUserData($key) ?: ''; 99 | } 100 | 101 | /** Get an array property from the user data. */ 102 | public function getUserDataArray(string $key): array 103 | { 104 | return $this->getUserData($key) ?: []; 105 | } 106 | 107 | public function getUserData(string $propertyPath): mixed 108 | { 109 | self::$accessor ??= PropertyAccess::createPropertyAccessorBuilder() 110 | ->disableExceptionOnInvalidIndex() 111 | ->disableExceptionOnInvalidPropertyPath() 112 | ->getPropertyAccessor(); 113 | 114 | // Cast the user data to a stdClass 115 | return self::$accessor->getValue($this->userData, $propertyPath); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Model/UnvalidatedOidcTokens.php: -------------------------------------------------------------------------------- 1 | accessToken = $tokens->accessToken; 24 | $this->idToken = $tokens->idToken; 25 | $this->expiry = $tokens->expiry ? DateTimeImmutable::createFromInterface($tokens->expiry) : null; 26 | $this->refreshToken = $tokens->refreshToken; 27 | $this->scope = $tokens->scope; 28 | 29 | return; 30 | } 31 | 32 | $this->accessToken = $tokens->access_token ?? null; 33 | $this->idToken = $tokens->id_token ?? null; 34 | 35 | if (isset($tokens->expires_in)) { 36 | $this->expiry = DateTimeImmutable::createFromFormat('U', (string)(time() + $tokens->expires_in)) 37 | ?: throw new RuntimeException('Failed to generate expiry'); 38 | } 39 | 40 | if (isset($tokens->refresh_token)) { 41 | $this->refreshToken = $tokens->refresh_token; 42 | } 43 | 44 | if (isset($tokens->scope)) { 45 | $this->scope = explode(' ', (string)$tokens->scope); 46 | } 47 | } 48 | 49 | public function getAccessToken(): ?string 50 | { 51 | return $this->accessToken; 52 | } 53 | 54 | public function getExpiry(): ?DateTimeImmutable 55 | { 56 | return $this->expiry; 57 | } 58 | 59 | public function getIdToken(): ?string 60 | { 61 | return $this->idToken; 62 | } 63 | 64 | public function getRefreshToken(): ?string 65 | { 66 | return $this->refreshToken; 67 | } 68 | 69 | public function getTokenByType(OidcTokenType $type): ?string 70 | { 71 | return match ($type) { 72 | OidcTokenType::ID => $this->idToken, 73 | OidcTokenType::ACCESS => $this->accessToken, 74 | OidcTokenType::REFRESH => $this->refreshToken, 75 | }; 76 | } 77 | 78 | /** @return string[]|null */ 79 | public function getScope(): ?array 80 | { 81 | return $this->scope; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/OidcClient.php: -------------------------------------------------------------------------------- 1 | OIDC configuration values */ 33 | protected ?array $configuration = null; 34 | private ?string $cacheKey = null; 35 | private const PKCE_ALGORITHMS = [ 36 | 'S256' => 'sha256', 37 | 'plain' => false, 38 | ]; 39 | 40 | /** 41 | * @param non-empty-string $wellKnownUrl 42 | * @param non-empty-string $clientId 43 | */ 44 | public function __construct( 45 | protected RequestStack $requestStack, 46 | protected HttpUtils $httpUtils, 47 | protected ?CacheInterface $wellKnownCache, 48 | protected OidcUrlFetcher $urlFetcher, 49 | protected OidcSessionStorage $sessionStorage, 50 | protected OidcJwtHelper $jwtHelper, 51 | protected string $wellKnownUrl, 52 | private readonly ?int $wellKnownCacheTime, 53 | private readonly string $clientId, 54 | private readonly string $clientSecret, 55 | private readonly string $redirectRoute, 56 | private readonly string $rememberMeParameter, 57 | protected ?OidcWellKnownParserInterface $wellKnownParser = null, 58 | private readonly ?string $codeChallengeMethod = null, 59 | private readonly bool $disableNonce = false) 60 | { 61 | if (!$this->wellKnownUrl || filter_var($this->wellKnownUrl, FILTER_VALIDATE_URL) === false) { 62 | throw new LogicException(sprintf('Invalid well known url (%s) for OIDC', $this->wellKnownUrl)); 63 | } 64 | 65 | if ($this->codeChallengeMethod && !array_key_exists($this->codeChallengeMethod, self::PKCE_ALGORITHMS)) { 66 | throw new LogicException(sprintf('Invalid PKCE algorithm (%s) for code challenge method', $this->codeChallengeMethod)); 67 | } 68 | } 69 | 70 | public function authenticate(Request $request): OidcTokens 71 | { 72 | // Check whether the request has an error state 73 | if ($request->request->has('error')) { 74 | throw new OidcAuthenticationException(sprintf('OIDC error: %s. Description: %s.', 75 | $request->request->get('error', ''), $request->request->get('error_description', ''))); 76 | } 77 | 78 | // Check whether the request contains the required state and code keys 79 | if (!$code = (string)$request->query->get('code')) { 80 | throw new OidcAuthenticationException('Missing code in query'); 81 | } 82 | if (!$state = (string)$request->query->get('state')) { 83 | throw new OidcAuthenticationException('Missing state in query'); 84 | } 85 | 86 | // Do a session check 87 | if ($state != $this->sessionStorage->getState()) { 88 | // Fail silently 89 | throw new OidcAuthenticationException('Invalid session state'); 90 | } 91 | 92 | // Clear session after check 93 | $this->sessionStorage->clearState(); 94 | 95 | // Request and verify the tokens 96 | return $this->verifyTokens( 97 | $this->requestTokens('authorization_code', $code, $this->getRedirectUrl()), 98 | !$this->disableNonce 99 | ); 100 | } 101 | 102 | public function refreshTokens(string $refreshToken, ?string $targetScope = null): OidcTokens 103 | { 104 | // Clear session after check 105 | $this->sessionStorage->clearState(); 106 | 107 | // Request and verify the tokens 108 | return $this->verifyTokens( 109 | $this->requestTokens( 110 | 'refresh_token', 111 | refreshToken: $refreshToken, 112 | scope: $targetScope 113 | ), 114 | verifyNonce: false 115 | ); 116 | } 117 | 118 | public function exchangeTokens(string $accessToken, ?string $targetScope = null, ?string $targetAudience = null): OidcTokens 119 | { 120 | // Clear session after check 121 | $this->sessionStorage->clearState(); 122 | 123 | // Request and verify exchange tokens 124 | $tokens = new OidcTokens( 125 | $this->requestTokens( 126 | 'urn:ietf:params:oauth:grant-type:token-exchange', 127 | subjectToken: $accessToken, 128 | scope: $targetScope, 129 | audience: $targetAudience 130 | ) 131 | ); 132 | $this->jwtHelper->verifyAccessToken($this->getIssuer(), $this->getJwksUri(), $tokens, false); 133 | 134 | return $tokens; 135 | } 136 | 137 | public function generateAuthorizationRedirect( 138 | ?string $prompt = null, 139 | array $scopes = ['openid'], 140 | bool $forceRememberMe = false, 141 | array $additionalQueryParams = []): RedirectResponse 142 | { 143 | $data = array_merge($additionalQueryParams, [ 144 | 'client_id' => $this->clientId, 145 | 'response_type' => 'code', 146 | 'redirect_uri' => $this->getRedirectUrl(), 147 | 'scope' => implode(' ', $scopes), 148 | 'state' => $this->generateState(), 149 | ]); 150 | 151 | if (!$this->disableNonce) { 152 | $data['nonce'] = $this->generateNonce(); 153 | } 154 | 155 | if ($prompt) { 156 | $validPrompts = ['none', 'login', 'consent', 'select_account', 'create']; 157 | if (!in_array($prompt, $validPrompts)) { 158 | throw new InvalidArgumentException(sprintf( 159 | 'The prompt parameter need to be one of ("%s"), but "%s" given', 160 | implode('", "', $validPrompts), 161 | $prompt 162 | )); 163 | } 164 | 165 | $data['prompt'] = $prompt; 166 | } 167 | 168 | if ($this->codeChallengeMethod) { 169 | $data = array_merge($data, [ 170 | 'code_challenge' => $this->generateCodeChallenge(), 171 | 'code_challenge_method' => $this->codeChallengeMethod, 172 | ]); 173 | } 174 | 175 | // Store remember me state 176 | $parameter = $this->requestStack->getCurrentRequest()?->get($this->rememberMeParameter); 177 | $this->sessionStorage->storeRememberMe($forceRememberMe || 'true' === $parameter || 'on' === $parameter || '1' === $parameter || 'yes' === $parameter || true === $parameter); 178 | 179 | // Remove security session state 180 | $session = $this->requestStack->getSession(); 181 | 182 | // BC for attribute definition 183 | $session->remove(match (true) { 184 | // Symfony 7 185 | defined('\Symfony\Component\Security\Http\SecurityRequestAttributes::AUTHENTICATION_ERROR') => \Symfony\Component\Security\Http\SecurityRequestAttributes::AUTHENTICATION_ERROR, 186 | // Symfony 6 187 | defined('\Symfony\Bundle\SecurityBundle\Security::AUTHENTICATION_ERROR') => \Symfony\Bundle\SecurityBundle\Security::AUTHENTICATION_ERROR, 188 | // Symfony 5 189 | default => \Symfony\Component\Security\Core\Security::AUTHENTICATION_ERROR, 190 | }); 191 | $session->remove(match (true) { 192 | // Symfony 7 193 | defined('\Symfony\Component\Security\Http\SecurityRequestAttributes::LAST_USERNAME') => \Symfony\Component\Security\Http\SecurityRequestAttributes::LAST_USERNAME, 194 | // Symfony 6 195 | defined('\Symfony\Bundle\SecurityBundle\Security::LAST_USERNAME') => \Symfony\Bundle\SecurityBundle\Security::LAST_USERNAME, 196 | // Symfony 5 197 | default => \Symfony\Component\Security\Core\Security::LAST_USERNAME, 198 | }); 199 | 200 | $endpointHasQuery = parse_url($this->getAuthorizationEndpoint(), PHP_URL_QUERY); 201 | 202 | return new RedirectResponse(sprintf('%s%s%s', $this->getAuthorizationEndpoint(), $endpointHasQuery ? '&' : '?', http_build_query($data))); 203 | } 204 | 205 | public function generateEndSessionEndpointRedirect( 206 | OidcTokens $tokens, 207 | ?string $postLogoutRedirectUrl = null, 208 | array $additionalQueryParams = []): RedirectResponse 209 | { 210 | $data = array_merge($additionalQueryParams, [ 211 | 'client_id' => $this->clientId, 212 | 'id_token_hint' => $tokens->getIdToken(), 213 | ]); 214 | 215 | if (null !== $postLogoutRedirectUrl) { 216 | $data = array_merge($data, [ 217 | 'post_logout_redirect_uri' => $postLogoutRedirectUrl, 218 | ]); 219 | } 220 | 221 | $endpointHasQuery = parse_url($this->getEndSessionEndpoint(), PHP_URL_QUERY); 222 | 223 | return new RedirectResponse(sprintf('%s%s%s', $this->getEndSessionEndpoint(), $endpointHasQuery ? '&' : '?', http_build_query($data))); 224 | } 225 | 226 | public function retrieveUserInfo(OidcTokens $tokens): OidcUserData 227 | { 228 | // Set the authorization header 229 | $headers = ["Authorization: Bearer {$tokens->getAccessToken()}"]; 230 | 231 | // Retrieve the user information and convert the encoding to UTF-8 to harden for surfconext UTF-8 bug 232 | $jsonData = $this->urlFetcher->fetchUrl($this->getUserinfoEndpoint(), null, $headers); 233 | $jsonData = mb_convert_encoding($jsonData, 'UTF-8'); 234 | if ($jsonData === false) { 235 | throw new OidcException('Invalid json data returned from user info endpoint'); 236 | } 237 | 238 | // Read the data 239 | $data = json_decode($jsonData, true); 240 | 241 | // Check data due 242 | if (!is_array($data)) { 243 | throw new OidcException('Error retrieving the user info from the endpoint.'); 244 | } 245 | 246 | return new OidcUserData($data); 247 | } 248 | 249 | public function introspect(OidcTokens $tokens, ?OidcTokenType $tokenType = null): OidcIntrospectionData 250 | { 251 | $headers = []; 252 | if (in_array('client_secret_basic', $this->getIntrospectionEndpointAuthMethodsSupported())) { 253 | $headers = [$this->generateBasicAuthorization()]; 254 | } 255 | 256 | $params = match ($tokenType) { 257 | OidcTokenType::ACCESS => [ 258 | 'token' => $tokens->getAccessToken(), 259 | 'token_type_hint' => 'access_token', 260 | ], 261 | OidcTokenType::REFRESH => [ 262 | 'token' => $tokens->getRefreshToken(), 263 | 'token_type_hint' => 'refresh_token', 264 | ], 265 | default => throw new InvalidArgumentException('Only access and refresh tokens can be introspected'), 266 | }; 267 | 268 | $jsonData = $this->urlFetcher->fetchUrl($this->getIntrospectionEndpoint(), $params, $headers); 269 | $jsonData = mb_convert_encoding($jsonData, 'UTF-8'); 270 | if ($jsonData === false) { 271 | throw new OidcException('Invalid json data returned from introspection endpoint'); 272 | } 273 | 274 | // Read the data 275 | $data = json_decode($jsonData, true); 276 | 277 | // Check data due 278 | if (!is_array($data)) { 279 | throw new OidcException('Error from the introspection endpoint.'); 280 | } 281 | 282 | return new OidcIntrospectionData($data); 283 | } 284 | 285 | /** 286 | * @throws OidcConfigurationException 287 | * @throws OidcConfigurationResolveException 288 | */ 289 | protected function getAuthorizationEndpoint(): string 290 | { 291 | return $this->getConfigurationValue('authorization_endpoint'); 292 | } 293 | 294 | /** 295 | * @throws OidcConfigurationException 296 | * @throws OidcConfigurationResolveException 297 | */ 298 | protected function getEndSessionEndpoint(): string 299 | { 300 | return $this->getConfigurationValue('end_session_endpoint'); 301 | } 302 | 303 | /** 304 | * @throws OidcConfigurationResolveException 305 | * @throws OidcConfigurationException 306 | * 307 | * @return non-empty-string 308 | */ 309 | protected function getIssuer(): string 310 | { 311 | return $this->getConfigurationValue('issuer'); 312 | } 313 | 314 | /** 315 | * @throws OidcConfigurationException 316 | * @throws OidcConfigurationResolveException 317 | */ 318 | protected function getJwksUri(): string 319 | { 320 | return $this->getConfigurationValue('jwks_uri'); 321 | } 322 | 323 | protected function getRedirectUrl(): string 324 | { 325 | return $this->httpUtils->generateUri( 326 | $this->requestStack->getCurrentRequest() ?? throw new RuntimeException('Current request could not be found'), 327 | $this->redirectRoute 328 | ); 329 | } 330 | 331 | /** 332 | * @throws OidcConfigurationException 333 | * @throws OidcConfigurationResolveException 334 | * 335 | * @return non-empty-string 336 | */ 337 | protected function getTokenEndpoint(): string 338 | { 339 | return $this->getConfigurationValue('token_endpoint'); 340 | } 341 | 342 | /** 343 | * @throws OidcConfigurationException 344 | * @throws OidcConfigurationResolveException 345 | * 346 | * @return string[] 347 | */ 348 | protected function getTokenEndpointAuthMethods(): array 349 | { 350 | return $this->getConfigurationValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); 351 | } 352 | 353 | /** 354 | * @throws OidcConfigurationException 355 | * @throws OidcConfigurationResolveException 356 | * 357 | * @return string[] 358 | */ 359 | protected function getCodeChallengeMethodsSupported(): array 360 | { 361 | $value = $this->getConfigurationValue('code_challenge_methods_supported'); 362 | 363 | if (!is_array($value)) { 364 | return []; 365 | } 366 | 367 | return $value; 368 | } 369 | 370 | /** 371 | * @throws OidcConfigurationException 372 | * @throws OidcConfigurationResolveException 373 | * 374 | * @return non-empty-string 375 | */ 376 | protected function getUserinfoEndpoint(): string 377 | { 378 | return $this->getConfigurationValue('userinfo_endpoint'); 379 | } 380 | 381 | /** 382 | * @throws OidcConfigurationException 383 | * @throws OidcConfigurationResolveException 384 | * 385 | * @return string[] 386 | */ 387 | protected function getIntrospectionEndpointAuthMethodsSupported(): array 388 | { 389 | try { 390 | return $this->getConfigurationValue('introspection_endpoint_auth_methods_supported'); 391 | } catch (OidcConfigurationException) { 392 | return $this->getTokenEndpointAuthMethods(); 393 | } 394 | } 395 | 396 | /** 397 | * @throws OidcConfigurationException 398 | * @throws OidcConfigurationResolveException 399 | * 400 | * @return non-empty-string 401 | */ 402 | protected function getIntrospectionEndpoint(): string 403 | { 404 | return $this->getConfigurationValue('introspection_endpoint'); 405 | } 406 | 407 | /** Generate a nonce to verify the response */ 408 | private function generateNonce(): string 409 | { 410 | $value = $this->generateRandomString(); 411 | 412 | $this->sessionStorage->storeNonce($value); 413 | 414 | return $value; 415 | } 416 | 417 | /** 418 | * Generate a code challenge based on the code verifier and PKCE Algorithm. 419 | * 420 | * @throws OidcConfigurationException 421 | * @throws OidcConfigurationResolveException 422 | * @throws OidcCodeChallengeMethodNotSupportedException 423 | */ 424 | private function generateCodeChallenge(): string 425 | { 426 | if (null === $this->codeChallengeMethod) { 427 | throw new OidcConfigurationException('Method should not called when a code challenge method isn\'t configured'); 428 | } 429 | 430 | if (!in_array($this->codeChallengeMethod, $this->getCodeChallengeMethodsSupported(), true)) { 431 | throw new OidcCodeChallengeMethodNotSupportedException($this->codeChallengeMethod); 432 | } 433 | 434 | $codeVerifier = bin2hex(random_bytes(64)); 435 | 436 | // Save the code verifier for later use in token verification 437 | $this->sessionStorage->storeCodeVerifier($codeVerifier); 438 | 439 | $pkceAlgorithm = self::PKCE_ALGORITHMS[$this->codeChallengeMethod]; 440 | 441 | // if $pkceAlgorithm is false handle it as plain 442 | if (!$pkceAlgorithm) { 443 | $codeChallenge = $codeVerifier; 444 | } else { 445 | $codeChallenge = rtrim(strtr(base64_encode(hash($pkceAlgorithm, $codeVerifier, true)), '+/', '-_'), '='); 446 | } 447 | 448 | return $codeChallenge; 449 | } 450 | 451 | /** Generate a secure random string for usage as state */ 452 | private function generateRandomString(): string 453 | { 454 | return md5(random_bytes(25)); 455 | } 456 | 457 | /** Generate a state to identify the request */ 458 | private function generateState(): string 459 | { 460 | $value = $this->generateRandomString(); 461 | $this->sessionStorage->storeState($value); 462 | 463 | return $value; 464 | } 465 | 466 | /** 467 | * Retrieve a configuration value from the provider well-known configuration. 468 | * 469 | * @throws OidcConfigurationException 470 | * @throws OidcConfigurationResolveException 471 | */ 472 | private function getConfigurationValue(string $key, mixed $default = null): mixed 473 | { 474 | // Resolve the configuration 475 | $this->resolveConfiguration(); 476 | 477 | if (!array_key_exists($key, $this->configuration)) { 478 | return $default ?? throw new OidcConfigurationException($key); 479 | } 480 | 481 | return $this->configuration[$key]; 482 | } 483 | 484 | /** 485 | * Request the tokens from the OIDC provider. 486 | * 487 | * @throws OidcException 488 | */ 489 | private function requestTokens( 490 | string $grantType, 491 | ?string $code = null, 492 | ?string $redirectUrl = null, 493 | ?string $refreshToken = null, 494 | ?string $subjectToken = null, 495 | ?string $scope = null, 496 | ?string $audience = null): UnvalidatedOidcTokens 497 | { 498 | $params = [ 499 | 'grant_type' => $grantType, 500 | 'client_id' => $this->clientId, 501 | 'client_secret' => $this->clientSecret, 502 | ]; 503 | 504 | if (null !== $code) { 505 | $params['code'] = $code; 506 | } 507 | 508 | if (null !== $redirectUrl) { 509 | $params['redirect_uri'] = $redirectUrl; 510 | } 511 | 512 | if (null !== $refreshToken) { 513 | $params['refresh_token'] = $refreshToken; 514 | } 515 | 516 | // Use basic auth if offered 517 | $headers = []; 518 | if (in_array('client_secret_basic', $this->getTokenEndpointAuthMethods())) { 519 | $headers = [$this->generateBasicAuthorization()]; 520 | unset($params['client_id']); 521 | unset($params['client_secret']); 522 | } 523 | 524 | if ($codeVerifier = $this->sessionStorage->getCodeVerifier()) { 525 | unset($params['client_secret']); 526 | 527 | $params = array_merge($params, [ 528 | 'code_verifier' => $codeVerifier, 529 | ]); 530 | } 531 | 532 | if (null !== $subjectToken) { 533 | $params['subject_token'] = $subjectToken; 534 | } 535 | 536 | if (null !== $scope) { 537 | $params['scope'] = $scope; 538 | } 539 | 540 | if (null !== $audience) { 541 | $params['audience'] = $audience; 542 | } 543 | 544 | $jsonToken = json_decode($this->urlFetcher->fetchUrl($this->getTokenEndpoint(), $params, $headers)); 545 | 546 | // Throw an error if the server returns one 547 | if (isset($jsonToken->error)) { 548 | if (isset($jsonToken->error_description)) { 549 | throw new OidcAuthenticationException($jsonToken->error_description); 550 | } 551 | throw new OidcAuthenticationException(sprintf('Got response: %s', $jsonToken->error)); 552 | } 553 | 554 | // Clear code verifier from session after check 555 | $this->sessionStorage->clearCodeVerifier(); 556 | 557 | return new UnvalidatedOidcTokens($jsonToken); 558 | } 559 | 560 | /** @throws OidcException */ 561 | private function verifyTokens(UnvalidatedOidcTokens $unvalidatedTokens, bool $verifyNonce = true): OidcTokens 562 | { 563 | $tokens = new OidcTokens($unvalidatedTokens); 564 | $this->jwtHelper->verifyTokens($this->getIssuer(), $this->getJwksUri(), $tokens, $verifyNonce); 565 | 566 | return $tokens; 567 | } 568 | 569 | /** 570 | * Retrieves the well-known configuration and saves it in the class. 571 | * 572 | * @throws OidcConfigurationResolveException 573 | * 574 | * @phpstan-assert !null $this->configuration 575 | */ 576 | private function resolveConfiguration(): void 577 | { 578 | // Check whether the configuration is already available 579 | if ($this->configuration !== null) { 580 | return; 581 | } 582 | 583 | if ($this->wellKnownCache && $this->wellKnownCacheTime !== null) { 584 | try { 585 | $this->cacheKey ??= '_drenso_oidc_client__well_known__' . (new AsciiSlugger('en'))->slug($this->wellKnownUrl); 586 | $config = $this->wellKnownCache->get($this->cacheKey, function (ItemInterface $item) { 587 | $item->expiresAfter($this->wellKnownCacheTime); 588 | 589 | return $this->retrieveWellKnownConfiguration(); 590 | }); 591 | } catch (\Psr\Cache\InvalidArgumentException $e) { 592 | throw new OidcConfigurationResolveException('Cache failed: ' . $e->getMessage(), previous: $e); 593 | } 594 | } else { 595 | $config = $this->retrieveWellKnownConfiguration(); 596 | } 597 | 598 | // Set the configuration 599 | $this->configuration = $config; 600 | } 601 | 602 | /** 603 | * Retrieves the well-known configuration from the configured url. 604 | * 605 | * @throws OidcConfigurationResolveException 606 | * 607 | * @return array 608 | */ 609 | private function retrieveWellKnownConfiguration(): array 610 | { 611 | try { 612 | $wellKnown = $this->urlFetcher->fetchUrl($this->wellKnownUrl); 613 | } catch (Exception $e) { 614 | throw new OidcConfigurationResolveException(sprintf('Could not retrieve OIDC configuration from "%s".', $this->wellKnownUrl), 0, $e); 615 | } 616 | 617 | // Parse the configuration 618 | if (($config = json_decode($wellKnown, true)) === null) { 619 | throw new OidcConfigurationResolveException(sprintf('Could not parse OIDC configuration. Response data: "%s"', $wellKnown)); 620 | } 621 | 622 | return $this->wellKnownParser?->parseWellKnown($config) ?? $config; 623 | } 624 | 625 | private function generateBasicAuthorization(): string 626 | { 627 | return 'Authorization: Basic ' . base64_encode(urlencode($this->clientId) . ':' . urlencode($this->clientSecret)); 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /src/OidcClientInterface.php: -------------------------------------------------------------------------------- 1 | $additionalQueryParams additional query parameters which will be added to the generated redirect request 53 | * 54 | * @throws OidcConfigurationException 55 | * @throws OidcConfigurationResolveException 56 | * @throws OidcCodeChallengeMethodNotSupportedException When the IdP doesn't support the request code challenge method 57 | */ 58 | public function generateAuthorizationRedirect( 59 | ?string $prompt = null, 60 | array $scopes = ['openid'], 61 | bool $forceRememberMe = false, 62 | array $additionalQueryParams = [], 63 | ): RedirectResponse; 64 | 65 | /** 66 | * Create the redirect that should be followed in order to end the current session. 67 | * 68 | * @param OidcTokens $tokens OidcTokens object containing the information to pass to the end_session endpoint 69 | * @param string|null $postLogoutRedirectUrl Contains the url where the provider redirects the user to after logging out 70 | * @param array $additionalQueryParams additional query parameters which will be added to the generated redirect request 71 | * 72 | * @throws OidcConfigurationException 73 | * @throws OidcConfigurationResolveException 74 | */ 75 | public function generateEndSessionEndpointRedirect( 76 | OidcTokens $tokens, 77 | ?string $postLogoutRedirectUrl, 78 | array $additionalQueryParams = [], 79 | ): RedirectResponse; 80 | 81 | /** 82 | * Retrieve the user information. 83 | * 84 | * @throws OidcException 85 | */ 86 | public function retrieveUserInfo(OidcTokens $tokens): OidcUserData; 87 | 88 | /** 89 | * Introspect the supplied token. 90 | * 91 | * @throws OidcException 92 | */ 93 | public function introspect(OidcTokens $tokens, ?OidcTokenType $tokenType = null): OidcIntrospectionData; 94 | } 95 | -------------------------------------------------------------------------------- /src/OidcClientLocator.php: -------------------------------------------------------------------------------- 1 | defaultClient; 19 | if (!$this->locator->has($name)) { 20 | throw new OidcClientNotFoundException($name); 21 | } 22 | 23 | try { 24 | return $this->locator->get($name); 25 | } catch (Throwable $e) { 26 | throw new OidcClientNotFoundException($name, $e); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/OidcJwtHelper.php: -------------------------------------------------------------------------------- 1 | */ 56 | protected ?array $jwks = null; 57 | protected ?string $jwksUri = null; 58 | private ?string $cacheKey = null; 59 | 60 | /** @param non-empty-string $clientId */ 61 | public function __construct( 62 | protected readonly ?CacheInterface $jwksCache, 63 | protected ?ClockInterface $clock, 64 | protected readonly OidcUrlFetcher $urlFetcher, 65 | protected readonly ?OidcSessionStorage $sessionStorage, 66 | protected readonly string $clientId, 67 | protected readonly ?int $jwksCacheTime = 3600, 68 | protected readonly int $leewaySeconds = 300, 69 | protected readonly ?OidcTokenConstraintProviderInterface $oidcTokenConstraintProvider = null) 70 | { 71 | } 72 | 73 | /** @throws InvalidJwtTokenException When the token is not a valid JWT */ 74 | public static function parseToken(string $token): UnencryptedToken 75 | { 76 | try { 77 | if (!$token) { 78 | throw new InvalidJwtTokenException('Token string cannot be empty'); 79 | } 80 | 81 | $parsedToken = (self::$parser ??= new Parser(new JoseEncoder()))->parse($token); 82 | } catch (InvalidTokenStructure $e) { 83 | throw new InvalidJwtTokenException('Invalid token structure', previous: $e); 84 | } 85 | 86 | /** @noinspection PhpConditionAlreadyCheckedInspection */ 87 | if (!$parsedToken instanceof UnencryptedToken) { 88 | throw new InvalidJwtTokenException('Not an unencrypted token.'); 89 | } 90 | 91 | return $parsedToken; 92 | } 93 | 94 | /** 95 | * Validate the supplied OidcTokens. 96 | * 97 | * @param non-empty-string $issuer 98 | * 99 | * @throws OidcConfigurationResolveException|OidcConfigurationException Thrown on invalid configuration 100 | * @throws OidcAuthenticationException Throw when a token is invalid 101 | */ 102 | public function verifyTokens(string $issuer, string $jwksUri, OidcTokens $tokens, bool $verifyNonce): void 103 | { 104 | $this->verifyIdToken($issuer, $jwksUri, $tokens, $verifyNonce); 105 | $this->verifyAccessToken($issuer, $jwksUri, $tokens, $verifyNonce); 106 | } 107 | 108 | /** 109 | * @param non-empty-string $issuer 110 | * 111 | * @throws OidcConfigurationException 112 | * @throws OidcConfigurationResolveException 113 | * @throws OidcAuthenticationException 114 | */ 115 | public function verifyIdToken(string $issuer, string $jwksUri, OidcTokens $tokens, bool $verifyNonce): void 116 | { 117 | $idToken = $tokens->getTokenByType(OidcTokenType::ID) ?? throw new OidcException('ID token missing'); 118 | $accessToken = $tokens->getTokenByType(OidcTokenType::ACCESS); 119 | 120 | $additionalIdTokenConstraints = $this->oidcTokenConstraintProvider?->getAdditionalConstraints(OidcTokenType::ID) ?? []; 121 | try { 122 | $this->verifyToken($issuer, $jwksUri, OidcTokenType::ID, self::parseToken($idToken), $verifyNonce, $accessToken, ...$additionalIdTokenConstraints); 123 | } catch (InvalidJwtTokenException $e) { 124 | throw new OidcAuthenticationException('Invalid ID token', $e); 125 | } 126 | } 127 | 128 | /** 129 | * @param non-empty-string $issuer 130 | * 131 | * @throws OidcConfigurationException 132 | * @throws OidcConfigurationResolveException 133 | * @throws OidcAuthenticationException 134 | */ 135 | public function verifyAccessToken(string $issuer, string $jwksUri, OidcTokens $tokens, bool $verifyNonce): void 136 | { 137 | $accessToken = $tokens->getTokenByType(OidcTokenType::ACCESS) ?? throw new OidcException('Access token missing'); 138 | $additionalAccessTokenConstraints = $this->oidcTokenConstraintProvider?->getAdditionalConstraints(OidcTokenType::ACCESS) ?? []; 139 | try { 140 | $this->verifyToken($issuer, $jwksUri, OidcTokenType::ACCESS, self::parseToken($accessToken), false, null, ...$additionalAccessTokenConstraints); 141 | } catch (InvalidJwtTokenException) { 142 | // An access token is not required to be a JWT token. 143 | // If it cannot be parsed as token, ignore it and skip validation 144 | } 145 | } 146 | 147 | /** 148 | * Validate a token. 149 | * 150 | * @param non-empty-string $issuer 151 | * 152 | * @throws OidcConfigurationResolveException|OidcConfigurationException Thrown on invalid configuration 153 | * @throws OidcAuthenticationException Throw when a token is invalid 154 | */ 155 | public function verifyToken( 156 | string $issuer, 157 | string $jwksUri, 158 | OidcTokenType $tokenType, 159 | UnencryptedToken $token, 160 | bool $verifyNonce, 161 | ?string $accessToken = null, 162 | Constraint ...$additionalConstraints): void 163 | { 164 | self::$validator ??= new Validator(); 165 | 166 | // Parse the token 167 | $signer = $this->getTokenSigner($token); 168 | $key = $this->getTokenKey($jwksUri, $token); 169 | 170 | try { 171 | self::$validator->assert($token, new SignedWith($signer, $key)); 172 | } catch (RequiredConstraintsViolated $e) { 173 | throw new OidcAuthenticationException( 174 | "Unable to verify signature - {$e->getMessage()}", 175 | previous: $e 176 | ); 177 | } 178 | 179 | // Default claims 180 | $constraints = []; 181 | $issuedByConstraint = new IssuedBy($issuer); 182 | $looseValidConstraint = new LooseValidAt($this->getClock(), new DateInterval("PT{$this->leewaySeconds}S")); 183 | 184 | switch ($tokenType) { 185 | case OidcTokenType::ID: 186 | $constraints = [ 187 | $issuedByConstraint, 188 | $looseValidConstraint, 189 | new PermittedFor($this->clientId), 190 | ]; 191 | 192 | if ($token->claims()->has('at_hash') && $accessToken) { 193 | // Validate the at (access token) hash 194 | $bit = substr((string)$token->headers()->get('alg'), 2, 3); 195 | if (!$bit || !is_numeric($bit)) { 196 | throw new OidcAuthenticationException('Could not determine at hash algorithm'); 197 | } 198 | 199 | $constraints[] = new HasClaimWithValue( 200 | 'at_hash', 201 | self::urlEncode( 202 | substr(hash("sha$bit", $accessToken, true), 0, (int)($bit / 16)), 203 | ), 204 | ); 205 | } 206 | 207 | if ($verifyNonce) { 208 | if (!$this->sessionStorage) { 209 | throw new OidcConfigurationException('Session storage has not been configured for nonce validation'); 210 | } 211 | 212 | $constraints[] = new HasClaimWithValue('nonce', $this->sessionStorage->getNonce()); 213 | $this->sessionStorage->clearNonce(); 214 | } 215 | 216 | break; 217 | case OidcTokenType::ACCESS: 218 | case OidcTokenType::REFRESH: 219 | if ($token->claims()->has(RegisteredClaims::ISSUER)) { 220 | $constraints[] = $issuedByConstraint; 221 | } 222 | 223 | if ($token->claims()->has(RegisteredClaims::ISSUED_AT) 224 | && $token->claims()->has(RegisteredClaims::NOT_BEFORE) 225 | && $token->claims()->has(RegisteredClaims::EXPIRATION_TIME)) { 226 | $constraints[] = $looseValidConstraint; 227 | } 228 | 229 | if (empty($constraints) && empty($additionalConstraints)) { 230 | // No constraints defined 231 | return; 232 | } 233 | 234 | break; 235 | } 236 | 237 | try { 238 | self::$validator->assert($token, ...$constraints, ...$additionalConstraints); 239 | } catch (RequiredConstraintsViolated $e) { 240 | throw new OidcAuthenticationException( 241 | "Unable to verify JWT claims - {$e->getMessage()}", 242 | previous: $e 243 | ); 244 | } 245 | } 246 | 247 | /** @throws OidcConfigurationResolveException */ 248 | private function getTokenKey(string $jwksUri, Token $token): Key 249 | { 250 | try { 251 | $jwks = $this->getJwks($jwksUri); 252 | $matchingJwkForToken = $this->getMatchingJwkForToken($jwks, $token); 253 | } catch (OidcAuthenticationException $e) { 254 | if (!$this->isCacheEnabled()) { 255 | throw $e; 256 | } 257 | 258 | // Try again, but force without cache in case a new key was added 259 | $jwks = $this->getJwks($jwksUri, true); 260 | $matchingJwkForToken = $this->getMatchingJwkForToken($jwks, $token); 261 | } 262 | 263 | try { 264 | // phpseclib is used to load the JWK, but it requires a single JWK to be JSON encoded 265 | $jwkData = json_encode(['keys' => [$matchingJwkForToken]], JSON_THROW_ON_ERROR); 266 | } catch (JsonException $e) { 267 | throw new OidcAuthenticationException('Failed to generate JWK json data', previous: $e); 268 | } 269 | 270 | try { 271 | return InMemory::plainText(PublicKeyLoader::loadPublicKey($jwkData)->toString('pkcs8')); 272 | } catch (Exception $e) { 273 | throw new OidcAuthenticationException('Failed to load JWK', previous: $e); 274 | } 275 | } 276 | 277 | private function getTokenSigner(Token $token): Signer 278 | { 279 | $algorithm = $token->headers()->get('alg'); 280 | if (!is_string($algorithm)) { 281 | throw new OidcAuthenticationException('Invalid JWT token header, missing signature algorithm'); 282 | } 283 | 284 | $keyAlgorithm = match ($algorithm) { 285 | 'RS256' => new Sha256(), 286 | 'RS384' => new Sha384(), 287 | 'RS512' => new Sha512(), 288 | 'ES256' => new Ecdsa\Sha256(), 289 | 'ES384' => new Ecdsa\Sha384(), 290 | 'ES512' => new Ecdsa\Sha512(), 291 | default => throw new OidcAuthenticationException("JWT algorithm $algorithm is not supported"), 292 | }; 293 | 294 | if ($algorithm !== $keyAlgorithm->algorithmId()) { 295 | throw new OidcAuthenticationException('Key algorithm does not match token algorithm'); 296 | } 297 | 298 | return $keyAlgorithm; 299 | } 300 | 301 | /** @param list $keys */ 302 | private function getMatchingJwkForToken(array $keys, Token $token): object 303 | { 304 | $headers = $token->headers(); 305 | $algorithm = $headers->get('alg'); 306 | $keyId = $headers->get('kid'); 307 | $kty = match (true) { 308 | str_starts_with((string)$algorithm, 'RS') => 'RSA', 309 | str_starts_with((string)$algorithm, 'ES') => 'EC', 310 | default => null, 311 | }; 312 | 313 | foreach ($keys as $key) { 314 | if ($kty && $key->kty === $kty) { 315 | if ($keyId === null || $key->kid === $keyId) { 316 | return $key; 317 | } 318 | } else { 319 | if ($key->alg === $algorithm && $key->kid === $keyId) { 320 | return $key; 321 | } 322 | } 323 | } 324 | 325 | if ($keyId !== null) { 326 | throw new OidcAuthenticationException("Unable to find a key for (algorithm, key id): $algorithm, $keyId"); 327 | } else { 328 | throw new OidcAuthenticationException('Unable to find a signing key'); 329 | } 330 | } 331 | 332 | /** 333 | * @throws OidcConfigurationResolveException 334 | * 335 | * @return list 336 | */ 337 | private function getJwks(string $jwksUri, bool $forceNoCache = false): array 338 | { 339 | if (!$jwksUri) { 340 | throw new OidcAuthenticationException('Unable to verify signature due to no jwks_uri being defined'); 341 | } 342 | 343 | // Only a single uri can be loaded in one instance 344 | if ($this->jwksUri !== null && $this->jwksUri !== $jwksUri) { 345 | throw new OidcAuthenticationException('The jwks uri does not match with an earlier invocation'); 346 | } 347 | 348 | // If already loaded, directly return JWK data, but not when no cache has been enforced 349 | if (!$forceNoCache && $this->jwks !== null) { 350 | return $this->jwks; 351 | } 352 | 353 | if ($this->isCacheEnabled()) { 354 | try { 355 | $this->cacheKey ??= '_drenso_oidc_client__jwks__' . (new AsciiSlugger('en'))->slug($jwksUri); 356 | if ($forceNoCache) { 357 | // Clear the cache item to force refresh 358 | $this->jwksCache->delete($this->cacheKey); 359 | } 360 | 361 | $jwks = $this->jwksCache->get($this->cacheKey, function (ItemInterface $item) use ($jwksUri) { 362 | $item->expiresAfter($this->jwksCacheTime); 363 | 364 | return json_decode($this->urlFetcher->fetchUrl($jwksUri))->keys; 365 | }); 366 | } catch (InvalidArgumentException $e) { 367 | throw new OidcConfigurationResolveException('Cache failed: ' . $e->getMessage(), previous: $e); 368 | } 369 | } else { 370 | $jwks = json_decode($this->urlFetcher->fetchUrl($jwksUri))->keys; 371 | } 372 | 373 | if ($jwks === null) { 374 | throw new OidcAuthenticationException('Error decoding JSON from jwks_uri'); 375 | } 376 | 377 | $this->jwksUri = $jwksUri; 378 | 379 | return $this->jwks = $jwks; 380 | } 381 | 382 | private static function urlEncode(string $str): string 383 | { 384 | $enc = base64_encode($str); 385 | $enc = rtrim($enc, '='); 386 | 387 | return strtr($enc, '+/', '-_'); 388 | } 389 | 390 | private function getClock(): ClockInterface 391 | { 392 | return $this->clock ??= new class implements ClockInterface { 393 | public function now(): DateTimeImmutable 394 | { 395 | return new DateTimeImmutable(); 396 | } 397 | }; 398 | } 399 | 400 | /** @phpstan-assert-if-true !null $this->jwksCache */ 401 | public function isCacheEnabled(): bool 402 | { 403 | return $this->jwksCache && $this->jwksCacheTime !== null; 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /src/OidcSessionStorage.php: -------------------------------------------------------------------------------- 1 | getSession()->remove($this->nonceKey()); 17 | } 18 | 19 | public function clearCodeVerifier(): void 20 | { 21 | $this->getSession()->remove($this->codeVerifierKey()); 22 | } 23 | 24 | public function clearRememberMe(): void 25 | { 26 | $this->getSession()->remove($this->rememberKey()); 27 | } 28 | 29 | public function clearState(): void 30 | { 31 | $this->getSession()->remove($this->stateKey()); 32 | } 33 | 34 | public function getNonce(): ?string 35 | { 36 | return $this->getSession()->get($this->nonceKey()); 37 | } 38 | 39 | public function getCodeVerifier(): ?string 40 | { 41 | return $this->getSession()->get($this->codeVerifierKey()); 42 | } 43 | 44 | public function getRememberMe(): bool 45 | { 46 | return $this->getSession()->get($this->rememberKey()) ?? false; 47 | } 48 | 49 | public function getState(): ?string 50 | { 51 | return $this->getSession()->get($this->stateKey()); 52 | } 53 | 54 | public function storeNonce(string $value): void 55 | { 56 | $this->getSession()->set($this->nonceKey(), $value); 57 | } 58 | 59 | public function storeCodeVerifier(string $value): void 60 | { 61 | $this->getSession()->set($this->codeVerifierKey(), $value); 62 | } 63 | 64 | public function storeRememberMe(bool $value): void 65 | { 66 | $this->getSession()->set($this->rememberKey(), $value); 67 | } 68 | 69 | public function storeState(string $value): void 70 | { 71 | $this->getSession()->set($this->stateKey(), $value); 72 | } 73 | 74 | private function getSession(): SessionInterface 75 | { 76 | return $this->requestStack->getSession(); 77 | } 78 | 79 | private function codeVerifierKey(): string 80 | { 81 | return 'drenso.oidc.session.code_verifier.' . $this->clientName; 82 | } 83 | 84 | private function nonceKey(): string 85 | { 86 | return 'drenso.oidc.session.nonce.' . $this->clientName; 87 | } 88 | 89 | private function rememberKey(): string 90 | { 91 | return 'drenso.oidc.session.remember_me.' . $this->clientName; 92 | } 93 | 94 | private function stateKey(): string 95 | { 96 | return 'drenso.oidc.session.state.' . $this->clientName; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/OidcTokenConstraintProviderInterface.php: -------------------------------------------------------------------------------- 1 | $customClientOptions 15 | */ 16 | public function __construct( 17 | private readonly array $customClientHeaders = [], 18 | private readonly array $customClientOptions = [], 19 | ) { 20 | } 21 | 22 | /** 23 | * Retrieve the content from the specified url. 24 | * 25 | * @param non-empty-string $url 26 | * @param array|null $params if this is set the request type will be POST 27 | * @param string[] $headers extra headers to be sent with the request 28 | */ 29 | public function fetchUrl(string $url, ?array $params = null, array $headers = []): string 30 | { 31 | // Create a new cURL resource handle 32 | $ch = curl_init(); 33 | 34 | // Determine whether this is a GET or POST 35 | if ($params !== null) { 36 | $params = http_build_query($params); 37 | 38 | // Allows to keep the POST method even after redirect 39 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); 40 | curl_setopt($ch, CURLOPT_POSTFIELDS, $params); 41 | 42 | // Add POST-specific headers 43 | $headers[] = 'Content-Type: application/x-www-form-urlencoded'; 44 | $headers[] = 'Content-Length: ' . strlen($params); 45 | } 46 | 47 | // Add a User-Agent header to prevent firewall blocks 48 | $curlVersion = (curl_version() ?: [])['version'] ?? 'unknown'; 49 | $headers[] = "User-Agent: curl/$curlVersion drenso/symfony-oidc"; 50 | 51 | // Add custom headers to existing headers 52 | $headers = array_merge($headers, $this->customClientHeaders); 53 | 54 | // Include headers 55 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 56 | 57 | // Set URL to download 58 | curl_setopt($ch, CURLOPT_URL, $url); 59 | 60 | // Include header in result? 61 | curl_setopt($ch, CURLOPT_HEADER, false); 62 | 63 | // Allows following redirects 64 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 65 | 66 | // Setup certificate checking 67 | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); 68 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); 69 | 70 | // Should cURL return or print out the data? (true = return, false = print) 71 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 72 | 73 | // Timeout in seconds 74 | curl_setopt($ch, CURLOPT_TIMEOUT, 20); 75 | 76 | foreach ($this->customClientOptions as $opt => $value) { 77 | curl_setopt($ch, $opt, $value); 78 | } 79 | 80 | // Download the given URL, and return output 81 | $output = curl_exec($ch); 82 | 83 | if ($output === false) { 84 | throw new OidcAuthenticationException('Curl error: ' . curl_error($ch)); 85 | } 86 | 87 | // Close the cURL resource, and free system resources 88 | curl_close($ch); 89 | 90 | return $output; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/OidcWellKnownParserInterface.php: -------------------------------------------------------------------------------- 1 | $config 9 | * 10 | * @return array 11 | */ 12 | public function parseWellKnown(array $config): array; 13 | } 14 | -------------------------------------------------------------------------------- /src/Resources/config/services.php: -------------------------------------------------------------------------------- 1 | services() 20 | ->set(DrensoOidcExtension::AUTHENTICATOR_ID, OidcAuthenticator::class) 21 | ->abstract() 22 | 23 | ->set(DrensoOidcExtension::URL_FETCHER_ID, OidcUrlFetcher::class) 24 | ->abstract() 25 | 26 | ->set(DrensoOidcExtension::SESSION_STORAGE_ID, OidcSessionStorage::class) 27 | ->args([ 28 | service(RequestStack::class), 29 | ]) 30 | ->abstract() 31 | 32 | ->set(DrensoOidcExtension::JWT_HELPER_ID, OidcJwtHelper::class) 33 | ->args([ 34 | service(CacheInterface::class)->nullOnInvalid(), 35 | service(ClockInterface::class)->nullOnInvalid(), 36 | ]) 37 | ->abstract() 38 | 39 | ->set(DrensoOidcExtension::CLIENT_ID, OidcClient::class) 40 | ->args([ 41 | service(RequestStack::class), 42 | service(HttpUtils::class), 43 | service(CacheInterface::class)->nullOnInvalid(), 44 | ]) 45 | ->abstract() 46 | 47 | ->set(DrensoOidcExtension::CLIENT_LOCATOR_ID, OidcClientLocator::class) 48 | ->alias(OidcClientLocator::class, DrensoOidcExtension::CLIENT_LOCATOR_ID) 49 | ; 50 | }; 51 | -------------------------------------------------------------------------------- /src/Security/Exception/InvalidJwtTokenException.php: -------------------------------------------------------------------------------- 1 | options['use_forward']); 23 | unset($this->options['require_previous_session']); 24 | 25 | // Set extra options 26 | $this->addOption('client', 'default'); 27 | $this->addOption('user_identifier_property', 'sub'); 28 | $this->addOption('user_identifier_from_idtoken', false); 29 | $this->addOption('enable_remember_me', false); 30 | $this->addOption('enable_end_session_listener', false); 31 | $this->addOption('use_logout_target_path', true); 32 | $this->addOption('enable_retrieve_user_info', true); 33 | } 34 | 35 | public function getPriority(): int 36 | { 37 | return self::PRIORITY; 38 | } 39 | 40 | public function getKey(): string 41 | { 42 | return 'oidc'; 43 | } 44 | 45 | public function createAuthenticator( 46 | ContainerBuilder $container, 47 | string $firewallName, 48 | array $config, 49 | string $userProviderId): string 50 | { 51 | $authenticatorId = sprintf('%s.%s', DrensoOidcExtension::AUTHENTICATOR_ID, $firewallName); 52 | $clientReference = new Reference(sprintf('%s.%s', DrensoOidcExtension::CLIENT_ID, $config['client'])); 53 | 54 | $container 55 | ->setDefinition($authenticatorId, new ChildDefinition(DrensoOidcExtension::AUTHENTICATOR_ID)) 56 | ->addArgument(new Reference('security.http_utils')) 57 | ->addArgument($clientReference) 58 | ->addArgument(new Reference(sprintf('%s.%s', DrensoOidcExtension::SESSION_STORAGE_ID, $config['client']))) 59 | ->addArgument(new Reference($userProviderId)) 60 | ->addArgument(new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config))) 61 | ->addArgument(new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config))) 62 | ->addArgument($config['check_path']) 63 | ->addArgument($config['login_path']) 64 | ->addArgument($config['user_identifier_property']) 65 | ->addArgument($config['enable_remember_me']) 66 | ->addArgument($config['user_identifier_from_idtoken']) 67 | ->addArgument($config['enable_retrieve_user_info']); 68 | 69 | $logoutListenerId = sprintf('security.logout.listener.default.%s', $firewallName); 70 | 71 | // Check if "logout" config is specified in the firewall and "enable_end_session_listener" is set to true 72 | if ($config['enable_end_session_listener'] && $container->hasDefinition($logoutListenerId)) { 73 | $endSessionListenerId = sprintf('%s.%s', DrensoOidcExtension::END_SESSION_LISTENER_ID, $firewallName); 74 | 75 | /** If "use_logout_target_path" is true (default) pass the target path to the {@see OidcEndSessionSubscriber} */ 76 | $logoutTargetPath = $config['use_logout_target_path'] 77 | ? $container->getDefinition($logoutListenerId)->getArgument(1) 78 | : null; 79 | 80 | $container 81 | ->setDefinition($endSessionListenerId, new Definition(OidcEndSessionSubscriber::class)) 82 | ->addArgument($clientReference) 83 | ->addArgument(new Reference('security.http_utils')) 84 | ->addArgument($logoutTargetPath) // Set the configured logout target path (null or string) 85 | ->addTag('kernel.event_subscriber', [ 86 | 'dispatcher' => sprintf('security.event_dispatcher.%s', $firewallName), 87 | ]) 88 | ; 89 | } 90 | 91 | return $authenticatorId; 92 | } 93 | 94 | /** 95 | * The following methods are required for Symfony 5.4 compatibility, but are not used. 96 | * 97 | * @todo: Remove when dropping support for Symfony 5.4 98 | */ 99 | protected function createAuthProvider( 100 | ContainerBuilder $container, 101 | string $id, 102 | array $config, 103 | string $userProviderId): string 104 | { 105 | throw new UnsupportedManagerException(); 106 | } 107 | 108 | protected function getListenerId(): string 109 | { 110 | throw new UnsupportedManagerException(); 111 | } 112 | 113 | public function getPosition(): string 114 | { 115 | throw new UnsupportedManagerException(); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Security/OidcAuthenticator.php: -------------------------------------------------------------------------------- 1 | $oidcUserProvider 39 | * @param non-empty-string $userIdentifierProperty 40 | */ 41 | public function __construct( 42 | private readonly HttpUtils $httpUtils, 43 | private readonly OidcClientInterface $oidcClient, 44 | private readonly OidcSessionStorage $sessionStorage, 45 | private readonly OidcUserProviderInterface $oidcUserProvider, 46 | private readonly AuthenticationSuccessHandlerInterface $successHandler, 47 | private readonly AuthenticationFailureHandlerInterface $failureHandler, 48 | private readonly string $checkPath, 49 | private readonly string $loginPath, 50 | private readonly string $userIdentifierProperty, 51 | private readonly bool $enableRememberMe, 52 | private readonly bool $userIdentifierFromIdToken = false, 53 | private readonly bool $enableRetrieveUserInfo = true, 54 | ) { 55 | } 56 | 57 | public function supports(Request $request): ?bool 58 | { 59 | return 60 | $this->httpUtils->checkRequestPath($request, $this->checkPath) 61 | && $request->query->has('code') 62 | && $request->query->has('state'); 63 | } 64 | 65 | public function start(Request $request, ?AuthenticationException $authException = null): Response 66 | { 67 | return $this->httpUtils->createRedirectResponse($request, $this->loginPath); 68 | } 69 | 70 | public function authenticate(Request $request): Passport 71 | { 72 | try { 73 | // Try to authenticate the request 74 | $authData = $this->oidcClient->authenticate($request); 75 | 76 | // Optionally retrieve the user data with the authentication data 77 | if ($this->enableRetrieveUserInfo) { 78 | $userData = $this->oidcClient->retrieveUserInfo($authData); 79 | } else { 80 | if (!$this->userIdentifierFromIdToken) { 81 | throw new OidcConfigurationDisableUserInfoNotSupportedException(); 82 | } 83 | $userData = new OidcUserData([]); 84 | } 85 | 86 | // Look for the user identifier in either the id_token or the userinfo endpoint 87 | if ($this->userIdentifierFromIdToken) { 88 | $userIdentifier = OidcJwtHelper::parseToken($authData->getIdToken()) 89 | ->claims() 90 | ->get($this->userIdentifierProperty); 91 | } else { 92 | $userIdentifier = $userData->getUserDataString($this->userIdentifierProperty); 93 | } 94 | 95 | // Ensure the user exists 96 | if (!$userIdentifier) { 97 | throw new UserNotFoundException( 98 | sprintf('User identifier property (%s) yielded empty user identifier', $this->userIdentifierProperty)); 99 | } 100 | $this->oidcUserProvider->ensureUserExists($userIdentifier, $userData, $authData); 101 | 102 | // Create the passport 103 | $passport = new SelfValidatingPassport(new UserBadge( 104 | $userIdentifier, 105 | fn (string $userIdentifier) => $this->oidcUserProvider->loadOidcUser($userIdentifier), 106 | )); 107 | $passport->setAttribute(OidcToken::AUTH_DATA_ATTR, $authData); 108 | $passport->setAttribute(OidcToken::USER_DATA_ATTR, $userData); 109 | 110 | if ($this->enableRememberMe && $this->sessionStorage->getRememberMe()) { 111 | // Add remember me badge when enabled 112 | $passport->addBadge((new RememberMeBadge())->enable()); 113 | $this->sessionStorage->clearRememberMe(); 114 | } 115 | 116 | return $passport; 117 | } catch (OidcException $e) { 118 | throw new OidcAuthenticationException('OIDC authentication failed', $e); 119 | } 120 | } 121 | 122 | public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response 123 | { 124 | return $this->successHandler->onAuthenticationSuccess($request, $token); 125 | } 126 | 127 | public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response 128 | { 129 | return $this->failureHandler->onAuthenticationFailure($request, $exception); 130 | } 131 | 132 | public function createToken(Passport $passport, string $firewallName): TokenInterface 133 | { 134 | return new OidcToken($passport, $firewallName); 135 | } 136 | 137 | /** @todo: Remove when dropping support for Symfony 5.4 */ 138 | public function createAuthenticatedToken( 139 | PassportInterface $passport, 140 | string $firewallName): TokenInterface 141 | { 142 | throw new UnsupportedManagerException(); 143 | } 144 | 145 | public function isInteractive(): bool 146 | { 147 | return true; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Security/Token/OidcToken.php: -------------------------------------------------------------------------------- 1 | getUser(), $firewallName, $passport->getUser()->getRoles()); 18 | 19 | // Load passport data into token 20 | $this->setAttribute(self::AUTH_DATA_ATTR, $passport->getAttribute(self::AUTH_DATA_ATTR)); 21 | $this->setAttribute(self::USER_DATA_ATTR, $passport->getAttribute(self::USER_DATA_ATTR)); 22 | } 23 | 24 | public function getAuthData(): OidcTokens 25 | { 26 | return $this->getAttribute(self::AUTH_DATA_ATTR); 27 | } 28 | 29 | public function getUserData(): OidcUserData 30 | { 31 | return $this->getAttribute(self::USER_DATA_ATTR); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Security/UserProvider/OidcUserProviderInterface.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | interface OidcUserProviderInterface extends UserProviderInterface 17 | { 18 | /** @throws OidcException Can be thrown when the user cannot be created */ 19 | public function ensureUserExists(string $userIdentifier, OidcUserData $userData, OidcTokens $tokens): void; 20 | 21 | /** Custom user loader method to be able to distinguish oidc authentications */ 22 | public function loadOidcUser(string $userIdentifier): UserInterface; 23 | } 24 | --------------------------------------------------------------------------------