diff --git a/inc/class-command.php b/inc/class-command.php index 6b753c7..e5d7fd6 100644 --- a/inc/class-command.php +++ b/inc/class-command.php @@ -9,6 +9,7 @@ namespace WP\OAuth2; use WP\JWT\JWT; +use function cli\prompt; use function WP_CLI\Utils\get_flag_value; class Command { @@ -30,6 +31,9 @@ class Command { * --redirect_uri= * : The URI users will be redirected to after connecting. * + * [--sign=] + * : Path to key file to sign the software statement with. + * * [--=] * : Additional claims. * @@ -47,6 +51,7 @@ public function create_software_statement( $args, $assoc_args ) { $name = get_flag_value( $assoc_args, 'client_name' ); $redirect_uri = get_flag_value( $assoc_args, 'redirect_uri' ); + $sign = get_flag_value( $assoc_args, 'sign' ); $statement = array( 'client_uri' => $client_uri, @@ -55,7 +60,7 @@ public function create_software_statement( $args, $assoc_args ) { 'client_name' => $name, ); - unset( $assoc_args['client_name'], $assoc_args['redirect_uri'] ); + unset( $assoc_args['client_name'], $assoc_args['redirect_uri'], $assoc_args['sign'] ); $statement = array_merge( $assoc_args, $statement ); $valid = DynamicClient::validate_statement( (object) $statement ); @@ -64,7 +69,22 @@ public function create_software_statement( $args, $assoc_args ) { \WP_CLI::error( $valid ); } - $signed = JWT::encode( $statement, '', 'none' ); + if ( $sign ) { + $passphrase = prompt( 'Passphrase', '', ': ', true ); + $key = openssl_pkey_get_private( 'file://' . $sign, $passphrase ); + + if ( ! is_resource( $key ) ) { + \WP_CLI::error( 'Invalid private key: ' . openssl_error_string() ); + } + + if ( ! isset( $statement['iss'] ) ) { + $statement['iss'] = $client_uri; + } + + $signed = JWT::encode( $statement, $key, 'RS256' ); + } else { + $signed = JWT::encode( $statement, '', 'none' ); + } if ( is_wp_error( $signed ) ) { \WP_CLI::error( $signed ); diff --git a/inc/class-dynamicclient.php b/inc/class-dynamicclient.php index 0be51ab..b5561c5 100644 --- a/inc/class-dynamicclient.php +++ b/inc/class-dynamicclient.php @@ -23,12 +23,13 @@ class DynamicClient implements ClientInterface { const SOFTWARE_ID_KEY = '_oauth2_software_id_'; const SOFTWARE_STATEMENT_KEY = '_oauth2_software_statement'; + const VERIFIED_KEY = '_oauth2_verified_statement'; const SCHEMA = array( 'type' => 'object', 'properties' => array( 'software_id' => array( 'type' => 'string', - 'format' => 'uuid', // Todo support in rest_validate + 'format' => 'uuid', 'required' => true, ), 'client_name' => array( @@ -58,6 +59,9 @@ class DynamicClient implements ClientInterface { /** @var \stdClass */ private $statement; + /** @var bool */ + private $verified; + /** @var Client|WP_Error */ private $persisted = false; @@ -65,9 +69,11 @@ class DynamicClient implements ClientInterface { * DynamicClient constructor. * * @param \stdClass $statement Software Statement. + * @param bool $verified Whether the statement was verified. */ - protected function __construct( $statement ) { + protected function __construct( $statement, $verified ) { $this->statement = $statement; + $this->verified = $verified; } /** @@ -78,14 +84,85 @@ protected function __construct( $statement ) { * @return DynamicClient|WP_Error */ public static function from_jwt( $jwt ) { - $statement = JWT::decode( $jwt, '', array( 'none' ), 'unsecure' ); - $valid = static::validate_statement( $statement ); + $iss = JWT::get_claim( $jwt, 'iss' ); + + if ( ! is_wp_error( $iss ) ) { + $key = self::get_signing_key( $iss ); + + if ( is_wp_error( $key ) ) { + return $key; + } + + $statement = JWT::decode( $jwt, $key, array( 'RS256' ) ); + $verified = true; + } else { + $statement = JWT::decode( $jwt, '', array( 'none' ), 'unsecure' ); + $verified = false; + } + + $valid = static::validate_statement( $statement ); if ( is_wp_error( $valid ) ) { return $valid; } - return new static( $statement ); + return new static( $statement, $verified ); + } + + /** + * Gets the signing key for a JWT based on its ISS. + * + * @param string $iss + * + * @return resource|WP_Error + */ + protected static function get_signing_key( $iss ) { + $host = parse_url( $iss, PHP_URL_HOST ); + + if ( ! $host ) { + return new WP_Error( 'invalid_host', __( 'Could not get a valid host.', 'oauth2' ) ); + } + + $body = self::fetch_signing_key( $host ); + + if ( ! $body ) { + return new WP_Error( 'empty_body', __( 'Empty body returned by at the well known URL.', 'oauth2' ) ); + } + + $key = openssl_pkey_get_public( $body ); + + if ( ! is_resource( $key ) ) { + return new WP_Error( 'invalid_key', sprintf( __( 'Invalid public key: %s.', 'oauth2' ), openssl_error_string() ?: 'unknown' ) ); + } + + return $key; + } + + /** + * Fetch the signing key from the given hostname. + * + * @param string $host + * + * @return string|WP_Error + */ + protected static function fetch_signing_key( $host ) { + $transient = 'oauth2_key_' . $host; + + if ( false === ( $body = get_site_transient( $transient ) ) || ! is_string( $body ) ) { + $url = 'https://' . $host . '/.well-known/wp-api/oauth2.pem'; + + $response = wp_safe_remote_get( $url ); + + if ( is_wp_error( $response ) ) { + $body = ''; + } else { + $body = trim( wp_remote_retrieve_body( $response ) ); + } + + set_site_transient( $transient, $body, 5 * MINUTE_IN_SECONDS ); + } + + return $body; } /** @@ -112,6 +189,14 @@ public static function validate_statement( $statement ) { } } + if ( isset( $statement->iss ) ) { + $iss_host = parse_url( $statement->iss, PHP_URL_HOST ); + + if ( ! $iss_host || $iss_host !== $client_host ) { + return new WP_Error( 'client_uri_mismatch', __( 'The statement issuing URI is not on the same domain as the client URI.', 'oauth2' ) ); + } + } + return true; } @@ -233,6 +318,15 @@ public function get_software_statement() { return $this->statement; } + /** + * Checks if the software statement was verified as being signed by the client_uri. + * + * @return bool + */ + public function is_verified() { + return $this->verified; + } + /** * Persists a dynamic client to a real client. * @@ -295,6 +389,7 @@ protected function create_persisted_dynamic_client() { update_post_meta( $client->get_post_id(), static::SOFTWARE_ID_KEY . $this->get_id(), 1 ); update_post_meta( $client->get_post_id(), static::SOFTWARE_STATEMENT_KEY, $this->statement ); + update_post_meta( $client->get_post_id(), static::VERIFIED_KEY, $this->is_verified() ); if ( current_user_can( 'publish_post', $client->get_post_id() ) ) { $approved = $client->approve(); diff --git a/theme/oauth2-authorize.php b/theme/oauth2-authorize.php index a33c7d9..ae05a68 100644 --- a/theme/oauth2-authorize.php +++ b/theme/oauth2-authorize.php @@ -67,16 +67,25 @@ float: left; } - .new-client-warning { + #login .notice { margin: 5px 0 15px; - background-color: #fff8e5; + background-color: #fff; border: 1px solid #ccd0d4; - border-left-color: #ffb900; border-left-width: 4px; padding: 1px 12px; } - #login .new-client-warning p { + #login .notice-warning { + background-color: #fff8e5; + border-left-color: #ffb900; + } + + #login .notice-success { + background-color: #ecf7ed; + border-left-color: #46b450; + } + + #login .notice p { margin: 0.5em 0; padding: 2px; } @@ -105,15 +114,27 @@ ); if ( $client instanceof \WP\OAuth2\DynamicClient ) { - printf( - '

%s

', - sprintf( + if ( $client->is_verified() ) { + printf( + '

%s

', + sprintf( /* translators: %1$s: client name. %2$s: the app URI. */ - __( '%1$s is an application by %2$s.', 'oauth2' ), - esc_html( $client->get_name() ), - sprintf( '%1$s', esc_url( $client->get_software_statement()->client_uri ) ) - ) - ); + __( '%1$s is verified to be an application by %2$s.', 'oauth2' ), + esc_html( $client->get_name() ), + sprintf( '%1$s', esc_url( $client->get_software_statement()->client_uri ) ) + ) + ); + } else { + printf( + '

%s

', + sprintf( + /* translators: %1$s: client name. %2$s: the app URI. */ + __( '%1$s is an application by %2$s.', 'oauth2' ), + esc_html( $client->get_name() ), + sprintf( '%1$s', esc_url( $client->get_software_statement()->client_uri ) ) + ) + ); + } } ?>