Skip to content

Back channel logout #246

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions HOWTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ add_action('openid-connect-generic-redirect-user-back', function( $redirect_url,
This plugin stores meta data about the user for both practical and debugging purposes.

* `openid-connect-generic-subject-identity` - The identity of the user provided by the IDP server.
* `openid-connect-generic-last-session-id` - The user's last IDP session ID if provided by the IDP server.
* `openid-connect-generic-last-id-token-claim` - The user's most recent `id_token` claim, decoded and stored as an array.
* `openid-connect-generic-last-user-claim` - The user's most recent `user_claim`, stored as an array.
* `openid-connect-generic-last-token-response` - The user's most recent `token_response`, stored as an array.
165 changes: 155 additions & 10 deletions includes/openid-connect-generic-client-wrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,20 @@ public static function register( OpenID_Connect_Generic_Client $client, OpenID_C
*/
add_action( 'wp_ajax_openid-connect-authorize', array( $client_wrapper, 'authentication_request_callback' ) );
add_action( 'wp_ajax_nopriv_openid-connect-authorize', array( $client_wrapper, 'authentication_request_callback' ) );
add_action( 'wp_ajax_openid-connect-backchannel-logout', array( $client_wrapper, 'backchannel_logout_request_callback' ) );
add_action( 'wp_ajax_nopriv_openid-connect-backchannel-logout', array( $client_wrapper, 'backchannel_logout_request_callback' ) );
}

if ( $settings->alternate_redirect_uri ) {
if ( $settings->alternate_redirect_uri || $settings->keycloak_legacy_backchannel_logout_enable ) {
// Provide an alternate route for authentication_request_callback.
add_rewrite_rule( '^openid-connect-authorize/?', 'index.php?openid-connect-authorize=1', 'top' );
add_rewrite_tag( '%openid-connect-authorize%', '1' );
if ( $settings->alternate_redirect_uri ) {
add_rewrite_rule( '^openid-connect-authorize/?', 'index.php?openid-connect-authorize=1', 'top' );
add_rewrite_tag( '%openid-connect-authorize%', '1' );
}
if ( $settings->keycloak_legacy_backchannel_logout_enable ) {
add_rewrite_rule( '^k_logout/?', 'index.php?openid-connect-backchannel-logout=1', 'top' );
add_rewrite_tag( '%openid-connect-backchannel-logout%', '1' );
}
add_action( 'parse_request', array( $client_wrapper, 'alternate_redirect_uri_parse_request' ) );
}

Expand All @@ -138,6 +146,11 @@ public function alternate_redirect_uri_parse_request( $query ) {
$this->authentication_request_callback();
exit;
}
if ( isset( $query->query_vars['openid-connect-backchannel-logout'] ) &&
'1' === $query->query_vars['openid-connect-backchannel-logout'] ) {
$this->backchannel_logout_request_callback();
exit;
}

return $query;
}
Expand Down Expand Up @@ -440,7 +453,14 @@ public function authentication_request_callback() {
}

// Attempting to exchange an authorization code for an authentication token.
$token_result = $client->request_authentication_token( $code );
$token_result = null;
$k_client_session_state = null;
if ( $this->settings->keycloak_legacy_backchannel_logout_enable ) {
$k_client_session_state = session_id();
$token_result = $client->request_authentication_token( $code, array( 'client_session_state' => $k_client_session_state ) );
} else {
$token_result = $client->request_authentication_token( $code, array() );
}

if ( is_wp_error( $token_result ) ) {
$this->error_redirect( $token_result );
Expand Down Expand Up @@ -508,6 +528,13 @@ public function authentication_request_callback() {
* Request is authenticated and authorized - start user handling
*/
$subject_identity = $client->get_subject_identity( $id_token_claim );
if ( $this->settings->keycloak_legacy_backchannel_logout_enable ) {
// for Keycloak, we use the client_session_state that we sent
// to Keycloak's token endpoint previously.
$session_id = $k_client_session_state;
} else {
$session_id = $client->get_session_id( $id_token_claim );
}
$user = $this->get_user_by_identity( $subject_identity );

if ( ! $user ) {
Expand All @@ -532,12 +559,12 @@ public function authentication_request_callback() {
}

// Login the found / created user.
$this->login_user( $user, $token_response, $id_token_claim, $user_claim, $subject_identity );
$this->login_user( $user, $token_response, $id_token_claim, $user_claim, $session_id );

do_action( 'openid-connect-generic-user-logged-in', $user );

// Log our success.
$this->logger->log( "Successful login for: {$user->user_login} ({$user->ID})", 'login-success' );
$this->logger->log( "Successful login for: {$user->user_login} (ID: {$user->ID}, sub: {$subject_identity}, sid:{$session_id})", 'login-success' );

// Default redirect to the homepage.
$redirect_url = home_url();
Expand All @@ -563,6 +590,99 @@ public function authentication_request_callback() {
exit;
}

/**
* Process backchannel logout requests from the IDP.
*
* @return void
*/
function backchannel_logout_request_callback() {
$client = $this->client;

// This processes the OIDC Backchannel logout request, which
// is expected to be made using HTTP POST.

$token = null;
if ( $this->settings->keycloak_legacy_backchannel_logout_enable ) {
// With keycloak legacy processing, the token comes from
// the post body (which is 'text/plain').
$token = file_get_contents( 'php://input' );

} else {
// when using standard OIDC processing, the token is part of
// the POST form data field 'logout_token'.
$token = $_POST['logout_token'];
}

if ( ! isset( $token ) ) {
$this->error_redirect( new WP_Error( 'no-logout-token', __( 'No logout token.', 'daggerhart-openid-connect-generic' ) ) );
}

// FIXME: token is not validated here, see below.

$claims = $client->parse_jwt( $token );
if ( is_wp_error( $claims ) ) {
$this->error_redirect( $claims );
}

if ( $this->settings->keycloak_legacy_backchannel_logout_enable ) {
// In Keycloak Legacy BCL configuration, we do not receive a
// user id, just the session_id() that we passed to KC's
// token endpoint via the proprietary 'client_session_state' parameter.
$subject_identity = null;
$session_id = $claims['adapterSessionIds'][0];
} else {
// Token validation and parsing as defined in
// https://openid.net/specs/openid-connect-backchannel-1_0.html#rfc.section.2.6 .

//
// FIXME: #1 and #2 (decryption and token signature) are not yet done here,
// because we're lacking the necessary infrastructure.
// The token introspection endpoint may be a viable alternative:
// https://tools.ietf.org/html/rfc7662 .
//

// parse token into claims
// Further validations in Section 2.6.
$validation = $client->validate_logout_token_claim( $claims );
if ( is_wp_error( $validation ) ) {
$this->error_redirect( $validation );
}

// now that we have valid claims, we can start the actual logout
// https://openid.net/specs/openid-connect-backchannel-1_0.html#rfc.section.2.7 .
$subject_identity = $client->get_subject_identity( $claims );
$session_id = $client->get_session_id( $claims );
}

$user = null;
if ( isset( $subject_identity ) ) {
$user = $this->get_user_by_identity( $subject_identity );
} else if ( isset( $session_id ) ) {
$user = $this->get_user_by_session_id( $session_id );
}
if ( ! $user && isset( $subject_identity ) ) {
// NOTE: The spec demands that if the user has already logged out,
// the logout request is successful. We actually fulfil this request
// even though it is not obvious: Because the user's 'sub' claim is
// stored as a user attribute and remains there after the user logged
// out, we'd still find her/him.
// So we only ever get here if the user never logged in before.
$this->error_redirect( new WP_Error( '', __( 'User not found', 'daggerhart-openid-connect-generic' ) ) );
}

if ( $user ) {
// get all sessions for user with ID $user_id.
$sessions = WP_Session_Tokens::get_instance( $user->ID );
$sessions->destroy_all();

$this->logger->log( "Successful backchannel logout for: {$user->user_login} (ID: {$user->ID}, sub: {$subject_identity}, sid:{$session_id})", 'backchannel-logout-success' );
} else {
$this->logger->log( "Backchannel logout failed, no user found for sub: {$subject_identity}, sid: {$session_id}" );
}

exit;
}

/**
* Validate the potential WP_User.
*
Expand All @@ -586,15 +706,16 @@ public function validate_user( $user ) {
* @param array $token_response The token response.
* @param array $id_token_claim The ID token claim.
* @param array $user_claim The authenticated user claim.
* @param string $subject_identity The subject identity from the IDP.
* @param string $session_id The session ID from the IDP, if provided.
*
* @return void
*/
public function login_user( $user, $token_response, $id_token_claim, $user_claim, $subject_identity ) {
public function login_user( $user, $token_response, $id_token_claim, $user_claim, $session_id ) {
// Store the tokens for future reference.
update_user_meta( $user->ID, 'openid-connect-generic-last-token-response', $token_response );
update_user_meta( $user->ID, 'openid-connect-generic-last-id-token-claim', $id_token_claim );
update_user_meta( $user->ID, 'openid-connect-generic-last-user-claim', $user_claim );
update_user_meta( $user->ID, 'openid-connect-generic-last-session-id', strval( $session_id ) );

// Create the WP session, so we know its token.
$expiration = time() + apply_filters( 'auth_cookie_expiration', 2 * DAY_IN_SECONDS, $user->ID, false );
Expand Down Expand Up @@ -648,12 +769,36 @@ public function save_refresh_token( $manager, $token, $token_response ) {
*/
public function get_user_by_identity( $subject_identity ) {
// Look for user by their openid-connect-generic-subject-identity value.
return $this->get_user_by_meta_key( 'openid-connect-generic-subject-identity', $subject_identity );
}

/**
* Get the user that has meta data matching a
*
* @param string $session_id The IDP session id of the user.
*
* @return false|WP_User
*/
function get_user_by_session_id( $session_id ) {
// Look for user by their openid-connect-generic-last-session-id value.
return $this->get_user_by_meta_key( 'openid-connect-generic-last-session-id', $session_id );
}

/**
* Get the user that has meta data matching the pair of
*
* @param string $meta_key The user's metadata key.
* @param string $meta_value The user's metadata value.
*
* @return false|WP_User
*/
private function get_user_by_meta_key( $meta_key, $meta_value ) {
$user_query = new WP_User_Query(
array(
'meta_query' => array(
array(
'key' => 'openid-connect-generic-subject-identity',
'value' => $subject_identity,
'key' => $meta_key,
'value' => $meta_value,
),
),
)
Expand Down
80 changes: 70 additions & 10 deletions includes/openid-connect-generic-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,24 +191,29 @@ public function get_authentication_code( $request ) {
* Using the authorization_code, request an authentication token from the IDP.
*
* @param string|WP_Error $code The authorization code.
* @param array<string> $additional_params additional parameters for the token request.
*
* @return array<mixed>|WP_Error
*/
public function request_authentication_token( $code ) {
public function request_authentication_token( $code, $additional_params ) {

// Add Host header - required for when the openid-connect endpoint is behind a reverse-proxy.
$parsed_url = parse_url( $this->endpoint_token );
$host = $parsed_url['host'];

$request = array(
'body' => array(
$body = array_merge(
$additional_params,
array(
'code' => $code,
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
'redirect_uri' => $this->redirect_uri,
'grant_type' => 'authorization_code',
'scope' => $this->scope,
),
)
);
$request = array(
'body' => $body,
'headers' => array( 'Host' => $host ),
);

Expand Down Expand Up @@ -425,17 +430,29 @@ public function get_id_token_claim( $token_response ) {
return new WP_Error( 'no-identity-token', __( 'No identity token.', 'daggerhart-openid-connect-generic' ), $token_response );
}

return $this->parse_jwt( $token_response['id_token'] );
}

/**
* Parse a JWT token into an array of claims
*
* @param string $token The token encoded as string.
*
* @return array|WP_Error
*/
function parse_jwt( $token ) {

// Break apart the id_token in the response for decoding.
$tmp = explode( '.', $token_response['id_token'] );
$tmp = explode( '.', $token );

if ( ! isset( $tmp[1] ) ) {
return new WP_Error( 'missing-identity-token', __( 'Missing identity token.', 'daggerhart-openid-connect-generic' ), $token_response );
if ( ! isset( $tmp[1] ) || empty( $tmp[1] ) ) {
return new WP_Error( 'invalid-token', __( 'Cannot parse token string', 'daggerhart-openid-connect-generic' ), $token );
}

// Extract the id_token's claims from the token.
$id_token_claim = json_decode(
$token_claim = json_decode(
base64_decode(
str_replace( // Because token is encoded in base64 URL (and not just base64).
str_replace( // Because token may be encoded in base64 URL (and not just base64, see https://en.wikipedia.org/wiki/Base64#Variants_summary_table).
array( '-', '_' ),
array( '+', '/' ),
$tmp[1]
Expand All @@ -444,7 +461,7 @@ public function get_id_token_claim( $token_response ) {
true
);

return $id_token_claim;
return $token_claim;
}

/**
Expand All @@ -467,6 +484,38 @@ public function validate_id_token_claim( $id_token_claim ) {
return true;
}

/**
* Ensure the logout_token_claim contains the required values.
* See https://openid.net/specs/openid-connect-backchannel-1_0.html#rfc.section.2.6
* for details
*
* @param array $logout_token_claim The logout token claim.
*
* @return bool|WP_Error
*/
function validate_logout_token_claim( $logout_token_claim ) {
if ( ! is_array( $logout_token_claim ) ) {
return new WP_Error( 'bad-logout-token-claim', __( 'Bad logout token claim.', 'daggerhart-openid-connect-generic' ), $logout_token_claim );
}

// Section 2.6, #4.
$has_sub = isset( $logout_token_claim['sub'] ) && ! empty( $logout_token_claim['sub'] );
$has_sid = isset( $logout_token_claim['sid'] ) && ! empty( $logout_token_claim['sid'] );
if ( ! $has_sub && ! $has_sid ) {
return new WP_Error( 'no-subject-identity-or-session', __( 'No subject identity or session id.', 'daggerhart-openid-connect-generic' ), $logout_token_claim );
}

// Section 2.6, #6.
$has_nonce = isset( $logout_token_claim['nonce'] ) && ! empty( $logout_token_claim['nonce'] );
if ( ! $has_sub && ! $has_sid ) {
return new WP_Error( 'nonce-not-allowed', __( 'Nonce claim not allowed in logout token.', 'daggerhart-openid-connect-generic' ), $logout_token_claim );
}

// NOTE: right now we're not performing further validations. #7-#10 are OPTIONAL,
// however, #3 and #5 are REQUIRED.
return true;
}

/**
* Attempt to exchange the access_token for a user_claim.
*
Expand Down Expand Up @@ -538,4 +587,15 @@ public function get_subject_identity( $id_token_claim ) {
return $id_token_claim['sub'];
}

/**
* Retrieve the session id from the token claims. This is the OpenID Providers's
* session ID, it is not directly related to a wordpress session context.
*
* @param array $token_claims The token claims.
*
* @return mixed
*/
function get_session_id( $token_claims ) {
return $token_claims['sid'];
}
}
1 change: 1 addition & 0 deletions includes/openid-connect-generic-option-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
*
* @property bool $enforce_privacy The flag to indicates whether a user us required to be authenticated to access the site.
* @property bool $alternate_redirect_uri The flag to indicate whether to use the alternative redirect URI.
* @property bool $keycloak_legacy_backchannel_logout_enable The flag to enable Keycloak < v12.0.0 Backchannel Logout.
* @property bool $token_refresh_enable The flag whether to support refresh tokens by IDPs.
* @property bool $link_existing_users The flag to indicate whether to link to existing WordPress-only accounts or greturn an error.
* @property bool $create_if_does_not_exist The flag to indicate whether to create new users or not.
Expand Down
Loading