Skip to content
Closed
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
150 changes: 141 additions & 9 deletions includes/openid-connect-generic-client-wrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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 ) {
Expand All @@ -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;
Expand All @@ -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.
*
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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,
),
),
)
Expand Down
81 changes: 71 additions & 10 deletions includes/openid-connect-generic-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,21 +208,25 @@ function get_authentication_code( $request ) {
*
* @return array<mixed>|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 ),
);

Expand Down Expand Up @@ -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]
Expand All @@ -436,7 +452,7 @@ function get_id_token_claim( $token_response ) {
true
);

return $id_token_claim;
return $token_claim;
}

/**
Expand All @@ -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.
*
Expand Down Expand Up @@ -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'];
}
}
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
6 changes: 6 additions & 0 deletions includes/openid-connect-generic-settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<wordpress-url>/" (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' ),
Expand Down
2 changes: 2 additions & 0 deletions openid-connect-generic.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions readme.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.