Skip to content

Instantly share code, notes, and snippets.

@TimothyBJacobs
Created January 19, 2021 23:55
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save TimothyBJacobs/94de611c0d36cec70249feac7cf3eda9 to your computer and use it in GitHub Desktop.
Save TimothyBJacobs/94de611c0d36cec70249feac7cf3eda9 to your computer and use it in GitHub Desktop.
App Passwords Client Demo Plugin
<?php
declare( strict_types=1 );
/*
* Plugin Name: Demo App Passwords Client
*/
namespace TimothyBJacobs\AppPasswordsClientDemo;
const META_KEY = '_app_passwords_client_demo_creds';
const PAGE = 'app-passwords-demo';
const CONNECT_ACTION = 'apd-connect';
const DISCONNECT_ACTION = 'apd-disconnect';
const CALLBACK_ACTION = 'apd-callback';
const APP_ID = '9bacbe61-2a44-5c6a-8ab0-496817caf619';
add_action( 'admin_menu', function () {
$hook = add_management_page( __( 'App Passwords Demo' ), __( 'App Passwords Demo' ), 'exist', PAGE, __NAMESPACE__ . '\\render_page' );
add_action( "load-${hook}", __NAMESPACE__ . '\\load' );
} );
function render_page(): void {
$url = admin_url( 'tools.php?page=' . PAGE );
$user_id = get_current_user_id();
$me = null;
if ( has_credentials( $user_id ) ) {
$me = api_request( $user_id, '/wp/v2/users/me' );
}
?>
<div class="wrap">
<h1><?php _e( 'App Passwords Demo' ) ?></h1>
<?php if ( is_wp_error( $me ) ) : ?>
<div class="notice notice-error"><p><?php echo esc_html( $me->get_error_message() ); ?></p></div>
<?php elseif ( $me ): ?>
<pre><?php echo wp_json_encode( $me, JSON_PRETTY_PRINT ); ?></pre>
<form method="post" action="<?php echo esc_url( $url ); ?>">
<?php wp_nonce_field( DISCONNECT_ACTION ); ?>
<?php submit_button( __( 'Disconnect' ), 'primary', DISCONNECT_ACTION ); ?>
</form>
<?php else: ?>
<form method="post" action="<?php echo esc_url( $url ); ?>">
<div class="form-wrap">
<div class="form-field">
<label for="apd-website"><?php _e( 'Website' ) ?></label>
<input type="url" name="apd_website" id="apd-website"/>
</div>
<?php wp_nonce_field( CONNECT_ACTION ); ?>
<?php submit_button( __( 'Connect' ), 'primary', CONNECT_ACTION ); ?>
</div>
</form>
<?php endif; ?>
</div>
<?php
}
/**
* Runs when the App Passwords Demo page loads.
*
* Handles form actions.
*/
function load(): void {
if ( isset( $_POST[ CONNECT_ACTION ] ) ) {
check_admin_referer( CONNECT_ACTION );
$redirect = build_authorization_redirect( $_POST['apd_website'] ?? '' );
if ( is_wp_error( $redirect ) ) {
wp_die( $redirect );
}
// This is intentionally not using wp_safe_redirect() as we are sending the user to another domain.
wp_redirect( $redirect );
die;
}
if ( isset( $_POST[ DISCONNECT_ACTION ] ) ) {
check_admin_referer( DISCONNECT_ACTION );
delete_user_meta( get_current_user_id(), META_KEY );
}
if ( ! empty( $_GET[ CALLBACK_ACTION ] ) ) {
if ( ! wp_verify_nonce( $_GET['state'] ?? '', CALLBACK_ACTION ) ) {
wp_nonce_ays( CALLBACK_ACTION );
die;
}
if ( ( $_GET['success'] ?? '' ) === 'false' ) {
wp_die( __( 'Authorization rejected.' ) );
}
$site_url = $_GET['site_url'] ?? '';
$user_login = $_GET['user_login'] ?? '';
$password = $_GET['password'] ?? '';
if ( ! $site_url || ! $user_login || ! $password ) {
wp_die( __( 'Malformed authorization callback.' ) );
}
$root = discover( $site_url );
if ( is_wp_error( $root ) ) {
wp_die( $root );
}
try {
store_credentials( get_current_user_id(), $root, $user_login, $password );
wp_safe_redirect( admin_url( 'tools.php?page=' . PAGE ) );
die;
} catch ( \Exception $e ) {
wp_die( $e->getMessage() );
}
}
}
/**
* Makes an authenticated API request.
*
* @param int $user_id The user ID to make the request as.
* @param string $route The route to access.
*
* @return array|\WP_Error
*/
function api_request( int $user_id, string $route ) {
$creds = get_credentials( $user_id );
if ( ! $creds ) {
return new \WP_Error( 'no_credentials', __( 'No credentials stored for this user.' ) );
}
[ $api_root, $username, $password ] = $creds;
$query = wp_parse_url( $api_root, PHP_URL_QUERY );
$url = untrailingslashit( $api_root ) . $route;
if ( $query ) {
parse_str( $query, $qv );
if ( isset( $qv['rest_route'] ) ) {
$url = add_query_arg( 'rest_route', $route, $api_root );
}
}
$response = wp_safe_remote_get( $url, [
'headers' => [
'Authorization' => 'Basic ' . base64_encode( "{$username}:{$password}" )
]
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$status = wp_remote_retrieve_response_code( $response );
if ( $status !== 200 ) {
return new \WP_Error( 'non_200_status', sprintf( __( 'The website returned a %d status code.' ), $status ) );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( JSON_ERROR_NONE !== json_last_error() ) {
return new \WP_Error( 'invalid_json', json_last_error_msg() );
}
return $body;
}
/**
* Gets the secret key used for encrypting App Password credentials.
*
* Looks for a `APP_PASSWORDS_CLIENT_SECRET` constant. You can generate your secret key like this:
*
* wp eval 'echo bin2hex(\Sodium\randombytes_buf(\Sodium\CRYPTO_SECRETBOX_KEYBYTES)) . PHP_EOL;'
*
* @return string
*/
function get_secret_key(): string {
if ( ! defined( 'APP_PASSWORDS_CLIENT_SECRET' ) || ! APP_PASSWORDS_CLIENT_SECRET ) {
wp_die( 'Must define APP_PASSWORDS_CLIENT_SECRET in your `wp-config.php` file.' );
}
return hex2bin( APP_PASSWORDS_CLIENT_SECRET );
}
/**
* Stores the REST API credentials for the given user.
*
* @param int $user_id The user ID to store the credentials for.
* @param string $api_root The REST API root for the website.
* @param string $username The credential username.
* @param string $password The credential password.
*
* @throws \SodiumException
*/
function store_credentials( int $user_id, string $api_root, string $username, string $password ) {
$key = get_secret_key();
$nonce = \Sodium\randombytes_buf( \Sodium\CRYPTO_SECRETBOX_NONCEBYTES );
$ciphertext = \Sodium\crypto_secretbox( $password, $nonce, $key );
$saved = update_user_meta( $user_id, META_KEY, [
'ciphertext' => bin2hex( $ciphertext ),
'nonce' => bin2hex( $nonce ),
'username' => $username,
'api_root' => $api_root,
] );
if ( ! $saved ) {
throw new \Exception( 'Failed to save credentials.' );
}
}
/**
* Checks if the user has credentials stored.
*
* @param int $user_id
*
* @return bool
*/
function has_credentials( int $user_id ): bool {
return metadata_exists( 'user', $user_id, META_KEY );
}
/**
* Gets the REST API credentials for the given user.
*
* @param int $user_id The user ID to retrieve the credentials for.
*
* @return array|null An array with the API Root, username, and password, or null if no valid credentials found.
*/
function get_credentials( int $user_id ): ?array {
$meta = get_user_meta( $user_id, META_KEY, true );
if ( ! $meta ) {
return null;
}
$key = get_secret_key();
$nonce = hex2bin( $meta['nonce'] );
$ciphertext = hex2bin( $meta['ciphertext'] );
$plaintext = \Sodium\crypto_secretbox_open( $ciphertext, $nonce, $key );
if ( $plaintext === false ) {
delete_user_meta( $user_id, META_KEY );
return null;
}
return [ $meta['api_root'], $meta['username'], $plaintext ];
}
/**
* Builds the authorization redirect link.
*
* @param string $url
*
* @return string|\WP_Error
*/
function build_authorization_redirect( string $url ) {
$auth_url = get_authorize_url( $url );
if ( is_wp_error( $auth_url ) ) {
return $auth_url;
}
$success_url = wp_nonce_url( add_query_arg( [ 'page' => PAGE, CALLBACK_ACTION => '1' ], admin_url( 'tools.php' ) ), CALLBACK_ACTION, 'state' );
return add_query_arg( [
'app_name' => urlencode( __( 'App Passwords Demo' ) ),
'app_id' => urlencode( APP_ID ),
'success_url' => urlencode( $success_url ),
], $auth_url );
}
/**
* Looks up the Authorize Application URL for the given website.
*
* @param string $url The website to lookup.
*
* @return string|\WP_Error The authorization URL or a WP_Error if none found.
*/
function get_authorize_url( string $url ) {
$root = discover( $url );
if ( is_wp_error( $root ) ) {
return $root;
}
$response = wp_safe_remote_get( $root );
if ( is_wp_error( $response ) ) {
return $response;
}
$status = wp_remote_retrieve_response_code( $response );
if ( $status !== 200 ) {
return new \WP_Error( 'non_200_status', sprintf( __( 'The website returned a %d status code.' ), $status ) );
}
$index = json_decode( wp_remote_retrieve_body( $response ), true );
if ( JSON_ERROR_NONE !== json_last_error() ) {
return new \WP_Error( 'invalid_json', json_last_error_msg() );
}
$auth_url = $index['authentication']['application-passwords']['endpoints']['authorization'] ?? '';
if ( ! $auth_url ) {
return new \WP_Error( 'no_application_passwords_support', __( 'Application passwords is not available for this website.' ) );
}
return $auth_url;
}
/**
* Discovers the REST API root from the given site URL.
*
* @param string $url
*
* @return string|\WP_Error
*/
function discover( string $url ) {
$response = wp_safe_remote_head( $url );
if ( is_wp_error( $response ) ) {
return $response;
}
$link = wp_remote_retrieve_header( $response, 'Link' );
if ( ! $link ) {
return new \WP_Error( 'no_link_header', __( 'REST API cannot be discovered. No link header found.' ) );
}
$parsed = parse_header_with_attributes( $link );
foreach ( $parsed as $url => $attr ) {
if ( ( $attr['rel'] ?? '' ) === 'https://api.w.org/' ) {
return $url;
}
}
return new \WP_Error( 'no_link', __( 'REST API cannot be discovered. No REST API link found.' ) );
}
/**
* Parse a header that has attributes.
*
* @param string $header
*
* @return array
*/
function parse_header_with_attributes( string $header ): array {
$parsed = array();
$list = explode( ',', $header );
foreach ( $list as $value ) {
$attrs = array();
$parts = explode( ';', trim( $value ) );
$main = trim( $parts[0], ' <>' );
foreach ( $parts as $part ) {
if ( false === strpos( $part, '=' ) ) {
continue;
}
[ $key, $value ] = explode( '=', $part, 2 );
$key = trim( $key );
$value = trim( $value, '" ' );
$attrs[ $key ] = $value;
}
$parsed[ $main ] = $attrs;
}
return $parsed;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment