Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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 DELETE_ACTION = 'apd-remote-delete';
const CALLBACK_ACTION = 'apd-callback';
const APP_ID = 'ab9d13e5-a5bf-5d02-babe-d45f5a706e2c'; // https://www.uuidtools.com/v5
\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();
$request = null;
$routes = [
'/wp/v2/plugins',
'/wp/v2/users/me',
];
if (has_credentials($user_id)) {
$route = !empty($_GET['route']) && \in_array(\wp_unslash(\urldecode($_GET['route'])), $routes, true) ? $_GET['route'] : \urlencode('/wp/v2/users/me');
$request = api_request($user_id, \wp_unslash(\urldecode($route)));
}
?>
<div class="wrap">
<h1><?php \_e('App Passwords Demo') ?></h1>
<?php if (\is_wp_error($request)) : ?>
<div class="notice notice-error"><p><?php echo \esc_html($request->get_error_message()); ?></p></div>
<?php elseif ($request): ?>
<form method="get" action="">
<input type="hidden" name="page" value="<?php echo PAGE; ?>">
<label for="route">
<select name="route" onchange="this.form.submit()">
<option value="">Select a Route</option>
<?php foreach ($routes as $route) : ?>
<?php $selected = !empty($_GET['route']) && $route === \wp_unslash(\urldecode($_GET['route'])); ?>
<option<?php echo !$selected ? '' : ' selected'; ?>
value="<?php echo \urlencode($route); ?>"><?php echo $route; ?>
</option>
<?php endforeach; ?>
</select>
</label>
</form>
<pre><?php echo \wp_json_encode($request, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES); ?></pre>
<form method="post" action="<?php echo esc_url($url); ?>">
<?php \wp_nonce_field(DISCONNECT_ACTION); ?>
<p>
<?php \submit_button(__('Disconnect'), 'secondary', DISCONNECT_ACTION, false); ?>
<label for="delete">
&nbsp;<input type="checkbox" name="<?php echo DELETE_ACTION; ?>">
<?php \printf(__('Delete App Password from <code>%s</code>'), \esc_url(get_credentials($user_id)[0] ?? '')); ?>
</label>
</p>
</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);
if (isset($_POST[DELETE_ACTION])) {
// GET /wp/v2/users/me
$user_id = api_request(\get_current_user_id(), '/wp/v2/users/me')['id'] ?? 0;
// GET /wp/v2/users/<user_id>/application-passwords
$passwords = api_request($user_id, \sprintf('/wp/v2/users/%d/application-passwords', $user_id));
if (\is_wp_error($passwords)) {
\wp_die($passwords->get_error_message());
}
$uuids = [];
foreach ($passwords as $password) {
if (!isset($password['app_id']) || $password['app_id'] !== APP_ID) {
continue;
}
$uuids[] = $password['uuid'] ?? '';
}
\array_walk($uuids, static function (string $uuid, int $key) use ($user_id, &$uuids): void {
// DELETE /wp/v2/users/<user_id>/application-passwords/<uuid>
api_request(
$user_id,
\sprintf('/wp/v2/users/%d/application-passwords/%s', $user_id, $uuid),
['method' => \Requests::DELETE]
);
unset($uuids[$key]); // Cleanup
});
}
\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.
* @param array $args
*
* @return array|\WP_Error
*/
function api_request(int $user_id, string $route, array $args = [])
{
$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_request(
$url,
\wp_parse_args($args,
[
'method' => \Requests::GET,
'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
* @throws \Exception
*/
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 = [];
$list = \explode(',', $header);
foreach ($list as $value) {
$attrs = [];
$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