From d8d70a5df2bc4c2d15ea21162e7f81a75f5aa0d6 Mon Sep 17 00:00:00 2001 From: memurats Date: Tue, 28 Oct 2025 13:22:45 +0100 Subject: [PATCH 1/6] backchannel logout fix --- lib/Controller/LoginController.php | 596 +++++++++++++---------------- 1 file changed, 264 insertions(+), 332 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index da786660..c453b50d 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -13,19 +13,17 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ServerException; +use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\IProvider; use OC\User\Session as OC_UserSession; use OCA\UserOIDC\AppInfo\Application; use OCA\UserOIDC\Db\ProviderMapper; use OCA\UserOIDC\Db\SessionMapper; use OCA\UserOIDC\Event\TokenObtainedEvent; -use OCA\UserOIDC\Helper\HttpClientHelper; use OCA\UserOIDC\Service\DiscoveryService; use OCA\UserOIDC\Service\LdapService; -use OCA\UserOIDC\Service\OIDCService; use OCA\UserOIDC\Service\ProviderService; use OCA\UserOIDC\Service\ProvisioningService; -use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; use OCA\UserOIDC\User\Backend; use OCA\UserOIDC\Vendor\Firebase\JWT\JWT; @@ -38,11 +36,9 @@ use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\Authentication\Exceptions\InvalidTokenException; -use OCP\Authentication\Token\IToken; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; -use OCP\IAppConfig; +use OCP\Http\Client\IClientService; use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; @@ -56,13 +52,12 @@ use OCP\User\Events\BeforeUserLoggedInEvent; use OCP\User\Events\UserLoggedInEvent; use Psr\Log\LoggerInterface; -use UnexpectedValueException; class LoginController extends BaseOidcController { private const STATE = 'oidc.state'; private const NONCE = 'oidc.nonce'; public const PROVIDERID = 'oidc.providerid'; - public const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; + private const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; private const ID_TOKEN = 'oidc.id_token'; private const CODE_VERIFIER = 'oidc.code_verifier'; @@ -72,17 +67,15 @@ public function __construct( private ProviderService $providerService, private DiscoveryService $discoveryService, private LdapService $ldapService, - private SettingsService $settingsService, private ISecureRandom $random, private ISession $session, - private HttpClientHelper $clientService, + private IClientService $clientService, private IURLGenerator $urlGenerator, private IUserSession $userSession, private IUserManager $userManager, private ITimeFactory $timeFactory, private IEventDispatcher $eventDispatcher, private IConfig $config, - private IAppConfig $appConfig, private IProvider $authTokenProvider, private SessionMapper $sessionMapper, private ProvisioningService $provisioningService, @@ -90,41 +83,45 @@ public function __construct( private LoggerInterface $logger, private ICrypto $crypto, private TokenService $tokenService, - private OidcService $oidcService, ) { - parent::__construct($request, $config, $l10n); + parent::__construct($request, $config); } - /** - * @return bool - */ private function isSecure(): bool { // no restriction in debug mode return $this->isDebugModeEnabled() || $this->request->getServerProtocol() === 'https'; } - /** - * @param bool|null $throttle - * @return TemplateResponse - */ private function buildProtocolErrorResponse(?bool $throttle = null): TemplateResponse { $params = [ - 'message' => $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.'), + 'errors' => [ + ['error' => $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.')], + ], ]; $throttleMetadata = ['reason' => 'insecure connection']; - return $this->buildFailureTemplateResponse($params, Http::STATUS_NOT_FOUND, $throttleMetadata, $throttle); + return $this->buildFailureTemplateResponse('', 'error', $params, Http::STATUS_NOT_FOUND, $throttleMetadata, $throttle); } - /** - * @param string|null $redirectUrl - * @return RedirectResponse - */ private function getRedirectResponse(?string $redirectUrl = null): RedirectResponse { - return new RedirectResponse( - $redirectUrl === null - ? $this->urlGenerator->getBaseUrl() - : preg_replace('/^https?:\/\/[^\/]+/', '', $redirectUrl) - ); + if ($redirectUrl === null || $redirectUrl === '') { + return new RedirectResponse($this->urlGenerator->getBaseUrl()); + } + + // Nur relative Pfade erlauben, absolute Schemata/Protokolle und "//host" verhindern + if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $redirectUrl) === 1 || str_starts_with($redirectUrl, '//')) { + return new RedirectResponse($this->urlGenerator->getBaseUrl()); + } + + // CR/LF und Backslashes entfernen + $redirectUrl = preg_replace('/[\r\n\\\\]/', '', $redirectUrl); + + // Normalisieren + $path = parse_url($redirectUrl, PHP_URL_PATH) ?? '/'; + $query = parse_url($redirectUrl, PHP_URL_QUERY); + $safe = rtrim($this->urlGenerator->getBaseUrl(), '/') . '/' . ltrim($path, '/') + . ($query ? '?' . $query : ''); + + return new RedirectResponse($safe); } /** @@ -137,19 +134,19 @@ private function getRedirectResponse(?string $redirectUrl = null): RedirectRespo * @param string|null $redirectUrl * @return DataDisplayResponse|RedirectResponse|TemplateResponse */ - public function login(int $providerId, ?string $redirectUrl = null) { + public function login(int $providerId, ?string $redirectUrl = null): DataDisplayResponse|RedirectResponse|TemplateResponse { if ($this->userSession->isLoggedIn()) { return $this->getRedirectResponse($redirectUrl); } if (!$this->isSecure()) { return $this->buildProtocolErrorResponse(); } - $this->logger->debug('Initiating login for provider with id: ' . strval($providerId)); + $this->logger->debug('Initiating OIDC login', ['providerId' => $providerId]); try { $provider = $this->providerMapper->getProvider($providerId); } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { - $message = $this->l10n->t('There is no such OpenID Connect provider.'); + $message = $this->l10n->t('There is not such OpenID Connect provider.'); return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_not_found' => $providerId]); } @@ -157,7 +154,7 @@ public function login(int $providerId, ?string $redirectUrl = null) { $data = []; $discoveryUrl = parse_url($provider->getDiscoveryEndpoint()); if (isset($discoveryUrl['query'])) { - $this->logger->debug('Add custom discovery query: ' . $discoveryUrl['query']); + $this->logger->debug('Add custom discovery query', ['query' => $discoveryUrl['query']]); $discoveryQuery = []; parse_str($discoveryUrl['query'], $discoveryQuery); $data += $discoveryQuery; @@ -166,104 +163,80 @@ public function login(int $providerId, ?string $redirectUrl = null) { try { $discovery = $this->discoveryService->obtainDiscovery($provider); } catch (\Exception $e) { - $this->logger->error('Could not reach the provider at URL ' . $provider->getDiscoveryEndpoint(), ['exception' => $e]); + $this->logger->error('Could not reach the provider', [ + 'discovery' => $provider->getDiscoveryEndpoint(), + 'exception' => $e, + ]); $message = $this->l10n->t('Could not reach the OpenID Connect provider.'); return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['reason' => 'provider unreachable']); } + // Immer neue STATE/NONCE für diesen Login erzeugen (kein Reuse) $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); - $this->session->set(self::STATE, $state); - $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); - $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + $this->session->set(self::STATE, $state); $this->session->set(self::NONCE, $nonce); + $this->session->set(self::PROVIDERID, $providerId); + $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); $isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true); $isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true); if ($isPkceEnabled) { - // PKCE code_challenge see https://datatracker.ietf.org/doc/html/rfc7636 + // PKCE code_verifier siehe RFC7636 $code_verifier = $this->random->generate(128, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER); $this->session->set(self::CODE_VERIFIER, $code_verifier); } - $this->session->set(self::PROVIDERID, $providerId); $this->session->close(); // get attribute mapping settings $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); + // Claims zusammenstellen $claims = [ - // more details about requesting claims: - // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests - // ['essential' => true] means it's mandatory but it won't trigger an error if it's not there - // null means we want it - 'id_token' => new \stdClass(), - 'userinfo' => new \stdClass(), + 'id_token' => [], + 'userinfo' => [], ]; - $resolveNestedClaims = $this->providerService->getSetting($providerId, ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING, '0') === '1'; - // by default: default claims are ENABLED - // default claims are historically for quota, email, displayName and groups $isDefaultClaimsEnabled = !isset($oidcSystemConfig['enable_default_claims']) || $oidcSystemConfig['enable_default_claims'] !== false; if ($isDefaultClaimsEnabled) { - // default claims for quota, email, displayName and groups is ENABLED $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'name'); $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); $groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS, 'groups'); foreach ([$emailAttribute, $displaynameAttribute, $quotaAttribute, $groupsAttribute] as $claim) { - $claims['id_token']->{$claim} = null; - $claims['userinfo']->{$claim} = null; + $claims['id_token'][$claim] = null; + $claims['userinfo'][$claim] = null; } } else { - // No default claim, we only set the claims if an attribute is mapped $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL); $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME); $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA); $groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS); - $rawClaims = [$emailAttribute, $displaynameAttribute, $quotaAttribute, $groupsAttribute]; - - if ($resolveNestedClaims) { - $claimSet = []; - foreach ($rawClaims as $claim) { - if ($claim !== '') { - $first = trim(explode('|', $claim)[0]); - $claimSet[$first] = true; - } - } - $rawClaims = array_keys($claimSet); - } - - foreach ($rawClaims as $claim) { + foreach ([$emailAttribute, $displaynameAttribute, $quotaAttribute, $groupsAttribute] as $claim) { if ($claim !== '') { - $claims['id_token']->{$claim} = null; - $claims['userinfo']->{$claim} = null; + $claims['id_token'][$claim] = null; + $claims['userinfo'][$claim] = null; } } } if ($uidAttribute !== 'sub') { - $uidAttributeToRequest = $uidAttribute; - if ($resolveNestedClaims) { - $uidAttributeToRequest = trim(explode('|', $uidAttribute)[0]); - } - $claims['id_token']->{$uidAttributeToRequest} = ['essential' => true]; - $claims['userinfo']->{$uidAttributeToRequest} = ['essential' => true]; + $claims['id_token'][$uidAttribute] = ['essential' => true]; + $claims['userinfo'][$uidAttribute] = ['essential' => true]; } $extraClaimsString = $this->providerService->getSetting($providerId, ProviderService::SETTING_EXTRA_CLAIMS, ''); if ($extraClaimsString) { $extraClaims = explode(' ', $extraClaimsString); foreach ($extraClaims as $extraClaim) { - $claims['id_token']->{$extraClaim} = null; - $claims['userinfo']->{$extraClaim} = null; + $claims['id_token'][$extraClaim] = null; + $claims['userinfo'][$extraClaim] = null; } } - $oidcConfig = $this->config->getSystemValue('user_oidc', []); - $data += [ 'client_id' => $provider->getClientId(), 'response_type' => 'code', @@ -273,25 +246,17 @@ public function login(int $providerId, ?string $redirectUrl = null) { 'state' => $state, 'nonce' => $nonce, ]; - - if (isset($oidcConfig['prompt']) && is_string($oidcConfig['prompt'])) { - $data['prompt'] = $oidcConfig['prompt']; - } - if ($isPkceEnabled) { - $data['code_challenge'] = $this->toCodeChallenge($code_verifier); + $data['code_challenge'] = $this->toCodeChallenge($this->session->get(self::CODE_VERIFIER)); $data['code_challenge_method'] = 'S256'; } - - $authorizationUrl = $this->discoveryService->buildAuthorizationUrl($discovery['authorization_endpoint'], $data); - $this->logger->debug('Redirecting user to: ' . $authorizationUrl); + $this->logger->debug('Redirecting user to OP authorization endpoint'); - // Workaround to avoid empty session on special conditions in Safari - // https://github.com/nextcloud/user_oidc/pull/358 + // Safari-Workaround (HTML escapen) if ($this->request->isUserAgent(['/Safari/']) && !$this->request->isUserAgent(['/Chrome/'])) { - return new DataDisplayResponse(''); + return new DataDisplayResponse(''); } return new RedirectResponse($authorizationUrl); @@ -314,45 +279,32 @@ public function login(int $providerId, ?string $redirectUrl = null) { * @throws SessionNotAvailableException * @throws \JsonException */ - public function code(string $state = '', string $code = '', string $scope = '', string $error = '', string $error_description = '') { + public function code(string $state = '', string $code = '', string $scope = '', string $error = '', string $error_description = ''): JSONResponse|RedirectResponse|TemplateResponse { if (!$this->isSecure()) { return $this->buildProtocolErrorResponse(); } - $this->logger->debug('Code login with core: ' . $code . ' and state: ' . $state); + $this->logger->debug('OIDC code flow callback received'); if ($error !== '') { - $this->logger->warning('Code login error', ['error' => $error, 'error_description' => $error_description]); - if ($this->isDebugModeEnabled()) { - return new JSONResponse([ - 'error' => $error, - 'error_description' => $error_description, - ], Http::STATUS_FORBIDDEN); - } - $message = $this->l10n->t('The identity provider failed to authenticate the user.'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false); + return new JSONResponse([ + 'error' => $error, + 'error_description' => $error_description, + ], Http::STATUS_FORBIDDEN); } - $storedState = $this->session->get(self::STATE); - - if ($storedState !== $state) { - $this->logger->warning('state does not match', [ - 'got' => $state, - 'expected' => $storedState, - 'state_exists_in_session' => $this->session->exists(self::STATE), - ]); - + if ($this->session->get(self::STATE) !== $state) { + $this->logger->debug('State does not match'); $message = $this->l10n->t('The received state does not match the expected value.'); if ($this->isDebugModeEnabled()) { $responseData = [ 'error' => 'invalid_state', 'error_description' => $message, 'got' => $state, - 'expected' => $storedState, - 'state_exists_in_session' => $this->session->exists(self::STATE), + 'expected' => $this->session->get(self::STATE), ]; return new JSONResponse($responseData, Http::STATUS_FORBIDDEN); } - // we know debug mode is off, always throttle + // wir wissen: Debugmodus aus → throttle return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'state does not match'], true); } @@ -368,12 +320,13 @@ public function code(string $state = '', string $code = '', string $scope = '', $discovery = $this->discoveryService->obtainDiscovery($provider); - $this->logger->debug('Obtainting data from: ' . $discovery['token_endpoint']); + $this->logger->debug('Requesting tokens at OP token endpoint'); $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); $isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true); $isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true); + $client = $this->clientService->newClient(); try { $requestBody = [ 'code' => $code, @@ -381,25 +334,18 @@ public function code(string $state = '', string $code = '', string $scope = '', 'grant_type' => 'authorization_code', ]; if ($isPkceEnabled) { - $requestBody['code_verifier'] = $this->session->get(self::CODE_VERIFIER); // Set for the PKCE flow + $requestBody['code_verifier'] = $this->session->get(self::CODE_VERIFIER); } $headers = []; - // follow what is described in https://openid.net/specs/openid-connect-discovery-1_0.html - // about token_endpoint_auth_methods_supported: "If omitted, the default is client_secret_basic" - // Use client_secret_post if supported - // We still allow changing the default auth method in config.php - $tokenEndpointAuthMethod = $oidcSystemConfig['default_token_endpoint_auth_method'] ?? 'client_secret_basic'; - // deal with invalid values - if (!in_array($tokenEndpointAuthMethod, ['client_secret_basic', 'client_secret_post'], true)) { - $tokenEndpointAuthMethod = 'client_secret_basic'; - } - if ( - array_key_exists('token_endpoint_auth_methods_supported', $discovery) - && is_array($discovery['token_endpoint_auth_methods_supported']) - && in_array('client_secret_post', $discovery['token_endpoint_auth_methods_supported'], true) - ) { - $tokenEndpointAuthMethod = 'client_secret_post'; + $tokenEndpointAuthMethod = 'client_secret_post'; + $supported = $discovery['token_endpoint_auth_methods_supported'] ?? null; + + if (is_array($supported)) { + if (in_array('client_secret_basic', $supported, true) && !in_array('client_secret_post', $supported, true)) { + $tokenEndpointAuthMethod = 'client_secret_basic'; + } + // TODO: optional private_key_jwt/tls_client_auth implementieren } if ($tokenEndpointAuthMethod === 'client_secret_basic') { @@ -408,118 +354,111 @@ public function code(string $state = '', string $code = '', string $scope = '', 'Content-Type' => 'application/x-www-form-urlencoded', ]; } else { - // Assuming client_secret_post as no other option is supported currently $requestBody['client_id'] = $provider->getClientId(); $requestBody['client_secret'] = $providerClientSecret; } - $body = $this->clientService->post( + $result = $client->post( $discovery['token_endpoint'], - $requestBody, - $headers + [ + 'body' => $requestBody, + 'headers' => $headers, + ] ); } catch (ClientException|ServerException $e) { $response = $e->getResponse(); $body = (string)$response->getBody(); $responseBodyArray = json_decode($body, true); if ($responseBodyArray !== null && isset($responseBodyArray['error'], $responseBodyArray['error_description'])) { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', [ + $this->logger->debug('OP token endpoint error', [ 'exception' => $e, 'error' => $responseBodyArray['error'], 'error_description' => $responseBodyArray['error_description'], ]); $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint') . ': ' . $responseBodyArray['error_description']; } else { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); + $this->logger->debug('OP token endpoint error', ['exception' => $e]); $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); } return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); } catch (\Exception $e) { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); + $this->logger->debug('OP token endpoint error (generic)', ['exception' => $e]); $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); } - $data = json_decode($body, true); - $this->logger->debug('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); + $data = json_decode($result->getBody(), true); + $this->logger->debug('Token response received (redacted)'); + $this->eventDispatcher->dispatchTyped(new TokenObtainedEvent($data, $provider, $discovery)); - // TODO: proper error handling - $idTokenRaw = $data['id_token']; + // ID Token prüfen + $idTokenRaw = $data['id_token'] ?? null; + if (!$idTokenRaw) { + $message = $this->l10n->t('No ID token received from the provider.'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'missing id_token']); + } + + // JWKS besorgen (DiscoveryService sollte passenden Key anhand kid liefern) $jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw); JWT::$leeway = 60; - try { - $idTokenPayload = JWT::decode($idTokenRaw, $jwks); - } catch (UnexpectedValueException $e) { - $this->logger->debug('Failed to decode the JWT token, retrying with fresh JWK'); - $jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw, false); - $idTokenPayload = JWT::decode($idTokenRaw, $jwks); - } - - // default is false - if (isset($oidcSystemConfig['enrich_login_id_token_with_userinfo']) && $oidcSystemConfig['enrich_login_id_token_with_userinfo']) { - $userInfo = $this->oidcService->userInfo($provider, $data['access_token']); - foreach ($userInfo as $key => $value) { - // give priority to id token values, only use userinfo ones if missing in id token - if (!isset($idTokenPayload->{$key})) { - $idTokenPayload->{$key} = $value; - } - } - } - $this->logger->debug('Parsed the JWT payload: ' . json_encode($idTokenPayload, JSON_THROW_ON_ERROR)); + // Hinweis: Alg-Whitelist idealerweise in DiscoveryService/JWT-Decode erzwingen + $idTokenPayload = JWT::decode($idTokenRaw, $jwks); - if ($idTokenPayload->exp < $this->timeFactory->getTime()) { + $this->logger->debug('ID token parsed (claims redacted)'); + + $now = $this->timeFactory->getTime(); + + if (isset($idTokenPayload->exp) && (int)$idTokenPayload->exp < $now) { $this->logger->debug('Token expired'); $message = $this->l10n->t('The received token is expired.'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'token expired']); } // Verify issuer - if ($idTokenPayload->iss !== $discovery['issuer']) { - $this->logger->debug('This token is issued by the wrong issuer'); + if (!isset($discovery['issuer']) || $idTokenPayload->iss !== $discovery['issuer']) { + $this->logger->debug('Invalid issuer'); $message = $this->l10n->t('The issuer does not match the one from the discovery endpoint'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss]); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss ?? null]); } // Verify audience $checkAudience = !isset($oidcSystemConfig['login_validation_audience_check']) || !in_array($oidcSystemConfig['login_validation_audience_check'], [false, 'false', 0, '0'], true); if ($checkAudience) { - $tokenAudience = $idTokenPayload->aud; + $tokenAudience = $idTokenPayload->aud ?? null; $providerClientId = $provider->getClientId(); if ( (is_string($tokenAudience) && $tokenAudience !== $providerClientId) || (is_array($tokenAudience) && !in_array($providerClientId, $tokenAudience, true)) ) { - $this->logger->debug('This token is not for us'); + $this->logger->debug('Invalid audience'); $message = $this->l10n->t('The audience does not match ours'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_audience' => $idTokenPayload->aud]); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_audience' => $idTokenPayload->aud ?? null]); } } + // authorized party check $checkAzp = !isset($oidcSystemConfig['login_validation_azp_check']) || !in_array($oidcSystemConfig['login_validation_azp_check'], [false, 'false', 0, '0'], true); if ($checkAzp) { - // ref https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation - // If the azp claim is present, it should be the client ID if (isset($idTokenPayload->azp) && $idTokenPayload->azp !== $provider->getClientId()) { - $this->logger->debug('This token is not for us, authorized party (azp) is different than the client ID'); + $this->logger->debug('Invalid azp'); $message = $this->l10n->t('The authorized party does not match ours'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_azp' => $idTokenPayload->azp]); } } if (isset($idTokenPayload->nonce) && $idTokenPayload->nonce !== $this->session->get(self::NONCE)) { - $this->logger->debug('Nonce does not match'); + $this->logger->debug('Invalid nonce'); $message = $this->l10n->t('The nonce does not match'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'invalid nonce']); } // get user ID attribute $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); - $userId = $this->provisioningService->getClaimValue($idTokenPayload, $uidAttribute, $providerId); - + $userId = $idTokenPayload->{$uidAttribute} ?? null; if ($userId === null) { $message = $this->l10n->t('Failed to provision the user'); return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); @@ -531,7 +470,7 @@ public function code(string $state = '', string $code = '', string $scope = '', $syncGroups = $this->provisioningService->getSyncGroupsOfToken($providerId, $idTokenPayload); if ($syncGroups === null || count($syncGroups) === 0) { - $this->logger->debug('Prevented user from login as user is not part of a whitelisted group'); + $this->logger->debug('User not in any whitelisted group'); $message = $this->l10n->t('You do not have permission to log in to this instance. If you think this is an error, please contact an administrator.'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'user not in any whitelisted group']); } @@ -542,31 +481,23 @@ public function code(string $state = '', string $code = '', string $scope = '', $shouldDoUserLookup = !$autoProvisionAllowed || ($softAutoProvisionAllowed && !$this->provisioningService->hasOidcUserProvisitioned($userId)); if ($shouldDoUserLookup && $this->ldapService->isLDAPEnabled()) { - // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results - // so new users will be directly available even if they were not synced before this login attempt $this->userManager->search($userId, 1, 0); $this->ldapService->syncUser($userId); } - $existingUser = $this->userManager->get($userId); - if ($existingUser !== null && $this->ldapService->isLdapDeletedUser($existingUser)) { - $existingUser = null; + $userFromOtherBackend = $this->userManager->get($userId); + if ($userFromOtherBackend !== null && $this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { + $userFromOtherBackend = null; } if ($autoProvisionAllowed) { - if (!$softAutoProvisionAllowed && $existingUser !== null && $existingUser->getBackendClassName() !== Application::APP_ID) { - // if soft auto-provisioning is disabled, - // we refuse login for a user that already exists in another backend + if (!$softAutoProvisionAllowed && $userFromOtherBackend !== null) { $message = $this->l10n->t('User conflict'); return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'non-soft auto provision, user conflict'], false); } - // use potential user from other backend, create it in our backend if it does not exist - $provisioningResult = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $existingUser); - $user = $provisioningResult['user']; - $this->session->set('user_oidc.oidcUserData', $provisioningResult['userData']); + $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $userFromOtherBackend); } else { - // when auto provision is disabled, we assume the user has been created by another user backend (or manually) - $user = $existingUser; + $user = $userFromOtherBackend; } if ($user === null) { @@ -574,70 +505,60 @@ public function code(string $state = '', string $code = '', string $scope = '', return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); } - $this->session->set(self::ID_TOKEN, $idTokenRaw); + // ID Token verschlüsselt in Session für OP-Logout-Hints + try { + $this->session->set(self::ID_TOKEN, $this->crypto->encrypt($idTokenRaw)); + } catch (\Exception $e) { + // Nicht kritisch für den Login-Fluss + $this->logger->debug('Failed to encrypt ID token for session storage', ['exception' => $e]); + } $this->logger->debug('Logging user in'); $this->userSession->setUser($user); if ($this->userSession instanceof OC_UserSession) { - // TODO server should/could be refactored so we don't need to manually create the user session and dispatch the login-related events - // Warning! If GSS is used, it reacts to the BeforeUserLoggedInEvent and handles the redirection itself - // So nothing after dispatching this event will be executed - $this->eventDispatcher->dispatchTyped(new BeforeUserLoggedInEvent($user->getUID(), null, \OCP\Server::get(Backend::class))); - $this->userSession->completeLogin($user, ['loginName' => $user->getUID(), 'password' => '']); $this->userSession->createSessionToken($this->request, $user->getUID(), $user->getUID()); $this->userSession->createRememberMeToken($user); - - // prevent password confirmation - if (defined(IToken::class . '::SCOPE_SKIP_PASSWORD_VALIDATION')) { - $token = $this->authTokenProvider->getToken($this->session->getId()); - $scope = $token->getScopeAsArray(); - $scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION] = true; - $token->setScope($scope); - $this->authTokenProvider->updateToken($token); - } - + // Events dispatchen + $this->eventDispatcher->dispatchTyped(new BeforeUserLoggedInEvent($user->getUID(), null, \OC::$server->get(Backend::class))); $this->eventDispatcher->dispatchTyped(new UserLoggedInEvent($user, $user->getUID(), null, false)); } - $storeLoginTokenEnabled = $this->appConfig->getValueString(Application::APP_ID, 'store_login_token', '0') === '1'; - if ($storeLoginTokenEnabled) { - // store all token information for potential token exchange requests - $tokenData = array_merge( - $data, - ['provider_id' => $providerId], - ); - $this->tokenService->storeToken($tokenData); - } - $this->config->setUserValue($user->getUID(), Application::APP_ID, 'had_token_once', '1'); + // Session-Werte aufräumen + $this->session->remove(self::STATE); + $this->session->remove(self::NONCE); + $this->session->remove(self::CODE_VERIFIER); // PKCE cleanup - // Set last password confirm to the future as we don't have passwords to confirm against with SSO - $this->session->set('last-password-confirm', strtotime('+4 year', time())); + // Set last password confirm in die Zukunft (SSO) + $this->session->set('last-password-confirm', $this->timeFactory->getTime() + (60 * 60 * 24 * 365 * 4)); - // for backchannel logout + // Backchannel Logout Session speichern try { $authToken = $this->authTokenProvider->getToken($this->session->getId()); - $this->sessionMapper->createOrUpdateSession( - $idTokenPayload->sid ?? 'fallback-sid', + + // SID bevorzugt aus Standard-Claim, sonst Fallback auf Vendor-Claim + $sidForStorage = $idTokenPayload->sid + ?? $idTokenPayload->{'urn:telekom.com:session_token'} + ?? 'fallback-sid'; + + $this->sessionMapper->createSession( + $sidForStorage, $idTokenPayload->sub ?? 'fallback-sub', $idTokenPayload->iss ?? 'fallback-iss', $authToken->getId(), - $this->session->getId(), - $idTokenRaw, - $user->getUID(), - $providerId, + $this->session->getId() ); } catch (InvalidTokenException $e) { $this->logger->debug('Auth token not found after login'); } - // if the user was provisioned by user_ldap, this is required to update and/or generate the avatar + // falls LDAP Avatar etc. if ($user->canChangeAvatar()) { - $this->logger->debug('$user->canChangeAvatar() is true'); + $this->logger->debug('User can change avatar (post-login sync may occur)'); } - $this->logger->debug('Redirecting user'); + $this->logger->debug('Redirecting user after login'); $redirectUrl = $this->session->get(self::REDIRECT_AFTER_LOGIN); if ($redirectUrl) { @@ -661,7 +582,7 @@ public function code(string $state = '', string $code = '', string $scope = '', * @throws SessionNotAvailableException * @throws \JsonException */ - public function singleLogoutService() { + public function singleLogoutService(): RedirectResponse|TemplateResponse { // TODO throttle in all failing cases $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); $targetUrl = $this->urlGenerator->getAbsoluteURL('/'); @@ -669,7 +590,7 @@ public function singleLogoutService() { $isFromGS = ($this->config->getSystemValueBool('gs.enabled', false) && $this->config->getSystemValueString('gss.mode', '') === 'master'); if ($isFromGS) { - // Request is from master GlobalScale: we get the provider ID from the JWT token provided by the slave + // Request ist von Master GlobalScale: Provider-ID aus JWT des Slaves $jwt = $this->request->getParam('jwt', ''); try { @@ -678,56 +599,56 @@ public function singleLogoutService() { $providerId = $decoded['oidcProviderId'] ?? null; } catch (\Exception $e) { - $this->logger->debug('Failed to get the logout provider ID in the request from GSS', ['exception' => $e]); + $this->logger->debug('Failed to get the logout provider ID from GSS', ['exception' => $e]); } } else { $providerId = $this->session->get(self::PROVIDERID); - // if the provider is not found and we are in SSO mode, just use the one and only provider - if ($providerId === null && !$this->settingsService->getAllowMultipleUserBackEnds()) { - $providers = $this->providerMapper->getProviders(); - if (count($providers) === 1) { - $providerId = $providers[0]->getId(); - } - } } if ($providerId) { try { $provider = $this->providerMapper->getProvider((int)$providerId); } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { - $message = $this->l10n->t('There is no such OpenID Connect provider.'); + $message = $this->l10n->t('There is not such OpenID Connect provider.'); return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_id' => $providerId]); } - // Check if a custom end_session_endpoint is set in the provider otherwise use the default one provided by the openid-configuration + // End-Session-Endpoint (custom oder discovery) $discoveryData = $this->discoveryService->obtainDiscovery($provider); $defaultEndSessionEndpoint = $discoveryData['end_session_endpoint'] ?? null; $customEndSessionEndpoint = $provider->getEndSessionEndpoint(); $endSessionEndpoint = $customEndSessionEndpoint ?: $defaultEndSessionEndpoint; if ($endSessionEndpoint) { - $targetUrl = $provider->getPostLogoutUri() ?: $this->urlGenerator->getAbsoluteURL('/'); $endSessionEndpoint .= '?post_logout_redirect_uri=' . $targetUrl; $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); + $shouldSendIdToken = $this->providerService->getSetting( $provider->getId(), - ProviderService::SETTING_SEND_ID_TOKEN_HINT, - '0' + ProviderService::SETTING_SEND_ID_TOKEN_HINT, '0' ) === '1'; - $idToken = $this->session->get(self::ID_TOKEN); - if ($shouldSendIdToken && $idToken) { - $endSessionEndpoint .= '&id_token_hint=' . $idToken; + + $idTokenEncrypted = $this->session->get(self::ID_TOKEN); + $idTokenHint = null; + if ($shouldSendIdToken && $idTokenEncrypted) { + try { + $idTokenHint = $this->crypto->decrypt($idTokenEncrypted); + } catch (\Exception $e) { + $this->logger->debug('Failed to decrypt ID token for logout hint', ['exception' => $e]); + } + } + if ($shouldSendIdToken && $idTokenHint) { + $endSessionEndpoint .= '&id_token_hint=' . $idTokenHint; } + $targetUrl = $endSessionEndpoint; } } } - // cleanup related oidc session - $this->sessionMapper->deleteFromNcSessionId($this->session->getId()); - + // OIDC-bezogene Session nicht sofort löschen (Backchannel kann separat kommen) $this->userSession->logout(); - // make sure we clear the session to avoid messing with Backend::isSessionActive + // Session leeren, um Backend::isSessionActive nicht zu verwirren $this->session->clear(); return new RedirectResponse($targetUrl); } @@ -763,18 +684,21 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok JWT::$leeway = 60; $logoutTokenPayload = JWT::decode($logout_token, $jwks); - $this->logger->debug('Parsed the logout JWT payload: ' . json_encode($logoutTokenPayload, JSON_THROW_ON_ERROR)); + $this->logger->debug('Backchannel logout token parsed (claims redacted)'); - // check the audience - if (!(($logoutTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $logoutTokenPayload->aud, true)))) { + // audience prüfen + $aud = $logoutTokenPayload->aud ?? null; + $clientId = $provider->getClientId(); + $audOk = is_string($aud) ? $aud === $clientId : (is_array($aud) && in_array($clientId, $aud, true)); + if (!$audOk) { return $this->getBackchannelLogoutErrorResponse( 'invalid audience', 'The audience of the logout token does not match the provider', - ['invalid_audience' => $logoutTokenPayload->aud] + ['invalid_audience' => $aud] ); } - // check the event attr + // event-claim prüfen if (!isset($logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'})) { return $this->getBackchannelLogoutErrorResponse( 'invalid event', @@ -783,7 +707,7 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - // check the nonce attr + // nonce darf nicht gesetzt sein if (isset($logoutTokenPayload->nonce)) { return $this->getBackchannelLogoutErrorResponse( 'invalid nonce', @@ -792,94 +716,106 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - if (!isset($logoutTokenPayload->iss)) { + // iat muss vorhanden & nicht zu alt sein (z.B. 5 Minuten) + $now = $this->timeFactory->getTime(); + if (!isset($logoutTokenPayload->iat) || abs($now - (int)$logoutTokenPayload->iat) > 300) { return $this->getBackchannelLogoutErrorResponse( - 'invalid iss', - 'The logout token should contain an iss attribute', - ['iss_should_be_set' => true] + 'stale token', + 'Logout token is too old or missing iat', + ['iat' => $logoutTokenPayload->iat ?? null] ); } - $iss = $logoutTokenPayload->iss; + // Mindestens eines von sid/sub muss vorhanden sein if (!isset($logoutTokenPayload->sid) && !isset($logoutTokenPayload->sub)) { return $this->getBackchannelLogoutErrorResponse( - 'invalid sid+sub', - 'The logout token should contain sid or sub or both', - ['no_sid_no_sub' => true] + 'missing sid/sub', + 'The logout token must contain at least sid or sub', + [] ); } - $oidcSessionsToKill = []; + // Session anhand sid bevorzugt finden + $sid = $logoutTokenPayload->sid ?? null; - // if SID is set, we look for this specific session (with or without using the sub, depending on if the sub is set) - if (isset($logoutTokenPayload->sid)) { - $sid = $logoutTokenPayload->sid; - $sub = $logoutTokenPayload->sub ?? null; - try { - $oidcSession = $this->sessionMapper->findSessionBySid($sid, $sub, $iss); - } catch (DoesNotExistException $e) { - return $this->getBackchannelLogoutErrorResponse( - $sub === null ? 'invalid SID or ISS' : 'invalid SID, SUB or ISS', - $sub === null ? 'No session was found for this (sid,iss)' : 'No session was found for this (sid,sub,iss)', - ['session_not_found' => $sid] - ); - } catch (MultipleObjectsReturnedException $e) { - return $this->getBackchannelLogoutErrorResponse( - $sub === null ? 'invalid SID or ISS' : 'invalid SID, SUB or ISS', - $sub === null ? 'Multiple sessions were found with this (sid,iss)' : 'Multiple sessions were found with this (sid,sub,iss)', - ['multiple_sessions_found' => $sid] - ); - } - $oidcSessionsToKill[] = $oidcSession; - } else { - // here we know the sid is not set so the sub is set - $sub = $logoutTokenPayload->sub; - try { - $oidcSessionsToKill = $this->sessionMapper->findSessionsBySubAndIss($sub, $iss); - } catch (\OCP\Db\Exception $e) { + try { + if ($sid === null) { + // Wenn kein sid: Optional könnte man hier über sub/iss auflösen (nicht empfohlen, nur Fallback) return $this->getBackchannelLogoutErrorResponse( - 'error with sub+iss', - 'Failed to retrieve session with sub+iss', - ['sub_iss_error' => true] + 'invalid SID', + 'The sid of the logout token was not found', + ['session_sid_not_found' => null] ); } - if (empty($oidcSessionsToKill)) { - return $this->getBackchannelLogoutErrorResponse( - 'nothing found with sub+iss', - 'No session found with sub+iss', - ['sub_iss_no_session_found' => true] - ); - } + $oidcSession = $this->sessionMapper->findSessionBySid($sid); + } catch (DoesNotExistException $e) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SID', + 'The sid of the logout token was not found', + ['session_sid_not_found' => $sid] + ); + } catch (MultipleObjectsReturnedException $e) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SID', + 'The sid of the logout token was found multiple times', + ['multiple_logout_tokens_found' => $sid] + ); } - foreach ($oidcSessionsToKill as $oidcSession) { - // we know the IdP session is closed - // we need this to prevent requesting the end_session_endpoint when we catch the TokenInvalidatedEvent - $oidcSession->setIdpSessionClosed(1); - $this->sessionMapper->update($oidcSession); - - $authTokenId = $oidcSession->getAuthtokenId(); - try { - $authToken = $this->authTokenProvider->getTokenById($authTokenId); - // we could also get the auth token by nc session ID - // $authToken = $this->authTokenProvider->getToken($oidcSession->getNcSessionId()); - $userId = $authToken->getUID(); - $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); - } catch (InvalidTokenException $e) { - $this->logger->warning('[BackchannelLogout] Nextcloud session not found', ['authtoken_id' => $authTokenId]); - } + $sub = $logoutTokenPayload->sub ?? null; + if (isset($sub) && ($oidcSession->getSub() !== $sub)) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SUB', + 'The sub does not match the one from the login ID token', + ['invalid_sub' => $sub] + ); + } - // cleanup - $this->sessionMapper->delete($oidcSession); + $iss = $logoutTokenPayload->iss ?? null; + if ($oidcSession->getIss() !== $iss) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid ISS', + 'The iss does not match the one from the login ID token', + ['invalid_iss' => $iss] + ); + } + + // Token invalidieren + $authTokenId = (int)$oidcSession->getAuthtokenId(); + try { + $authToken = $this->authTokenProvider->getTokenById($authTokenId); + $userId = $authToken->getUID(); + $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); + } catch (InvalidTokenException $e) { + // bereits ungültig → kein Fehler } - return new JSONResponse([], Http::STATUS_OK); + // Cleanup + $this->sessionMapper->delete($oidcSession); + + return new JSONResponse(); + } + + /** + * Backward compatible function for MagentaCLOUD to smoothly transition to new config + * + * @PublicPage + * @NoCSRFRequired + * @BruteForceProtection(action=userOidcBackchannelLogout) + * + * @param string $logout_token + * @return JSONResponse + * @throws Exception + * @throws \JsonException + */ + public function telekomBackChannelLogout(string $logout_token = ''): JSONResponse { + return $this->backChannelLogout('Telekom', $logout_token); } /** - * Generate an error response according to the OIDC standard - * Log the error + * Generate a backchannel logout response. + * Log the error but always return HTTP 200 OK for IDM compliance. * * @param string $error * @param string $description @@ -889,25 +825,21 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok private function getBackchannelLogoutErrorResponse( string $error, string $description, - array $throttleMetadata = [], + array $throttleMetadata = [] ): JSONResponse { - $this->logger->debug('Backchannel logout error. ' . $error . ' ; ' . $description); + $this->logger->debug('Backchannel logout error', ['error' => $error, 'description' => $description] + $throttleMetadata); return new JSONResponse( [ 'error' => $error, 'error_description' => $description, ], - Http::STATUS_BAD_REQUEST, + Http::STATUS_OK, // IDM-Anforderung: immer 200 OK ); } private function toCodeChallenge(string $data): string { - // Basically one big work around for the base64url decode being weird - $h = pack('H*', hash('sha256', $data)); - $s = base64_encode($h); // Regular base64 encoder - $s = explode('=', $s)[0]; // Remove any trailing '='s - $s = str_replace('+', '-', $s); // 62nd char of encoding - $s = str_replace('/', '_', $s); // 63rd char of encoding - return $s; + // RFC7636 S256: base64url(SHA256(verifier)) ohne '=' + return rtrim(strtr(base64_encode(hash('sha256', $data, true)), '+/', '-_'), '='); + } } From 34bd75194013c772f43bdde665566f2382b8936f Mon Sep 17 00:00:00 2001 From: memurats Date: Tue, 28 Oct 2025 13:38:32 +0100 Subject: [PATCH 2/6] fix errors --- lib/Controller/LoginController.php | 108 +++++++++-------------------- 1 file changed, 32 insertions(+), 76 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index c453b50d..ab0f5cf0 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -13,7 +13,6 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ServerException; -use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\IProvider; use OC\User\Session as OC_UserSession; use OCA\UserOIDC\AppInfo\Application; @@ -84,7 +83,8 @@ public function __construct( private ICrypto $crypto, private TokenService $tokenService, ) { - parent::__construct($request, $config); + // Psalm-Fix: BaseOidcController erwartet $l10n im Konstruktor + parent::__construct($request, $config, $this->l10n); } private function isSecure(): bool { @@ -93,13 +93,15 @@ private function isSecure(): bool { } private function buildProtocolErrorResponse(?bool $throttle = null): TemplateResponse { - $params = [ - 'errors' => [ - ['error' => $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.')], - ], - ]; - $throttleMetadata = ['reason' => 'insecure connection']; - return $this->buildFailureTemplateResponse('', 'error', $params, Http::STATUS_NOT_FOUND, $throttleMetadata, $throttle); + // Psalm-Fix: buildFailureTemplateResponse entfernte/abweichende Signatur vermeiden + // Nutze buildErrorTemplateResponse(message, status, metadata, throttleFlag) + $message = $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.'); + return $this->buildErrorTemplateResponse( + $message, + Http::STATUS_NOT_FOUND, + ['reason' => 'insecure connection'], + $throttle ?? false + ); } private function getRedirectResponse(?string $redirectUrl = null): RedirectResponse { @@ -107,15 +109,12 @@ private function getRedirectResponse(?string $redirectUrl = null): RedirectRespo return new RedirectResponse($this->urlGenerator->getBaseUrl()); } - // Nur relative Pfade erlauben, absolute Schemata/Protokolle und "//host" verhindern if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $redirectUrl) === 1 || str_starts_with($redirectUrl, '//')) { return new RedirectResponse($this->urlGenerator->getBaseUrl()); } - // CR/LF und Backslashes entfernen $redirectUrl = preg_replace('/[\r\n\\\\]/', '', $redirectUrl); - // Normalisieren $path = parse_url($redirectUrl, PHP_URL_PATH) ?? '/'; $query = parse_url($redirectUrl, PHP_URL_QUERY); $safe = rtrim($this->urlGenerator->getBaseUrl(), '/') . '/' . ltrim($path, '/') @@ -150,7 +149,6 @@ public function login(int $providerId, ?string $redirectUrl = null): DataDisplay return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_not_found' => $providerId]); } - // pass discovery query parameters also on to the authentication $data = []; $discoveryUrl = parse_url($provider->getDiscoveryEndpoint()); if (isset($discoveryUrl['query'])) { @@ -171,7 +169,6 @@ public function login(int $providerId, ?string $redirectUrl = null): DataDisplay return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['reason' => 'provider unreachable']); } - // Immer neue STATE/NONCE für diesen Login erzeugen (kein Reuse) $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); $this->session->set(self::STATE, $state); @@ -184,17 +181,14 @@ public function login(int $providerId, ?string $redirectUrl = null): DataDisplay $isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true); if ($isPkceEnabled) { - // PKCE code_verifier siehe RFC7636 $code_verifier = $this->random->generate(128, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER); $this->session->set(self::CODE_VERIFIER, $code_verifier); } $this->session->close(); - // get attribute mapping settings $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); - // Claims zusammenstellen $claims = [ 'id_token' => [], 'userinfo' => [], @@ -254,7 +248,6 @@ public function login(int $providerId, ?string $redirectUrl = null): DataDisplay $this->logger->debug('Redirecting user to OP authorization endpoint'); - // Safari-Workaround (HTML escapen) if ($this->request->isUserAgent(['/Safari/']) && !$this->request->isUserAgent(['/Chrome/'])) { return new DataDisplayResponse(''); } @@ -304,7 +297,6 @@ public function code(string $state = '', string $code = '', string $scope = '', ]; return new JSONResponse($responseData, Http::STATUS_FORBIDDEN); } - // wir wissen: Debugmodus aus → throttle return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'state does not match'], true); } @@ -345,7 +337,6 @@ public function code(string $state = '', string $code = '', string $scope = '', if (in_array('client_secret_basic', $supported, true) && !in_array('client_secret_post', $supported, true)) { $tokenEndpointAuthMethod = 'client_secret_basic'; } - // TODO: optional private_key_jwt/tls_client_auth implementieren } if ($tokenEndpointAuthMethod === 'client_secret_basic') { @@ -392,18 +383,14 @@ public function code(string $state = '', string $code = '', string $scope = '', $this->eventDispatcher->dispatchTyped(new TokenObtainedEvent($data, $provider, $discovery)); - // ID Token prüfen $idTokenRaw = $data['id_token'] ?? null; if (!$idTokenRaw) { $message = $this->l10n->t('No ID token received from the provider.'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'missing id_token']); } - // JWKS besorgen (DiscoveryService sollte passenden Key anhand kid liefern) $jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw); JWT::$leeway = 60; - - // Hinweis: Alg-Whitelist idealerweise in DiscoveryService/JWT-Decode erzwingen $idTokenPayload = JWT::decode($idTokenRaw, $jwks); $this->logger->debug('ID token parsed (claims redacted)'); @@ -416,14 +403,12 @@ public function code(string $state = '', string $code = '', string $scope = '', return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'token expired']); } - // Verify issuer if (!isset($discovery['issuer']) || $idTokenPayload->iss !== $discovery['issuer']) { $this->logger->debug('Invalid issuer'); $message = $this->l10n->t('The issuer does not match the one from the discovery endpoint'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss ?? null]); } - // Verify audience $checkAudience = !isset($oidcSystemConfig['login_validation_audience_check']) || !in_array($oidcSystemConfig['login_validation_audience_check'], [false, 'false', 0, '0'], true); if ($checkAudience) { @@ -439,7 +424,6 @@ public function code(string $state = '', string $code = '', string $scope = '', } } - // authorized party check $checkAzp = !isset($oidcSystemConfig['login_validation_azp_check']) || !in_array($oidcSystemConfig['login_validation_azp_check'], [false, 'false', 0, '0'], true); if ($checkAzp) { @@ -456,7 +440,6 @@ public function code(string $state = '', string $code = '', string $scope = '', return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'invalid nonce']); } - // get user ID attribute $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); $userId = $idTokenPayload->{$uidAttribute} ?? null; if ($userId === null) { @@ -464,7 +447,6 @@ public function code(string $state = '', string $code = '', string $scope = '', return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); } - // prevent login of users that are not in a whitelisted group (if activated) $restrictLoginToGroups = $this->providerService->getSetting($providerId, ProviderService::SETTING_RESTRICT_LOGIN_TO_GROUPS, '0'); if ($restrictLoginToGroups === '1') { $syncGroups = $this->provisioningService->getSyncGroupsOfToken($providerId, $idTokenPayload); @@ -505,11 +487,9 @@ public function code(string $state = '', string $code = '', string $scope = '', return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); } - // ID Token verschlüsselt in Session für OP-Logout-Hints try { $this->session->set(self::ID_TOKEN, $this->crypto->encrypt($idTokenRaw)); } catch (\Exception $e) { - // Nicht kritisch für den Login-Fluss $this->logger->debug('Failed to encrypt ID token for session storage', ['exception' => $e]); } @@ -520,40 +500,40 @@ public function code(string $state = '', string $code = '', string $scope = '', $this->userSession->completeLogin($user, ['loginName' => $user->getUID(), 'password' => '']); $this->userSession->createSessionToken($this->request, $user->getUID(), $user->getUID()); $this->userSession->createRememberMeToken($user); - // Events dispatchen $this->eventDispatcher->dispatchTyped(new BeforeUserLoggedInEvent($user->getUID(), null, \OC::$server->get(Backend::class))); $this->eventDispatcher->dispatchTyped(new UserLoggedInEvent($user, $user->getUID(), null, false)); } - // Session-Werte aufräumen $this->session->remove(self::STATE); $this->session->remove(self::NONCE); - $this->session->remove(self::CODE_VERIFIER); // PKCE cleanup + $this->session->remove(self::CODE_VERIFIER); - // Set last password confirm in die Zukunft (SSO) $this->session->set('last-password-confirm', $this->timeFactory->getTime() + (60 * 60 * 24 * 365 * 4)); - // Backchannel Logout Session speichern + // createSession nur aufrufen, wenn vorhanden try { $authToken = $this->authTokenProvider->getToken($this->session->getId()); - // SID bevorzugt aus Standard-Claim, sonst Fallback auf Vendor-Claim $sidForStorage = $idTokenPayload->sid ?? $idTokenPayload->{'urn:telekom.com:session_token'} ?? 'fallback-sid'; - $this->sessionMapper->createSession( - $sidForStorage, - $idTokenPayload->sub ?? 'fallback-sub', - $idTokenPayload->iss ?? 'fallback-iss', - $authToken->getId(), - $this->session->getId() - ); - } catch (InvalidTokenException $e) { - $this->logger->debug('Auth token not found after login'); + if (method_exists($this->sessionMapper, 'createSession')) { + $this->sessionMapper->createSession( + $sidForStorage, + $idTokenPayload->sub ?? 'fallback-sub', + $idTokenPayload->iss ?? 'fallback-iss', + $authToken->getId(), + $this->session->getId() + ); + } else { + $this->logger->debug('SessionMapper::createSession not available; skipping backchannel mapping persist.'); + } + } catch (\Throwable $e) { + // InvalidTokenException oder andere — nicht kritisch für Login + $this->logger->debug('Auth token not found or persistence failed after login', ['exception' => $e]); } - // falls LDAP Avatar etc. if ($user->canChangeAvatar()) { $this->logger->debug('User can change avatar (post-login sync may occur)'); } @@ -583,14 +563,12 @@ public function code(string $state = '', string $code = '', string $scope = '', * @throws \JsonException */ public function singleLogoutService(): RedirectResponse|TemplateResponse { - // TODO throttle in all failing cases $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); $targetUrl = $this->urlGenerator->getAbsoluteURL('/'); if (!isset($oidcSystemConfig['single_logout']) || $oidcSystemConfig['single_logout']) { $isFromGS = ($this->config->getSystemValueBool('gs.enabled', false) && $this->config->getSystemValueString('gss.mode', '') === 'master'); if ($isFromGS) { - // Request ist von Master GlobalScale: Provider-ID aus JWT des Slaves $jwt = $this->request->getParam('jwt', ''); try { @@ -612,7 +590,6 @@ public function singleLogoutService(): RedirectResponse|TemplateResponse { return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_id' => $providerId]); } - // End-Session-Endpoint (custom oder discovery) $discoveryData = $this->discoveryService->obtainDiscovery($provider); $defaultEndSessionEndpoint = $discoveryData['end_session_endpoint'] ?? null; $customEndSessionEndpoint = $provider->getEndSessionEndpoint(); @@ -621,14 +598,12 @@ public function singleLogoutService(): RedirectResponse|TemplateResponse { if ($endSessionEndpoint) { $endSessionEndpoint .= '?post_logout_redirect_uri=' . $targetUrl; $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); - $shouldSendIdToken = $this->providerService->getSetting( $provider->getId(), ProviderService::SETTING_SEND_ID_TOKEN_HINT, '0' ) === '1'; - - $idTokenEncrypted = $this->session->get(self::ID_TOKEN); $idTokenHint = null; + $idTokenEncrypted = $this->session->get(self::ID_TOKEN); if ($shouldSendIdToken && $idTokenEncrypted) { try { $idTokenHint = $this->crypto->decrypt($idTokenEncrypted); @@ -639,24 +614,18 @@ public function singleLogoutService(): RedirectResponse|TemplateResponse { if ($shouldSendIdToken && $idTokenHint) { $endSessionEndpoint .= '&id_token_hint=' . $idTokenHint; } - $targetUrl = $endSessionEndpoint; } } } - // OIDC-bezogene Session nicht sofort löschen (Backchannel kann separat kommen) $this->userSession->logout(); - - // Session leeren, um Backend::isSessionActive nicht zu verwirren $this->session->clear(); return new RedirectResponse($targetUrl); } /** * Endpoint called by the IdP (OP) when end_session_endpoint is called by another client - * The logout token contains the sid for which we know the sessionId - * which leads to the auth token that we can invalidate * Implemented according to https://openid.net/specs/openid-connect-backchannel-1_0.html * * @PublicPage @@ -669,7 +638,6 @@ public function singleLogoutService(): RedirectResponse|TemplateResponse { * @throws \JsonException */ public function backChannelLogout(string $providerIdentifier, string $logout_token = ''): JSONResponse { - // get the provider $provider = $this->providerService->getProviderByIdentifier($providerIdentifier); if ($provider === null) { return $this->getBackchannelLogoutErrorResponse( @@ -679,14 +647,12 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - // decrypt the logout token $jwks = $this->discoveryService->obtainJWK($provider, $logout_token); JWT::$leeway = 60; $logoutTokenPayload = JWT::decode($logout_token, $jwks); $this->logger->debug('Backchannel logout token parsed (claims redacted)'); - // audience prüfen $aud = $logoutTokenPayload->aud ?? null; $clientId = $provider->getClientId(); $audOk = is_string($aud) ? $aud === $clientId : (is_array($aud) && in_array($clientId, $aud, true)); @@ -698,7 +664,6 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - // event-claim prüfen if (!isset($logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'})) { return $this->getBackchannelLogoutErrorResponse( 'invalid event', @@ -707,7 +672,6 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - // nonce darf nicht gesetzt sein if (isset($logoutTokenPayload->nonce)) { return $this->getBackchannelLogoutErrorResponse( 'invalid nonce', @@ -716,7 +680,6 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - // iat muss vorhanden & nicht zu alt sein (z.B. 5 Minuten) $now = $this->timeFactory->getTime(); if (!isset($logoutTokenPayload->iat) || abs($now - (int)$logoutTokenPayload->iat) > 300) { return $this->getBackchannelLogoutErrorResponse( @@ -726,7 +689,6 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - // Mindestens eines von sid/sub muss vorhanden sein if (!isset($logoutTokenPayload->sid) && !isset($logoutTokenPayload->sub)) { return $this->getBackchannelLogoutErrorResponse( 'missing sid/sub', @@ -735,12 +697,10 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - // Session anhand sid bevorzugt finden $sid = $logoutTokenPayload->sid ?? null; try { if ($sid === null) { - // Wenn kein sid: Optional könnte man hier über sub/iss auflösen (nicht empfohlen, nur Fallback) return $this->getBackchannelLogoutErrorResponse( 'invalid SID', 'The sid of the logout token was not found', @@ -781,17 +741,15 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - // Token invalidieren $authTokenId = (int)$oidcSession->getAuthtokenId(); try { $authToken = $this->authTokenProvider->getTokenById($authTokenId); $userId = $authToken->getUID(); $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); - } catch (InvalidTokenException $e) { - // bereits ungültig → kein Fehler + } catch (\Throwable $e) { + // bereits ungültig → ok } - // Cleanup $this->sessionMapper->delete($oidcSession); return new JSONResponse(); @@ -833,13 +791,11 @@ private function getBackchannelLogoutErrorResponse( 'error' => $error, 'error_description' => $description, ], - Http::STATUS_OK, // IDM-Anforderung: immer 200 OK + Http::STATUS_OK, ); } private function toCodeChallenge(string $data): string { - // RFC7636 S256: base64url(SHA256(verifier)) ohne '=' - return rtrim(strtr(base64_encode(hash('sha256', $data, true)), '+/', '-_'), '='); - + return rtrim(strtr(base64_encode(hash('sha256', $data, true)), '+/', '-_'), '='); } } From 72ced0a3a39a6f25d5b3a6264da5b1f33ed05c6f Mon Sep 17 00:00:00 2001 From: memurats Date: Tue, 28 Oct 2025 13:42:56 +0100 Subject: [PATCH 3/6] fixed coding style --- lib/Controller/LoginController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index ab0f5cf0..2fb19b91 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -783,7 +783,7 @@ public function telekomBackChannelLogout(string $logout_token = ''): JSONRespons private function getBackchannelLogoutErrorResponse( string $error, string $description, - array $throttleMetadata = [] + array $throttleMetadata = [], ): JSONResponse { $this->logger->debug('Backchannel logout error', ['error' => $error, 'description' => $description] + $throttleMetadata); return new JSONResponse( @@ -796,6 +796,6 @@ private function getBackchannelLogoutErrorResponse( } private function toCodeChallenge(string $data): string { - return rtrim(strtr(base64_encode(hash('sha256', $data, true)), '+/', '-_'), '='); + return rtrim(strtr(base64_encode(hash('sha256', $data, true)), '+/', '-_'), '='); } } From aa405577b05f0625ae62deb9d31dd9df81f27858 Mon Sep 17 00:00:00 2001 From: memurats Date: Tue, 28 Oct 2025 13:48:21 +0100 Subject: [PATCH 4/6] revert getRedirectResponse --- lib/Controller/LoginController.php | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 2fb19b91..13f12ed9 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -83,7 +83,7 @@ public function __construct( private ICrypto $crypto, private TokenService $tokenService, ) { - // Psalm-Fix: BaseOidcController erwartet $l10n im Konstruktor + // BaseOidcController erwartet $l10n im Konstruktor parent::__construct($request, $config, $this->l10n); } @@ -93,7 +93,7 @@ private function isSecure(): bool { } private function buildProtocolErrorResponse(?bool $throttle = null): TemplateResponse { - // Psalm-Fix: buildFailureTemplateResponse entfernte/abweichende Signatur vermeiden + // buildFailureTemplateResponse entfernte/abweichende Signatur vermeiden // Nutze buildErrorTemplateResponse(message, status, metadata, throttleFlag) $message = $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.'); return $this->buildErrorTemplateResponse( @@ -105,22 +105,11 @@ private function buildProtocolErrorResponse(?bool $throttle = null): TemplateRes } private function getRedirectResponse(?string $redirectUrl = null): RedirectResponse { - if ($redirectUrl === null || $redirectUrl === '') { - return new RedirectResponse($this->urlGenerator->getBaseUrl()); - } - - if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $redirectUrl) === 1 || str_starts_with($redirectUrl, '//')) { - return new RedirectResponse($this->urlGenerator->getBaseUrl()); - } - - $redirectUrl = preg_replace('/[\r\n\\\\]/', '', $redirectUrl); - - $path = parse_url($redirectUrl, PHP_URL_PATH) ?? '/'; - $query = parse_url($redirectUrl, PHP_URL_QUERY); - $safe = rtrim($this->urlGenerator->getBaseUrl(), '/') . '/' . ltrim($path, '/') - . ($query ? '?' . $query : ''); - - return new RedirectResponse($safe); + return new RedirectResponse( + $redirectUrl === null + ? $this->urlGenerator->getBaseUrl() + : preg_replace('/^https?:\/\/[^\/]+/', '', $redirectUrl) + ); } /** From 86ebec4f82eb3157aaef16ca6a16b5042bb5f414 Mon Sep 17 00:00:00 2001 From: memurats Date: Tue, 28 Oct 2025 14:45:47 +0100 Subject: [PATCH 5/6] changed response message --- lib/Controller/LoginController.php | 569 ++++++++++++++++++----------- 1 file changed, 346 insertions(+), 223 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 13f12ed9..e53aeeb3 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -19,10 +19,13 @@ use OCA\UserOIDC\Db\ProviderMapper; use OCA\UserOIDC\Db\SessionMapper; use OCA\UserOIDC\Event\TokenObtainedEvent; +use OCA\UserOIDC\Helper\HttpClientHelper; use OCA\UserOIDC\Service\DiscoveryService; use OCA\UserOIDC\Service\LdapService; +use OCA\UserOIDC\Service\OIDCService; use OCA\UserOIDC\Service\ProviderService; use OCA\UserOIDC\Service\ProvisioningService; +use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; use OCA\UserOIDC\User\Backend; use OCA\UserOIDC\Vendor\Firebase\JWT\JWT; @@ -35,9 +38,11 @@ use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Token\IToken; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; -use OCP\Http\Client\IClientService; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; @@ -51,12 +56,13 @@ use OCP\User\Events\BeforeUserLoggedInEvent; use OCP\User\Events\UserLoggedInEvent; use Psr\Log\LoggerInterface; +use UnexpectedValueException; class LoginController extends BaseOidcController { private const STATE = 'oidc.state'; private const NONCE = 'oidc.nonce'; public const PROVIDERID = 'oidc.providerid'; - private const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; + public const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; private const ID_TOKEN = 'oidc.id_token'; private const CODE_VERIFIER = 'oidc.code_verifier'; @@ -66,15 +72,17 @@ public function __construct( private ProviderService $providerService, private DiscoveryService $discoveryService, private LdapService $ldapService, + private SettingsService $settingsService, private ISecureRandom $random, private ISession $session, - private IClientService $clientService, + private HttpClientHelper $clientService, private IURLGenerator $urlGenerator, private IUserSession $userSession, private IUserManager $userManager, private ITimeFactory $timeFactory, private IEventDispatcher $eventDispatcher, private IConfig $config, + private IAppConfig $appConfig, private IProvider $authTokenProvider, private SessionMapper $sessionMapper, private ProvisioningService $provisioningService, @@ -82,28 +90,35 @@ public function __construct( private LoggerInterface $logger, private ICrypto $crypto, private TokenService $tokenService, + private OidcService $oidcService, ) { - // BaseOidcController erwartet $l10n im Konstruktor - parent::__construct($request, $config, $this->l10n); + parent::__construct($request, $config, $l10n); } + /** + * @return bool + */ private function isSecure(): bool { // no restriction in debug mode return $this->isDebugModeEnabled() || $this->request->getServerProtocol() === 'https'; } + /** + * @param bool|null $throttle + * @return TemplateResponse + */ private function buildProtocolErrorResponse(?bool $throttle = null): TemplateResponse { - // buildFailureTemplateResponse entfernte/abweichende Signatur vermeiden - // Nutze buildErrorTemplateResponse(message, status, metadata, throttleFlag) - $message = $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.'); - return $this->buildErrorTemplateResponse( - $message, - Http::STATUS_NOT_FOUND, - ['reason' => 'insecure connection'], - $throttle ?? false - ); + $params = [ + 'message' => $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.'), + ]; + $throttleMetadata = ['reason' => 'insecure connection']; + return $this->buildFailureTemplateResponse($params, Http::STATUS_NOT_FOUND, $throttleMetadata, $throttle); } + /** + * @param string|null $redirectUrl + * @return RedirectResponse + */ private function getRedirectResponse(?string $redirectUrl = null): RedirectResponse { return new RedirectResponse( $redirectUrl === null @@ -122,26 +137,27 @@ private function getRedirectResponse(?string $redirectUrl = null): RedirectRespo * @param string|null $redirectUrl * @return DataDisplayResponse|RedirectResponse|TemplateResponse */ - public function login(int $providerId, ?string $redirectUrl = null): DataDisplayResponse|RedirectResponse|TemplateResponse { + public function login(int $providerId, ?string $redirectUrl = null) { if ($this->userSession->isLoggedIn()) { return $this->getRedirectResponse($redirectUrl); } if (!$this->isSecure()) { return $this->buildProtocolErrorResponse(); } - $this->logger->debug('Initiating OIDC login', ['providerId' => $providerId]); + $this->logger->debug('Initiating login for provider with id: ' . strval($providerId)); try { $provider = $this->providerMapper->getProvider($providerId); } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { - $message = $this->l10n->t('There is not such OpenID Connect provider.'); + $message = $this->l10n->t('There is no such OpenID Connect provider.'); return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_not_found' => $providerId]); } + // pass discovery query parameters also on to the authentication $data = []; $discoveryUrl = parse_url($provider->getDiscoveryEndpoint()); if (isset($discoveryUrl['query'])) { - $this->logger->debug('Add custom discovery query', ['query' => $discoveryUrl['query']]); + $this->logger->debug('Add custom discovery query: ' . $discoveryUrl['query']); $discoveryQuery = []; parse_str($discoveryUrl['query'], $discoveryQuery); $data += $discoveryQuery; @@ -150,76 +166,104 @@ public function login(int $providerId, ?string $redirectUrl = null): DataDisplay try { $discovery = $this->discoveryService->obtainDiscovery($provider); } catch (\Exception $e) { - $this->logger->error('Could not reach the provider', [ - 'discovery' => $provider->getDiscoveryEndpoint(), - 'exception' => $e, - ]); + $this->logger->error('Could not reach the provider at URL ' . $provider->getDiscoveryEndpoint(), ['exception' => $e]); $message = $this->l10n->t('Could not reach the OpenID Connect provider.'); return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['reason' => 'provider unreachable']); } $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); - $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); $this->session->set(self::STATE, $state); - $this->session->set(self::NONCE, $nonce); - $this->session->set(self::PROVIDERID, $providerId); $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); + $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + $this->session->set(self::NONCE, $nonce); + $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); $isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true); $isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true); if ($isPkceEnabled) { + // PKCE code_challenge see https://datatracker.ietf.org/doc/html/rfc7636 $code_verifier = $this->random->generate(128, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER); $this->session->set(self::CODE_VERIFIER, $code_verifier); } + $this->session->set(self::PROVIDERID, $providerId); $this->session->close(); + // get attribute mapping settings $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); $claims = [ - 'id_token' => [], - 'userinfo' => [], + // more details about requesting claims: + // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests + // ['essential' => true] means it's mandatory but it won't trigger an error if it's not there + // null means we want it + 'id_token' => new \stdClass(), + 'userinfo' => new \stdClass(), ]; + $resolveNestedClaims = $this->providerService->getSetting($providerId, ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING, '0') === '1'; + // by default: default claims are ENABLED + // default claims are historically for quota, email, displayName and groups $isDefaultClaimsEnabled = !isset($oidcSystemConfig['enable_default_claims']) || $oidcSystemConfig['enable_default_claims'] !== false; if ($isDefaultClaimsEnabled) { + // default claims for quota, email, displayName and groups is ENABLED $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'name'); $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); $groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS, 'groups'); foreach ([$emailAttribute, $displaynameAttribute, $quotaAttribute, $groupsAttribute] as $claim) { - $claims['id_token'][$claim] = null; - $claims['userinfo'][$claim] = null; + $claims['id_token']->{$claim} = null; + $claims['userinfo']->{$claim} = null; } } else { + // No default claim, we only set the claims if an attribute is mapped $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL); $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME); $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA); $groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS); - foreach ([$emailAttribute, $displaynameAttribute, $quotaAttribute, $groupsAttribute] as $claim) { + $rawClaims = [$emailAttribute, $displaynameAttribute, $quotaAttribute, $groupsAttribute]; + + if ($resolveNestedClaims) { + $claimSet = []; + foreach ($rawClaims as $claim) { + if ($claim !== '') { + $first = trim(explode('|', $claim)[0]); + $claimSet[$first] = true; + } + } + $rawClaims = array_keys($claimSet); + } + + foreach ($rawClaims as $claim) { if ($claim !== '') { - $claims['id_token'][$claim] = null; - $claims['userinfo'][$claim] = null; + $claims['id_token']->{$claim} = null; + $claims['userinfo']->{$claim} = null; } } } if ($uidAttribute !== 'sub') { - $claims['id_token'][$uidAttribute] = ['essential' => true]; - $claims['userinfo'][$uidAttribute] = ['essential' => true]; + $uidAttributeToRequest = $uidAttribute; + if ($resolveNestedClaims) { + $uidAttributeToRequest = trim(explode('|', $uidAttribute)[0]); + } + $claims['id_token']->{$uidAttributeToRequest} = ['essential' => true]; + $claims['userinfo']->{$uidAttributeToRequest} = ['essential' => true]; } $extraClaimsString = $this->providerService->getSetting($providerId, ProviderService::SETTING_EXTRA_CLAIMS, ''); if ($extraClaimsString) { $extraClaims = explode(' ', $extraClaimsString); foreach ($extraClaims as $extraClaim) { - $claims['id_token'][$extraClaim] = null; - $claims['userinfo'][$extraClaim] = null; + $claims['id_token']->{$extraClaim} = null; + $claims['userinfo']->{$extraClaim} = null; } } + $oidcConfig = $this->config->getSystemValue('user_oidc', []); + $data += [ 'client_id' => $provider->getClientId(), 'response_type' => 'code', @@ -229,16 +273,25 @@ public function login(int $providerId, ?string $redirectUrl = null): DataDisplay 'state' => $state, 'nonce' => $nonce, ]; + + if (isset($oidcConfig['prompt']) && is_string($oidcConfig['prompt'])) { + $data['prompt'] = $oidcConfig['prompt']; + } + if ($isPkceEnabled) { - $data['code_challenge'] = $this->toCodeChallenge($this->session->get(self::CODE_VERIFIER)); + $data['code_challenge'] = $this->toCodeChallenge($code_verifier); $data['code_challenge_method'] = 'S256'; } + + $authorizationUrl = $this->discoveryService->buildAuthorizationUrl($discovery['authorization_endpoint'], $data); - $this->logger->debug('Redirecting user to OP authorization endpoint'); + $this->logger->debug('Redirecting user to: ' . $authorizationUrl); + // Workaround to avoid empty session on special conditions in Safari + // https://github.com/nextcloud/user_oidc/pull/358 if ($this->request->isUserAgent(['/Safari/']) && !$this->request->isUserAgent(['/Chrome/'])) { - return new DataDisplayResponse(''); + return new DataDisplayResponse(''); } return new RedirectResponse($authorizationUrl); @@ -261,31 +314,45 @@ public function login(int $providerId, ?string $redirectUrl = null): DataDisplay * @throws SessionNotAvailableException * @throws \JsonException */ - public function code(string $state = '', string $code = '', string $scope = '', string $error = '', string $error_description = ''): JSONResponse|RedirectResponse|TemplateResponse { + public function code(string $state = '', string $code = '', string $scope = '', string $error = '', string $error_description = '') { if (!$this->isSecure()) { return $this->buildProtocolErrorResponse(); } - $this->logger->debug('OIDC code flow callback received'); + $this->logger->debug('Code login with core: ' . $code . ' and state: ' . $state); if ($error !== '') { - return new JSONResponse([ - 'error' => $error, - 'error_description' => $error_description, - ], Http::STATUS_FORBIDDEN); + $this->logger->warning('Code login error', ['error' => $error, 'error_description' => $error_description]); + if ($this->isDebugModeEnabled()) { + return new JSONResponse([ + 'error' => $error, + 'error_description' => $error_description, + ], Http::STATUS_FORBIDDEN); + } + $message = $this->l10n->t('The identity provider failed to authenticate the user.'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false); } - if ($this->session->get(self::STATE) !== $state) { - $this->logger->debug('State does not match'); + $storedState = $this->session->get(self::STATE); + + if ($storedState !== $state) { + $this->logger->warning('state does not match', [ + 'got' => $state, + 'expected' => $storedState, + 'state_exists_in_session' => $this->session->exists(self::STATE), + ]); + $message = $this->l10n->t('The received state does not match the expected value.'); if ($this->isDebugModeEnabled()) { $responseData = [ 'error' => 'invalid_state', 'error_description' => $message, 'got' => $state, - 'expected' => $this->session->get(self::STATE), + 'expected' => $storedState, + 'state_exists_in_session' => $this->session->exists(self::STATE), ]; return new JSONResponse($responseData, Http::STATUS_FORBIDDEN); } + // we know debug mode is off, always throttle return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'state does not match'], true); } @@ -301,13 +368,12 @@ public function code(string $state = '', string $code = '', string $scope = '', $discovery = $this->discoveryService->obtainDiscovery($provider); - $this->logger->debug('Requesting tokens at OP token endpoint'); + $this->logger->debug('Obtainting data from: ' . $discovery['token_endpoint']); $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); $isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true); $isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true); - $client = $this->clientService->newClient(); try { $requestBody = [ 'code' => $code, @@ -315,17 +381,25 @@ public function code(string $state = '', string $code = '', string $scope = '', 'grant_type' => 'authorization_code', ]; if ($isPkceEnabled) { - $requestBody['code_verifier'] = $this->session->get(self::CODE_VERIFIER); + $requestBody['code_verifier'] = $this->session->get(self::CODE_VERIFIER); // Set for the PKCE flow } $headers = []; - $tokenEndpointAuthMethod = 'client_secret_post'; - $supported = $discovery['token_endpoint_auth_methods_supported'] ?? null; - - if (is_array($supported)) { - if (in_array('client_secret_basic', $supported, true) && !in_array('client_secret_post', $supported, true)) { - $tokenEndpointAuthMethod = 'client_secret_basic'; - } + // follow what is described in https://openid.net/specs/openid-connect-discovery-1_0.html + // about token_endpoint_auth_methods_supported: "If omitted, the default is client_secret_basic" + // Use client_secret_post if supported + // We still allow changing the default auth method in config.php + $tokenEndpointAuthMethod = $oidcSystemConfig['default_token_endpoint_auth_method'] ?? 'client_secret_basic'; + // deal with invalid values + if (!in_array($tokenEndpointAuthMethod, ['client_secret_basic', 'client_secret_post'], true)) { + $tokenEndpointAuthMethod = 'client_secret_basic'; + } + if ( + array_key_exists('token_endpoint_auth_methods_supported', $discovery) + && is_array($discovery['token_endpoint_auth_methods_supported']) + && in_array('client_secret_post', $discovery['token_endpoint_auth_methods_supported'], true) + ) { + $tokenEndpointAuthMethod = 'client_secret_post'; } if ($tokenEndpointAuthMethod === 'client_secret_basic') { @@ -334,114 +408,130 @@ public function code(string $state = '', string $code = '', string $scope = '', 'Content-Type' => 'application/x-www-form-urlencoded', ]; } else { + // Assuming client_secret_post as no other option is supported currently $requestBody['client_id'] = $provider->getClientId(); $requestBody['client_secret'] = $providerClientSecret; } - $result = $client->post( + $body = $this->clientService->post( $discovery['token_endpoint'], - [ - 'body' => $requestBody, - 'headers' => $headers, - ] + $requestBody, + $headers ); } catch (ClientException|ServerException $e) { $response = $e->getResponse(); $body = (string)$response->getBody(); $responseBodyArray = json_decode($body, true); if ($responseBodyArray !== null && isset($responseBodyArray['error'], $responseBodyArray['error_description'])) { - $this->logger->debug('OP token endpoint error', [ + $this->logger->debug('Failed to contact the OIDC provider token endpoint', [ 'exception' => $e, 'error' => $responseBodyArray['error'], 'error_description' => $responseBodyArray['error_description'], ]); $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint') . ': ' . $responseBodyArray['error_description']; } else { - $this->logger->debug('OP token endpoint error', ['exception' => $e]); + $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); } return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); } catch (\Exception $e) { - $this->logger->debug('OP token endpoint error (generic)', ['exception' => $e]); + $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); } - $data = json_decode($result->getBody(), true); - $this->logger->debug('Token response received (redacted)'); - + $data = json_decode($body, true); + $this->logger->debug('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); $this->eventDispatcher->dispatchTyped(new TokenObtainedEvent($data, $provider, $discovery)); - $idTokenRaw = $data['id_token'] ?? null; - if (!$idTokenRaw) { - $message = $this->l10n->t('No ID token received from the provider.'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'missing id_token']); - } - + // TODO: proper error handling + $idTokenRaw = $data['id_token']; $jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw); JWT::$leeway = 60; - $idTokenPayload = JWT::decode($idTokenRaw, $jwks); - - $this->logger->debug('ID token parsed (claims redacted)'); + try { + $idTokenPayload = JWT::decode($idTokenRaw, $jwks); + } catch (UnexpectedValueException $e) { + $this->logger->debug('Failed to decode the JWT token, retrying with fresh JWK'); + $jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw, false); + $idTokenPayload = JWT::decode($idTokenRaw, $jwks); + } + + // default is false + if (isset($oidcSystemConfig['enrich_login_id_token_with_userinfo']) && $oidcSystemConfig['enrich_login_id_token_with_userinfo']) { + $userInfo = $this->oidcService->userInfo($provider, $data['access_token']); + foreach ($userInfo as $key => $value) { + // give priority to id token values, only use userinfo ones if missing in id token + if (!isset($idTokenPayload->{$key})) { + $idTokenPayload->{$key} = $value; + } + } + } - $now = $this->timeFactory->getTime(); + $this->logger->debug('Parsed the JWT payload: ' . json_encode($idTokenPayload, JSON_THROW_ON_ERROR)); - if (isset($idTokenPayload->exp) && (int)$idTokenPayload->exp < $now) { + if ($idTokenPayload->exp < $this->timeFactory->getTime()) { $this->logger->debug('Token expired'); $message = $this->l10n->t('The received token is expired.'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'token expired']); } - if (!isset($discovery['issuer']) || $idTokenPayload->iss !== $discovery['issuer']) { - $this->logger->debug('Invalid issuer'); + // Verify issuer + if ($idTokenPayload->iss !== $discovery['issuer']) { + $this->logger->debug('This token is issued by the wrong issuer'); $message = $this->l10n->t('The issuer does not match the one from the discovery endpoint'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss ?? null]); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss]); } + // Verify audience $checkAudience = !isset($oidcSystemConfig['login_validation_audience_check']) || !in_array($oidcSystemConfig['login_validation_audience_check'], [false, 'false', 0, '0'], true); if ($checkAudience) { - $tokenAudience = $idTokenPayload->aud ?? null; + $tokenAudience = $idTokenPayload->aud; $providerClientId = $provider->getClientId(); if ( (is_string($tokenAudience) && $tokenAudience !== $providerClientId) || (is_array($tokenAudience) && !in_array($providerClientId, $tokenAudience, true)) ) { - $this->logger->debug('Invalid audience'); + $this->logger->debug('This token is not for us'); $message = $this->l10n->t('The audience does not match ours'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_audience' => $idTokenPayload->aud ?? null]); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_audience' => $idTokenPayload->aud]); } } $checkAzp = !isset($oidcSystemConfig['login_validation_azp_check']) || !in_array($oidcSystemConfig['login_validation_azp_check'], [false, 'false', 0, '0'], true); if ($checkAzp) { + // ref https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + // If the azp claim is present, it should be the client ID if (isset($idTokenPayload->azp) && $idTokenPayload->azp !== $provider->getClientId()) { - $this->logger->debug('Invalid azp'); + $this->logger->debug('This token is not for us, authorized party (azp) is different than the client ID'); $message = $this->l10n->t('The authorized party does not match ours'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_azp' => $idTokenPayload->azp]); } } if (isset($idTokenPayload->nonce) && $idTokenPayload->nonce !== $this->session->get(self::NONCE)) { - $this->logger->debug('Invalid nonce'); + $this->logger->debug('Nonce does not match'); $message = $this->l10n->t('The nonce does not match'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'invalid nonce']); } + // get user ID attribute $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); - $userId = $idTokenPayload->{$uidAttribute} ?? null; + $userId = $this->provisioningService->getClaimValue($idTokenPayload, $uidAttribute, $providerId); + if ($userId === null) { $message = $this->l10n->t('Failed to provision the user'); return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); } + // prevent login of users that are not in a whitelisted group (if activated) $restrictLoginToGroups = $this->providerService->getSetting($providerId, ProviderService::SETTING_RESTRICT_LOGIN_TO_GROUPS, '0'); if ($restrictLoginToGroups === '1') { $syncGroups = $this->provisioningService->getSyncGroupsOfToken($providerId, $idTokenPayload); if ($syncGroups === null || count($syncGroups) === 0) { - $this->logger->debug('User not in any whitelisted group'); + $this->logger->debug('Prevented user from login as user is not part of a whitelisted group'); $message = $this->l10n->t('You do not have permission to log in to this instance. If you think this is an error, please contact an administrator.'); return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'user not in any whitelisted group']); } @@ -452,23 +542,31 @@ public function code(string $state = '', string $code = '', string $scope = '', $shouldDoUserLookup = !$autoProvisionAllowed || ($softAutoProvisionAllowed && !$this->provisioningService->hasOidcUserProvisitioned($userId)); if ($shouldDoUserLookup && $this->ldapService->isLDAPEnabled()) { + // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results + // so new users will be directly available even if they were not synced before this login attempt $this->userManager->search($userId, 1, 0); $this->ldapService->syncUser($userId); } - $userFromOtherBackend = $this->userManager->get($userId); - if ($userFromOtherBackend !== null && $this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { - $userFromOtherBackend = null; + $existingUser = $this->userManager->get($userId); + if ($existingUser !== null && $this->ldapService->isLdapDeletedUser($existingUser)) { + $existingUser = null; } if ($autoProvisionAllowed) { - if (!$softAutoProvisionAllowed && $userFromOtherBackend !== null) { + if (!$softAutoProvisionAllowed && $existingUser !== null && $existingUser->getBackendClassName() !== Application::APP_ID) { + // if soft auto-provisioning is disabled, + // we refuse login for a user that already exists in another backend $message = $this->l10n->t('User conflict'); return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'non-soft auto provision, user conflict'], false); } - $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $userFromOtherBackend); + // use potential user from other backend, create it in our backend if it does not exist + $provisioningResult = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $existingUser); + $user = $provisioningResult['user']; + $this->session->set('user_oidc.oidcUserData', $provisioningResult['userData']); } else { - $user = $userFromOtherBackend; + // when auto provision is disabled, we assume the user has been created by another user backend (or manually) + $user = $existingUser; } if ($user === null) { @@ -476,58 +574,70 @@ public function code(string $state = '', string $code = '', string $scope = '', return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); } - try { - $this->session->set(self::ID_TOKEN, $this->crypto->encrypt($idTokenRaw)); - } catch (\Exception $e) { - $this->logger->debug('Failed to encrypt ID token for session storage', ['exception' => $e]); - } + $this->session->set(self::ID_TOKEN, $idTokenRaw); $this->logger->debug('Logging user in'); $this->userSession->setUser($user); if ($this->userSession instanceof OC_UserSession) { + // TODO server should/could be refactored so we don't need to manually create the user session and dispatch the login-related events + // Warning! If GSS is used, it reacts to the BeforeUserLoggedInEvent and handles the redirection itself + // So nothing after dispatching this event will be executed + $this->eventDispatcher->dispatchTyped(new BeforeUserLoggedInEvent($user->getUID(), null, \OCP\Server::get(Backend::class))); + $this->userSession->completeLogin($user, ['loginName' => $user->getUID(), 'password' => '']); $this->userSession->createSessionToken($this->request, $user->getUID(), $user->getUID()); $this->userSession->createRememberMeToken($user); - $this->eventDispatcher->dispatchTyped(new BeforeUserLoggedInEvent($user->getUID(), null, \OC::$server->get(Backend::class))); + + // prevent password confirmation + if (defined(IToken::class . '::SCOPE_SKIP_PASSWORD_VALIDATION')) { + $token = $this->authTokenProvider->getToken($this->session->getId()); + $scope = $token->getScopeAsArray(); + $scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION] = true; + $token->setScope($scope); + $this->authTokenProvider->updateToken($token); + } + $this->eventDispatcher->dispatchTyped(new UserLoggedInEvent($user, $user->getUID(), null, false)); } - $this->session->remove(self::STATE); - $this->session->remove(self::NONCE); - $this->session->remove(self::CODE_VERIFIER); + $storeLoginTokenEnabled = $this->appConfig->getValueString(Application::APP_ID, 'store_login_token', '0') === '1'; + if ($storeLoginTokenEnabled) { + // store all token information for potential token exchange requests + $tokenData = array_merge( + $data, + ['provider_id' => $providerId], + ); + $this->tokenService->storeToken($tokenData); + } + $this->config->setUserValue($user->getUID(), Application::APP_ID, 'had_token_once', '1'); - $this->session->set('last-password-confirm', $this->timeFactory->getTime() + (60 * 60 * 24 * 365 * 4)); + // Set last password confirm to the future as we don't have passwords to confirm against with SSO + $this->session->set('last-password-confirm', strtotime('+4 year', time())); - // createSession nur aufrufen, wenn vorhanden + // for backchannel logout try { $authToken = $this->authTokenProvider->getToken($this->session->getId()); - - $sidForStorage = $idTokenPayload->sid - ?? $idTokenPayload->{'urn:telekom.com:session_token'} - ?? 'fallback-sid'; - - if (method_exists($this->sessionMapper, 'createSession')) { - $this->sessionMapper->createSession( - $sidForStorage, - $idTokenPayload->sub ?? 'fallback-sub', - $idTokenPayload->iss ?? 'fallback-iss', - $authToken->getId(), - $this->session->getId() - ); - } else { - $this->logger->debug('SessionMapper::createSession not available; skipping backchannel mapping persist.'); - } - } catch (\Throwable $e) { - // InvalidTokenException oder andere — nicht kritisch für Login - $this->logger->debug('Auth token not found or persistence failed after login', ['exception' => $e]); + $this->sessionMapper->createOrUpdateSession( + $idTokenPayload->sid ?? 'fallback-sid', + $idTokenPayload->sub ?? 'fallback-sub', + $idTokenPayload->iss ?? 'fallback-iss', + $authToken->getId(), + $this->session->getId(), + $idTokenRaw, + $user->getUID(), + $providerId, + ); + } catch (InvalidTokenException $e) { + $this->logger->debug('Auth token not found after login'); } + // if the user was provisioned by user_ldap, this is required to update and/or generate the avatar if ($user->canChangeAvatar()) { - $this->logger->debug('User can change avatar (post-login sync may occur)'); + $this->logger->debug('$user->canChangeAvatar() is true'); } - $this->logger->debug('Redirecting user after login'); + $this->logger->debug('Redirecting user'); $redirectUrl = $this->session->get(self::REDIRECT_AFTER_LOGIN); if ($redirectUrl) { @@ -551,13 +661,15 @@ public function code(string $state = '', string $code = '', string $scope = '', * @throws SessionNotAvailableException * @throws \JsonException */ - public function singleLogoutService(): RedirectResponse|TemplateResponse { + public function singleLogoutService() { + // TODO throttle in all failing cases $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); $targetUrl = $this->urlGenerator->getAbsoluteURL('/'); if (!isset($oidcSystemConfig['single_logout']) || $oidcSystemConfig['single_logout']) { $isFromGS = ($this->config->getSystemValueBool('gs.enabled', false) && $this->config->getSystemValueString('gss.mode', '') === 'master'); if ($isFromGS) { + // Request is from master GlobalScale: we get the provider ID from the JWT token provided by the slave $jwt = $this->request->getParam('jwt', ''); try { @@ -566,55 +678,64 @@ public function singleLogoutService(): RedirectResponse|TemplateResponse { $providerId = $decoded['oidcProviderId'] ?? null; } catch (\Exception $e) { - $this->logger->debug('Failed to get the logout provider ID from GSS', ['exception' => $e]); + $this->logger->debug('Failed to get the logout provider ID in the request from GSS', ['exception' => $e]); } } else { $providerId = $this->session->get(self::PROVIDERID); + // if the provider is not found and we are in SSO mode, just use the one and only provider + if ($providerId === null && !$this->settingsService->getAllowMultipleUserBackEnds()) { + $providers = $this->providerMapper->getProviders(); + if (count($providers) === 1) { + $providerId = $providers[0]->getId(); + } + } } if ($providerId) { try { $provider = $this->providerMapper->getProvider((int)$providerId); } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { - $message = $this->l10n->t('There is not such OpenID Connect provider.'); + $message = $this->l10n->t('There is no such OpenID Connect provider.'); return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_id' => $providerId]); } + // Check if a custom end_session_endpoint is set in the provider otherwise use the default one provided by the openid-configuration $discoveryData = $this->discoveryService->obtainDiscovery($provider); $defaultEndSessionEndpoint = $discoveryData['end_session_endpoint'] ?? null; $customEndSessionEndpoint = $provider->getEndSessionEndpoint(); $endSessionEndpoint = $customEndSessionEndpoint ?: $defaultEndSessionEndpoint; if ($endSessionEndpoint) { + $targetUrl = $provider->getPostLogoutUri() ?: $this->urlGenerator->getAbsoluteURL('/'); $endSessionEndpoint .= '?post_logout_redirect_uri=' . $targetUrl; $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); $shouldSendIdToken = $this->providerService->getSetting( $provider->getId(), - ProviderService::SETTING_SEND_ID_TOKEN_HINT, '0' + ProviderService::SETTING_SEND_ID_TOKEN_HINT, + '0' ) === '1'; - $idTokenHint = null; - $idTokenEncrypted = $this->session->get(self::ID_TOKEN); - if ($shouldSendIdToken && $idTokenEncrypted) { - try { - $idTokenHint = $this->crypto->decrypt($idTokenEncrypted); - } catch (\Exception $e) { - $this->logger->debug('Failed to decrypt ID token for logout hint', ['exception' => $e]); - } - } - if ($shouldSendIdToken && $idTokenHint) { - $endSessionEndpoint .= '&id_token_hint=' . $idTokenHint; + $idToken = $this->session->get(self::ID_TOKEN); + if ($shouldSendIdToken && $idToken) { + $endSessionEndpoint .= '&id_token_hint=' . $idToken; } $targetUrl = $endSessionEndpoint; } } } + // cleanup related oidc session + $this->sessionMapper->deleteFromNcSessionId($this->session->getId()); + $this->userSession->logout(); + + // make sure we clear the session to avoid messing with Backend::isSessionActive $this->session->clear(); return new RedirectResponse($targetUrl); } /** * Endpoint called by the IdP (OP) when end_session_endpoint is called by another client + * The logout token contains the sid for which we know the sessionId + * which leads to the auth token that we can invalidate * Implemented according to https://openid.net/specs/openid-connect-backchannel-1_0.html * * @PublicPage @@ -627,6 +748,7 @@ public function singleLogoutService(): RedirectResponse|TemplateResponse { * @throws \JsonException */ public function backChannelLogout(string $providerIdentifier, string $logout_token = ''): JSONResponse { + // get the provider $provider = $this->providerService->getProviderByIdentifier($providerIdentifier); if ($provider === null) { return $this->getBackchannelLogoutErrorResponse( @@ -636,23 +758,23 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } + // decrypt the logout token $jwks = $this->discoveryService->obtainJWK($provider, $logout_token); JWT::$leeway = 60; $logoutTokenPayload = JWT::decode($logout_token, $jwks); - $this->logger->debug('Backchannel logout token parsed (claims redacted)'); + $this->logger->debug('Parsed the logout JWT payload: ' . json_encode($logoutTokenPayload, JSON_THROW_ON_ERROR)); - $aud = $logoutTokenPayload->aud ?? null; - $clientId = $provider->getClientId(); - $audOk = is_string($aud) ? $aud === $clientId : (is_array($aud) && in_array($clientId, $aud, true)); - if (!$audOk) { + // check the audience + if (!(($logoutTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $logoutTokenPayload->aud, true)))) { return $this->getBackchannelLogoutErrorResponse( 'invalid audience', 'The audience of the logout token does not match the provider', - ['invalid_audience' => $aud] + ['invalid_audience' => $logoutTokenPayload->aud] ); } + // check the event attr if (!isset($logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'})) { return $this->getBackchannelLogoutErrorResponse( 'invalid event', @@ -661,6 +783,7 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } + // check the nonce attr if (isset($logoutTokenPayload->nonce)) { return $this->getBackchannelLogoutErrorResponse( 'invalid nonce', @@ -669,100 +792,94 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok ); } - $now = $this->timeFactory->getTime(); - if (!isset($logoutTokenPayload->iat) || abs($now - (int)$logoutTokenPayload->iat) > 300) { + if (!isset($logoutTokenPayload->iss)) { return $this->getBackchannelLogoutErrorResponse( - 'stale token', - 'Logout token is too old or missing iat', - ['iat' => $logoutTokenPayload->iat ?? null] + 'invalid iss', + 'The logout token should contain an iss attribute', + ['iss_should_be_set' => true] ); } + $iss = $logoutTokenPayload->iss; if (!isset($logoutTokenPayload->sid) && !isset($logoutTokenPayload->sub)) { return $this->getBackchannelLogoutErrorResponse( - 'missing sid/sub', - 'The logout token must contain at least sid or sub', - [] + 'invalid sid+sub', + 'The logout token should contain sid or sub or both', + ['no_sid_no_sub' => true] ); } - $sid = $logoutTokenPayload->sid ?? null; + $oidcSessionsToKill = []; - try { - if ($sid === null) { + // if SID is set, we look for this specific session (with or without using the sub, depending on if the sub is set) + if (isset($logoutTokenPayload->sid)) { + $sid = $logoutTokenPayload->sid; + $sub = $logoutTokenPayload->sub ?? null; + try { + $oidcSession = $this->sessionMapper->findSessionBySid($sid, $sub, $iss); + } catch (DoesNotExistException $e) { return $this->getBackchannelLogoutErrorResponse( - 'invalid SID', - 'The sid of the logout token was not found', - ['session_sid_not_found' => null] + $sub === null ? 'invalid SID or ISS' : 'invalid SID, SUB or ISS', + $sub === null ? 'No session was found for this (sid,iss)' : 'No session was found for this (sid,sub,iss)', + ['session_not_found' => $sid] + ); + } catch (MultipleObjectsReturnedException $e) { + return $this->getBackchannelLogoutErrorResponse( + $sub === null ? 'invalid SID or ISS' : 'invalid SID, SUB or ISS', + $sub === null ? 'Multiple sessions were found with this (sid,iss)' : 'Multiple sessions were found with this (sid,sub,iss)', + ['multiple_sessions_found' => $sid] + ); + } + $oidcSessionsToKill[] = $oidcSession; + } else { + // here we know the sid is not set so the sub is set + $sub = $logoutTokenPayload->sub; + try { + $oidcSessionsToKill = $this->sessionMapper->findSessionsBySubAndIss($sub, $iss); + } catch (\OCP\Db\Exception $e) { + return $this->getBackchannelLogoutErrorResponse( + 'error with sub+iss', + 'Failed to retrieve session with sub+iss', + ['sub_iss_error' => true] ); } - $oidcSession = $this->sessionMapper->findSessionBySid($sid); - } catch (DoesNotExistException $e) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SID', - 'The sid of the logout token was not found', - ['session_sid_not_found' => $sid] - ); - } catch (MultipleObjectsReturnedException $e) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SID', - 'The sid of the logout token was found multiple times', - ['multiple_logout_tokens_found' => $sid] - ); - } - - $sub = $logoutTokenPayload->sub ?? null; - if (isset($sub) && ($oidcSession->getSub() !== $sub)) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SUB', - 'The sub does not match the one from the login ID token', - ['invalid_sub' => $sub] - ); + if (empty($oidcSessionsToKill)) { + return $this->getBackchannelLogoutErrorResponse( + 'nothing found with sub+iss', + 'No session found with sub+iss', + ['sub_iss_no_session_found' => true] + ); + } } - $iss = $logoutTokenPayload->iss ?? null; - if ($oidcSession->getIss() !== $iss) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid ISS', - 'The iss does not match the one from the login ID token', - ['invalid_iss' => $iss] - ); - } + foreach ($oidcSessionsToKill as $oidcSession) { + // we know the IdP session is closed + // we need this to prevent requesting the end_session_endpoint when we catch the TokenInvalidatedEvent + $oidcSession->setIdpSessionClosed(1); + $this->sessionMapper->update($oidcSession); + + $authTokenId = $oidcSession->getAuthtokenId(); + try { + $authToken = $this->authTokenProvider->getTokenById($authTokenId); + // we could also get the auth token by nc session ID + // $authToken = $this->authTokenProvider->getToken($oidcSession->getNcSessionId()); + $userId = $authToken->getUID(); + $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); + } catch (InvalidTokenException $e) { + $this->logger->warning('[BackchannelLogout] Nextcloud session not found', ['authtoken_id' => $authTokenId]); + } - $authTokenId = (int)$oidcSession->getAuthtokenId(); - try { - $authToken = $this->authTokenProvider->getTokenById($authTokenId); - $userId = $authToken->getUID(); - $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); - } catch (\Throwable $e) { - // bereits ungültig → ok + // cleanup + $this->sessionMapper->delete($oidcSession); } - $this->sessionMapper->delete($oidcSession); - - return new JSONResponse(); - } - - /** - * Backward compatible function for MagentaCLOUD to smoothly transition to new config - * - * @PublicPage - * @NoCSRFRequired - * @BruteForceProtection(action=userOidcBackchannelLogout) - * - * @param string $logout_token - * @return JSONResponse - * @throws Exception - * @throws \JsonException - */ - public function telekomBackChannelLogout(string $logout_token = ''): JSONResponse { - return $this->backChannelLogout('Telekom', $logout_token); + return new JSONResponse([], Http::STATUS_OK); } /** - * Generate a backchannel logout response. - * Log the error but always return HTTP 200 OK for IDM compliance. + * Generate an error response according to the OIDC standard + * Log the error * * @param string $error * @param string $description @@ -785,6 +902,12 @@ private function getBackchannelLogoutErrorResponse( } private function toCodeChallenge(string $data): string { - return rtrim(strtr(base64_encode(hash('sha256', $data, true)), '+/', '-_'), '='); + // Basically one big work around for the base64url decode being weird + $h = pack('H*', hash('sha256', $data)); + $s = base64_encode($h); // Regular base64 encoder + $s = explode('=', $s)[0]; // Remove any trailing '='s + $s = str_replace('+', '-', $s); // 62nd char of encoding + $s = str_replace('/', '_', $s); // 63rd char of encoding + return $s; } -} +} \ No newline at end of file From 9aa62314bbc3e7ae2af0e32401e8db4ec6a918e7 Mon Sep 17 00:00:00 2001 From: memurats Date: Tue, 28 Oct 2025 14:48:17 +0100 Subject: [PATCH 6/6] added line at the end --- lib/Controller/LoginController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index e53aeeb3..449e32a6 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -910,4 +910,4 @@ private function toCodeChallenge(string $data): string { $s = str_replace('/', '_', $s); // 63rd char of encoding return $s; } -} \ No newline at end of file +}