diff --git a/includes/openid-connect-generic-client-wrapper.php b/includes/openid-connect-generic-client-wrapper.php index a407003b..3c145ad1 100644 --- a/includes/openid-connect-generic-client-wrapper.php +++ b/includes/openid-connect-generic-client-wrapper.php @@ -104,12 +104,20 @@ static public 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' ) ); } @@ -134,6 +142,11 @@ 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; } @@ -352,7 +365,14 @@ 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 ); + } if ( is_wp_error( $token_result ) ) { $this->error_redirect( $token_result ); @@ -420,6 +440,13 @@ 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 ) { @@ -444,12 +471,12 @@ 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' ); // Redirect back to the origin page if enabled. $redirect_url = isset( $_COOKIE[ $this->cookie_redirect_key ] ) ? esc_url_raw( $_COOKIE[ $this->cookie_redirect_key ] ) : false; @@ -465,6 +492,94 @@ function authentication_request_callback() { exit; } + 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 ) ) { + return new WP_Error( 'no-logout-token', __( 'No logout token.', 'daggerhart-openid-connect-generic' ), $token_response ); + } + + // 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. * @@ -492,11 +607,12 @@ function validate_user( $user ) { * * @return void */ - function login_user( $user, $token_response, $id_token_claim, $user_claim, $subject_identity ) { + 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 ); @@ -550,12 +666,28 @@ function save_refresh_token( $manager, $token, $token_response ) { */ 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 ); + } + + 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, ), ), ) diff --git a/includes/openid-connect-generic-client.php b/includes/openid-connect-generic-client.php index 11eceac0..137e1a4b 100644 --- a/includes/openid-connect-generic-client.php +++ b/includes/openid-connect-generic-client.php @@ -208,21 +208,25 @@ function get_authentication_code( $request ) { * * @return array|WP_Error */ - function request_authentication_token( $code ) { + 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 ), ); @@ -417,17 +421,29 @@ 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] @@ -436,7 +452,7 @@ function get_id_token_claim( $token_response ) { true ); - return $id_token_claim; + return $token_claim; } /** @@ -459,6 +475,40 @@ function validate_id_token_claim( $id_token_claim ) { return true; } + /** + * Ensure the id_token_claim contains the required values. + * See https://openid.net/specs/openid-connect-backchannel-1_0.html#rfc.section.2.6 + * for details - this method validates + * @param array $id_token_claim The ID 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. #5 would break compatiblity with keycloak + // legacy BCL, and #3 would require more infrastructure in this plugin for JWT + // validation (the same validation should be applied to identity tokens, but + // currently this plugin doesn't implement that as well). + return true; + } + /** * Attempt to exchange the access_token for a user_claim. * @@ -530,4 +580,15 @@ 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']; + } } diff --git a/includes/openid-connect-generic-option-settings.php b/includes/openid-connect-generic-option-settings.php index 7b6ec2a0..6fc1fa19 100644 --- a/includes/openid-connect-generic-option-settings.php +++ b/includes/openid-connect-generic-option-settings.php @@ -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. diff --git a/includes/openid-connect-generic-settings-page.php b/includes/openid-connect-generic-settings-page.php index 197052e2..2c18dc68 100644 --- a/includes/openid-connect-generic-settings-page.php +++ b/includes/openid-connect-generic-settings-page.php @@ -304,6 +304,12 @@ private function get_settings_fields() { 'type' => 'checkbox', 'section' => 'authorization_settings', ), + 'keycloak_legacy_backchannel_logout_enable' => array( + 'title' => __( 'Support Keycloak Legacy Backchannel Logout (< v12.0.0)', 'daggerhart-openid-connect-generic' ), + 'description' => __( 'Keycloak before version 12.0.0 only supports proprietary backchannel logout. This enables support for it by adding a specific route for it ("/keycloak/k_logout"). To enable it in a legacy keycloak, set the "Admin URL" on the client settings page in the Keycloak Admin Console to "/" (the root path). OIDC compliant backchannel logout is supported regardless whether this feature is enabled or not. You must flush rewrite rules after changing this setting. This can be done by saving the Permalinks settings page.', 'daggerhart-openid-connect-generic' ), + 'type' => 'checkbox', + 'section' => 'client_settings', + ), 'nickname_key' => array( 'title' => __( 'Nickname Key', 'daggerhart-openid-connect-generic' ), 'description' => __( 'Where in the user claim array to find the user\'s nickname. Possible standard values: preferred_username, name, or sub.', 'daggerhart-openid-connect-generic' ), diff --git a/openid-connect-generic.php b/openid-connect-generic.php index 7045e133..f4a51f2f 100644 --- a/openid-connect-generic.php +++ b/openid-connect-generic.php @@ -55,6 +55,7 @@ User Meta - openid-connect-generic-subject-identity - the identity of the user provided by the idp + - openid-connect-generic-last-session-id - the user's most recent IDP session ID if provided by the idp - openid-connect-generic-last-id-token-claim - the user's most recent id_token claim, decoded - openid-connect-generic-last-user-claim - the user's most recent user_claim - openid-connect-generic-last-token-response - the user's most recent token response @@ -346,6 +347,7 @@ static public function bootstrap() { // Plugin settings. 'enforce_privacy' => 0, 'alternate_redirect_uri' => 0, + 'keycloak_legacy_backchannel_logout_enable' => 0, 'token_refresh_enable' => 1, 'link_existing_users' => 0, 'create_if_does_not_exist' => 1, diff --git a/readme.md b/readme.md index 565e00f6..b8f6731c 100644 --- a/readme.md +++ b/readme.md @@ -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.