client_id ); } /** * Build and return an access + refresh JWT pair. * * @param int $user_id WordPress user ID. * @param string $app_pass_uuid UUID of the Application Password. * @param string $client_id Client ID URL from the CIMD record. * @return void */ private function issue_token_pair( int $user_id, string $app_pass_uuid, string $client_id = '' ): void { $secret = SecretManager::get_secret(); // iss is home_url() (the Site Address), matching the base get_rest_url() // uses for aud and where the OAuth/.well-known routes are actually served. $issuer = home_url(); $now = time(); $aud = get_rest_url( null, 'mcp/mcp-oauth-server' ); // Mint a fresh refresh-token id and persist it as the only one accepted // for this session. This overwrites any prior marker, so the previously // issued refresh token (if any) is invalidated the instant this returns. $refresh_jti = bin2hex( random_bytes( 16 ) ); update_user_meta( $user_id, self::REFRESH_JTI_META_PREFIX . $app_pass_uuid, $refresh_jti ); $access_payload = [ 'iss' => $issuer, 'aud' => $aud, 'sub' => (string) $user_id, 'app_pass_id' => $app_pass_uuid, 'client_id' => $client_id, 'scope' => 'mcp', 'iat' => $now, 'exp' => $now + HOUR_IN_SECONDS, ]; $refresh_payload = [ 'iss' => $issuer, 'sub' => (string) $user_id, 'app_pass_id' => $app_pass_uuid, 'client_id' => $client_id, 'type' => 'refresh', 'jti' => $refresh_jti, 'iat' => $now, 'exp' => $now + ( 30 * DAY_IN_SECONDS ), ]; $access_token = JWT::encode( $access_payload, $secret ); $refresh_token = JWT::encode( $refresh_payload, $secret ); McpLogger::log( 'TOKEN', 'token pair issued', [ 'user_id' => $user_id, 'app_pass_uuid' => $app_pass_uuid, 'access_exp' => $access_payload['exp'], 'refresh_exp' => $refresh_payload['exp'], 'access_token_len' => strlen( $access_token ), ] ); nocache_headers(); wp_send_json( [ 'access_token' => $access_token, 'token_type' => 'Bearer', 'expires_in' => HOUR_IN_SECONDS, 'refresh_token' => $refresh_token, 'scope' => 'mcp', ] ); } /** * Evict the oldest MCP sessions for a user+client so that creating one more * stays within MAX_SESSIONS_PER_CLIENT. * * Only Application Passwords this feature created are considered — they are * matched by the `app_id` we set to the client_id at creation time, so * Application Passwords the user created for other integrations are never * touched. Deleting one fires `wp_delete_application_password`, which removes * its `mcp_refresh_jti_*` meta via purge_refresh_jti_meta(), so no orphaned * rows are left behind. * * @param int $user_id WordPress user ID. * @param string $client_id Client ID URL the sessions belong to. * @return void */ private function prune_sessions( int $user_id, string $client_id ): void { if ( '' === $client_id ) { return; } $passwords = \WP_Application_Passwords::get_user_application_passwords( $user_id ); // Keep only this feature's Application Passwords for this client. $ours = array_values( array_filter( $passwords, static function ( $item ) use ( $client_id ) { return $item['app_id'] === $client_id; } ) ); // Below the cap: the new session about to be created still fits. if ( count( $ours ) < self::MAX_SESSIONS_PER_CLIENT ) { return; } // Oldest first. usort( $ours, static function ( $a, $b ) { return $a['created'] <=> $b['created']; } ); // Evict enough of the oldest so that, after the new one is created, the // total sits at exactly MAX_SESSIONS_PER_CLIENT. $evict_count = ( count( $ours ) - self::MAX_SESSIONS_PER_CLIENT ) + 1; for ( $i = 0; $i < $evict_count; $i++ ) { $uuid = (string) ( $ours[ $i ]['uuid'] ?? '' ); if ( '' === $uuid ) { continue; } \WP_Application_Passwords::delete_application_password( $user_id, $uuid ); McpLogger::log( 'TOKEN', 'evicted oldest MCP session to enforce per-client cap', [ 'user_id' => $user_id, 'client_id' => $client_id, 'app_pass_uuid' => $uuid, ] ); } } /** * Purge the stored refresh-token rotation marker for a deleted session. * * Hooked on WordPress's `wp_delete_application_password` action so the * per-session user meta is removed wherever the Application Password that * anchors the session is deleted — the revoke endpoint, the admin page, or * WordPress core — leaving no orphaned rows behind. * * @param int $user_id WordPress user ID. * @param array $item The Application Password record being deleted. * @return void */ public function purge_refresh_jti_meta( int $user_id, array $item ): void { $uuid = (string) ( $item['uuid'] ?? '' ); if ( '' === $uuid ) { return; } delete_user_meta( $user_id, self::REFRESH_JTI_META_PREFIX . $uuid ); } /** * Send a JSON error response and exit. * * @param int $status HTTP status code. * @param string $error OAuth error code. * @param string $description Optional human-readable description. * @return void */ private function send_error( int $status, string $error, string $description = '' ): void { status_header( $status ); nocache_headers(); $body = [ 'error' => $error ]; if ( '' !== $description ) { $body['error_description'] = $description; } wp_send_json( $body ); } }