diff --git a/.gitignore b/.gitignore index 0daae7f..2230b45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ - +.claude node_modules vendor npm-debug.log diff --git a/includes/class-indieauth-fedcm-endpoint.php b/includes/class-indieauth-fedcm-endpoint.php new file mode 100644 index 0000000..b213ae6 --- /dev/null +++ b/includes/class-indieauth-fedcm-endpoint.php @@ -0,0 +1,617 @@ +codes = new Token_User( '_indieauth_code_' ); + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + add_filter( 'indieauth_metadata', array( $this, 'metadata' ) ); + add_filter( 'rest_index_indieauth_endpoints', array( $this, 'rest_index' ) ); + + // Login Status API hooks. + add_action( 'set_logged_in_cookie', array( $this, 'set_login_status_logged_in' ) ); + add_action( 'clear_auth_cookie', array( $this, 'set_login_status_logged_out' ) ); + + // FedCM IdP registration script. + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); + } + + /** + * Enqueue FedCM registration script. + * + * Automatically registers this site as a FedCM Identity Provider + * when the user visits specific admin pages. + * + * @param string $hook_suffix The current admin page. + */ + public function enqueue_scripts( $hook_suffix ) { + // Only register on dashboard or IndieAuth settings pages. + $allowed_pages = array( + 'index.php', + 'settings_page_indieauth', + 'indieweb_page_indieauth', + ); + + if ( ! in_array( $hook_suffix, $allowed_pages, true ) ) { + return; + } + + $script_path = plugin_dir_path( __DIR__ ) . 'js/fedcm-register.js'; + $version = file_exists( $script_path ) ? (string) filemtime( $script_path ) : '1.0.0'; + + wp_enqueue_script( + 'indieauth-fedcm-register', + plugins_url( 'js/fedcm-register.js', __DIR__ ), + array(), + $version, + true + ); + + wp_localize_script( + 'indieauth-fedcm-register', + 'indieAuthFedCM', + array( + 'configUrl' => self::get_config_endpoint(), + ) + ); + } + + /** + * Set Login Status API header to logged-in. + * + * Sends the FedCM Login Status API header when user logs in. + * + * @see https://fedidcg.github.io/FedCM/#login-status + */ + public function set_login_status_logged_in() { + if ( ! headers_sent() ) { + header( 'Set-Login: logged-in' ); + } + } + + /** + * Set Login Status API header to logged-out. + * + * Sends the FedCM Login Status API header when user logs out. + * + * @see https://fedidcg.github.io/FedCM/#login-status + */ + public function set_login_status_logged_out() { + if ( ! headers_sent() ) { + header( 'Set-Login: logged-out' ); + } + } + + /** + * Get the FedCM config endpoint URL. + * + * @return string The config endpoint URL. + */ + public static function get_config_endpoint() { + return rest_url( '/indieauth/1.0/fedcm/config.json' ); + } + + /** + * Get the accounts endpoint URL. + * + * @return string The accounts endpoint URL. + */ + public static function get_accounts_endpoint() { + return rest_url( '/indieauth/1.0/fedcm/accounts' ); + } + + /** + * Get the client metadata endpoint URL. + * + * @return string The client metadata endpoint URL. + */ + public static function get_client_metadata_endpoint() { + return rest_url( '/indieauth/1.0/fedcm/client_metadata' ); + } + + /** + * Get the assertion endpoint URL. + * + * @return string The assertion endpoint URL. + */ + public static function get_assertion_endpoint() { + return rest_url( '/indieauth/1.0/fedcm/assertion' ); + } + + /** + * Get the login URL. + * + * @return string The login URL. + */ + public static function get_login_url() { + return wp_login_url(); + } + + /** + * Add FedCM endpoints to REST index. + * + * @param array $index The REST index endpoints. + * @return array Modified index. + */ + public function rest_index( $index ) { + $index['fedcm_config'] = self::get_config_endpoint(); + return $index; + } + + /** + * Add FedCM metadata to IndieAuth metadata endpoint. + * + * @param array $metadata The metadata array. + * @return array Modified metadata. + */ + public function metadata( $metadata ) { + $metadata['fedcm_config_url'] = self::get_config_endpoint(); + return $metadata; + } + + /** + * Register REST API routes. + */ + public function register_routes() { + // FedCM Config endpoint. + register_rest_route( + 'indieauth/1.0', + '/fedcm/config.json', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'config' ), + 'permission_callback' => '__return_true', + ), + ) + ); + + // Accounts endpoint. + register_rest_route( + 'indieauth/1.0', + '/fedcm/accounts', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'accounts' ), + 'permission_callback' => '__return_true', + ), + ) + ); + + // Client metadata endpoint. + register_rest_route( + 'indieauth/1.0', + '/fedcm/client_metadata', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'client_metadata' ), + 'args' => array( + 'client_id' => array( + 'validate_callback' => 'indieauth_validate_client_identifier', + 'sanitize_callback' => 'esc_url_raw', + ), + ), + 'permission_callback' => '__return_true', + ), + ) + ); + + // ID assertion endpoint. + register_rest_route( + 'indieauth/1.0', + '/fedcm/assertion', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'assertion' ), + 'args' => array( + 'client_id' => array( + 'required' => true, + 'validate_callback' => 'indieauth_validate_client_identifier', + 'sanitize_callback' => 'esc_url_raw', + ), + 'account_id' => array( + 'required' => true, + 'validate_callback' => 'indieauth_validate_user_identifier', + 'sanitize_callback' => 'esc_url_raw', + ), + 'nonce' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + 'params' => array( + 'required' => true, // PKCE params are required for IndieAuth. + 'validate_callback' => array( $this, 'validate_params' ), + ), + ), + 'permission_callback' => '__return_true', + ), + ) + ); + } + + /** + * Validate the params argument contains required PKCE fields. + * + * @param mixed $value The params value. + * @return bool|WP_Error True if valid, WP_Error otherwise. + */ + public function validate_params( $value ) { + if ( is_string( $value ) ) { + $value = json_decode( $value, true ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + return new WP_Error( 'invalid_params', __( 'Invalid JSON in params', 'indieauth' ) ); + } + } + + if ( ! is_array( $value ) ) { + return new WP_Error( 'invalid_params', __( 'Invalid params format', 'indieauth' ) ); + } + + if ( empty( $value['code_challenge'] ) || empty( $value['code_challenge_method'] ) ) { + return new WP_Error( 'invalid_params', __( 'PKCE parameters required', 'indieauth' ) ); + } + + if ( 'S256' !== $value['code_challenge_method'] ) { + return new WP_Error( 'invalid_params', __( 'Unsupported code_challenge_method', 'indieauth' ) ); + } + + return true; + } + + /** + * Check if the request has the required FedCM header. + * + * @param WP_REST_Request $request The request object. + * @return bool True if valid FedCM request. + */ + private function is_valid_fedcm_request( $request ) { + $sec_fetch_dest = $request->get_header( 'Sec-Fetch-Dest' ); + return 'webidentity' === $sec_fetch_dest; + } + + /** + * Validate Origin header matches client_id scheme, hostname, and port. + * + * Per FedCM spec, the full origin (scheme + host + port) must match. + * + * @param WP_REST_Request $request The request object. + * @param string $client_id The client ID. + * @return bool True if valid. + */ + private function validate_origin( $request, $client_id ) { + $origin = $request->get_header( 'Origin' ); + if ( ! $origin ) { + return false; + } + + $origin_parts = wp_parse_url( $origin ); + $client_id_parts = wp_parse_url( $client_id ); + + if ( ! is_array( $origin_parts ) || ! is_array( $client_id_parts ) ) { + return false; + } + + $origin_scheme = isset( $origin_parts['scheme'] ) ? $origin_parts['scheme'] : null; + $origin_host = isset( $origin_parts['host'] ) ? $origin_parts['host'] : null; + $origin_port = isset( $origin_parts['port'] ) ? (int) $origin_parts['port'] : null; + $client_id_scheme = isset( $client_id_parts['scheme'] ) ? $client_id_parts['scheme'] : null; + $client_id_host = isset( $client_id_parts['host'] ) ? $client_id_parts['host'] : null; + $client_id_port = isset( $client_id_parts['port'] ) ? (int) $client_id_parts['port'] : null; + + if ( null === $origin_scheme || null === $origin_host || null === $client_id_scheme || null === $client_id_host ) { + return false; + } + + // Scheme and host must match. + if ( $origin_scheme !== $client_id_scheme || $origin_host !== $client_id_host ) { + return false; + } + + // Port must match (null means default port for scheme). + return $origin_port === $client_id_port; + } + + /** + * Add CORS headers for FedCM responses. + * + * @param WP_REST_Response $response The response object. + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response Modified response. + */ + private function add_cors_headers( $response, $request ) { + $origin = $request->get_header( 'Origin' ); + + if ( $origin ) { + $response->header( 'Access-Control-Allow-Origin', $origin ); + $response->header( 'Access-Control-Allow-Credentials', 'true' ); + } + + return $response; + } + + /** + * FedCM Config endpoint handler. + * + * Returns the IdP configuration for FedCM. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response The config response. + */ + public function config( $request ) { + $config = array( + 'accounts_endpoint' => self::get_accounts_endpoint(), + 'client_metadata_endpoint' => self::get_client_metadata_endpoint(), + 'id_assertion_endpoint' => self::get_assertion_endpoint(), + 'login_url' => self::get_login_url(), + ); + + /** + * Filter the FedCM config response. + * + * @param array $config The config array. + * @param WP_REST_Request $request The request object. + */ + $config = apply_filters( 'indieauth_fedcm_config', $config, $request ); + + $response = new WP_REST_Response( $config, 200 ); + + // Config endpoint must be accessible cross-origin per FedCM spec. + return $this->add_cors_headers( $response, $request ); + } + + /** + * Accounts endpoint handler. + * + * Returns the list of accounts for the current logged-in user. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response|WP_Error The accounts response or error. + */ + public function accounts( $request ) { + // Validate FedCM request header. + if ( ! $this->is_valid_fedcm_request( $request ) ) { + return new WP_REST_Response( + array( 'error' => 'Missing or invalid Sec-Fetch-Dest header' ), + 400 + ); + } + + // Check if user is logged in. + if ( ! is_user_logged_in() ) { + return new WP_REST_Response( + array( 'error' => 'Not logged in' ), + 401 + ); + } + + $user = wp_get_current_user(); + $me = get_url_from_user( $user->ID ); + + // Build the account object. + $account = array( + 'id' => $me, + 'name' => $user->display_name, + 'email' => $user->user_email, + 'given_name' => $user->first_name ? $user->first_name : $user->display_name, + ); + + // Add picture if available. + $avatar = get_avatar_url( + $user->ID, + array( + 'size' => 256, + 'default' => '404', + ) + ); + if ( $avatar ) { + $account['picture'] = $avatar; + } + + /** + * Filter the FedCM account data. + * + * @param array $account The account data. + * @param WP_User $user The WordPress user. + */ + $account = apply_filters( 'indieauth_fedcm_account', $account, $user ); + + $response = new WP_REST_Response( + array( 'accounts' => array( $account ) ), + 200, + array() + ); + + return $this->add_cors_headers( $response, $request ); + } + + /** + * Client metadata endpoint handler. + * + * Returns metadata about the requesting client. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response The client metadata response. + */ + public function client_metadata( $request ) { + $client_id = $request->get_param( 'client_id' ); + + $metadata = array(); + + // Try to discover client metadata. + if ( $client_id ) { + $client = IndieAuth_Client_Taxonomy::get_client( $client_id ); + if ( $client && ! is_wp_error( $client ) ) { + if ( ! empty( $client['privacy_policy'] ) ) { + $metadata['privacy_policy_url'] = $client['privacy_policy']; + } + if ( ! empty( $client['terms_of_service'] ) ) { + $metadata['terms_of_service_url'] = $client['terms_of_service']; + } + } + } + + /** + * Filter the FedCM client metadata. + * + * @param array $metadata The metadata array. + * @param string $client_id The client ID. + */ + $metadata = apply_filters( 'indieauth_fedcm_client_metadata', $metadata, $client_id ); + + $response = new WP_REST_Response( + $metadata, + 200, + array() + ); + + return $this->add_cors_headers( $response, $request ); + } + + /** + * ID assertion endpoint handler. + * + * Issues an authorization code for FedCM authentication. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response|WP_Error The assertion response or error. + */ + public function assertion( $request ) { + // Validate FedCM request header. + if ( ! $this->is_valid_fedcm_request( $request ) ) { + return new WP_REST_Response( + array( 'error' => 'Missing or invalid Sec-Fetch-Dest header' ), + 400 + ); + } + + $client_id = $request->get_param( 'client_id' ); + $account_id = $request->get_param( 'account_id' ); + $nonce = $request->get_param( 'nonce' ); + $params = $request->get_param( 'params' ); + + // Validate origin. + if ( ! $this->validate_origin( $request, $client_id ) ) { + return new WP_REST_Response( + array( 'error' => 'Origin mismatch' ), + 403 + ); + } + + // Check if user is logged in. + if ( ! is_user_logged_in() ) { + return new WP_REST_Response( + array( 'error' => 'Not logged in' ), + 401 + ); + } + + $user = wp_get_current_user(); + $me = get_url_from_user( $user->ID ); + + // Verify account_id matches the current user. + if ( normalize_url( $account_id ) !== normalize_url( $me ) ) { + return new WP_REST_Response( + array( 'error' => 'Account mismatch' ), + 403 + ); + } + + // Parse PKCE params (already validated by validate_params callback). + if ( is_string( $params ) ) { + $params = json_decode( $params, true ); + } + + $code_challenge = sanitize_text_field( $params['code_challenge'] ); + $code_challenge_method = sanitize_text_field( $params['code_challenge_method'] ); + + // Generate authorization code. + $uuid = wp_generate_uuid4(); + + // Determine scope - default to 'profile' for FedCM. + $scope = 'profile'; + if ( is_array( $params ) && isset( $params['scope'] ) ) { + $scope = sanitize_text_field( $params['scope'] ); + } + + /** + * Filter the scope used for FedCM-issued authorization codes. + * + * @param string $scope The scope to be stored with the authorization code. + * @param WP_REST_Request $request The REST request object. + * @param WP_User $user The authenticated WordPress user. + */ + $scope = apply_filters( 'indieauth_fedcm_scope', $scope, $request, $user ); + + $token = array( + 'response_type' => 'code', + 'client_id' => $client_id, + 'redirect_uri' => 'urn:ietf:wg:oauth:2.0:oob', // FedCM doesn't redirect; use OOB constant. + 'scope' => $scope, + 'me' => $me, + 'code_challenge' => $code_challenge, + 'code_challenge_method' => $code_challenge_method, + 'user' => $user->ID, + 'uuid' => $uuid, + 'fedcm' => true, // Mark as FedCM-issued code. + ); + + if ( $nonce ) { + $token['nonce'] = sanitize_text_field( $nonce ); + } + + $token = array_filter( $token ); + + $this->codes->set_user( $user->ID ); + $code = $this->codes->set( $token, 600 ); + + // Build the token response. + $token_response = array( + 'code' => $code, + 'metadata_endpoint' => IndieAuth_Metadata_Endpoint::get_endpoint(), + ); + + /** + * Filter the FedCM assertion token response. + * + * @param array $token_response The token response. + * @param WP_REST_Request $request The request object. + * @param WP_User $user The WordPress user. + */ + $token_response = apply_filters( 'indieauth_fedcm_assertion_token', $token_response, $request, $user ); + + // Add client to taxonomy for tracking. + IndieAuth_Client_Taxonomy::add_client( $client_id ); + + $response = new WP_REST_Response( + array( 'token' => wp_json_encode( $token_response ) ), + 200, + array() + ); + + return $this->add_cors_headers( $response, $request ); + } +} diff --git a/includes/class-indieauth-webidentity.php b/includes/class-indieauth-webidentity.php new file mode 100644 index 0000000..d9c9d5b --- /dev/null +++ b/includes/class-indieauth-webidentity.php @@ -0,0 +1,85 @@ + $provider_urls, + ); + + header( 'Content-Type: application/json' ); + header( 'Access-Control-Allow-Origin: *' ); + echo wp_json_encode( $response ); + exit; + } + + /** + * Flush rewrite rules on activation. + */ + public static function flush_rewrite_rules() { + $instance = new self(); + $instance->add_rewrite_rules(); + flush_rewrite_rules(); + } +} diff --git a/indieauth.php b/indieauth.php index c00250e..c9ce45a 100644 --- a/indieauth.php +++ b/indieauth.php @@ -101,6 +101,16 @@ public static function cancel_schedule() { */ public static function activation() { self::schedule(); + + // Flush rewrite rules for FedCM well-known endpoint. + // Load the class file explicitly since activation runs before init. + $webidentity_file = plugin_dir_path( __FILE__ ) . 'includes/class-indieauth-webidentity.php'; + if ( file_exists( $webidentity_file ) ) { + require_once $webidentity_file; + if ( class_exists( 'IndieAuth_WebIdentity' ) ) { + IndieAuth_WebIdentity::flush_rewrite_rules(); + } + } } /** @@ -168,6 +178,8 @@ public static function init() { 'class-indieauth-revocation-endpoint.php', // Revocation endpoint. 'class-indieauth-introspection-endpoint.php', // Introspection endpoint. 'class-indieauth-userinfo-endpoint.php', // User info endpoint. + 'class-indieauth-fedcm-endpoint.php', // FedCM endpoint. + 'class-indieauth-webidentity.php', // Web Identity well-known handler. 'class-token-list-table.php', // Token management UI. 'class-indieauth-token-ui.php', ); @@ -180,6 +192,8 @@ public static function init() { new IndieAuth_Revocation_Endpoint(); new IndieAuth_Introspection_Endpoint(); new IndieAuth_Userinfo_Endpoint(); + new IndieAuth_FedCM_Endpoint(); + new IndieAuth_WebIdentity(); if ( WP_DEBUG ) { self::load( 'class-indieauth-debug.php' ); diff --git a/js/fedcm-register.js b/js/fedcm-register.js new file mode 100644 index 0000000..62762e7 --- /dev/null +++ b/js/fedcm-register.js @@ -0,0 +1,28 @@ +/** + * FedCM IdP Registration + * + * Automatically registers this site as a FedCM Identity Provider. + * + * @see https://indieweb.org/FedCM_for_IndieAuth + * @see https://fedidcg.github.io/FedCM/#idp-registration + */ +( function() { + 'use strict'; + + var configUrl = typeof indieAuthFedCM !== 'undefined' ? indieAuthFedCM.configUrl : null; + + if ( ! configUrl ) { + return; + } + + if ( typeof IdentityProvider === 'undefined' || typeof IdentityProvider.register !== 'function' ) { + return; + } + + IdentityProvider.register( configUrl ).catch( function( error ) { + // Registration failed or was rejected - log for debugging but don't interrupt user. + if ( typeof console !== 'undefined' && typeof console.debug === 'function' ) { + console.debug( 'IndieAuth FedCM: IdentityProvider.register() failed:', error ); + } + } ); +} )(); diff --git a/tests/phpunit/tests/includes/class-test-fedcm-endpoint.php b/tests/phpunit/tests/includes/class-test-fedcm-endpoint.php new file mode 100644 index 0000000..c9a283a --- /dev/null +++ b/tests/phpunit/tests/includes/class-test-fedcm-endpoint.php @@ -0,0 +1,606 @@ +get_error_code() ) { + $error_data = $response->get_error_data(); + if ( isset( $error_data['params']['account_id'] ) ) { + $account_id = $request->get_param( 'account_id' ); + // Allow test URLs that contain the author ID. + if ( $account_id === static::$author_url ) { + // Only clear the error if account_id is the only invalid param. + if ( 1 === count( $error_data['params'] ) ) { + return null; + } + } + } + } + return $response; + } + + public function tear_down() { + global $wp_rewrite; + $wp_rewrite->set_permalink_structure( '' ); + $wp_rewrite->flush_rules(); + parent::tear_down(); + } + + public static function wpSetUpBeforeClass( $factory ) { + static::$author_id = $factory->user->create( + array( + 'role' => 'author', + 'display_name' => 'Test Author', + 'user_nicename' => 'testauthor', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$author_id ); + } + + /** + * Create a REST request for FedCM endpoints. + * + * @param string $method HTTP method. + * @param string $route Endpoint route. + * @param array $params Request parameters. + * @param array $headers Request headers. + * @return WP_REST_Response + */ + public function create_request( $method, $route, $params = array(), $headers = array() ) { + $request = new WP_REST_Request( $method, '/indieauth/1.0/fedcm/' . $route ); + $request->set_header( 'Content-Type', 'application/x-www-form-urlencoded' ); + + if ( ! empty( $params ) ) { + if ( 'GET' === $method ) { + $request->set_query_params( $params ); + } else { + $request->set_body_params( $params ); + } + } + + if ( ! empty( $headers ) ) { + $request->set_headers( $headers ); + } + + return rest_get_server()->dispatch( $request ); + } + + /** + * Test config endpoint returns valid configuration. + */ + public function test_config_endpoint_returns_valid_config() { + $response = $this->create_request( 'GET', 'config.json' ); + + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'accounts_endpoint', $data ); + $this->assertArrayHasKey( 'client_metadata_endpoint', $data ); + $this->assertArrayHasKey( 'id_assertion_endpoint', $data ); + $this->assertArrayHasKey( 'login_url', $data ); + + // Verify URLs are absolute. + $this->assertStringStartsWith( 'http', $data['accounts_endpoint'] ); + $this->assertStringStartsWith( 'http', $data['client_metadata_endpoint'] ); + $this->assertStringStartsWith( 'http', $data['id_assertion_endpoint'] ); + } + + /** + * Test accounts endpoint returns 400 without Sec-Fetch-Dest header. + */ + public function test_accounts_endpoint_requires_sec_fetch_dest_header() { + wp_set_current_user( self::$author_id ); + + $response = $this->create_request( 'GET', 'accounts' ); + + $this->assertEquals( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'Missing or invalid Sec-Fetch-Dest header', $data['error'] ); + } + + /** + * Test accounts endpoint returns 401 when not logged in. + */ + public function test_accounts_endpoint_requires_login() { + wp_set_current_user( 0 ); + + $response = $this->create_request( + 'GET', + 'accounts', + array(), + array( 'Sec-Fetch-Dest' => 'webidentity' ) + ); + + $this->assertEquals( 401, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'Not logged in', $data['error'] ); + } + + /** + * Test accounts endpoint returns user data when properly authenticated. + */ + public function test_accounts_endpoint_returns_user_data() { + wp_set_current_user( self::$author_id ); + + $response = $this->create_request( + 'GET', + 'accounts', + array(), + array( 'Sec-Fetch-Dest' => 'webidentity' ) + ); + + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'accounts', $data ); + $this->assertCount( 1, $data['accounts'] ); + + $account = $data['accounts'][0]; + $this->assertArrayHasKey( 'id', $account ); + $this->assertArrayHasKey( 'name', $account ); + $this->assertEquals( 'Test Author', $account['name'] ); + } + + /** + * Test client_metadata endpoint returns empty object for unknown client. + */ + public function test_client_metadata_endpoint_returns_empty_for_unknown_client() { + $response = $this->create_request( + 'GET', + 'client_metadata', + array( 'client_id' => 'https://unknown-client.example.com/' ) + ); + + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + } + + /** + * Test assertion endpoint requires Sec-Fetch-Dest header. + */ + public function test_assertion_endpoint_requires_sec_fetch_dest_header() { + wp_set_current_user( self::$author_id ); + + $params = wp_json_encode( + array( + 'code_challenge' => 'test_challenge', + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + ) + ); + + $this->assertEquals( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'Missing or invalid Sec-Fetch-Dest header', $data['error'] ); + } + + /** + * Test assertion endpoint requires Origin header matching client_id. + */ + public function test_assertion_endpoint_requires_matching_origin() { + wp_set_current_user( self::$author_id ); + + $params = wp_json_encode( + array( + 'code_challenge' => 'test_challenge', + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://different-origin.example.com', + ) + ); + + $this->assertEquals( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'Origin mismatch', $data['error'] ); + } + + /** + * Test assertion endpoint requires Origin scheme to match client_id scheme. + */ + public function test_assertion_endpoint_requires_matching_origin_scheme() { + wp_set_current_user( self::$author_id ); + + $params = wp_json_encode( + array( + 'code_challenge' => 'test_challenge', + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'http://app.example.com', // HTTP instead of HTTPS. + ) + ); + + $this->assertEquals( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'Origin mismatch', $data['error'] ); + } + + /** + * Test assertion endpoint requires Origin port to match client_id port. + */ + public function test_assertion_endpoint_requires_matching_origin_port() { + wp_set_current_user( self::$author_id ); + + $params = wp_json_encode( + array( + 'code_challenge' => 'test_challenge', + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com:8080/', + 'account_id' => self::$author_url, + 'params' => $params, + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', // Missing port. + ) + ); + + $this->assertEquals( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'Origin mismatch', $data['error'] ); + } + + /** + * Test assertion endpoint requires login. + */ + public function test_assertion_endpoint_requires_login() { + wp_set_current_user( 0 ); + + $params = wp_json_encode( + array( + 'code_challenge' => 'test_challenge', + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', + ) + ); + + $this->assertEquals( 401, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'Not logged in', $data['error'] ); + } + + /** + * Test assertion endpoint requires PKCE parameters. + */ + public function test_assertion_endpoint_requires_pkce() { + wp_set_current_user( self::$author_id ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => wp_json_encode( array() ), // Empty params - missing PKCE. + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', + ) + ); + + $this->assertEquals( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response->get_data() ) ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_param', $data['error'] ); + } + + /** + * Test assertion endpoint requires S256 code_challenge_method. + */ + public function test_assertion_endpoint_requires_s256_method() { + wp_set_current_user( self::$author_id ); + + $params = wp_json_encode( + array( + 'code_challenge' => 'test_challenge', + 'code_challenge_method' => 'plain', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', + ) + ); + + $this->assertEquals( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_param', $data['error'] ); + } + + /** + * Test assertion endpoint rejects invalid JSON in params. + */ + public function test_assertion_endpoint_rejects_invalid_json() { + wp_set_current_user( self::$author_id ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => '{invalid json}', + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', + ) + ); + + $this->assertEquals( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_param', $data['error'] ); + } + + /** + * Test assertion endpoint issues valid authorization code. + */ + public function test_assertion_endpoint_issues_authorization_code() { + wp_set_current_user( self::$author_id ); + + $code_verifier = 'a6128783714cfda1d388e2e98b6ae8221ac31aca31959e59512c59f5'; + $code_challenge = base64_urlencode( hash( 'sha256', $code_verifier, true ) ); + + $params = wp_json_encode( + array( + 'code_challenge' => $code_challenge, + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', + ) + ); + + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'token', $data ); + + $token_data = json_decode( $data['token'], true ); + $this->assertArrayHasKey( 'code', $token_data ); + $this->assertArrayHasKey( 'metadata_endpoint', $token_data ); + $this->assertNotEmpty( $token_data['code'] ); + } + + /** + * Test assertion endpoint verifies account_id matches current user. + */ + public function test_assertion_endpoint_verifies_account_id() { + wp_set_current_user( self::$author_id ); + + $params = wp_json_encode( + array( + 'code_challenge' => 'test_challenge', + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => 'https://different-user.example.com/', + 'params' => $params, + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', + ) + ); + + $this->assertEquals( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $this->assertEquals( 'Account mismatch', $data['error'] ); + } + + /** + * Test assertion endpoint stores nonce in authorization code. + */ + public function test_assertion_endpoint_stores_nonce() { + wp_set_current_user( self::$author_id ); + + $code_verifier = 'a6128783714cfda1d388e2e98b6ae8221ac31aca31959e59512c59f5'; + $code_challenge = base64_urlencode( hash( 'sha256', $code_verifier, true ) ); + + $params = wp_json_encode( + array( + 'code_challenge' => $code_challenge, + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + 'nonce' => 'test-nonce-12345', + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', + ) + ); + + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $data = $response->get_data(); + $token_data = json_decode( $data['token'], true ); + + // Verify the code was stored with the nonce. + $tokens = new Token_User( '_indieauth_code_' ); + $code_data = $tokens->get( $token_data['code'] ); + + $this->assertArrayHasKey( 'nonce', $code_data ); + $this->assertEquals( 'test-nonce-12345', $code_data['nonce'] ); + } + + /** + * Test that FedCM-issued codes are marked as such. + */ + public function test_assertion_endpoint_marks_code_as_fedcm() { + wp_set_current_user( self::$author_id ); + + $code_verifier = 'a6128783714cfda1d388e2e98b6ae8221ac31aca31959e59512c59f5'; + $code_challenge = base64_urlencode( hash( 'sha256', $code_verifier, true ) ); + + $params = wp_json_encode( + array( + 'code_challenge' => $code_challenge, + 'code_challenge_method' => 'S256', + ) + ); + + $response = $this->create_request( + 'POST', + 'assertion', + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => 'https://app.example.com', + ) + ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $token_data = json_decode( $data['token'], true ); + + // Verify the code is marked as FedCM-issued. + $tokens = new Token_User( '_indieauth_code_' ); + $code_data = $tokens->get( $token_data['code'] ); + + $this->assertArrayHasKey( 'fedcm', $code_data ); + $this->assertTrue( $code_data['fedcm'] ); + } +} diff --git a/tests/phpunit/tests/includes/class-test-functions.php b/tests/phpunit/tests/includes/class-test-functions.php index da3112b..7a2f18e 100644 --- a/tests/phpunit/tests/includes/class-test-functions.php +++ b/tests/phpunit/tests/includes/class-test-functions.php @@ -3,17 +3,18 @@ class IndieAuthFunctionsTest extends WP_UnitTestCase { protected static $author_id; - public static function wpSetUpBeforeClass( $factory ) { - static::$author_id = $factory->user->create( - array( - 'role' => 'author', - ) - ); - } - - public static function wpTearDownAfterClass() { - self::delete_user( self::$author_id ); - } + public static function wpSetUpBeforeClass( $factory ) { + static::$author_id = $factory->user->create( + array( + 'role' => 'author', + 'user_nicename' => 'testauthor', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$author_id ); + } // Test Getting the Author URL through get_user_by_identifier. public function test_authorurl() { @@ -41,7 +42,7 @@ public function test_urltoauthor() { public function test_profile_return() { $author = get_user_by( 'ID', static::$author_id ); - + $expected = array( 'name' => $author->display_name, 'url' => empty( $author->user_url ) ? get_author_posts_url( $author->ID ) : $author->user_url, @@ -66,12 +67,12 @@ public function test_profile_return() { } public function test_validate_user_identifier() { - foreach( + foreach( array( 'https://example.com/', 'https://example.com/username', 'https://example.com/users?id=100' ) as $pass ) { $this->assertNotEquals( false, indieauth_validate_user_identifier( $pass ) ); } - foreach( - array( + foreach( + array( 'example.com', // schemeless 'mailto:user@example.com', // invalid scheme 'https://example.com/foo/./bar', // single dot @@ -86,12 +87,12 @@ public function test_validate_user_identifier() { } public function test_validate_client_identifier() { - foreach( + foreach( array( 'https://example.com/', 'https://example.com/application', 'https://example.com/app?id=100', 'https://127.0.0.1', 'http://::1', 'https://localhost', 'https://example.com:8443' ) as $pass ) { $this->assertNotEquals( false, indieauth_validate_client_identifier( $pass ) ); } - foreach( - array( + foreach( + array( 'example.com', // schemeless 'mailto:user@example.com', // invalid scheme 'https://example.com/foo/./bar', // single dot @@ -105,12 +106,12 @@ public function test_validate_client_identifier() { } public function test_validate_issuer_identifier() { - foreach( + foreach( array( 'https://example.com/', 'https://example.com/application', 'https://127.0.0.1', 'https://localhost', 'https://example.com:8443' ) as $pass ) { $this->assertNotEquals( false, indieauth_validate_issuer_identifier( $pass ) ); } - foreach( - array( + foreach( + array( 'example.com', // schemeless 'http://example.com', // http scheme 'mailto:user@example.com', // invalid scheme