Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 83 additions & 35 deletions lib/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,9 @@ public function singleLogoutService() {
* 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
* Note : in a RP-initiated logout scenario
* the invalidation step should not be required since it would have been cleared
* in singleLogoutService()
* Implemented according to https://openid.net/specs/openid-connect-backchannel-1_0.html
*
* @param string $providerIdentifier
Expand All @@ -863,7 +866,18 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok
return $this->getBackchannelLogoutErrorResponse(
'provider not found',
'The provider was not found in Nextcloud',
['provider_not_found' => $providerIdentifier]
['severity' => 'warning', 'extra_context' => 'Got provider identifier: ' . $providerIdentifier]
);
}

try {
$discovery = $this->discoveryService->obtainDiscovery($provider);
} catch (\Exception $e) {
$this->logger->error('Could not reach the provider at URL ' . $provider->getDiscoveryEndpoint(), ['exception' => $e]);
return $this->getBackchannelLogoutErrorResponse(
'could not reach provider endpoint',
'URL: ' . $provider->getDiscoveryEndpoint() . 'was not reachable',
['severity' => 'error']
);
}

Expand All @@ -874,6 +888,27 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok

$this->logger->debug('Parsed the logout JWT payload: ' . json_encode($logoutTokenPayload, JSON_THROW_ON_ERROR));

// REQUIRED claims check step
// https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken
$requiredClaims = ['iss', 'aud', 'iat', 'exp', 'jti', 'events'];
$missingClaims = [];
$logoutTokenArray = (array) $logoutTokenPayload;
foreach ($requiredClaims as $claim) {
if (!in_array($claim, $logoutTokenArray)) {
$missingClaims[] = $claim;
}
}
if (!empty($missingClaims)) {
return $this->getBackchannelLogoutErrorResponse(
'missing one or more claims',
'missing the following claim(s) : ' . implode(', ', $missingClaims),
['severity' => 'warning', 'extra_context' => 'Probably is an IdP side issue']
);
}

// Logout token validation step
// https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation

// check the audience
$aud = $logoutTokenPayload->aud;
$clientId = $provider->getClientId();
Expand All @@ -882,16 +917,16 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok
return $this->getBackchannelLogoutErrorResponse(
'invalid audience',
'The audience of the logout token does not match the provider',
['invalid_audience' => $logoutTokenPayload->aud]
['severity' => 'warning', 'extra_context' => 'Probably is an IdP side issue']
);
}

// check the event attr
if (!isset($logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'})) {
if (!$logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'}) {
return $this->getBackchannelLogoutErrorResponse(
'invalid event',
'The backchannel-logout event was not found in the logout token',
['invalid_event' => true]
['severity' => 'warning', 'extra_context' => 'Probably is an IdP side issue']
);
}

Expand All @@ -900,24 +935,32 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok
return $this->getBackchannelLogoutErrorResponse(
'invalid nonce',
'The logout token should not contain a nonce attribute',
['nonce_should_not_be_set' => true]
['severity' => 'warning', 'extra_context' => 'Probably is an IdP side issue']
);
}

if (!isset($logoutTokenPayload->iss)) {
$iss = $logoutTokenPayload->iss;
if ($iss !== $discovery['issuer']) {
return $this->getBackchannelLogoutErrorResponse(
'invalid iss',
'The logout token should contain an iss attribute',
['iss_should_be_set' => true]
'The iss of the logout token does not match the issuer',
['severity' => 'warning', 'extra_context' => 'Probably is an IdP side issue']
);
}

if (!isset($logoutTokenPayload->exp) || $logoutTokenPayload->exp < $this->timeFactory->getTime()) {
return $this->getBackchannelLogoutErrorResponse(
'invalid exp',
'The logout token is expired',
['severity' => 'warning', 'extra_context' => 'Probably is an IdP side issue']
);
}
$iss = $logoutTokenPayload->iss;

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]
['severity' => 'warning', 'extra_context' => 'Probably is an IdP side issue']
);
}

Expand All @@ -929,42 +972,31 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok
$sub = $logoutTokenPayload->sub ?? null;
try {
$oidcSession = $this->sessionMapper->findSessionBySid($sid, $sub, $iss);
$oidcSessionsToKill[] = $oidcSession;
} catch (DoesNotExistException $e) {
// Already-logged-out is a success per OIDC Backchannel Logout 1.0 §2.6.
// https://openid.net/specs/openid-connect-backchannel-1_0.html#BCActions
$this->logger->debug(
'[BackchannelLogout] no RP session for (sid,iss) — treating as already-logged-out',
['sid' => $sid, 'sub_present' => $sub !== null]
);
return new JSONResponse([], Http::STATUS_OK);
$this->logger->debug('[BackchannelLogout] OIDC session not found with sid+sub+iss (expected for a RP-initiated logout)');
} catch (MultipleObjectsReturnedException $e) {
Comment thread
Spitfireap marked this conversation as resolved.
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]
$this->logger->warning('[BackchannelLogout] Multiple OIDC sessions retrieved (sid+sub+iss). '
. 'This should not happen. Please check that you have created your DB indexes'
);
}
$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]
} catch (\OCP\DB\Exception $e) {
$this->logger->error(
'[BackchannelLogout] Database failure while trying to retrieve user session (sub+iss)'
. ['exception' => $e]
);
}

if (empty($oidcSessionsToKill)) {
// Already-logged-out is a success per OIDC Backchannel Logout 1.0 §2.6.
$this->logger->debug(
'[BackchannelLogout] no RP sessions for (sub,iss) — treating as already-logged-out',
['sub' => $sub]
);
return new JSONResponse([], Http::STATUS_OK);
$this->logger->debug('[BackchannelLogout] OIDC session not found with sub+iss (expected for a RP-initiated logout)');
}
}

Expand All @@ -989,7 +1021,12 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok
$this->sessionMapper->delete($oidcSession);
}

return new JSONResponse([], Http::STATUS_OK);
// Tell the Idp not to cache the response
// Per RFC : https://openid.net/specs/openid-connect-backchannel-1_0.html#BCResponse
$response = new JSONResponse([], Http::STATUS_OK);
$response->cacheFor(0);

return $response;
}

/**
Expand All @@ -998,22 +1035,33 @@ public function backChannelLogout(string $providerIdentifier, string $logout_tok
*
* @param string $error
* @param string $description
* @param array $throttleMetadata
* @param array $metadata
* @return JSONResponse
*/
private function getBackchannelLogoutErrorResponse(
string $error,
string $description,
array $throttleMetadata = [],
array $metadata,
): JSONResponse {
$this->logger->debug('Backchannel logout error. ' . $error . ' ; ' . $description);
return new JSONResponse(
$logSeverity = 'debug';
if (isset($metadata["severity"])) {
$logSeverity = $metadata["severity"];
unset($metadata["severity"]);
}
$this->logger->log($logSeverity, 'Backchannel logout error. ' . $error . ' ; ' . $description,
$metadata);

$response = new JSONResponse(
[
'error' => $error,
'error_description' => $description,
],
Http::STATUS_BAD_REQUEST,
);
// Tell the Idp not to cache the response
// Per RFC : https://openid.net/specs/openid-connect-backchannel-1_0.html#BCResponse
$response->cacheFor(0);
return $response;
}

private function toCodeChallenge(string $data): string {
Expand Down