From 06ac13199bd090d5cb6bca842c090db6e939eb5d Mon Sep 17 00:00:00 2001 From: timo Date: Mon, 21 Jul 2025 17:21:13 +0200 Subject: [PATCH 01/20] All guests can be moderators This patch also modifies the container images build to push to the gh-registry. --- .github/workflows/ci-docker.yml | 19 +++++++++---------- app/Models/Room.php | 3 ++- resources/js/components/RoomTabSettings.vue | 6 ------ resources/js/views/AdminRoomTypesView.vue | 17 ----------------- 4 files changed, 11 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 4a9d749fb..e04bba68e 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -2,12 +2,11 @@ name: CI Docker build on: workflow_dispatch: - release: - types: [published] + # release: + # types: [published] push: branches: - - develop - - "[0-9].x" + - guest-mods jobs: build-and-push-image: runs-on: ubuntu-latest @@ -22,17 +21,18 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub + - name: Log in to the Container registry uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: - images: pilos/pilos + images: ghcr.io/${{ github.repository }} flavor: | latest=auto prefix= @@ -59,5 +59,4 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=pilos/pilos:buildcache - cache-to: type=registry,ref=pilos/pilos:buildcache,mode=max + diff --git a/app/Models/Room.php b/app/Models/Room.php index 987ef3549..9cb2620c6 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -135,6 +135,7 @@ protected function casts() 'lobby' => [ 'cast' => RoomLobby::class, 'expert' => true, + 'only' => [RoomLobby::ENABLED, RoomLobby::DISABLED], ], 'visibility' => [ 'cast' => RoomVisibility::class, @@ -356,7 +357,7 @@ public function getRole(?User $user, ?RoomToken $token): RoomUserRole return $token->role; } - return RoomUserRole::GUEST; + return $this->getRoomSetting('default_role'); } if ($this->owner->is($user) || $user->can('rooms.manage')) { diff --git a/resources/js/components/RoomTabSettings.vue b/resources/js/components/RoomTabSettings.vue index 617d93617..b2736b5f6 100644 --- a/resources/js/components/RoomTabSettings.vue +++ b/resources/js/components/RoomTabSettings.vue @@ -216,12 +216,6 @@ const form = computed(() => { options: [ { value: 0, label: t("app.disabled") }, { value: 1, label: t("app.enabled") }, - { - value: 2, - label: t( - "rooms.settings.video_conference.lobby.only_for_guests_enabled", - ), - }, ], component: RoomTabSettingsRadioGroup, warningMessage: lobbyAlert.value, diff --git a/resources/js/views/AdminRoomTypesView.vue b/resources/js/views/AdminRoomTypesView.vue index a58f2a179..d1b513425 100644 --- a/resources/js/views/AdminRoomTypesView.vue +++ b/resources/js/views/AdminRoomTypesView.vue @@ -564,23 +564,6 @@ /> -
- - -
Date: Mon, 14 Jul 2025 15:24:16 +0200 Subject: [PATCH 02/20] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4949fd88a..06acede1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show unavailable room types in change room type dialog ([#2265], [#2279]) - Infinite loading when navigating back after logout redirect due to bfcache ([#2282]) +### Fixed +- Logout session_expired warning message style ([68abce8](https://github.com/THM-Health/PILOS/commit/68abce87bcd241db3261a448cf53e430bd639e28)) + ## [v4.6.1] - 2025-06-16 ### Fixed From c366061b8c61a424778adeac6a6864d023649672 Mon Sep 17 00:00:00 2001 From: Samuel Weirich <4281791+SamuelWei@users.noreply.github.com> Date: Thu, 13 Apr 2023 17:35:28 +0200 Subject: [PATCH 03/20] Implement OIDC auth Use jumbojett/OpenID-Connect-PHP as a baseline, but major changes for jwt handling, optimised for laravel and removal of all unneeded features --- .env.example | 7 + app/Auth/ExternalUser.php | 3 + app/Auth/OIDC/AccessTokenHashChecker.php | 33 + app/Auth/OIDC/EventsChecker.php | 27 + app/Auth/OIDC/IssuerChecker.php | 25 + app/Auth/OIDC/OIDCController.php | 86 ++ app/Auth/OIDC/OIDCProvider.php | 157 +++ app/Auth/OIDC/OIDCServiceProvider.php | 48 + app/Auth/OIDC/OIDCUser.php | 29 + app/Auth/OIDC/OpenIDConnectClient.php | 1062 +++++++++++++++++ .../OIDC/OpenIDConnectClientException.php | 5 + .../OpenIDConnectCodeMissingException.php | 5 + .../OIDC/OpenIDConnectNetworkException.php | 5 + .../OIDC/OpenIDConnectProviderException.php | 5 + .../OIDC/OpenIDConnectValidationException.php | 5 + app/Auth/Shibboleth/ShibbolethProvider.php | 2 + .../api/v1/auth/LoginController.php | 11 +- app/Http/Middleware/VerifyCsrfToken.php | 1 + app/Http/Resources/Config.php | 1 + composer.json | 3 +- composer.lock | 733 +++++++++--- config/app.php | 1 + config/services.php | 15 + lang/en/admin.php | 1 + lang/en/auth.php | 11 +- resources/js/components/MainNav.vue | 3 +- resources/js/views/ExternalLogin.vue | 22 +- resources/js/views/Login.vue | 25 + resources/js/views/Logout.vue | 6 + routes/web.php | 7 + 30 files changed, 2171 insertions(+), 173 deletions(-) create mode 100644 app/Auth/OIDC/AccessTokenHashChecker.php create mode 100644 app/Auth/OIDC/EventsChecker.php create mode 100644 app/Auth/OIDC/IssuerChecker.php create mode 100644 app/Auth/OIDC/OIDCController.php create mode 100644 app/Auth/OIDC/OIDCProvider.php create mode 100644 app/Auth/OIDC/OIDCServiceProvider.php create mode 100644 app/Auth/OIDC/OIDCUser.php create mode 100644 app/Auth/OIDC/OpenIDConnectClient.php create mode 100644 app/Auth/OIDC/OpenIDConnectClientException.php create mode 100644 app/Auth/OIDC/OpenIDConnectCodeMissingException.php create mode 100644 app/Auth/OIDC/OpenIDConnectNetworkException.php create mode 100644 app/Auth/OIDC/OpenIDConnectProviderException.php create mode 100644 app/Auth/OIDC/OpenIDConnectValidationException.php diff --git a/.env.example b/.env.example index 3c5a693fd..0e7e6b712 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,13 @@ MAIL_FROM_NAME="${APP_NAME}" # Enable Shibboleth authentication #SHIBBOLETH_ENABLED=false +# OIDC config +#OIDC_ENABLED=false +#OIDC_ISSUER= +#OIDC_CLIENT_ID= +#OIDC_CLIENT_SECRET= +#OIDC_SCOPES=profile,email + # Enabled locales # Comma separated list, e.g. de,en # If unset all available locales are enabled (lang + resources/custom/lang) diff --git a/app/Auth/ExternalUser.php b/app/Auth/ExternalUser.php index 304ee9358..ac213d839 100644 --- a/app/Auth/ExternalUser.php +++ b/app/Auth/ExternalUser.php @@ -114,6 +114,9 @@ public function validate() } } + /** + * @throws MissingAttributeException + */ public function syncWithEloquentModel(User $eloquentUser, array $roles): User { // Validate attributes diff --git a/app/Auth/OIDC/AccessTokenHashChecker.php b/app/Auth/OIDC/AccessTokenHashChecker.php new file mode 100644 index 000000000..6a46b75b6 --- /dev/null +++ b/app/Auth/OIDC/AccessTokenHashChecker.php @@ -0,0 +1,33 @@ +openIDConnectClient->getIdTokenHeader()['alg']; + + $bit = match ($alg) { + 'EdDSA' => '512', + default => substr($alg, 2, 3), + }; + + $len = ((int) $bit) / 16; + $expected_at_hash = $this->openIDConnectClient->base64url_encode(substr(hash('sha'.$bit, $this->openIDConnectClient->getAccessToken(), true), 0, $len)); + + if ($value !== $expected_at_hash) { + throw new InvalidClaimException('The claim "at_hash" does not match the Access Token hash value.', 'at_hash', $value); + } + } + + public function supportedClaim(): string + { + return 'at_hash'; + } +} diff --git a/app/Auth/OIDC/EventsChecker.php b/app/Auth/OIDC/EventsChecker.php new file mode 100644 index 000000000..4c0a56902 --- /dev/null +++ b/app/Auth/OIDC/EventsChecker.php @@ -0,0 +1,27 @@ +openIDConnectClient->getIssuer() || $value === $this->openIDConnectClient->getWellKnownIssuer() || $value === $this->openIDConnectClient->getWellKnownIssuer(true)); + + if (! $isValid) { + throw new InvalidClaimException('The claim "iss" does not match the expected value.', 'iss', $value); + } + } + + public function supportedClaim(): string + { + return 'iss'; + } +} diff --git a/app/Auth/OIDC/OIDCController.php b/app/Auth/OIDC/OIDCController.php new file mode 100644 index 000000000..34d343b8c --- /dev/null +++ b/app/Auth/OIDC/OIDCController.php @@ -0,0 +1,86 @@ +middleware('guest'); + } + + /** + * Redirect to the OpenID Provider for authentication with an optional redirect back to a specific URL + */ + public function redirect(Request $request) + { + try { + return $this->provider->redirect($request->query('redirect')); + } catch (OpenIDConnectNetworkException $e) { + \Log::error($e->getMessage()); + + return redirect('/external_login?error=openid_connect_network_exception'); + } catch (\Throwable $e) { + \Log::error($e->getMessage()); + + return redirect('/external_login?error=openid_connect_exception'); + } + } + + /** + * Handle Authorization Code Flow redirect back from the OpenID Provider with an Authorization Code + */ + public function callback(Request $request): RedirectResponse + { + try { + $user = $this->provider->login($request); + } catch (OpenIDConnectCodeMissingException $e) { + \Log::error($e->getMessage()); + + return redirect()->route('auth.oidc.redirect'); + } catch (MissingAttributeException $e) { + \Log::error($e->getMessage()); + + return redirect('/external_login?error=missing_attributes'); + } catch (OpenIDConnectNetworkException $e) { + \Log::error($e->getMessage()); + + return redirect('/external_login?error=openid_connect_network_exception'); + } catch (\Throwable $e) { + \Log::error($e->getMessage()); + + // Any other error that occurs during the login process + return redirect('/external_login?error=openid_connect_exception'); + } + + \Log::info('External user {user} has been successfully authenticated.', ['user' => $user->getLogLabel(), 'type' => 'oidc']); + + // Update the last login timestamp + $user->last_login = now(); + $user->save(); + + $url = '/external_login'; + + if ($request->session()->has('redirect_url')) { + return redirect(\Uri::of($url) + ->withQuery(['redirect', $request->session()->get('redirect_url')]) + ->value()); + } + + return redirect($url); + } + + /** + * Handle the back-channel logout request from OpenID Provider + */ + public function logout(Request $request): Response + { + return $this->provider->backChannelLogout($request); + } +} diff --git a/app/Auth/OIDC/OIDCProvider.php b/app/Auth/OIDC/OIDCProvider.php new file mode 100644 index 000000000..4fd81d052 --- /dev/null +++ b/app/Auth/OIDC/OIDCProvider.php @@ -0,0 +1,157 @@ +openIDConnectClient->hasEndSessionEndpoint()) { + return false; + } + + try { + return $this->openIDConnectClient->getSignOutUrl(session('oidc_id_token'), $redirect); + } catch (\Throwable $e) { + \Log::error($e->getMessage()); + + return false; + } + } + + /** + * @throws OpenIDConnectNetworkException + * @throws OpenIDConnectClientException + */ + public function redirect($redirect = null) + { + if ($redirect) { + \Session::put('redirect_url', $redirect); + } + + return redirect($this->openIDConnectClient->getAuthenticationRequestUrl()); + } + + /** + * @throws RequestException + * @throws OpenIDConnectNetworkException + * @throws JsonException + * @throws OpenIDConnectProviderException + * @throws OpenIDConnectValidationException + * @throws ConnectionException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectCodeMissingException + * @throws MissingAttributeException + * @throws InvalidClaimException + * @throws JsonException + * @throws MissingMandatoryClaimException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws OpenIDConnectValidationException + */ + public function login(Request $request): User + { + if (! $this->openIDConnectClient->authenticate($request)) { + // Response is missing the code parameters + throw new OpenIDConnectCodeMissingException("Authentication failed, missing 'code' parameter in response."); + } + + $claims = $this->openIDConnectClient->getVerifiedClaims(); + + // Create new open-id connect user + $user_info = get_object_vars($this->openIDConnectClient->requestUserInfo()); + $oidc_user = new OIDCUser($user_info); + + // Get eloquent user (existing or new) + $user = $oidc_user->createOrFindEloquentModel('oidc'); + + // Sync attributes + $oidc_user->syncWithEloquentModel($user, config('services.oidc.mapping')->roles); + + Auth::login($user); + + $sessionData = [ + ['key' => 'oidc_sub', 'value' => $user_info['sub']], + ]; + + if (isset($claims->sid)) { + $sessionData[] = ['key' => 'oidc_sid', 'value' => $claims->sid]; + } + + session(['session_data' => $sessionData]); + + session()->put('oidc_id_token', $this->openIDConnectClient->serializeJWS($this->openIDConnectClient->getIdToken())); + + return $user; + } + + public function backChannelLogout(Request $request): Response + { + try { + $this->openIDConnectClient->verifyLogoutToken($request); + + $claims = $this->openIDConnectClient->getVerifiedClaims(); + $sub = $this->openIDConnectClient->getSubjectFromBackChannel(); + $sid = $this->openIDConnectClient->getSidFromBackChannel(); + $jti = $this->openIDConnectClient->getJtiFromBackChannel(); + + $exp = $claims->exp; + + if (Cache::has('oidc-jti-'.$jti)) { + // Token has already been used + return response('', 400) + ->header('Cache-Control', 'no-store'); + } + + // Store the JTI in cache to prevent replay attacks, until the expiration time of the token + Cache::put('oidc-jti-'.$jti, true, Carbon::createFromTimestamp($exp)); + + } catch (\Throwable $e) { + Log::error('OIDC back-channel logout failed', ['exception' => $e]); + + return response('', 400) + ->header('Cache-Control', 'no-store'); + } + + if ($sid) { + // If sid is present, delete only the session with that sid + + $lookupSessions = SessionData::where('key', 'oidc_sid')->where('value', $sid)->get(); + } else { + // If sid is not present, delete all sessions with that sub + + $lookupSessions = SessionData::where('key', 'oidc_sub')->where('value', $sub)->get(); + } + + foreach ($lookupSessions as $lookupSession) { + $user = $lookupSession->session->user->getLogLabel(); + Log::info('Deleting session of user {user} via OIDC back-channel logout', ['user' => $user, 'type' => 'oidc']); + $lookupSession->session()->delete(); + } + + return response('', 200) + ->header('Cache-Control', 'no-store'); + } +} diff --git a/app/Auth/OIDC/OIDCServiceProvider.php b/app/Auth/OIDC/OIDCServiceProvider.php new file mode 100644 index 000000000..0503532e8 --- /dev/null +++ b/app/Auth/OIDC/OIDCServiceProvider.php @@ -0,0 +1,48 @@ +app->singleton(OIDCProvider::class, function (Application $app) { + $oidc = new OpenIDConnectClient( + config('services.oidc.issuer'), + config('services.oidc.client_id'), + config('services.oidc.client_secret'), + route('auth.oidc.callback'), + ); + + $oidc->addScope(config('services.oidc.scopes')); + $oidc->setLeeway(config('services.oidc.leeway')); + $oidc->setTimeout(config('services.oidc.timeout')); + $oidc->setCacheConfigMaxAge(config('services.oidc.cache_config_max_age')); + $oidc->setCacheJwksMaxAge(config('services.oidc.cache_jwks_max_age')); + + // Disable peer verification in only allowed in a local environment + if (! config('services.oidc.verify_peer') && $app->isLocal()) { + $oidc->setVerifyPeer(false); + } + + return new OIDCProvider($oidc); + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides(): array + { + return [OIDCProvider::class]; + } +} diff --git a/app/Auth/OIDC/OIDCUser.php b/app/Auth/OIDC/OIDCUser.php new file mode 100644 index 000000000..d03ef97e4 --- /dev/null +++ b/app/Auth/OIDC/OIDCUser.php @@ -0,0 +1,29 @@ +attributes; + + foreach ($attributeMap as $attribute => $oidc_attribute) { + foreach ($oidc_user as $attribute_name => $value) { + if (strcasecmp($oidc_attribute, $attribute_name) == 0) { + if (is_array($value)) { + foreach ($value as $sub_value) { + $this->addAttributeValue($attribute, $sub_value); + } + } else { + $this->addAttributeValue($attribute, $value); + } + } + } + } + } +} diff --git a/app/Auth/OIDC/OpenIDConnectClient.php b/app/Auth/OIDC/OpenIDConnectClient.php new file mode 100644 index 000000000..13666dbac --- /dev/null +++ b/app/Auth/OIDC/OpenIDConnectClient.php @@ -0,0 +1,1062 @@ + + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +namespace App\Auth\OIDC; + +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\RequestException; +use Illuminate\Http\Client\Response; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; +use Illuminate\Support\Uri; +use InvalidArgumentException; +use Jose\Component\Checker\AlgorithmChecker; +use Jose\Component\Checker\AudienceChecker; +use Jose\Component\Checker\ClaimCheckerManager; +use Jose\Component\Checker\ExpirationTimeChecker; +use Jose\Component\Checker\HeaderCheckerManager; +use Jose\Component\Checker\InvalidClaimException; +use Jose\Component\Checker\IsEqualChecker; +use Jose\Component\Checker\IssuedAtChecker; +use Jose\Component\Checker\MissingMandatoryClaimException; +use Jose\Component\Core\AlgorithmManagerFactory; +use Jose\Component\Core\JWK; +use Jose\Component\Core\JWKSet; +use Jose\Component\KeyManagement\JWKFactory; +use Jose\Component\Signature\Algorithm\EdDSA; +use Jose\Component\Signature\Algorithm\ES256; +use Jose\Component\Signature\Algorithm\ES384; +use Jose\Component\Signature\Algorithm\ES512; +use Jose\Component\Signature\Algorithm\HS256; +use Jose\Component\Signature\Algorithm\HS384; +use Jose\Component\Signature\Algorithm\HS512; +use Jose\Component\Signature\Algorithm\PS256; +use Jose\Component\Signature\Algorithm\PS384; +use Jose\Component\Signature\Algorithm\PS512; +use Jose\Component\Signature\Algorithm\RS256; +use Jose\Component\Signature\Algorithm\RS384; +use Jose\Component\Signature\Algorithm\RS512; +use Jose\Component\Signature\JWS; +use Jose\Component\Signature\JWSTokenSupport; +use Jose\Component\Signature\JWSVerifier; +use Jose\Component\Signature\Serializer\CompactSerializer; +use JsonException; +use Session; +use Symfony\Component\Clock\Clock; + +class OpenIDConnectClient +{ + /** + * @var array holds the provider configuration + */ + private array $providerConfig = []; + + /** + * @var bool Verify SSL peer on transactions + */ + private bool $verifyPeer = true; + + /** + * @var string if we acquire an access token it will be stored here + */ + protected string $accessToken; + + /** + * @var JWS if we acquire an id token it will be stored here + */ + protected JWS $idToken; + + /** + * @var object stores the token response + */ + private object $tokenResponse; + + /** + * @var array holds scopes + */ + private array $scopes = ['openid']; + + /** + * @var mixed holds well-known openid server properties + */ + private mixed $wellKnown = false; + + /** + * @var int timeout (seconds) + */ + protected int $timeOut = 60; + + /** + * @var int leeway (seconds) + */ + private int $leeway = 300; + + /** + * @var int fallback cache max age (seconds) for openid configuration + */ + private int $cacheConfigMaxAge = 0; + + /** + * @var int fallback cache max age (seconds) for jwks + */ + private int $cacheJwksMaxAge = 0; + + /** + * @var object holds verified jwt claims + */ + protected object $verifiedClaims; + + /** + * @var string if we acquire a sid in back-channel logout it will be stored here + */ + private ?string $backChannelSid = null; + + /** + * @var string if we acquire a sub in back-channel logout it will be stored here + */ + private ?string $backChannelSubject = null; + + /** + * @var string jti (JWT ID) of back-channel logout it will be stored here + */ + private string $backChannelJti; + + private AlgorithmManagerFactory $algorithmManagerFactory; + + private CompactSerializer $compactSerializer; + + private HeaderCheckerManager $headerCheckerManager; + + /** + * @param string $provider_url + */ + public function __construct(string $providerUrl, private string $clientID, private string $clientSecret, private string $redirectURL) + { + $this->setProviderURL($providerUrl); + $this->setIssuer($providerUrl); + + $algorithmManagerFactory = new AlgorithmManagerFactory; + $algorithmManagerFactory->add('PS256', new PS256); + $algorithmManagerFactory->add('RS256', new RS256); + $algorithmManagerFactory->add('PS384', new PS384); + $algorithmManagerFactory->add('RS384', new RS384); + $algorithmManagerFactory->add('PS512', new PS512); + $algorithmManagerFactory->add('RS512', new RS512); + $algorithmManagerFactory->add('HS256', new HS256); + $algorithmManagerFactory->add('HS512', new HS512); + $algorithmManagerFactory->add('HS384', new HS384); + $algorithmManagerFactory->add('ES256', new ES256); + $algorithmManagerFactory->add('ES384', new ES384); + $algorithmManagerFactory->add('ES512', new ES512); + $algorithmManagerFactory->add('EdDSA', new EdDSA); + $this->algorithmManagerFactory = $algorithmManagerFactory; + + $this->compactSerializer = new CompactSerializer; + + $jwsTokenSupport = new JWSTokenSupport; + $this->headerCheckerManager = new HeaderCheckerManager( + [ + new AlgorithmChecker($this->algorithmManagerFactory->aliases()), + ], + [ + $jwsTokenSupport, + ] + ); + } + + public function setProviderURL($provider_url) + { + $this->providerConfig['providerUrl'] = $provider_url; + } + + public function setIssuer($issuer) + { + $this->providerConfig['issuer'] = $issuer; + } + + /** + * Authenticate the user with the OpenID Connect provider using the authorization code + * + * @param Request $request The request object containing the authorization code and state + * @return bool Returns true if authentication is successful, false if the code is missing + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws ConnectionException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws RequestException + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectProviderException + * @throws JsonException + * @throws InvalidClaimException + * @throws MissingMandatoryClaimException + */ + public function authenticate(Request $request): bool + { + // Do a preemptive check to see if the provider has thrown an error from a previous redirect + if ($request->has('error')) { + $desc = $request->has('error_description') ? ' Description: '.$request->input('error_description') : ''; + throw new OpenIDConnectProviderException('Authentication Error Response'.$desc, $request->input('error')); + } + + // If the authorization code is missing, the authentication has failed + // User might have called the authentication URL directly + if (! $request->has('code')) { + return false; + } + + // Check OpenID Connect session + if (! $request->has('state') || ($request->input('state') !== $this->getState())) { + throw new OpenIDConnectValidationException('Authentication Response state invalid'); + } + + // Cleanup state + $this->unsetState(); + + // Request token from the server using the code + $token_json = $this->requestTokens($request->input('code')); + + if (! property_exists($token_json, 'id_token')) { + throw new OpenIDConnectValidationException('Token Response is missing id_token'); + } + + if (! property_exists($token_json, 'token_type') || Str::lower($token_json->token_type) !== 'bearer') { + throw new OpenIDConnectValidationException('Token Response token_type is not Bearer'); + } + + if (! property_exists($token_json, 'access_token')) { + throw new OpenIDConnectValidationException('Token Response is missing access_token'); + } + + $id_token = $token_json->id_token; + + $jws = $this->unserializeJWS($id_token); + + // Verify header + $this->verifyJWSHeader($jws); + + // Verify the signature + $this->verifyJWSSignature($jws); + + // Save the id token + $this->idToken = $jws; + + // Save the access token + $this->accessToken = $token_json->access_token; + + // Save the full response + $this->tokenResponse = $token_json; + + // Get claims from JWT + $claims = $this->getJWSClaims($jws); + + // Verify the claims in the id token + $this->verifyIdTokenClaims($claims); + + // Clean up the session a little + $this->unsetNonce(); + + // Save the verified claims + $this->verifiedClaims = $claims; + + // Success! + return true; + } + + /** + * Verify each claim in the id token according to the spec + * + * @throws InvalidClaimException + * @throws MissingMandatoryClaimException + */ + public function verifyIdTokenClaims(object $claims): void + { + $clock = new Clock; + $claimCheckerManager = new ClaimCheckerManager( + [ + new IssuedAtChecker(clock: $clock, allowedTimeDrift: $this->leeway), + new ExpirationTimeChecker(clock: $clock, allowedTimeDrift: $this->leeway), + new AudienceChecker(audience: $this->clientID), + new IsEqualChecker(key: 'nonce', value: $this->getNonce()), + new AccessTokenHashChecker($this), + new IssuerChecker($this), + ] + ); + + $claimCheckerManager->check((array) $claims, ['sub', 'aud', 'iss', 'iat', 'exp', 'nonce']); + } + + /** + * It calls the end-session endpoint of the OpenID Connect provider to notify the OpenID + * Connect provider that the end-user has logged out of the relying party site + * (the client application). + * + * @param string $idToken ID token (obtained at login) + * @param string|null $redirect URL to which the RP is requesting that the End-User's User Agent + * be redirected after a logout has been performed. The value MUST have been previously + * registered with the OP. Value can be null. + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + public function getSignOutUrl(string $idToken, string $redirect): string + { + $sign_out_endpoint = $this->getProviderConfigValue('end_session_endpoint'); + + $signout_params = [ + 'id_token_hint' => $idToken, + 'post_logout_redirect_uri' => $redirect, + ]; + + return Uri::of($sign_out_endpoint)->withQuery($signout_params)->value(); + } + + /** + * Decode and then verify a logout token sent as part of + * back-channel logout flows. + * + * This function should be evaluated as a boolean check + * in your route that receives the POST request for back-channel + * logout executed from the OP. + * + * @throws OpenIDConnectClientException + * @throws InvalidArgumentException + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectNetworkException + * @throws JsonException + * @throws InvalidClaimException + * @throws MissingMandatoryClaimException + */ + public function verifyLogoutToken(Request $request): void + { + // Check if the logout token is present in the request + if (! $request->has('logout_token')) { + throw new OpenIDConnectClientException('Back-channel logout: There was no logout_token in the request'); + } + + $logout_token = $request->input('logout_token'); + + $jws = $this->unserializeJWS($logout_token); + + // Verify header + $this->verifyJWSHeader($jws); + + // Verify the signature + $this->verifyJWSSignature($jws); + + // Get claims from JWT + $claims = $this->getJWSClaims($jws); + + // Verify Logout Token Claims + $this->verifyLogoutTokenClaims($claims); + + $this->verifiedClaims = $claims; + + // Set the sid, which could be used to map to a session in + // the RP, and therefore be used to help destroy the RP's + // session. + if (isset($claims->sid)) { + $this->backChannelSid = $claims->sid; + } + + // Set the sub, which could be used to map to a session in + // the RP, and therefore be used to help destroy the RP's + // session. + if (isset($claims->sub)) { + $this->backChannelSubject = $claims->sub; + } + + $this->backChannelJti = $claims->jti; + } + + /** + * Verify each claim in the logout token according to the + * spec for back-channel logout. + * + * @throws InvalidClaimException + * @throws MissingMandatoryClaimException + */ + public function verifyLogoutTokenClaims(object $claims): void + { + $clock = new Clock; + $claimCheckerManager = new ClaimCheckerManager( + [ + new IssuedAtChecker(clock: $clock, allowedTimeDrift: $this->leeway), + new ExpirationTimeChecker(clock: $clock, allowedTimeDrift: $this->leeway), + new AudienceChecker(audience: $this->clientID), + new IssuerChecker($this), + new EventsChecker('http://schemas.openid.net/event/backchannel-logout'), + ] + ); + + $claimCheckerManager->check((array) $claims, ['aud', 'iss', 'iat', 'exp', 'events', 'jti']); + + // Verify that the Logout Token doesn't contain a nonce Claim. + if (isset($claims->nonce)) { + throw new InvalidClaimException('"nonce" is not allowed.', 'nonce', $claims->nonce); + } + + // Verify that the logout token contains a sub or sid, or both + if (! isset($claims->sid) && ! isset($claims->sub)) { + throw new MissingMandatoryClaimException('The sid or sub claim is required.', array_keys((array) $claims)); + } + } + + /** + * @param array $scope - example: given_name, etc... + */ + public function addScope(array $scope) + { + $this->scopes = array_unique(array_merge($this->scopes, $scope)); + } + + /** + * Gets anything that we need configuration wise including endpoints, and other values + * + * @param string|null $default optional + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + protected function getProviderConfigValue(string $param, ?string $default = null): string + { + // If the configuration value is not available, attempt to fetch it from a well known config endpoint + // This is also known as auto "discovery" + if (! isset($this->providerConfig[$param])) { + $this->providerConfig[$param] = $this->getWellKnownConfigValue($param, $default); + } + + return $this->providerConfig[$param]; + } + + /** + * Gets anything that we need configuration wise including endpoints, and other values + * + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + protected function getWellKnownConfigValue(string $param, ?string $default = null): string + { + // If the configuration value is not available, attempt to fetch it from a well known config endpoint + // This is also known as auto "discovery" + if (! $this->wellKnown) { + $well_known_config_url = Str::finish($this->getProviderURL(), '/').'.well-known/openid-configuration'; + + // If we have the response cached, use it + if (\Cache::has($well_known_config_url)) { + $this->wellKnown = \Cache::get($well_known_config_url); + } else { + // Try to fetch the well known configuration + try { + $response = $this->getHttpClient()->get($well_known_config_url)->throw(); + } catch (\Throwable $e) { + throw new OpenIDConnectNetworkException('Unable to fetch openid configuration: '.$e->getMessage(), $e->getCode()); + } + $maxAge = $this->cacheConfigMaxAge; + if ($response->hasHeader('Cache-Control')) { + if (preg_match('/max-age=(\d+)/i', $response->header('Cache-Control'), $matches)) { + $maxAge = (int) $matches[1] ?: $maxAge; + } + } + if ($maxAge > 0) { + \Cache::put($well_known_config_url, $response->object(), $maxAge); + } + + $this->wellKnown = $response->object(); + } + } + + $value = $this->wellKnown->{$param} ?? false; + + if ($value) { + return $value; + } + + if (isset($default)) { + // Uses default value if provided + return $default; + } + + throw new OpenIDConnectClientException("The provider $param could not be fetched. Make sure your provider has a well known configuration available."); + } + + /** + * Create url for authorization request + * + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + public function getAuthenticationRequestUrl(): string + { + $auth_endpoint = $this->getProviderConfigValue('authorization_endpoint'); + + // Generate and store a nonce in the session + // The nonce is an arbitrary value + $nonce = $this->setNonce(Str::random()); + + // State essentially acts as a session key for OIDC + $state = $this->setState(Str::random()); + + $auth_params = [ + 'response_type' => 'code', + 'redirect_uri' => $this->redirectURL, + 'client_id' => $this->clientID, + 'nonce' => $nonce, + 'state' => $state, + 'scope' => implode(' ', $this->scopes), + ]; + + return Uri::of($auth_endpoint)->withQuery($auth_params)->value(); + } + + /** + * Requests ID and Access tokens + * + * @param string $code authorization code + * + * @throws ConnectionException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws RequestException + * @throws OpenIDConnectProviderException + */ + protected function requestTokens(string $code): ?object + { + $token_endpoint = $this->getProviderConfigValue('token_endpoint'); + + $token_params = [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->redirectURL, + ]; + + // Using client_secret_basic authentication + try { + $response = $this->getHttpClient() + ->withBasicAuth(urlencode($this->clientID), urlencode($this->clientSecret)) + ->asForm() + ->post($token_endpoint, $token_params); + } catch (\Throwable $e) { + throw new OpenIDConnectNetworkException('Unable to fetch tokens '.$e->getMessage(), $e->getCode()); + } + + try { + $this->tokenResponse = $response->throw()->object(); + } catch (\Throwable $e) { + throw new OpenIDConnectProviderException('Token Error Response '.$e->getMessage(), $e->getCode()); + } + + return $this->tokenResponse; + } + + private function getJWK(string $alg, string $key): JWK + { + return JWKFactory::createFromSecret( + $key, + [ + 'alg' => $alg, + 'use' => 'sig', + ] + ); + } + + /** + * Returns the claims from a JWS object + */ + public function getJWSClaims(JWS $jws): object + { + return json_decode($jws->getPayload()); + } + + /** + * Verifies the JWS signature of a JWS object + * + * @param JWS $jws The JWS object to verify + * + * @throws OpenIDConnectClientException + * @throws InvalidArgumentException + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectNetworkException + * @throws JsonException + */ + public function verifyJWSSignature(JWS $jws): void + { + $signature = $jws->getSignature(0); + $alg = $signature->getProtectedHeaderParameter('alg'); + + $algorithmManager = $this->algorithmManagerFactory->create([$alg]); + $jwsVerifier = new JWSVerifier($algorithmManager); + + switch ($alg) { + case 'PS256': + case 'RS256': + case 'PS384': + case 'RS384': + case 'PS512': + case 'RS512': + case 'ES256': + case 'ES384': + case 'ES512': + case 'EdDSA': + + if ($signature->hasProtectedHeaderParameter('jwk')) { + throw new OpenIDConnectClientException('Self signed JWK header is not valid'); + } else { + $jwksUri = $this->getProviderConfigValue('jwks_uri'); + if (! $jwksUri) { + throw new OpenIDConnectClientException('Unable to verify signature due to no jwks_uri being defined'); + } + + // If we have the response cached, use it + if (\Cache::has($jwksUri)) { + $jwkSetResponse = \Cache::get($jwksUri); + } else { + // Try to fetch the jwks + try { + $response = $this->getHttpClient()->get($jwksUri)->throw(); + } catch (\Throwable $e) { + throw new OpenIDConnectNetworkException('Unable to fetch jwks: '.$e->getMessage(), $e->getCode()); + } + $maxAge = $this->cacheJwksMaxAge; + if ($response->hasHeader('Cache-Control')) { + if (preg_match('/max-age=(\d+)/i', $response->header('Cache-Control'), $matches)) { + $maxAge = (int) $matches[1] ?: $maxAge; + } + } + if ($maxAge > 0) { + \Cache::put($jwksUri, $response->body(), $maxAge); + } + + $jwkSetResponse = $response->body(); + } + + $jwkSet = JWKSet::createFromJson($jwkSetResponse); + + $restrictions = []; + if ($signature->hasProtectedHeaderParameter('kid')) { + $restrictions['kid'] = $signature->getProtectedHeaderParameter('kid'); + } + + $jwk = $jwkSet->selectKey('sig', $algorithmManager->get($algorithmManager->list()[0]), $restrictions); + } + break; + case 'HS256': + case 'HS512': + case 'HS384': + $jwk = $this->getJWK($alg, $this->clientSecret); + break; + default: + throw new OpenIDConnectClientException('Unsupported signature algorithm: '.$alg); + } + + if ($jwk === null) { + throw new OpenIDConnectClientException('Unable to find JWK for algorithm: '.$alg); + } + + if (! $jwsVerifier->verifyWithKey($jws, $jwk, 0)) { + throw new OpenIDConnectValidationException('JWS signature invalid'); + } + } + + /** + * Verifies the JWS header of a JWS object + * + * @throws OpenIDConnectValidationException + */ + public function verifyJWSHeader(JWS $jws): void + { + try { + $this->headerCheckerManager->check($jws, 0, ['alg']); + } catch (\Throwable $e) { + throw new OpenIDConnectValidationException('Error verifying JWS header: '.$e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Unserializes a JWS string into a JWS object + * + * @throws InvalidArgumentException + */ + public function unserializeJWS(string $jws): JWS + { + return $this->compactSerializer->unserialize($jws); + } + + /** + * Serializes a JWS object into a JWS string + */ + public function serializeJWS(JWS $jws): string + { + return $this->compactSerializer->serialize($jws); + } + + /** + * Request claims about the End-User from UserInfo Endpoint + * + * + * @throws InvalidClaimException + * @throws JsonException + * @throws MissingMandatoryClaimException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectProviderException + */ + public function requestUserInfo(): object + { + $user_info_endpoint = $this->getProviderConfigValue('userinfo_endpoint'); + + // The accessToken has to be sent in the Authorization header. + // Accept json to indicate response type + try { + $response = $this->getHttpClient()->acceptJson()->withToken($this->accessToken)->get($user_info_endpoint); + } catch (\Throwable $e) { + throw new OpenIDConnectNetworkException('Unable to retrieve user data: '.$e->getMessage(), $e->getCode()); + } + + try { + $body = $response->throw(); + } catch (\Throwable $e) { + throw new OpenIDConnectProviderException('UserInfo Error Response: '.$e->getMessage(), $e->getCode()); + } + + return $this->getClaimsFromUserInfoResponse($body); + } + + /** + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws JsonException + * @throws MissingMandatoryClaimException + * @throws InvalidClaimException + */ + private function getClaimsFromUserInfoResponse(Response $response): object + { + // When we receive application/jwt, the UserInfo Response is signed and/or encrypted. + + /* + * The UserInfo Endpoint MUST return a content-type header to indicate which format is being returned. + * The content-type of the HTTP response MUST be application/json if the response body is a text JSON object; the response body SHOULD be encoded using UTF-8. + * + * If the UserInfo Response is signed and/or encrypted, then the Claims are returned in a JWT and the content-type MUST be application/jwt. + * The response MAY be encrypted without also being signed. + * If both signing and encryption are requested, the response MUST be signed then encrypted, with the result being a Nested JWT, as defined in [JWT]. + */ + + // Extract the content type from the response (remove optional charset) + $contentTypeHeader = $response->getHeader('Content-Type'); + if (empty($contentTypeHeader)) { + throw new OpenIDConnectClientException('User data response is missing Content-Type header'); + } + + $contentType = explode(';', $contentTypeHeader[0])[0]; + + if ($contentType === 'application/jwt') { + return $this->getClaimsFromSignedUserInfoResponse($response->body()); + + } else { + return $this->getClaimsFromUnsignedUserInfoResponse($response->body()); + } + } + + /** + * @throws JsonException + * @throws MissingMandatoryClaimException + * @throws InvalidClaimException + */ + private function getClaimsFromUnsignedUserInfoResponse($content): object + { + $claims = json_decode($content, flags: JSON_THROW_ON_ERROR); + + /* + * The sub (subject) Claim MUST always be returned in the UserInfo Response. + * NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. + * The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match, the UserInfo Response values MUST NOT be used. + */ + + new ClaimCheckerManager( + [ + new IsEqualChecker('sub', $this->getIdTokenPayload()->sub), + ] + )->check((array) $claims, ['sub']); + + return $claims; + } + + /** + * @throws OpenIDConnectValidationException + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + * @throws JsonException + * @throws InvalidArgumentException + * @throws MissingMandatoryClaimException + * @throws InvalidClaimException + */ + private function getClaimsFromSignedUserInfoResponse(string $jwt): object + { + $jws = $this->unserializeJWS($jwt); + + // Verify header + $this->verifyJWSHeader($jws); + + // Verify the signature + $this->verifyJWSSignature($jws); + + // Get claims from JWT + $claims = $this->getJWSClaims($jws); + + /* + * The sub (subject) Claim MUST always be returned in the UserInfo Response. + * NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. + * The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match, the UserInfo Response values MUST NOT be used. + * + * If signed, the UserInfo Response MUST contain the Claims iss (issuer) and aud (audience) as members. + * The iss value MUST be the OP's Issuer Identifier URL. The aud value MUST be or include the RP's Client ID value. + */ + + new ClaimCheckerManager( + [ + new AudienceChecker($this->clientID), + new IssuerChecker($this), + new IsEqualChecker('sub', $this->getIdTokenPayload()->sub), + ] + )->check((array) $claims, ['sub', 'aud', 'iss']); + + return $claims; + } + + public function getVerifiedClaims(): object + { + return $this->verifiedClaims; + } + + protected function getHttpClient(): PendingRequest + { + $client = Http::timeout($this->timeOut); + + if (! $this->verifyPeer) { + $client = $client->withoutVerifying(); + } + + return $client; + } + + /** + * @throws OpenIDConnectClientException + * @throws OpenIDConnectNetworkException + */ + public function getWellKnownIssuer(bool $appendSlash = false): string + { + return $this->getWellKnownConfigValue('issuer').($appendSlash ? '/' : ''); + } + + /** + * @throws OpenIDConnectClientException + */ + public function getIssuer(): string + { + + if (! isset($this->providerConfig['issuer'])) { + throw new OpenIDConnectClientException('The issuer has not been set'); + } + + return $this->providerConfig['issuer']; + } + + /** + * @return mixed + * + * @throws OpenIDConnectClientException + */ + public function getProviderURL() + { + if (! isset($this->providerConfig['providerUrl'])) { + throw new OpenIDConnectClientException('The provider URL has not been set'); + } + + return $this->providerConfig['providerUrl']; + } + + /** + * @return string|null + */ + public function getAccessToken() + { + return $this->accessToken; + } + + /** + * @return JWS|null + */ + public function getIdToken() + { + return $this->idToken; + } + + /** + * @return array + */ + public function getIdTokenHeader() + { + return $this->getIdToken()->getSignature(0)->getProtectedHeader(); + } + + /** + * @return object + */ + public function getIdTokenPayload() + { + return $this->getJWSClaims($this->getIdToken()); + } + + /** + * Stores nonce + */ + protected function setNonce(string $nonce): string + { + Session::put('openid_connect_nonce', $nonce); + + return $nonce; + } + + /** + * Get stored nonce + * + * @return string + */ + protected function getNonce() + { + return Session::get('openid_connect_nonce', false); + } + + /** + * Cleanup nonce + * + * @return void + */ + protected function unsetNonce() + { + Session::remove('openid_connect_nonce'); + } + + /** + * Stores $state + */ + protected function setState(string $state): string + { + Session::put('openid_connect_state', $state); + + return $state; + } + + /** + * Get stored state + * + * @return string + */ + protected function getState() + { + return Session::get('openid_connect_state', false); + } + + /** + * Cleanup state + * + * @return void + */ + protected function unsetState() + { + Session::remove('openid_connect_state'); + } + + /** + * Set timeout (seconds) + */ + public function setTimeout(int $timeout) + { + $this->timeOut = $timeout; + } + + public function setLeeway(int $leeway) + { + $this->leeway = $leeway; + } + + public function setVerifyPeer(bool $verifyPeer): void + { + $this->verifyPeer = $verifyPeer; + } + + public function setCacheJwksMaxAge(int $cacheJwksMaxAge): void + { + $this->cacheJwksMaxAge = $cacheJwksMaxAge; + } + + public function setCacheConfigMaxAge(int $cacheConfigMaxAge): void + { + $this->cacheConfigMaxAge = $cacheConfigMaxAge; + } + + public function getSidFromBackChannel(): ?string + { + return $this->backChannelSid; + } + + public function getSubjectFromBackChannel(): ?string + { + return $this->backChannelSubject; + } + + public function getJtiFromBackChannel(): string + { + return $this->backChannelJti; + } + + /** + * Checks if an end_session_endpoint is available in the OIDC provider's well-known configuration. + * + * @return {boolean} + */ + public function hasEndSessionEndpoint(): bool + { + return (bool) $this->getProviderConfigValue('end_session_endpoint', false); + } + + public function base64url_encode($data) + { + // Convert Base64 to Base64URL by replacing "+" with "-" and "/" with "_" and remove tailing "=" if any + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/app/Auth/OIDC/OpenIDConnectClientException.php b/app/Auth/OIDC/OpenIDConnectClientException.php new file mode 100644 index 000000000..8ba817343 --- /dev/null +++ b/app/Auth/OIDC/OpenIDConnectClientException.php @@ -0,0 +1,5 @@ +authenticator) { case 'shibboleth': $redirect = app(ShibbolethProvider::class)->logout(url('/logout')); - + break; + case 'oidc': + $redirect = app(OIDCProvider::class)->logout(url('/logout')); + if (! $redirect) { + $message = 'oidc_incomplete'; + } break; } @@ -84,6 +92,7 @@ public function logout(Request $request) return response()->json([ 'redirect' => $redirect, + 'message' => $message, ]); } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 08344dda3..f617a8b0b 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -13,5 +13,6 @@ class VerifyCsrfToken extends Middleware */ protected $except = [ 'auth/shibboleth/logout', + 'auth/oidc/logout', ]; } diff --git a/app/Http/Resources/Config.php b/app/Http/Resources/Config.php index 7cd2e6c7e..e539a143e 100644 --- a/app/Http/Resources/Config.php +++ b/app/Http/Resources/Config.php @@ -107,6 +107,7 @@ public function toArray($request) 'local' => config('auth.local.enabled'), 'ldap' => config('ldap.enabled'), 'shibboleth' => config('services.shibboleth.enabled'), + 'oidc' => config('services.oidc.enabled'), ], ]; } diff --git a/composer.json b/composer.json index 463985865..9ce0cb57b 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "spatie/laravel-ignition": "^2.0", "spatie/laravel-settings": "^3.3", "symfony/var-exporter": "^7.0", - "ext-redis": "*" + "ext-redis": "*", + "web-token/jwt-framework": "^4.0" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.0", diff --git a/composer.lock b/composer.lock index 41788ed2f..1707e0a74 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dc99a7e9dd4a98aad3fd7c11e20f2b30", + "content-hash": "0c1389e9346d23e7196d7db4865419ef", "packages": [ { "name": "brick/math", @@ -5940,6 +5940,115 @@ ], "time": "2025-01-13T13:04:43+00:00" }, + { + "name": "spomky-labs/pki-framework", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/eced5b5ce70518b983ff2be486e902bbd15135ae", + "reference": "eced5b5ce70518b983ff2be486e902bbd15135ae", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-06-13T08:35:04+00:00" + }, { "name": "symfony/clock", "version": "v7.3.0", @@ -6014,6 +6123,81 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/config", + "version": "v7.2.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "e0b050b83ba999aa77a3736cb6d5b206d65b9d0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/e0b050b83ba999aa77a3736cb6d5b206d65b9d0d", + "reference": "e0b050b83ba999aa77a3736cb6d5b206d65b9d0d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.2.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-03T21:14:15+00:00" + }, { "name": "symfony/console", "version": "v7.3.3", @@ -6177,6 +6361,86 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/dependency-injection", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "8656c4848b48784c4bb8c4ae50d2b43f832cead8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/8656c4848b48784c4bb8c4ae50d2b43f832cead8", + "reference": "8656c4848b48784c4bb8c4ae50d2b43f832cead8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.5", + "symfony/var-exporter": "^6.4.20|^7.2.5" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T04:04:43+00:00" + }, { "name": "symfony/deprecation-contracts", "version": "v3.6.0", @@ -6467,7 +6731,137 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-25T15:15:23+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.3.2" }, "funding": [ { @@ -6478,40 +6872,50 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { - "name": "symfony/finder", - "version": "v7.3.2", + "name": "symfony/http-client-contracts", + "version": "v3.6.0", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "php": ">=8.1" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Contracts\\HttpClient\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6520,18 +6924,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Generic abstractions related to HTTP clients", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" }, "funding": [ { @@ -6542,16 +6954,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-04-29T11:18:49+00:00" }, { "name": "symfony/http-foundation", @@ -8712,6 +9120,142 @@ ], "time": "2024-11-21T01:49:47+00:00" }, + { + "name": "web-token/jwt-framework", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-framework.git", + "reference": "82cd173980cc98f72e6cdcaf00ebcbf4111f3d84" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-framework/zipball/82cd173980cc98f72e6cdcaf00ebcbf4111f3d84", + "reference": "82cd173980cc98f72e6cdcaf00ebcbf4111f3d84", + "shasum": "" + }, + "require": { + "brick/math": "^0.12 || ^0.13", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.2", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "spomky-labs/pki-framework": "^1.2.1", + "symfony/config": "^7.0", + "symfony/console": "^7.0", + "symfony/dependency-injection": "^7.0", + "symfony/event-dispatcher": "^7.0", + "symfony/http-client-contracts": "^3.4", + "symfony/http-kernel": "^7.0" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "replace": { + "web-token/jwt-bundle": "self.version", + "web-token/jwt-experimental": "self.version", + "web-token/jwt-library": "self.version" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^2.0|^3.0", + "ergebnis/phpunit-slow-test-detector": "^2.14", + "ext-curl": "*", + "ext-gmp": "*", + "ext-sodium": "*", + "infection/infection": "^0.29", + "matthiasnoback/symfony-config-test": "5.1.x-dev", + "paragonie/sodium_compat": "^1.20|^2.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-doctrine": "^1.3|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.4|^2.0", + "phpstan/phpstan-symfony": "^1.3|^2.0", + "phpunit/phpunit": "^10.5.10|^11.0", + "qossmic/deptrac": "^2.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "spomky-labs/aes-key-wrap": "^7.0", + "staabm/phpstan-dba": "^0.2.79|^0.3", + "staabm/phpstan-todo-by": "^0.1.25|^0.2", + "struggle-for-php/sfp-phpstan-psr-log": "^0.20|^0.21|^0.22|^0.23", + "symfony/browser-kit": "^7.0", + "symfony/clock": "^7.0", + "symfony/finder": "^7.0", + "symfony/framework-bundle": "^7.0", + "symfony/http-client": "^7.0", + "symfony/serializer": "^7.0", + "symfony/var-dumper": "^7.0", + "symfony/yaml": "^7.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "spomky-labs/aes-key-wrap": "To enable AES Key Wrap algorithm.", + "symfony/serializer": "Use the Symfony serializer to serialize/unserialize JWS and JWE tokens.", + "symfony/var-dumper": "Used to show data on the debug toolbar." + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Jose\\Component\\": "src/Library/", + "Jose\\Experimental\\": "src/Experimental/", + "Jose\\Bundle\\JoseFramework\\": "src/Bundle/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-bundle/contributors" + } + ], + "description": "JSON Object Signing and Encryption library for PHP and Symfony Bundle.", + "homepage": "https://github.com/web-token/jwt-framework", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-framework/issues", + "source": "https://github.com/web-token/jwt-framework/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-03-12T11:25:35+00:00" + }, { "name": "webmozart/assert", "version": "1.11.0", @@ -11147,147 +11691,6 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/config", - "version": "v7.2.6", - "source": { - "type": "git", - "url": "https://github.com/symfony/config.git", - "reference": "e0b050b83ba999aa77a3736cb6d5b206d65b9d0d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/e0b050b83ba999aa77a3736cb6d5b206d65b9d0d", - "reference": "e0b050b83ba999aa77a3736cb6d5b206d65b9d0d", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^7.1", - "symfony/polyfill-ctype": "~1.8" - }, - "conflict": { - "symfony/finder": "<6.4", - "symfony/service-contracts": "<2.5" - }, - "require-dev": { - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Config\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/config/tree/v7.2.6" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-04-03T21:14:15+00:00" - }, - { - "name": "symfony/filesystem", - "version": "v7.2.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.2.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-10-25T15:15:23+00:00" - }, { "name": "symfony/stopwatch", "version": "v7.2.4", diff --git a/config/app.php b/config/app.php index c06eb170e..5f76dcd33 100644 --- a/config/app.php +++ b/config/app.php @@ -240,6 +240,7 @@ App\Auth\LDAP\LDAPServiceProvider::class, App\Auth\Shibboleth\ShibbolethServiceProvider::class, + App\Auth\OIDC\OIDCServiceProvider::class, App\Providers\TranslationServiceProvider::class, diff --git a/config/services.php b/config/services.php index 697364a5a..2b7eb63d6 100644 --- a/config/services.php +++ b/config/services.php @@ -1,6 +1,7 @@ (bool) env('SHIBBOLETH_SESSION_CHECK_ENABLED', true), 'logout' => env('SHIBBOLETH_LOGOUT_URL', '/Shibboleth.sso/Logout'), ], + + 'oidc' => [ + 'enabled' => $oidcEnabled, + 'issuer' => env('OIDC_ISSUER'), + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'scopes' => explode(',', env('OIDC_SCOPES', 'profile,email')), + 'leeway' => (int) env('OIDC_LEEWAY', 300), + 'timeout' => (int) env('OIDC_TIMEOUT', 10), + 'verify_peer' => (bool) env('OIDC_VERIFY_PEER', true), + 'cache_config_max_age' => (int) env('OIDC_CACHE_CONFIG_MAX_AGE', 0), + 'cache_jwks_max_age' => (int) env('OIDC_CACHE_JWKS_MAX_AGE', 0), + 'mapping' => $oidcEnabled ? json_decode(file_get_contents(app_path('Auth/config/oidc_mapping.json'))) : null, + ], ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index ee80c91f7..5d4761dc4 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -441,6 +441,7 @@ 'authenticator' => [ 'ldap' => 'LDAP', 'local' => 'Local', + 'oidc' => 'OIDC', 'shibboleth' => 'Shibboleth', 'title' => 'Authentication Type', ], diff --git a/lang/en/auth.php b/lang/en/auth.php index ed420cb8f..db642a8a5 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -13,8 +13,11 @@ 'error' => [ 'login_failed' => 'Login failed', 'missing_attributes' => 'Attributes for authentication are missing.', + 'openid_connect_exception' => 'Authentication failed due to an error.', + 'openid_connect_network_exception' => 'Failed to connect to the authentication provider.', 'reason' => 'Error reason', - 'shibboleth_session_duplicate_exception' => 'The Shibboleth session is already in use. Please log in again.', + 'shibboleth_session_duplicate_exception' => 'The Shibboleth session is already in use.', + 'try_again' => 'Please try logging in again or contact support if the problem persists.', ], 'failed' => 'These credentials do not match our records.', 'flash' => [ @@ -37,6 +40,12 @@ 'logout_success' => 'Successfully logged out', 'new_password' => 'New password', 'new_password_confirmation' => 'New password confirmation', + 'oidc' => [ + 'redirect' => 'Log in', + 'tab_title' => 'OpenID Connect', + 'title' => 'Log in with OpenID Connect', + 'logout_incomplete' => 'You are still logged in at the OpenID Connect provider.', + ], 'password' => 'Password', 'reset_password' => 'Reset password', 'send_email_confirm_mail' => 'A verification email has been sent to :email. Please confirm the new email address by clicking on the link in the email.', diff --git a/resources/js/components/MainNav.vue b/resources/js/components/MainNav.vue index 5dd9fca19..93234c95a 100644 --- a/resources/js/components/MainNav.vue +++ b/resources/js/components/MainNav.vue @@ -362,7 +362,8 @@ async function logout() { return; } - await router.push({ name: "logout" }); + const message = response.data.message || null; + await router.push({ name: "logout", query: { message } }); loadingStore.setLoadingFinished(); } diff --git a/resources/js/views/ExternalLogin.vue b/resources/js/views/ExternalLogin.vue index 3345a878b..6999de534 100644 --- a/resources/js/views/ExternalLogin.vue +++ b/resources/js/views/ExternalLogin.vue @@ -11,15 +11,29 @@ v-if="props.error === 'missing_attributes'" severity="error" :closable="false" - >{{ $t("auth.error.missing_attributes") }}{{ $t("auth.error.missing_attributes") }} + {{ $t("auth.error.try_again") }} {{ - $t("auth.error.shibboleth_session_duplicate_exception") - }}{{ $t("auth.error.shibboleth_session_duplicate_exception") }} + {{ $t("auth.error.try_again") }} + {{ $t("auth.error.openid_connect_network_exception") }} + {{ $t("auth.error.try_again") }} + {{ $t("auth.error.openid_connect_exception") }} + {{ $t("auth.error.try_again") }}