diff options
Diffstat (limited to 'oidc/openid-connect-generic-client.php')
-rwxr-xr-x | oidc/openid-connect-generic-client.php | 430 |
1 files changed, 430 insertions, 0 deletions
diff --git a/oidc/openid-connect-generic-client.php b/oidc/openid-connect-generic-client.php new file mode 100755 index 0000000..100d3a1 --- /dev/null +++ b/oidc/openid-connect-generic-client.php @@ -0,0 +1,430 @@ +<?php + +class OpenID_Connect_Generic_Client { + + private $client_id; + private $client_secret; + private $scope; + private $endpoint_login; + private $endpoint_userinfo; + private $endpoint_token; + + // login flow "ajax" endpoint + private $redirect_uri; + + // states are only valid for 3 minutes + private $state_time_limit = 180; + + // logger object + private $logger; + + /** + * Client constructor + * + * @param $client_id + * @param $client_secret + * @param $scope + * @param $endpoint_login + * @param $endpoint_userinfo + * @param $endpoint_token + * @param $redirect_uri + * @param $state_time_limit time states are valid in seconds + */ + function __construct( $client_id, $client_secret, $scope, $endpoint_login, $endpoint_userinfo, $endpoint_token, $redirect_uri, $state_time_limit, $logger){ + $this->client_id = $client_id; + $this->client_secret = $client_secret; + $this->scope = $scope; + $this->endpoint_login = $endpoint_login; + $this->endpoint_userinfo = $endpoint_userinfo; + $this->endpoint_token = $endpoint_token; + $this->redirect_uri = $redirect_uri; + $this->state_time_limit = $state_time_limit; + $this->logger = $logger; + } + + /** + * Create a single use authentication url + * + * @return string + */ + function make_authentication_url() { + $separator = '?'; + if ( stripos( $this->endpoint_login, '?' ) !== FALSE ) { + $separator = '&'; + } + $url = sprintf( '%1$s%2$sresponse_type=code&scope=%3$s&client_id=%4$s&state=%5$s&redirect_uri=%6$s', + $this->endpoint_login, + $separator, + rawurlencode( $this->scope ), + rawurlencode( $this->client_id ), + $this->new_state(), + rawurlencode( $this->redirect_uri ) + ); + + $this->logger->log( apply_filters( 'openid-connect-generic-auth-url', $url ), 'make_authentication_url' ); + return apply_filters( 'openid-connect-generic-auth-url', $url ); + } + + /** + * Validate the request for login authentication + * + * @param $request + * + * @return array|\WP_Error + */ + function validate_authentication_request( $request ){ + // look for an existing error of some kind + if ( isset( $request['error'] ) ) { + return new WP_Error( 'unknown-error', 'An unknown error occurred.', $request ); + } + + // make sure we have a legitimate authentication code and valid state + if ( ! isset( $request['code'] ) ) { + return new WP_Error( 'no-code', 'No authentication code present in the request.', $request ); + } + + // check the client request state + if( ! isset( $request['state']) ) { + do_action( 'openid-connect-generic-no-state-provided' ); + return new WP_Error( 'missing-state', __( 'Missing state.' ), $request ); + } + + if ( ! $this->check_state( $request['state'] ) ) { + return new WP_Error( 'invalid-state', __( 'Invalid state.' ), $request ); + } + + return $request; + } + + /** + * Get the authorization code from the request + * + * @param $request array + * + * @return string|\WP_Error + */ + function get_authentication_code( $request ){ + return $request['code']; + } + + /** + * Using the authorization_code, request an authentication token from the idp + * + * @param $code - authorization_code + * + * @return array|\WP_Error + */ + function request_authentication_token( $code ) { + + // 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( + 'code' => $code, + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'redirect_uri' => $this->redirect_uri, + 'grant_type' => 'authorization_code', + 'scope' => $this->scope, + ), + 'headers' => array( 'Host' => $host ) + ); + + // allow modifications to the request + $request = apply_filters( 'openid-connect-generic-alter-request', $request, 'get-authentication-token' ); + + // call the server and ask for a token + $this->logger->log( $this->endpoint_token, 'request_authentication_token' ); + $response = wp_remote_post( $this->endpoint_token, $request ); + + if ( is_wp_error( $response ) ){ + $response->add( 'request_authentication_token' , __( 'Request for authentication token failed.' ) ); + } + + return $response; + } + + /** + * Using the refresh token, request new tokens from the idp + * + * @param $refresh_token - refresh token previously obtained from token response. + * + * @return array|\WP_Error + */ + function request_new_tokens( $refresh_token ) { + $request = array( + 'body' => array( + 'refresh_token' => $refresh_token, + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'grant_type' => 'refresh_token' + ) + ); + + // allow modifications to the request + $request = apply_filters( 'openid-connect-generic-alter-request', $request, 'refresh-token' ); + + // call the server and ask for new tokens + $this->logger->log( $this->endpoint_token, 'request_new_tokens' ); + $response = wp_remote_post( $this->endpoint_token, $request ); + + if ( is_wp_error( $response ) ) { + $response->add( 'refresh_token' , __( 'Refresh token failed.' ) ); + } + + return $response; + } + + /** + * Extract and decode the token body of a token response + * + * @param $token_result + * @return array|mixed|object + */ + function get_token_response( $token_result ){ + if ( ! isset( $token_result['body'] ) ){ + return new WP_Error( 'missing-token-body', __( 'Missing token body.' ), $token_result ); + } + + // extract token response from token + $token_response = json_decode( $token_result['body'], true ); + + if ( isset( $token_response[ 'error' ] ) ) { + $error = $token_response[ 'error' ]; + $error_description = $error; + if ( isset( $token_response[ 'error_description' ] ) ) { + $error_description = $token_response[ 'error_description' ]; + } + return new WP_Error( $error, $error_description, $token_result ); + } + + return $token_response; + } + + + /** + * Exchange an access_token for a user_claim from the userinfo endpoint + * + * @param $access_token + * + * @return array|\WP_Error + */ + function request_userinfo( $access_token ) { + // allow modifications to the request + $request = apply_filters( 'openid-connect-generic-alter-request', array(), 'get-userinfo' ); + + // section 5.3.1 of the spec recommends sending the access token using the authorization header + // a filter may or may not have already added headers - make sure they exist then add the token + if ( !array_key_exists( 'headers', $request ) || !is_array( $request['headers'] ) ) { + $request['headers'] = array(); + } + + $request['headers']['Authorization'] = 'Bearer '.$access_token; + + // Add Host header - required for when the openid-connect endpoint is behind a reverse-proxy + $parsed_url = parse_url($this->endpoint_userinfo); + $host = $parsed_url['host']; + + if ( !empty( $parsed_url['port'] ) ) { + $host.= ":{$parsed_url['port']}"; + } + + $request['headers']['Host'] = $host; + + // attempt the request including the access token in the query string for backwards compatibility + $this->logger->log( $this->endpoint_userinfo, 'request_userinfo' ); + $response = wp_remote_post( $this->endpoint_userinfo, $request ); + + if ( is_wp_error( $response ) ){ + $response->add( 'request_userinfo' , __( 'Request for userinfo failed.' ) ); + } + + return $response; + } + + /** + * Generate a new state, save it as a transient, + * and return the state hash. + * + * @return string + */ + function new_state() { + // new state w/ timestamp + $state = md5( mt_rand() . microtime( true ) ); + set_transient( 'openid-connect-generic-state--' . $state, $state, $this->state_time_limit ); + + return $state; + } + + /** + * Check the existence of a given state transient. + * + * @param $state + * + * @return bool + */ + function check_state( $state ) { + + $state_found = true; + + if ( ! get_option( '_transient_openid-connect-generic-state--' . $state ) ) { + do_action( 'openid-connect-generic-state-not-found', $state ); + $state_found = false; + } + + $valid = get_transient( 'openid-connect-generic-state--' . $state ); + + if ( ! $valid && $state_found ) { + do_action( 'openid-connect-generic-state-expired', $state ); + } + + return !!$valid; + } + + /** + * Ensure that the token meets basic requirements + * + * @param $token_response + * + * @return bool|\WP_Error + */ + function validate_token_response( $token_response ){ + // we need to ensure 2 specific items exist with the token response in order + // to proceed with confidence: id_token and token_type == 'Bearer' + if ( ! isset( $token_response['id_token'] ) || + ! isset( $token_response['token_type'] ) || strcasecmp( $token_response['token_type'], 'Bearer' ) + ) { + return new WP_Error( 'invalid-token-response', 'Invalid token response', $token_response ); + } + + return true; + } + + /** + * Extract the id_token_claim from the token_response + * + * @param $token_response + * + * @return array|\WP_Error + */ + function get_id_token_claim( $token_response ){ + // name sure we have an id_token + if ( ! isset( $token_response['id_token'] ) ) { + return new WP_Error( 'no-identity-token', __( 'No identity token' ), $token_response ); + } + + // break apart the id_token in the response for decoding + $tmp = explode( '.', $token_response['id_token'] ); + + if ( ! isset( $tmp[1] ) ) { + return new WP_Error( 'missing-identity-token', __( 'Missing identity token' ), $token_response ); + } + + // Extract the id_token's claims from the token + $id_token_claim = json_decode( + base64_decode( + str_replace( // because token is encoded in base64 URL (and not just base64) + array('-', '_'), + array('+', '/'), + $tmp[1] + ) + ) + , true + ); + + return $id_token_claim; + } + + /** + * Ensure the id_token_claim contains the required values + * + * @param $id_token_claim + * + * @return bool|\WP_Error + */ + function validate_id_token_claim( $id_token_claim ){ + if ( ! is_array( $id_token_claim ) ) { + return new WP_Error( 'bad-id-token-claim', __( 'Bad ID token claim' ), $id_token_claim ); + } + + // make sure we can find our identification data and that it has a value + if ( ! isset( $id_token_claim['sub'] ) || empty( $id_token_claim['sub'] ) ) { + return new WP_Error( 'no-subject-identity', __( 'No subject identity' ), $id_token_claim ); + } + + return true; + } + + /** + * Attempt to exchange the access_token for a user_claim + * + * @param $token_response + * + * @return array|mixed|object|\WP_Error + */ + function get_user_claim( $token_response ){ + // send a userinfo request to get user claim + $user_claim_result = $this->request_userinfo( $token_response['access_token'] ); + + // make sure we didn't get an error, and that the response body exists + if ( is_wp_error( $user_claim_result ) || ! isset( $user_claim_result['body'] ) ) { + return new WP_Error( 'bad-claim', __( 'Bad user claim' ), $user_claim_result ); + } + + $user_claim = json_decode( $user_claim_result['body'], true ); + + return $user_claim; + } + + /** + * Make sure the user_claim has all required values, and that the subject + * identity matches of the id_token matches that of the user_claim. + * + * @param $user_claim + * @param $id_token_claim + * + * @return \WP_Error + */ + function validate_user_claim( $user_claim, $id_token_claim ) { + // must be an array + if ( ! is_array( $user_claim ) ){ + return new WP_Error( 'invalid-user-claim', __( 'Invalid user claim' ), $user_claim ); + } + + // allow for errors from the IDP + if ( isset( $user_claim['error'] ) ) { + $message = __( 'Error from the IDP' ); + if ( !empty( $user_claim['error_description'] ) ) { + $message = $user_claim['error_description']; + } + return new WP_Error( 'invalid-user-claim-' . $user_claim['error'], $message, $user_claim ); + } + + // make sure the id_token sub === user_claim sub, according to spec + if ( $id_token_claim['sub' ] !== $user_claim['sub'] ) { + return new WP_Error( 'incorrect-user-claim', __( 'Incorrect user claim' ), func_get_args() ); + } + + // allow for other plugins to alter the login success + $login_user = apply_filters( 'openid-connect-generic-user-login-test', true, $user_claim ); + + if ( ! $login_user ) { + return new WP_Error( 'unauthorized', __( 'Unauthorized access' ), $login_user ); + } + + return true; + } + + /** + * Retrieve the subject identity from the id_token + * + * @param $id_token_claim array + * + * @return mixed + */ + function get_subject_identity( $id_token_claim ){ + return $id_token_claim['sub']; + } +} |